diff --git a/src/lib/sso/oidc-config.test.ts b/src/lib/sso/oidc-config.test.ts index e549477..3cfbab5 100644 --- a/src/lib/sso/oidc-config.test.ts +++ b/src/lib/sso/oidc-config.test.ts @@ -24,6 +24,7 @@ describe("normalizeOidcProviderConfig", () => { expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo"); expect(result.oidcConfig.scopes).toEqual(["openid", "email"]); expect(result.oidcConfig.pkce).toBe(false); + expect(result.oidcConfig.discoveryEndpoint).toBe("https://auth.example.com/.well-known/openid-configuration"); }); it("derives missing fields from discovery", async () => { @@ -46,6 +47,24 @@ describe("normalizeOidcProviderConfig", () => { expect(result.oidcConfig.jwksEndpoint).toBe("https://auth.example.com/jwks"); expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo"); expect(result.oidcConfig.scopes).toEqual(["openid", "email", "profile"]); + expect(result.oidcConfig.discoveryEndpoint).toBe("https://auth.example.com/.well-known/openid-configuration"); + }); + + it("preserves trailing slash issuers when building discovery endpoints", async () => { + const trailingIssuer = "https://auth.example.com/application/o/example/"; + const requestedUrls: string[] = []; + const fetchMock: typeof fetch = async (url) => { + requestedUrls.push(typeof url === "string" ? url : url.url); + return new Response(JSON.stringify({ + authorization_endpoint: "https://auth.example.com/application/o/example/auth", + token_endpoint: "https://auth.example.com/application/o/example/token", + })); + }; + + const result = await normalizeOidcProviderConfig(trailingIssuer, {}, fetchMock); + + expect(requestedUrls[0]).toBe("https://auth.example.com/application/o/example/.well-known/openid-configuration"); + expect(result.oidcConfig.discoveryEndpoint).toBe("https://auth.example.com/application/o/example/.well-known/openid-configuration"); }); it("throws for invalid issuer URL", async () => { diff --git a/src/lib/sso/oidc-config.ts b/src/lib/sso/oidc-config.ts index 57af364..4f3d398 100644 --- a/src/lib/sso/oidc-config.ts +++ b/src/lib/sso/oidc-config.ts @@ -131,18 +131,21 @@ export async function normalizeOidcProviderConfig( throw new OidcConfigError("Issuer is required"); } - let normalizedIssuer: string; + const trimmedIssuer = issuer.trim(); + try { - const issuerUrl = new URL(issuer.trim()); - normalizedIssuer = issuerUrl.toString().replace(/\/$/, ""); + // Validate issuer but keep caller-provided formatting so we don't break provider expectations + new URL(trimmedIssuer); } catch { throw new OidcConfigError(`Invalid issuer URL: ${issuer}`); } + const issuerForDiscovery = trimmedIssuer.replace(/\/$/, ""); + const discoveryEndpoint = cleanUrl( rawConfig.discoveryEndpoint, "discovery endpoint", - ) ?? `${normalizedIssuer}/.well-known/openid-configuration`; + ) ?? `${issuerForDiscovery}/.well-known/openid-configuration`; const authorizationEndpoint = cleanUrl(rawConfig.authorizationEndpoint, "authorization endpoint"); const tokenEndpoint = cleanUrl(rawConfig.tokenEndpoint, "token endpoint"); diff --git a/src/pages/api/auth/sso/register.ts b/src/pages/api/auth/sso/register.ts index 21b193a..5d74ac2 100644 --- a/src/pages/api/auth/sso/register.ts +++ b/src/pages/api/auth/sso/register.ts @@ -29,12 +29,13 @@ export async function POST(context: APIContext) { ); } - // Validate issuer URL format + // Validate issuer URL format while preserving trailing slash when provided let validatedIssuer = issuer; if (issuer && typeof issuer === 'string' && issuer.trim() !== '') { try { - const issuerUrl = new URL(issuer.trim()); - validatedIssuer = issuerUrl.toString().replace(/\/$/, ''); // Remove trailing slash + const trimmedIssuer = issuer.trim(); + new URL(trimmedIssuer); + validatedIssuer = trimmedIssuer; } catch (e) { return new Response( JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }), diff --git a/src/pages/api/sso/discover.ts b/src/pages/api/sso/discover.ts index ae41ec9..8eb7d64 100644 --- a/src/pages/api/sso/discover.ts +++ b/src/pages/api/sso/discover.ts @@ -17,11 +17,11 @@ export async function POST(context: APIContext) { }); } - // Validate issuer URL format - let cleanIssuer: string; + // Validate issuer URL format while keeping trailing slash if provided + const trimmedIssuer = issuer.trim(); + let parsedIssuer: URL; try { - const issuerUrl = new URL(issuer.trim()); - cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); // Remove trailing slash + parsedIssuer = new URL(trimmedIssuer); } catch (e) { return new Response( JSON.stringify({ @@ -35,7 +35,8 @@ export async function POST(context: APIContext) { ); } - const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`; + const issuerForDiscovery = trimmedIssuer.replace(/\/$/, ""); + const discoveryUrl = `${issuerForDiscovery}/.well-known/openid-configuration`; try { // Fetch OIDC discovery document with timeout @@ -52,9 +53,9 @@ export async function POST(context: APIContext) { }); } 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(`Request timeout: The OIDC provider at ${trimmedIssuer} did not respond within 10 seconds`); } - throw new Error(`Network error: Could not connect to ${cleanIssuer}. Please verify the URL is correct and accessible.`); + throw new Error(`Network error: Could not connect to ${trimmedIssuer}. Please verify the URL is correct and accessible.`); } finally { clearTimeout(timeoutId); } @@ -63,7 +64,7 @@ export async function POST(context: APIContext) { 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.`); + throw new Error(`OIDC provider error (${response.status}): The server at ${trimmedIssuer} returned an error.`); } else { throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`); } @@ -73,12 +74,12 @@ export async function POST(context: APIContext) { try { config = await response.json(); } catch (parseError) { - throw new Error(`Invalid response: The discovery document from ${cleanIssuer} is not valid JSON.`); + throw new Error(`Invalid response: The discovery document from ${trimmedIssuer} is not valid JSON.`); } // Extract the essential endpoints const discoveredConfig = { - issuer: config.issuer || cleanIssuer, + issuer: config.issuer || trimmedIssuer, authorizationEndpoint: config.authorization_endpoint, tokenEndpoint: config.token_endpoint, userInfoEndpoint: config.userinfo_endpoint, @@ -88,7 +89,7 @@ export async function POST(context: APIContext) { responseTypes: config.response_types_supported || ["code"], grantTypes: config.grant_types_supported || ["authorization_code"], // Suggested domain from issuer - suggestedDomain: new URL(cleanIssuer).hostname.replace("www.", ""), + suggestedDomain: parsedIssuer.hostname.replace("www.", ""), }; return new Response(JSON.stringify(discoveredConfig), { @@ -111,4 +112,4 @@ export async function POST(context: APIContext) { } catch (error) { return createSecureErrorResponse(error, "SSO discover API"); } -} \ No newline at end of file +} diff --git a/src/pages/api/sso/providers.ts b/src/pages/api/sso/providers.ts index 5be56fd..ea63b3c 100644 --- a/src/pages/api/sso/providers.ts +++ b/src/pages/api/sso/providers.ts @@ -82,11 +82,10 @@ export async function POST(context: APIContext) { ); } - // Clean issuer URL (remove trailing slash); validate format - let cleanIssuer = issuer; + // Validate issuer URL format but keep trailing slash if provided + const trimmedIssuer = issuer.toString().trim(); try { - const issuerUrl = new URL(issuer.toString().trim()); - cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); + new URL(trimmedIssuer); } catch { return new Response( JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }), @@ -99,7 +98,7 @@ export async function POST(context: APIContext) { let normalized; try { - normalized = await normalizeOidcProviderConfig(cleanIssuer, { + normalized = await normalizeOidcProviderConfig(trimmedIssuer, { clientId, clientSecret, authorizationEndpoint, @@ -134,7 +133,7 @@ export async function POST(context: APIContext) { .insert(ssoProviders) .values({ id: nanoid(), - issuer: cleanIssuer, + issuer: trimmedIssuer, domain, oidcConfig: JSON.stringify(storedOidcConfig), userId: user.id, @@ -213,12 +212,10 @@ export async function PUT(context: APIContext) { // Parse existing config const existingConfig = JSON.parse(existingProvider.oidcConfig); - const effectiveIssuer = issuer || existingProvider.issuer; + const effectiveIssuer = issuer?.toString().trim() || existingProvider.issuer; - let cleanIssuer = effectiveIssuer; try { - const issuerUrl = new URL(effectiveIssuer.toString().trim()); - cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); + new URL(effectiveIssuer); } catch { return new Response( JSON.stringify({ error: `Invalid issuer URL format: ${effectiveIssuer}` }), @@ -244,7 +241,7 @@ export async function PUT(context: APIContext) { let normalized; try { - normalized = await normalizeOidcProviderConfig(cleanIssuer, mergedConfig); + normalized = await normalizeOidcProviderConfig(effectiveIssuer, mergedConfig); } catch (error) { if (error instanceof OidcConfigError) { return new Response( @@ -266,7 +263,7 @@ export async function PUT(context: APIContext) { const [updatedProvider] = await db .update(ssoProviders) .set({ - issuer: cleanIssuer, + issuer: effectiveIssuer, domain: domain || existingProvider.domain, oidcConfig: JSON.stringify(storedOidcConfig), organizationId: organizationId !== undefined ? organizationId : existingProvider.organizationId,