diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 2343bed..6c1b369 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -14,6 +14,23 @@ export const pathSupportedKeys = [ "oidc.headscale_api_key", ] as const; +function normalizeStringArray(values: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const value of values) { + const trimmed = value.trim(); + if (trimmed.length === 0 || seen.has(trimmed)) { + continue; + } + + seen.add(trimmed); + normalized.push(trimmed); + } + + return normalized; +} + const serverConfig = type({ host: 'string.ip = "0.0.0.0"', port: "number.integer = 3000", @@ -98,6 +115,8 @@ const oidcConfig = type({ .optional(), disable_api_key_login: "boolean = false", scope: 'string = "openid email profile"', + subject_claims: type("string[]").pipe(normalizeStringArray).optional(), + allow_weak_rsa_keys: "boolean = false", profile_picture_source: '"oidc" | "gravatar" = "oidc"', extra_params: "Record?", @@ -120,6 +139,8 @@ const partialOidcConfig = type({ redirect_uri: "string.url?", disable_api_key_login: "boolean?", scope: "string?", + subject_claims: type("string[]").pipe(normalizeStringArray).optional(), + allow_weak_rsa_keys: "boolean?", extra_params: "Record?", profile_picture_source: '"oidc" | "gravatar"?', diff --git a/app/server/index.ts b/app/server/index.ts index 4d803b1..bfbe78f 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -115,6 +115,8 @@ const appLoadContext = { : config.oidc.token_endpoint_auth_method, usePkce: config.oidc.use_pkce, scope: config.oidc.scope, + subjectClaims: config.oidc.subject_claims, + allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, extraParams: config.oidc.extra_params, profilePictureSource: config.oidc.profile_picture_source, }), diff --git a/app/server/oidc/provider.ts b/app/server/oidc/provider.ts index 797f864..162372a 100644 --- a/app/server/oidc/provider.ts +++ b/app/server/oidc/provider.ts @@ -1,4 +1,4 @@ -import { createHash, randomBytes } from "node:crypto"; +import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose"; import type { JWSHeaderParameters, JWTPayload, FlattenedJWSInput } from "jose"; @@ -21,6 +21,8 @@ export interface OidcConfig { usePkce?: boolean; scope?: string; + subjectClaims?: string[]; + allowWeakRsaKeys?: boolean; extraParams?: Record; profilePictureSource?: "oidc" | "gravatar"; } @@ -114,6 +116,23 @@ interface TokenErrorResponse { error_description?: string; } +interface JwksResponse { + keys?: Array; +} + +interface DecodedJwtParts { + header: Record; + payload: OidcClaims; + signature: Buffer; + signingInput: string; +} + +interface WeakRsaContext { + alg: "RS256" | "RS384" | "RS512"; + decoded: DecodedJwtParts; + candidateKeys: Array; +} + export function createOidcService(initialConfig: OidcConfig): OidcService { let config = Object.freeze({ ...initialConfig }); @@ -122,6 +141,13 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { let jwks: JwksResolver | undefined; let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined = initialConfig.tokenEndpointAuthMethod; + const weakJwksCache = new Map< + string, + { expiresAt: number; keys: Array } + >(); + let hasWarnedWeakKeyMode = false; + + maybeWarnWeakRsaMode(config); function status(): ReturnType { if (lastError) { @@ -355,6 +381,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { const claims = verifyResult.value; const enriched = await enrichWithUserInfo(resolved.value, tokens.access_token, claims); + + if (!resolveSubject(enriched)) { + return err({ + code: "missing_sub", + message: "ID token and userinfo response are missing all configured subject claims", + hint: `Your identity provider did not return a stable user identifier. Configure oidc.subject_claims or ensure one of these claims is present: ${getSubjectClaimOrder().join(", ")}.`, + }); + } + return ok(buildIdentity(enriched)); } @@ -523,14 +558,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { clockTolerance: 60, }); - if (!payload.sub) { - return err({ - code: "missing_sub", - message: "ID token is missing the 'sub' claim", - hint: "Your identity provider did not return a user identifier. Check that your OIDC client is configured to include the 'sub' claim.", - }); - } - if (payload.nonce !== expectedNonce) { return err({ code: "nonce_mismatch", @@ -555,6 +582,28 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { }); } + let weakRsaContext: WeakRsaContext | undefined; + try { + weakRsaContext = await getWeakRsaContext(idToken); + } catch (weakRsaCause) { + return err({ + code: "invalid_id_token", + message: `ID token verification failed: ${weakRsaCause instanceof Error ? weakRsaCause.message : String(weakRsaCause)}`, + }); + } + + if (weakRsaContext) { + if (!config.allowWeakRsaKeys) { + return err({ + code: "invalid_id_token", + message: "ID token was signed with a weak RSA key that Headplane rejects by default", + hint: "If your provider cannot rotate to a 2048-bit-or-larger RSA signing key, set oidc.allow_weak_rsa_keys to true as a temporary compatibility fallback.", + }); + } + + return verifyIdTokenWithWeakRsa(weakRsaContext, expectedNonce); + } + if (cause instanceof joseErrors.JWSSignatureVerificationFailed) { return err({ code: "invalid_id_token", @@ -570,13 +619,76 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { } } + async function verifyIdTokenWithWeakRsa( + weakRsaContext: WeakRsaContext, + expectedNonce: string, + ): Promise> { + for (const jwk of weakRsaContext.candidateKeys) { + try { + const key = createPublicKey({ key: jwk, format: "jwk" }); + const isValid = verify( + getNodeVerifyAlgorithm(weakRsaContext.alg), + Buffer.from(weakRsaContext.decoded.signingInput), + key, + weakRsaContext.decoded.signature, + ); + + if (!isValid) { + continue; + } + } catch (cause) { + return err({ + code: "invalid_id_token", + message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } + + if (!hasWarnedWeakKeyMode) { + hasWarnedWeakKeyMode = true; + log.warn( + "auth", + "OIDC issuer %s is using a weak RSA signing key. Accepting it only because oidc.allow_weak_rsa_keys=true.", + config.issuer, + ); + } + + const claimError = validateOidcClaims( + weakRsaContext.decoded.payload, + config.issuer, + config.clientId, + 60, + ); + if (claimError) { + return err(claimError); + } + + if (weakRsaContext.decoded.payload.nonce !== expectedNonce) { + return err({ + code: "nonce_mismatch", + message: `Nonce mismatch: expected ${expectedNonce}, got ${weakRsaContext.decoded.payload.nonce}`, + hint: "Please try signing in again. This can happen with stale browser sessions.", + }); + } + + return ok(weakRsaContext.decoded.payload); + } + + return err({ + code: "invalid_id_token", + message: "ID token signature verification failed", + hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.", + }); + } + async function enrichWithUserInfo( ep: ResolvedEndpoints, accessToken: string, claims: OidcClaims, ): Promise { - const needsEnrichment = !claims.name && !claims.email && !claims.picture; - if (!needsEnrichment || !ep.userinfoEndpoint) { + const needsEnrichment = + !claims.name && !claims.email && !claims.picture && !!resolveSubject(claims); + const needsSubjectEnrichment = !resolveSubject(claims); + if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) { return claims; } @@ -595,8 +707,17 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { } const userInfo = (await response.json()) as Record; + const subjectClaimValues = Object.fromEntries( + getSubjectClaimOrder() + .filter((claim) => claim !== "sub") + .map((claim) => [ + claim, + readClaimAsString(claims, claim) ?? readClaimAsString(userInfo, claim), + ]), + ); return { ...claims, + ...subjectClaimValues, name: claims.name ?? (userInfo.name as string | undefined), given_name: claims.given_name ?? (userInfo.given_name as string | undefined), family_name: claims.family_name ?? (userInfo.family_name as string | undefined), @@ -604,6 +725,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { claims.preferred_username ?? (userInfo.preferred_username as string | undefined), email: claims.email ?? (userInfo.email as string | undefined), picture: claims.picture ?? (userInfo.picture as string | undefined), + sub: claims.sub ?? readClaimAsString(userInfo, "sub"), }; } catch (cause) { log.debug( @@ -617,6 +739,11 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { } function buildIdentity(claims: OidcClaims): OidcIdentity { + const subject = resolveSubject(claims); + if (!subject) { + throw new Error("OIDC subject was not resolved before identity construction"); + } + const name = claims.name ?? (claims.given_name && claims.family_name @@ -637,7 +764,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { return { issuer: config.issuer, - subject: claims.sub!, + subject, name, username, email: claims.email, @@ -654,10 +781,104 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { function reload(newConfig: OidcConfig): void { config = Object.freeze({ ...newConfig }); + maybeWarnWeakRsaMode(config); invalidate(); } + function getSubjectClaimOrder(): string[] { + return [ + "sub", + ...normalizeSubjectClaims(config.subjectClaims).filter((claim) => claim !== "sub"), + ]; + } + + function resolveSubject(claims: OidcClaims): string | undefined { + for (const claim of getSubjectClaimOrder()) { + const value = readClaimAsString(claims, claim); + if (value) { + return value; + } + } + + return undefined; + } + return { status, discover, startFlow, handleCallback, invalidate, reload }; + + function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void { + if (!currentConfig.allowWeakRsaKeys) { + return; + } + + log.warn( + "auth", + "OIDC weak RSA compatibility mode is enabled for issuer %s. This lowers token verification security and should only be used as a temporary workaround.", + currentConfig.issuer, + ); + } + + async function getWeakRsaContext(idToken: string): Promise { + if (!endpoints?.jwksUri) { + return undefined; + } + + const decoded = decodeJwtParts(idToken); + const alg = decoded.header.alg; + if (alg !== "RS256" && alg !== "RS384" && alg !== "RS512") { + return undefined; + } + + const keys = await fetchSigningJwks(endpoints.jwksUri); + const candidateKeys = selectCandidateSigningKeys(keys, decoded.header.kid).filter((jwk) => + isWeakRsaKey(jwk), + ); + + if (candidateKeys.length === 0) { + return undefined; + } + + return { alg, decoded, candidateKeys }; + } + + async function fetchSigningJwks(jwksUri: string): Promise> { + const cached = weakJwksCache.get(jwksUri); + const now = Date.now(); + if (cached && cached.expiresAt > now) { + return cached.keys; + } + + const response = await fetch(jwksUri, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + throw new Error(`JWKS endpoint returned ${response.status}: ${jwksUri}`); + } + + const json = (await response.json()) as JwksResponse; + const keys = Array.isArray(json.keys) ? json.keys : []; + if (keys.length === 0) { + throw new Error("JWKS response did not contain any keys"); + } + + weakJwksCache.set(jwksUri, { + expiresAt: now + 60_000, + keys, + }); + + return keys; + } +} + +function readClaimAsString(claims: Record, claimName: string): string | undefined { + const value = claims[claimName]; + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; } function generateRandom(bytes = 32): string { @@ -667,3 +888,138 @@ function generateRandom(bytes = 32): string { function computeS256Challenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } + +function decodeJwtParts(token: string): DecodedJwtParts { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("JWT must have exactly 3 parts"); + } + + const [encodedHeader, encodedPayload, encodedSignature] = parts; + const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")) as Record< + string, + unknown + >; + const payload = JSON.parse( + Buffer.from(encodedPayload, "base64url").toString("utf8"), + ) as OidcClaims; + const signature = Buffer.from(encodedSignature, "base64url"); + + return { + header, + payload, + signature, + signingInput: `${encodedHeader}.${encodedPayload}`, + }; +} + +function selectCandidateSigningKeys( + keys: Array, + expectedKid: unknown, +): Array { + const kid = typeof expectedKid === "string" ? expectedKid : undefined; + const rsaKeys = keys.filter((key) => key.kty === "RSA"); + if (kid) { + const matchingKey = rsaKeys.find((key) => key.kid === kid); + if (matchingKey) { + return [matchingKey]; + } + + return []; + } + + return rsaKeys; +} + +function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" { + switch (alg) { + case "RS256": + return "RSA-SHA256"; + case "RS384": + return "RSA-SHA384"; + case "RS512": + return "RSA-SHA512"; + default: + throw new Error(`Unsupported RSA verification algorithm: ${alg}`); + } +} + +function isWeakRsaKey(jwk: JsonWebKey): boolean { + if (jwk.kty !== "RSA" || typeof jwk.n !== "string") { + return false; + } + + return getRsaModulusBitLength(jwk.n) < 2048; +} + +function getRsaModulusBitLength(base64UrlModulus: string): number { + const modulus = Buffer.from(base64UrlModulus, "base64url"); + if (modulus.length === 0) { + return 0; + } + + let leadingZeroBits = 0; + let currentByte = modulus[0]; + while ((currentByte & 0x80) === 0 && leadingZeroBits < 8) { + leadingZeroBits++; + currentByte <<= 1; + } + + return modulus.length * 8 - leadingZeroBits; +} + +function normalizeSubjectClaims(subjectClaims?: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const claim of subjectClaims ?? []) { + const trimmed = claim.trim(); + if (trimmed.length === 0 || seen.has(trimmed)) { + continue; + } + + seen.add(trimmed); + normalized.push(trimmed); + } + + return normalized; +} + +function validateOidcClaims( + payload: OidcClaims, + expectedIssuer: string, + expectedAudience: string, + clockToleranceSeconds: number, +): OidcError | undefined { + if (payload.iss !== expectedIssuer) { + return { + code: "invalid_id_token", + message: 'JWT claim validation failed: iss — unexpected "iss" claim value', + }; + } + + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!audiences.includes(expectedAudience)) { + return { + code: "invalid_id_token", + message: 'JWT claim validation failed: aud — unexpected "aud" claim value', + }; + } + + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === "number" && now - clockToleranceSeconds >= payload.exp) { + return { + code: "invalid_id_token", + message: "ID token is expired", + }; + } + + if (typeof payload.nbf === "number" && now + clockToleranceSeconds < payload.nbf) { + return { + code: "invalid_id_token", + message: "JWT claim validation failed: nbf — token is not active yet", + }; + } + + return undefined; +} diff --git a/config.example.yaml b/config.example.yaml index 5a2201a..714e622 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -217,6 +217,18 @@ integration: # The scopes to request when authenticating users. The default is below. # scope: "openid email profile" +# Optional fallback claims to use when your provider does not return a standard +# OIDC `sub` claim. Headplane always checks `sub` first, then each claim here +# in order. For Feishu/Lark, `["open_id", "email"]` is a reasonable fallback. +# subject_claims: +# - "open_id" +# - "email" + +# Allow ID token verification with legacy RSA keys smaller than 2048 bits. +# This is disabled by default because it lowers token verification security and +# should only be used as a temporary compatibility workaround. +# allow_weak_rsa_keys: false + # Extra query parameters can be passed to the authorization endpoint # by setting them here. This is useful for providers that require any kind # of custom hinting. diff --git a/docs/features/sso.md b/docs/features/sso.md index 5363a65..188df9e 100644 --- a/docs/features/sso.md +++ b/docs/features/sso.md @@ -70,6 +70,8 @@ oidc: # token_endpoint: "" # userinfo_endpoint: "" # scope: "openid email profile" + # subject_claims: ["open_id", "email"] + # allow_weak_rsa_keys: false # extra_params: # foo: "bar" ``` @@ -78,6 +80,40 @@ Headplane automatically discovers OIDC endpoints from your issuer's `/.well-known/openid-configuration`. If your IdP does not support discovery, you'll need to set the endpoints manually. +### Non-standard Subject Claims + +Some providers do not return the standard OIDC `sub` claim in the ID token. +Headplane always uses `sub` first, but you can configure fallback claims with +`oidc.subject_claims`. + +For Feishu/Lark, the recommended configuration is: + +```yaml +oidc: + subject_claims: ["open_id", "email"] +``` + +This keeps identity matching stable by preferring `open_id` and only falling +back to `email` if needed. + +### Legacy Weak RSA Signing Keys + +Some legacy providers still sign ID tokens with RSA keys smaller than 2048 +bits. Headplane rejects those keys by default. + +If your provider cannot rotate to a stronger signing key yet, you can +explicitly enable the compatibility fallback: + +```yaml +oidc: + allow_weak_rsa_keys: true +``` + +::: warning +This weakens ID token verification security and should only be used as a +temporary workaround while your provider rotates to a 2048-bit-or-larger key. +::: + ### PKCE ::: warning @@ -107,8 +143,9 @@ Headplane uses a two-step matching strategy: 1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane - extracts the last path segment and compares it to the `sub` claim from the - OIDC token. If they match, the user is linked. + extracts the last path segment and compares it to the resolved OIDC subject. + The resolved subject uses `sub` first, then falls back to any configured + `oidc.subject_claims`. If they match, the user is linked. 2. **Email match (fallback)**: If the subject doesn't match, Headplane falls back to comparing the user's email address from the OIDC `userinfo` endpoint @@ -217,9 +254,9 @@ flow can be skipped. Once completed, users are taken to the main dashboard. - **Invalid API Key**: The `headscale.api_key` may have expired. Generate a new one with `headscale apikeys create --expiration 999d`. -- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the - ID token. This is required by the OIDC spec but some providers need explicit - configuration. +- **Missing the `sub` claim**: If your IdP omits `sub`, configure + `oidc.subject_claims` with a stable fallback such as `open_id`. Only use + `email` as a fallback when it is stable for your users. - **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP matches `{server.base_url}/admin/oidc/callback` exactly. diff --git a/tests/unit/config/config-file.test.ts b/tests/unit/config/config-file.test.ts index c474a06..913c2c7 100644 --- a/tests/unit/config/config-file.test.ts +++ b/tests/unit/config/config-file.test.ts @@ -112,6 +112,77 @@ describe("Configuration YAML file loading", () => { expect(config.oidc?.enabled).toBe(false); }); + test("oidc.subject_claims can be configured from YAML", async () => { + const filePath = "/config/oidc-subject-claims.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { + issuer: "https://accounts.google.com", + client_id: "my-client-id", + client_secret: "my-client-secret", + headscale_api_key: "my-api-key", + subject_claims: ["open_id", "email"], + }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]); + }); + + test("oidc.subject_claims are trimmed, deduplicated, and drop empty values", async () => { + const filePath = "/config/oidc-subject-claims-normalized.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { + issuer: "https://accounts.google.com", + client_id: "my-client-id", + client_secret: "my-client-secret", + headscale_api_key: "my-api-key", + subject_claims: [" open_id ", "", "email", "open_id", " "], + }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]); + }); + + test("oidc.allow_weak_rsa_keys defaults to false", async () => { + const filePath = "/config/oidc-weak-rsa-default.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { + issuer: "https://accounts.google.com", + client_id: "my-client-id", + client_secret: "my-client-secret", + headscale_api_key: "my-api-key", + }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc?.allow_weak_rsa_keys).toBe(false); + }); + + test("oidc.allow_weak_rsa_keys can be enabled from YAML", async () => { + const filePath = "/config/oidc-weak-rsa-enabled.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { + issuer: "https://accounts.google.com", + client_id: "my-client-id", + client_secret: "my-client-secret", + headscale_api_key: "my-api-key", + allow_weak_rsa_keys: true, + }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc?.allow_weak_rsa_keys).toBe(true); + }); + test("partial oidc config with enabled field can be parsed", async () => { const filePath = "/config/oidc-partial.yaml"; writeYaml(filePath, { diff --git a/tests/unit/oidc/provider.test.ts b/tests/unit/oidc/provider.test.ts index 74fef95..ef889e2 100644 --- a/tests/unit/oidc/provider.test.ts +++ b/tests/unit/oidc/provider.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync, sign, type KeyObject } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { SignJWT, exportJWK, generateKeyPair } from "jose"; @@ -13,6 +14,8 @@ let server: Server; let baseUrl: string; let privateKey: CryptoKey; let publicJwk: Record; +let weakPrivateKey: KeyObject; +let weakPublicJwk: Record; const CLIENT_ID = "test-client"; const CLIENT_SECRET = "test-secret"; @@ -20,7 +23,11 @@ const CLIENT_SECRET = "test-secret"; let tokenHandler: (req: IncomingMessage, res: ServerResponse) => void; let userinfoHandler: ((req: IncomingMessage, res: ServerResponse) => void) | undefined; -async function signIdToken(claims: Record, nonce?: string) { +async function signIdToken( + claims: Record, + nonce?: string, + signingKey: CryptoKey | KeyObject = privateKey, +) { const jwt = new SignJWT({ nonce, ...claims }) .setProtectedHeader({ alg: "RS256", kid: "test-key" }) .setIssuer(baseUrl) @@ -28,7 +35,31 @@ async function signIdToken(claims: Record, nonce?: string) { .setIssuedAt() .setExpirationTime("5m"); - return jwt.sign(privateKey); + return jwt.sign(signingKey); +} + +function encodeBase64Url(value: string | Buffer) { + return Buffer.from(value).toString("base64url"); +} + +function signWeakRs256IdToken( + claims: Record, + nonce?: string, + options?: { kid?: string }, +) { + const header = { alg: "RS256", kid: options?.kid ?? "test-key", typ: "JWT" }; + const payload = { + nonce, + ...claims, + iss: baseUrl, + aud: CLIENT_ID, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 300, + }; + + const signingInput = `${encodeBase64Url(JSON.stringify(header))}.${encodeBase64Url(JSON.stringify(payload))}`; + const signature = sign("RSA-SHA256", Buffer.from(signingInput), weakPrivateKey); + return `${signingInput}.${encodeBase64Url(signature)}`; } // You would think this is a lot better in 2026, but no @@ -49,6 +80,14 @@ beforeAll(async () => { const exported = await exportJWK(keyPair.publicKey); publicJwk = { ...exported, kid: "test-key", use: "sig", alg: "RS256" }; + const weakKeyPair = generateKeyPairSync("rsa", { + modulusLength: 1024, + publicExponent: 0x10001, + }); + weakPrivateKey = weakKeyPair.privateKey; + const weakExported = await exportJWK(weakKeyPair.publicKey); + weakPublicJwk = { ...weakExported, kid: "test-key", use: "sig", alg: "RS256" }; + server = createServer(async (req, res) => { const url = new URL(req.url!, "http://localhost"); @@ -509,6 +548,217 @@ describe("handleCallback", () => { expect(result.error.code).toBe("missing_sub"); }); + test("rejects RS256 id tokens signed with 1024-bit RSA keys by default", async () => { + const originalPublicJwk = publicJwk; + publicJwk = weakPublicJwk; + + try { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = signWeakRs256IdToken({ sub: "weak-key-user" }, flowState.nonce); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("invalid_id_token"); + expect(result.error.hint).toContain("allow_weak_rsa_keys"); + } finally { + publicJwk = originalPublicJwk; + } + }); + + test("accepts RS256 id tokens signed with 1024-bit RSA keys when explicitly enabled", async () => { + const originalPublicJwk = publicJwk; + publicJwk = weakPublicJwk; + + try { + const svc = createOidcService(testConfig({ usePkce: false, allowWeakRsaKeys: true })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = signWeakRs256IdToken({ sub: "weak-key-user" }, flowState.nonce); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.subject).toBe("weak-key-user"); + } finally { + publicJwk = originalPublicJwk; + } + }); + + test("rejects weak RSA fallback when token kid does not match any JWKS key", async () => { + const originalPublicJwk = publicJwk; + publicJwk = weakPublicJwk; + + try { + const svc = createOidcService(testConfig({ usePkce: false, allowWeakRsaKeys: true })); + await svc.discover(); + svc.reload({ + ...testConfig({ + usePkce: false, + allowWeakRsaKeys: true, + authorizationEndpoint: `${baseUrl}/authorize`, + tokenEndpoint: `${baseUrl}/token`, + jwksUri: `${baseUrl}/jwks`, + }), + }); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = signWeakRs256IdToken({ sub: "weak-key-user" }, flowState.nonce, { + kid: "missing-key", + }); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("invalid_id_token"); + expect(result.error.message).toContain("no applicable key found"); + } finally { + publicJwk = originalPublicJwk; + } + }); + + test("uses configured open_id fallback when sub claim is missing", async () => { + const svc = createOidcService( + testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }), + ); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken( + { open_id: "feishu-open-id", name: "Feishu User" }, + flowState.nonce, + ); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.subject).toBe("feishu-open-id"); + }); + + test("uses configured fallback claim from userinfo when id token is missing subject", async () => { + const svc = createOidcService( + testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }), + ); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ name: "Feishu User" }, flowState.nonce); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ open_id: "userinfo-open-id", email: "user@example.com" })); + }; + + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.subject).toBe("userinfo-open-id"); + }); + test("invalid_client triggers auth method retry", async () => { const svc = createOidcService(testConfig({ usePkce: false })); const flowResult = await svc.startFlow();