Testing Authentik SSO Issues

This commit is contained in:
Arunavo Ray
2025-09-07 19:09:00 +05:30
parent c4b353aae8
commit c2f6e73054
6 changed files with 169 additions and 42 deletions

View File

@@ -8,7 +8,7 @@
"@astrojs/mdx": "4.3.4", "@astrojs/mdx": "4.3.4",
"@astrojs/node": "9.4.3", "@astrojs/node": "9.4.3",
"@astrojs/react": "^4.3.0", "@astrojs/react": "^4.3.0",
"@better-auth/sso": "^1.3.7", "@better-auth/sso": "^1.3.8",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
@@ -34,7 +34,7 @@
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"astro": "^5.13.4", "astro": "^5.13.4",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-auth": "^1.3.7", "better-auth": "^1.3.8",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -147,7 +147,7 @@
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@better-auth/sso": ["@better-auth/sso@1.3.7", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "better-auth": "^1.3.7", "fast-xml-parser": "^5.2.5", "jose": "^5.9.6", "oauth2-mock-server": "^7.2.0", "samlify": "^2.10.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-MTwBiNash7HN0nLtQiL1tvYgWBn6GjYj6EYvtrQeb0/+UW0tjBDgsl39ojiFFSWGuT0gxPv+ij8tQNaFmQ1+2g=="], "@better-auth/sso": ["@better-auth/sso@1.3.8", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "fast-xml-parser": "^5.2.5", "jose": "^5.10.0", "oauth2-mock-server": "^7.2.1", "samlify": "^2.10.1", "zod": "^4.1.5" }, "peerDependencies": { "better-auth": "1.3.8" } }, "sha512-ohJl4uTRwVACu8840A5Ys/z2jus/vEsCrWvOj/RannsZ6CxQAjr8utYYXXs6lVn08ynOcuT4m0OsYRbrw7a42g=="],
"@better-auth/utils": ["@better-auth/utils@0.2.6", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA=="], "@better-auth/utils": ["@better-auth/utils@0.2.6", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA=="],
@@ -683,7 +683,7 @@
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"better-auth": ["better-auth@1.3.7", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "^1.0.13", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-/1fEyx2SGgJQM5ujozDCh9eJksnVkNU/J7Fk/tG5Y390l8nKbrPvqiFlCjlMM+scR+UABJbQzA6An7HT50LHyQ=="], "better-auth": ["better-auth@1.3.8", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.16", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4", "zod": "^4.1.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-uRFzHbWkhr8eWNy+BJwyMnrZPOvQjwrcLND3nc6jusRteYA9cjeRGElgCPTWTIyWUfzaQ708Lb5Mdq9Gv41Qpw=="],
"better-call": ["better-call@1.0.16", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA=="], "better-call": ["better-call@1.0.16", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA=="],

View File

@@ -46,7 +46,7 @@
"@astrojs/mdx": "4.3.4", "@astrojs/mdx": "4.3.4",
"@astrojs/node": "9.4.3", "@astrojs/node": "9.4.3",
"@astrojs/react": "^4.3.0", "@astrojs/react": "^4.3.0",
"@better-auth/sso": "^1.3.7", "@better-auth/sso": "^1.3.8",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
@@ -72,7 +72,7 @@
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"astro": "^5.13.4", "astro": "^5.13.4",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-auth": "^1.3.7", "better-auth": "^1.3.8",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -7,15 +7,30 @@ export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin // Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
// This allows the client to connect to the auth server even when accessed from different origins // This allows the client to connect to the auth server even when accessed from different origins
baseURL: (() => { baseURL: (() => {
let url: string | undefined;
// Check for public environment variable first (for client-side access) // Check for public environment variable first (for client-side access)
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) { if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) {
return import.meta.env.PUBLIC_BETTER_AUTH_URL; url = import.meta.env.PUBLIC_BETTER_AUTH_URL;
} }
// Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') {
try {
// Validate URL format and remove trailing slash
const validatedUrl = new URL(url.trim());
return validatedUrl.origin; // Use origin to ensure clean URL without path
} catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
}
}
// Fall back to current origin if running in browser // Fall back to current origin if running in browser
if (typeof window !== 'undefined') { if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin; return window.location.origin;
} }
// Default for SSR
// Default for SSR - always return a valid URL
return 'http://localhost:4321'; return 'http://localhost:4321';
})(), })(),
basePath: '/api/auth', // Explicitly set the base path basePath: '/api/auth', // Explicitly set the base path

View File

@@ -19,42 +19,71 @@ export const auth = betterAuth({
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL) // Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
baseURL: (() => { baseURL: (() => {
const url = process.env.BETTER_AUTH_URL || "http://localhost:4321"; const url = process.env.BETTER_AUTH_URL;
const defaultUrl = "http://localhost:4321";
// Check if URL is provided and not empty
if (!url || typeof url !== 'string' || url.trim() === '') {
console.info('BETTER_AUTH_URL not set, using default:', defaultUrl);
return defaultUrl;
}
try { try {
// Validate URL format // Validate URL format and ensure it's a proper origin
new URL(url); const validatedUrl = new URL(url.trim());
return url; const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
} catch { console.info('Using BETTER_AUTH_URL:', cleanUrl);
console.warn(`Invalid BETTER_AUTH_URL: ${url}, falling back to localhost`); return cleanUrl;
return "http://localhost:4321"; } catch (e) {
console.error(`Invalid BETTER_AUTH_URL format: "${url}"`);
console.error('Error:', e);
console.info('Falling back to default:', defaultUrl);
return defaultUrl;
} }
})(), })(),
basePath: "/api/auth", // Specify the base path for auth endpoints basePath: "/api/auth", // Specify the base path for auth endpoints
// Trusted origins - this is how we support multiple access URLs // Trusted origins - this is how we support multiple access URLs
trustedOrigins: (() => { trustedOrigins: (() => {
const origins = [ const origins: string[] = [
"http://localhost:4321", "http://localhost:4321",
"http://localhost:8080", // Keycloak "http://localhost:8080", // Keycloak
]; ];
// Add the primary URL from BETTER_AUTH_URL // Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL || "http://localhost:4321"; const primaryUrl = process.env.BETTER_AUTH_URL;
try { if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
new URL(primaryUrl); try {
origins.push(primaryUrl); const validatedUrl = new URL(primaryUrl.trim());
} catch { origins.push(validatedUrl.origin);
// Skip if invalid } catch {
// Skip if invalid
}
} }
// Add additional trusted origins from environment // Add additional trusted origins from environment
// This is where users can specify multiple access URLs // This is where users can specify multiple access URLs
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) { if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
origins.push(...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map(o => o.trim())); const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
// Validate each additional origin
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
} }
// Remove duplicates and return // Remove duplicates and empty strings, then return
return [...new Set(origins.filter(Boolean))]; const uniqueOrigins = [...new Set(origins.filter(Boolean))];
console.info('Trusted origins:', uniqueOrigins);
return uniqueOrigins;
})(), })(),
// Authentication methods // Authentication methods

View File

@@ -25,9 +25,34 @@ export async function POST(context: APIContext) {
); );
} }
// Validate issuer URL format
let validatedIssuer = issuer;
if (issuer && typeof issuer === 'string' && issuer.trim() !== '') {
try {
const issuerUrl = new URL(issuer.trim());
validatedIssuer = issuerUrl.toString().replace(/\/$/, ''); // Remove trailing slash
} catch (e) {
return new Response(
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
} else {
return new Response(
JSON.stringify({ error: "Issuer URL cannot be empty" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
let registrationBody: any = { let registrationBody: any = {
providerId, providerId,
issuer, issuer: validatedIssuer,
domain, domain,
organizationId, organizationId,
}; };
@@ -91,14 +116,27 @@ export async function POST(context: APIContext) {
// Use provided scopes or default if not specified // Use provided scopes or default if not specified
const finalScopes = scopes || ["openid", "email", "profile"]; const finalScopes = scopes || ["openid", "email", "profile"];
// Validate endpoint URLs if provided
const validateUrl = (url: string | undefined, name: string): string | undefined => {
if (!url) return undefined;
if (typeof url !== 'string' || url.trim() === '') return undefined;
try {
const validatedUrl = new URL(url.trim());
return validatedUrl.toString();
} catch (e) {
console.warn(`Invalid ${name} URL: ${url}, skipping`);
return undefined;
}
};
registrationBody.oidcConfig = { registrationBody.oidcConfig = {
clientId, clientId: clientId || undefined,
clientSecret, clientSecret: clientSecret || undefined,
authorizationEndpoint, authorizationEndpoint: validateUrl(authorizationEndpoint, 'authorization endpoint'),
tokenEndpoint, tokenEndpoint: validateUrl(tokenEndpoint, 'token endpoint'),
jwksEndpoint, jwksEndpoint: validateUrl(jwksEndpoint, 'JWKS endpoint'),
discoveryEndpoint, discoveryEndpoint: validateUrl(discoveryEndpoint, 'discovery endpoint'),
userInfoEndpoint, userInfoEndpoint: validateUrl(userInfoEndpoint, 'userinfo endpoint'),
scopes: finalScopes, scopes: finalScopes,
pkce, pkce,
}; };

View File

@@ -10,26 +10,71 @@ export async function POST(context: APIContext) {
const { issuer } = await context.request.json(); const { issuer } = await context.request.json();
if (!issuer) { if (!issuer || typeof issuer !== 'string' || issuer.trim() === '') {
return new Response(JSON.stringify({ error: "Issuer URL is required" }), { return new Response(JSON.stringify({ error: "Issuer URL is required and must be a valid string" }), {
status: 400, status: 400,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
// Ensure issuer URL ends without trailing slash for well-known discovery // Validate issuer URL format
const cleanIssuer = issuer.replace(/\/$/, ""); let cleanIssuer: string;
try {
const issuerUrl = new URL(issuer.trim());
cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); // Remove trailing slash
} catch (e) {
return new Response(
JSON.stringify({
error: "Invalid issuer URL format",
details: `The provided URL "${issuer}" is not a valid URL. For Authentik, use format: https://your-authentik-domain/application/o/<app-slug>/`
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`; const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
try { try {
// Fetch OIDC discovery document // Fetch OIDC discovery document with timeout
const response = await fetch(discoveryUrl); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
let response: Response;
try {
response = await fetch(discoveryUrl, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
}
});
} catch (fetchError) {
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
throw new Error(`Request timeout: The OIDC provider at ${cleanIssuer} did not respond within 10 seconds`);
}
throw new Error(`Network error: Could not connect to ${cleanIssuer}. Please verify the URL is correct and accessible.`);
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch discovery document: ${response.status}`); if (response.status === 404) {
throw new Error(`OIDC discovery document not found at ${discoveryUrl}. For Authentik, ensure you're using the correct application slug in the URL.`);
} else if (response.status >= 500) {
throw new Error(`OIDC provider error (${response.status}): The server at ${cleanIssuer} returned an error.`);
} else {
throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`);
}
} }
const config = await response.json(); let config: any;
try {
config = await response.json();
} catch (parseError) {
throw new Error(`Invalid response: The discovery document from ${cleanIssuer} is not valid JSON.`);
}
// Extract the essential endpoints // Extract the essential endpoints
const discoveredConfig = { const discoveredConfig = {