auth: preserve issuer formatting for OIDC

This commit is contained in:
Arunavo Ray
2025-10-26 07:49:42 +05:30
parent a9dd646573
commit e41b4ffc56
5 changed files with 52 additions and 31 deletions

View File

@@ -24,6 +24,7 @@ describe("normalizeOidcProviderConfig", () => {
expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo"); expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo");
expect(result.oidcConfig.scopes).toEqual(["openid", "email"]); expect(result.oidcConfig.scopes).toEqual(["openid", "email"]);
expect(result.oidcConfig.pkce).toBe(false); 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 () => { 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.jwksEndpoint).toBe("https://auth.example.com/jwks");
expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo"); expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo");
expect(result.oidcConfig.scopes).toEqual(["openid", "email", "profile"]); 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 () => { it("throws for invalid issuer URL", async () => {

View File

@@ -131,18 +131,21 @@ export async function normalizeOidcProviderConfig(
throw new OidcConfigError("Issuer is required"); throw new OidcConfigError("Issuer is required");
} }
let normalizedIssuer: string; const trimmedIssuer = issuer.trim();
try { try {
const issuerUrl = new URL(issuer.trim()); // Validate issuer but keep caller-provided formatting so we don't break provider expectations
normalizedIssuer = issuerUrl.toString().replace(/\/$/, ""); new URL(trimmedIssuer);
} catch { } catch {
throw new OidcConfigError(`Invalid issuer URL: ${issuer}`); throw new OidcConfigError(`Invalid issuer URL: ${issuer}`);
} }
const issuerForDiscovery = trimmedIssuer.replace(/\/$/, "");
const discoveryEndpoint = cleanUrl( const discoveryEndpoint = cleanUrl(
rawConfig.discoveryEndpoint, rawConfig.discoveryEndpoint,
"discovery endpoint", "discovery endpoint",
) ?? `${normalizedIssuer}/.well-known/openid-configuration`; ) ?? `${issuerForDiscovery}/.well-known/openid-configuration`;
const authorizationEndpoint = cleanUrl(rawConfig.authorizationEndpoint, "authorization endpoint"); const authorizationEndpoint = cleanUrl(rawConfig.authorizationEndpoint, "authorization endpoint");
const tokenEndpoint = cleanUrl(rawConfig.tokenEndpoint, "token endpoint"); const tokenEndpoint = cleanUrl(rawConfig.tokenEndpoint, "token endpoint");

View File

@@ -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; let validatedIssuer = issuer;
if (issuer && typeof issuer === 'string' && issuer.trim() !== '') { if (issuer && typeof issuer === 'string' && issuer.trim() !== '') {
try { try {
const issuerUrl = new URL(issuer.trim()); const trimmedIssuer = issuer.trim();
validatedIssuer = issuerUrl.toString().replace(/\/$/, ''); // Remove trailing slash new URL(trimmedIssuer);
validatedIssuer = trimmedIssuer;
} catch (e) { } catch (e) {
return new Response( return new Response(
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }), JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),

View File

@@ -17,11 +17,11 @@ export async function POST(context: APIContext) {
}); });
} }
// Validate issuer URL format // Validate issuer URL format while keeping trailing slash if provided
let cleanIssuer: string; const trimmedIssuer = issuer.trim();
let parsedIssuer: URL;
try { try {
const issuerUrl = new URL(issuer.trim()); parsedIssuer = new URL(trimmedIssuer);
cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); // Remove trailing slash
} catch (e) { } catch (e) {
return new Response( return new Response(
JSON.stringify({ 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 { try {
// Fetch OIDC discovery document with timeout // Fetch OIDC discovery document with timeout
@@ -52,9 +53,9 @@ export async function POST(context: APIContext) {
}); });
} catch (fetchError) { } catch (fetchError) {
if (fetchError instanceof Error && fetchError.name === 'AbortError') { 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 { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
@@ -63,7 +64,7 @@ export async function POST(context: APIContext) {
if (response.status === 404) { 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.`); 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) { } 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 { } else {
throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`); throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`);
} }
@@ -73,12 +74,12 @@ export async function POST(context: APIContext) {
try { try {
config = await response.json(); config = await response.json();
} catch (parseError) { } 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 // Extract the essential endpoints
const discoveredConfig = { const discoveredConfig = {
issuer: config.issuer || cleanIssuer, issuer: config.issuer || trimmedIssuer,
authorizationEndpoint: config.authorization_endpoint, authorizationEndpoint: config.authorization_endpoint,
tokenEndpoint: config.token_endpoint, tokenEndpoint: config.token_endpoint,
userInfoEndpoint: config.userinfo_endpoint, userInfoEndpoint: config.userinfo_endpoint,
@@ -88,7 +89,7 @@ export async function POST(context: APIContext) {
responseTypes: config.response_types_supported || ["code"], responseTypes: config.response_types_supported || ["code"],
grantTypes: config.grant_types_supported || ["authorization_code"], grantTypes: config.grant_types_supported || ["authorization_code"],
// Suggested domain from issuer // Suggested domain from issuer
suggestedDomain: new URL(cleanIssuer).hostname.replace("www.", ""), suggestedDomain: parsedIssuer.hostname.replace("www.", ""),
}; };
return new Response(JSON.stringify(discoveredConfig), { return new Response(JSON.stringify(discoveredConfig), {
@@ -111,4 +112,4 @@ export async function POST(context: APIContext) {
} catch (error) { } catch (error) {
return createSecureErrorResponse(error, "SSO discover API"); return createSecureErrorResponse(error, "SSO discover API");
} }
} }

View File

@@ -82,11 +82,10 @@ export async function POST(context: APIContext) {
); );
} }
// Clean issuer URL (remove trailing slash); validate format // Validate issuer URL format but keep trailing slash if provided
let cleanIssuer = issuer; const trimmedIssuer = issuer.toString().trim();
try { try {
const issuerUrl = new URL(issuer.toString().trim()); new URL(trimmedIssuer);
cleanIssuer = issuerUrl.toString().replace(/\/$/, "");
} catch { } catch {
return new Response( return new Response(
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }), JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
@@ -99,7 +98,7 @@ export async function POST(context: APIContext) {
let normalized; let normalized;
try { try {
normalized = await normalizeOidcProviderConfig(cleanIssuer, { normalized = await normalizeOidcProviderConfig(trimmedIssuer, {
clientId, clientId,
clientSecret, clientSecret,
authorizationEndpoint, authorizationEndpoint,
@@ -134,7 +133,7 @@ export async function POST(context: APIContext) {
.insert(ssoProviders) .insert(ssoProviders)
.values({ .values({
id: nanoid(), id: nanoid(),
issuer: cleanIssuer, issuer: trimmedIssuer,
domain, domain,
oidcConfig: JSON.stringify(storedOidcConfig), oidcConfig: JSON.stringify(storedOidcConfig),
userId: user.id, userId: user.id,
@@ -213,12 +212,10 @@ export async function PUT(context: APIContext) {
// Parse existing config // Parse existing config
const existingConfig = JSON.parse(existingProvider.oidcConfig); const existingConfig = JSON.parse(existingProvider.oidcConfig);
const effectiveIssuer = issuer || existingProvider.issuer; const effectiveIssuer = issuer?.toString().trim() || existingProvider.issuer;
let cleanIssuer = effectiveIssuer;
try { try {
const issuerUrl = new URL(effectiveIssuer.toString().trim()); new URL(effectiveIssuer);
cleanIssuer = issuerUrl.toString().replace(/\/$/, "");
} catch { } catch {
return new Response( return new Response(
JSON.stringify({ error: `Invalid issuer URL format: ${effectiveIssuer}` }), JSON.stringify({ error: `Invalid issuer URL format: ${effectiveIssuer}` }),
@@ -244,7 +241,7 @@ export async function PUT(context: APIContext) {
let normalized; let normalized;
try { try {
normalized = await normalizeOidcProviderConfig(cleanIssuer, mergedConfig); normalized = await normalizeOidcProviderConfig(effectiveIssuer, mergedConfig);
} catch (error) { } catch (error) {
if (error instanceof OidcConfigError) { if (error instanceof OidcConfigError) {
return new Response( return new Response(
@@ -266,7 +263,7 @@ export async function PUT(context: APIContext) {
const [updatedProvider] = await db const [updatedProvider] = await db
.update(ssoProviders) .update(ssoProviders)
.set({ .set({
issuer: cleanIssuer, issuer: effectiveIssuer,
domain: domain || existingProvider.domain, domain: domain || existingProvider.domain,
oidcConfig: JSON.stringify(storedOidcConfig), oidcConfig: JSON.stringify(storedOidcConfig),
organizationId: organizationId !== undefined ? organizationId : existingProvider.organizationId, organizationId: organizationId !== undefined ? organizationId : existingProvider.organizationId,