mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 11:36:44 +03:00
auth: preserve issuer formatting for OIDC
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}` }),
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user