mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-09 21:16:48 +03:00
sso: normalize provider config via discovery
This commit is contained in:
202
src/lib/sso/oidc-config.ts
Normal file
202
src/lib/sso/oidc-config.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const DEFAULT_SCOPES = ["openid", "email", "profile"] as const;
|
||||
const DISCOVERY_TIMEOUT_MS = 10000;
|
||||
|
||||
const discoverySchema = z.object({
|
||||
issuer: z.string().url().optional(),
|
||||
authorization_endpoint: z.string().url().optional(),
|
||||
token_endpoint: z.string().url().optional(),
|
||||
userinfo_endpoint: z.string().url().optional(),
|
||||
jwks_uri: z.string().url().optional(),
|
||||
scopes_supported: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export class OidcConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OidcConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export type RawOidcConfig = {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
jwksEndpoint?: string;
|
||||
userInfoEndpoint?: string;
|
||||
discoveryEndpoint?: string;
|
||||
scopes?: string[];
|
||||
pkce?: boolean;
|
||||
mapping?: ProviderMapping;
|
||||
};
|
||||
|
||||
export type ProviderMapping = {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerified?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
|
||||
export type NormalizedOidcConfig = {
|
||||
oidcConfig: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksEndpoint?: string;
|
||||
userInfoEndpoint?: string;
|
||||
discoveryEndpoint: string;
|
||||
scopes: string[];
|
||||
pkce: boolean;
|
||||
};
|
||||
mapping: ProviderMapping;
|
||||
};
|
||||
|
||||
type FetchFn = typeof fetch;
|
||||
|
||||
function cleanUrl(value: string | undefined, field: string): string | undefined {
|
||||
if (!value || typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
return new URL(trimmed).toString();
|
||||
} catch {
|
||||
throw new OidcConfigError(`Invalid ${field} URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeScopes(scopes: string[] | undefined, fallback: readonly string[]): string[] {
|
||||
const candidates = Array.isArray(scopes) ? scopes : [];
|
||||
const sanitized = candidates
|
||||
.map(scope => scope?.trim())
|
||||
.filter((scope): scope is string => Boolean(scope));
|
||||
|
||||
if (sanitized.length === 0) {
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
return Array.from(new Set(sanitized));
|
||||
}
|
||||
|
||||
async function fetchDiscoveryDocument(url: string, fetchFn: FetchFn): Promise<z.infer<typeof discoverySchema>> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new OidcConfigError(`OIDC discovery request failed (${response.status} ${response.statusText})`);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new OidcConfigError("OIDC discovery response is not valid JSON");
|
||||
}
|
||||
|
||||
const parsed = discoverySchema.parse(payload);
|
||||
if (!parsed.authorization_endpoint || !parsed.token_endpoint) {
|
||||
throw new OidcConfigError("OIDC discovery document is missing required endpoints");
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof OidcConfigError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OidcConfigError(`OIDC discovery timed out after ${DISCOVERY_TIMEOUT_MS / 1000}s`);
|
||||
}
|
||||
throw new OidcConfigError(`Failed to fetch OIDC discovery document: ${error instanceof Error ? error.message : "unknown error"}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeOidcProviderConfig(
|
||||
issuer: string,
|
||||
rawConfig: RawOidcConfig,
|
||||
fetchFn: FetchFn = fetch,
|
||||
): Promise<NormalizedOidcConfig> {
|
||||
if (!issuer || typeof issuer !== "string") {
|
||||
throw new OidcConfigError("Issuer is required");
|
||||
}
|
||||
|
||||
let normalizedIssuer: string;
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
normalizedIssuer = issuerUrl.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
throw new OidcConfigError(`Invalid issuer URL: ${issuer}`);
|
||||
}
|
||||
|
||||
const discoveryEndpoint = cleanUrl(
|
||||
rawConfig.discoveryEndpoint,
|
||||
"discovery endpoint",
|
||||
) ?? `${normalizedIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
const authorizationEndpoint = cleanUrl(rawConfig.authorizationEndpoint, "authorization endpoint");
|
||||
const tokenEndpoint = cleanUrl(rawConfig.tokenEndpoint, "token endpoint");
|
||||
const jwksEndpoint = cleanUrl(rawConfig.jwksEndpoint, "JWKS endpoint");
|
||||
const userInfoEndpoint = cleanUrl(rawConfig.userInfoEndpoint, "userinfo endpoint");
|
||||
const providedScopes = Array.isArray(rawConfig.scopes) ? rawConfig.scopes : undefined;
|
||||
let scopes = sanitizeScopes(providedScopes, DEFAULT_SCOPES);
|
||||
|
||||
const shouldFetchDiscovery =
|
||||
!authorizationEndpoint ||
|
||||
!tokenEndpoint ||
|
||||
!jwksEndpoint ||
|
||||
!userInfoEndpoint ||
|
||||
!providedScopes ||
|
||||
providedScopes.length === 0;
|
||||
|
||||
let resolvedAuthorization = authorizationEndpoint;
|
||||
let resolvedToken = tokenEndpoint;
|
||||
let resolvedJwks = jwksEndpoint;
|
||||
let resolvedUserInfo = userInfoEndpoint;
|
||||
|
||||
if (shouldFetchDiscovery) {
|
||||
const discovery = await fetchDiscoveryDocument(discoveryEndpoint, fetchFn);
|
||||
resolvedAuthorization = resolvedAuthorization ?? discovery.authorization_endpoint;
|
||||
resolvedToken = resolvedToken ?? discovery.token_endpoint;
|
||||
resolvedJwks = resolvedJwks ?? discovery.jwks_uri;
|
||||
resolvedUserInfo = resolvedUserInfo ?? discovery.userinfo_endpoint;
|
||||
if (!providedScopes || providedScopes.length === 0) {
|
||||
scopes = sanitizeScopes(discovery.scopes_supported, DEFAULT_SCOPES);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedAuthorization || !resolvedToken) {
|
||||
throw new OidcConfigError("OIDC configuration must include authorization and token endpoints");
|
||||
}
|
||||
|
||||
return {
|
||||
oidcConfig: {
|
||||
clientId: rawConfig.clientId,
|
||||
clientSecret: rawConfig.clientSecret,
|
||||
authorizationEndpoint: resolvedAuthorization,
|
||||
tokenEndpoint: resolvedToken,
|
||||
jwksEndpoint: resolvedJwks,
|
||||
userInfoEndpoint: resolvedUserInfo,
|
||||
discoveryEndpoint,
|
||||
scopes,
|
||||
pkce: rawConfig.pkce !== false,
|
||||
},
|
||||
mapping: rawConfig.mapping ?? {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
image: "picture",
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user