diff --git a/bun.lock b/bun.lock index 763fa2a..c82e551 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@astrojs/mdx": "4.3.4", "@astrojs/node": "9.4.3", "@astrojs/react": "^4.3.0", - "@better-auth/sso": "^1.3.7", + "@better-auth/sso": "^1.3.8", "@octokit/rest": "^22.0.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", @@ -34,7 +34,7 @@ "@types/react-dom": "^19.1.9", "astro": "^5.13.4", "bcryptjs": "^3.0.2", - "better-auth": "^1.3.7", + "better-auth": "^1.3.8", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.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=="], - "@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=="], @@ -683,7 +683,7 @@ "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=="], diff --git a/package.json b/package.json index 781fe53..4482c37 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@astrojs/mdx": "4.3.4", "@astrojs/node": "9.4.3", "@astrojs/react": "^4.3.0", - "@better-auth/sso": "^1.3.7", + "@better-auth/sso": "^1.3.8", "@octokit/rest": "^22.0.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", @@ -72,7 +72,7 @@ "@types/react-dom": "^19.1.9", "astro": "^5.13.4", "bcryptjs": "^3.0.2", - "better-auth": "^1.3.7", + "better-auth": "^1.3.8", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index a193598..587066f 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -7,15 +7,30 @@ export const authClient = createAuthClient({ // 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 baseURL: (() => { + let url: string | undefined; + // Check for public environment variable first (for client-side access) 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 - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && window.location?.origin) { return window.location.origin; } - // Default for SSR + + // Default for SSR - always return a valid URL return 'http://localhost:4321'; })(), basePath: '/api/auth', // Explicitly set the base path diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 038f715..24ae5ce 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -19,42 +19,71 @@ export const auth = betterAuth({ // Base URL configuration - use the primary URL (Better Auth only supports single 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 { - // Validate URL format - new URL(url); - return url; - } catch { - console.warn(`Invalid BETTER_AUTH_URL: ${url}, falling back to localhost`); - return "http://localhost:4321"; + // Validate URL format and ensure it's a proper origin + const validatedUrl = new URL(url.trim()); + const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths + console.info('Using BETTER_AUTH_URL:', cleanUrl); + return cleanUrl; + } 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 // Trusted origins - this is how we support multiple access URLs trustedOrigins: (() => { - const origins = [ + const origins: string[] = [ "http://localhost:4321", "http://localhost:8080", // Keycloak ]; // Add the primary URL from BETTER_AUTH_URL - const primaryUrl = process.env.BETTER_AUTH_URL || "http://localhost:4321"; - try { - new URL(primaryUrl); - origins.push(primaryUrl); - } catch { - // Skip if invalid + const primaryUrl = process.env.BETTER_AUTH_URL; + if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') { + try { + const validatedUrl = new URL(primaryUrl.trim()); + origins.push(validatedUrl.origin); + } catch { + // Skip if invalid + } } // Add additional trusted origins from environment // This is where users can specify multiple access URLs 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 - return [...new Set(origins.filter(Boolean))]; + // Remove duplicates and empty strings, then return + const uniqueOrigins = [...new Set(origins.filter(Boolean))]; + console.info('Trusted origins:', uniqueOrigins); + return uniqueOrigins; })(), // Authentication methods diff --git a/src/pages/api/auth/sso/register.ts b/src/pages/api/auth/sso/register.ts index ef10489..c32b5b0 100644 --- a/src/pages/api/auth/sso/register.ts +++ b/src/pages/api/auth/sso/register.ts @@ -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 = { providerId, - issuer, + issuer: validatedIssuer, domain, organizationId, }; @@ -91,14 +116,27 @@ export async function POST(context: APIContext) { // Use provided scopes or default if not specified 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 = { - clientId, - clientSecret, - authorizationEndpoint, - tokenEndpoint, - jwksEndpoint, - discoveryEndpoint, - userInfoEndpoint, + clientId: clientId || undefined, + clientSecret: clientSecret || undefined, + authorizationEndpoint: validateUrl(authorizationEndpoint, 'authorization endpoint'), + tokenEndpoint: validateUrl(tokenEndpoint, 'token endpoint'), + jwksEndpoint: validateUrl(jwksEndpoint, 'JWKS endpoint'), + discoveryEndpoint: validateUrl(discoveryEndpoint, 'discovery endpoint'), + userInfoEndpoint: validateUrl(userInfoEndpoint, 'userinfo endpoint'), scopes: finalScopes, pkce, }; diff --git a/src/pages/api/sso/discover.ts b/src/pages/api/sso/discover.ts index acbf94d..ae41ec9 100644 --- a/src/pages/api/sso/discover.ts +++ b/src/pages/api/sso/discover.ts @@ -10,26 +10,71 @@ export async function POST(context: APIContext) { const { issuer } = await context.request.json(); - if (!issuer) { - return new Response(JSON.stringify({ error: "Issuer URL is required" }), { + if (!issuer || typeof issuer !== 'string' || issuer.trim() === '') { + return new Response(JSON.stringify({ error: "Issuer URL is required and must be a valid string" }), { status: 400, headers: { "Content-Type": "application/json" }, }); } - // Ensure issuer URL ends without trailing slash for well-known discovery - const cleanIssuer = issuer.replace(/\/$/, ""); + // Validate issuer URL format + 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//` + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`; try { - // Fetch OIDC discovery document - const response = await fetch(discoveryUrl); + // Fetch OIDC discovery document with timeout + 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) { - 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 const discoveredConfig = {