diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index c726b2e..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,7 +115,8 @@ const oidcConfig = type({ .optional(), disable_api_key_login: "boolean = false", scope: 'string = "openid email profile"', - subject_claims: "string[]?", + subject_claims: type("string[]").pipe(normalizeStringArray).optional(), + allow_weak_rsa_keys: "boolean = false", profile_picture_source: '"oidc" | "gravatar" = "oidc"', extra_params: "Record?", @@ -121,7 +139,8 @@ const partialOidcConfig = type({ redirect_uri: "string.url?", disable_api_key_login: "boolean?", scope: "string?", - subject_claims: "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 d64b5a5..bfbe78f 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -116,6 +116,7 @@ const appLoadContext = { 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 7906e52..162372a 100644 --- a/app/server/oidc/provider.ts +++ b/app/server/oidc/provider.ts @@ -22,6 +22,7 @@ export interface OidcConfig { usePkce?: boolean; scope?: string; subjectClaims?: string[]; + allowWeakRsaKeys?: boolean; extraParams?: Record; profilePictureSource?: "oidc" | "gravatar"; } @@ -119,6 +120,19 @@ 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 }); @@ -127,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) { @@ -547,10 +568,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { return ok(payload); } catch (cause) { - if (isWeakRsaJoseError(cause)) { - return verifyIdTokenWithWeakRsa(idToken, expectedNonce); - } - if (cause instanceof joseErrors.JWTClaimValidationFailed) { return err({ code: "invalid_id_token", @@ -565,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", @@ -581,81 +620,64 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { } async function verifyIdTokenWithWeakRsa( - idToken: string, + weakRsaContext: WeakRsaContext, expectedNonce: string, ): Promise> { - if (!endpoints?.jwksUri) { - return err({ - code: "invalid_id_token", - message: "JWKS URI is not available for weak RSA verification fallback", - }); - } + 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, + ); - let decoded: ReturnType; - try { - decoded = decodeJwtParts(idToken); - } catch (cause) { - return err({ - code: "invalid_id_token", - message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }); - } - - const alg = typeof decoded.header.alg === "string" ? decoded.header.alg : undefined; - if (!alg || !["RS256", "RS384", "RS512"].includes(alg)) { - return err({ - code: "invalid_id_token", - message: `ID token verification failed: unsupported weak RSA fallback algorithm ${alg ?? "unknown"}`, - }); - } - - let jwk: JsonWebKey; - try { - jwk = await fetchSigningJwk(endpoints.jwksUri, decoded.header.kid); - } catch (cause) { - return err({ - code: "invalid_id_token", - message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }); - } - - try { - const key = createPublicKey({ key: jwk, format: "jwk" }); - const isValid = verify( - getNodeVerifyAlgorithm(alg), - Buffer.from(decoded.signingInput), - key, - decoded.signature, - ); - - if (!isValid) { + if (!isValid) { + continue; + } + } catch (cause) { 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.", + message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, }); } - } 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); } - const claimError = validateOidcClaims(decoded.payload, config.issuer, config.clientId, 60); - if (claimError) { - return err(claimError); - } - - if (decoded.payload.nonce !== expectedNonce) { - return err({ - code: "nonce_mismatch", - message: `Nonce mismatch: expected ${expectedNonce}, got ${decoded.payload.nonce}`, - hint: "Please try signing in again. This can happen with stale browser sessions.", - }); - } - - return ok(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( @@ -719,7 +741,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { function buildIdentity(claims: OidcClaims): OidcIdentity { const subject = resolveSubject(claims); if (!subject) { - throw new Error("OIDC subject was resolved before identity construction"); + throw new Error("OIDC subject was not resolved before identity construction"); } const name = @@ -759,11 +781,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { function reload(newConfig: OidcConfig): void { config = Object.freeze({ ...newConfig }); + maybeWarnWeakRsaMode(config); invalidate(); } function getSubjectClaimOrder(): string[] { - return ["sub", ...(config.subjectClaims ?? []).filter((claim) => claim !== "sub")]; + return [ + "sub", + ...normalizeSubjectClaims(config.subjectClaims).filter((claim) => claim !== "sub"), + ]; } function resolveSubject(claims: OidcClaims): string | undefined { @@ -778,6 +804,71 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { } 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 { @@ -798,19 +889,7 @@ function computeS256Challenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } -function isWeakRsaJoseError(cause: unknown): cause is Error { - return ( - cause instanceof Error && - cause.message.includes("requires key modulusLength to be 2048 bits or larger") - ); -} - -function decodeJwtParts(token: string): { - header: Record; - payload: OidcClaims; - signature: Buffer; - signingInput: string; -} { +function decodeJwtParts(token: string): DecodedJwtParts { const parts = token.split("."); if (parts.length !== 3) { throw new Error("JWT must have exactly 3 parts"); @@ -834,39 +913,22 @@ function decodeJwtParts(token: string): { }; } -async function fetchSigningJwk( - jwksUri: string, +function selectCandidateSigningKeys( + keys: Array, expectedKid: unknown, -): Promise { - 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"); - } - +): Array { const kid = typeof expectedKid === "string" ? expectedKid : undefined; - const jwk = - (kid ? keys.find((key) => key.kid === kid) : undefined) ?? - (keys.length === 1 ? keys[0] : undefined); + const rsaKeys = keys.filter((key) => key.kty === "RSA"); + if (kid) { + const matchingKey = rsaKeys.find((key) => key.kid === kid); + if (matchingKey) { + return [matchingKey]; + } - if (!jwk) { - throw new Error(`No matching JWK found for kid ${kid ?? "(missing)"}`); + return []; } - if (jwk.kty !== "RSA") { - throw new Error(`Expected RSA signing key but received ${jwk.kty ?? "unknown"}`); - } - - return jwk; + return rsaKeys; } function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" { @@ -882,6 +944,47 @@ function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA } } +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, diff --git a/config.example.yaml b/config.example.yaml index 87721a0..714e622 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -224,6 +224,11 @@ integration: # - "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 4a2179c..188df9e 100644 --- a/docs/features/sso.md +++ b/docs/features/sso.md @@ -71,6 +71,7 @@ oidc: # userinfo_endpoint: "" # scope: "openid email profile" # subject_claims: ["open_id", "email"] + # allow_weak_rsa_keys: false # extra_params: # foo: "bar" ``` @@ -95,6 +96,24 @@ oidc: 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 @@ -124,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 @@ -234,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 0b7454f..913c2c7 100644 --- a/tests/unit/config/config-file.test.ts +++ b/tests/unit/config/config-file.test.ts @@ -130,6 +130,59 @@ describe("Configuration YAML file loading", () => { 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 e36f1be..ef889e2 100644 --- a/tests/unit/oidc/provider.test.ts +++ b/tests/unit/oidc/provider.test.ts @@ -42,8 +42,12 @@ function encodeBase64Url(value: string | Buffer) { return Buffer.from(value).toString("base64url"); } -function signWeakRs256IdToken(claims: Record, nonce?: string) { - const header = { alg: "RS256", kid: "test-key", typ: "JWT" }; +function signWeakRs256IdToken( + claims: Record, + nonce?: string, + options?: { kid?: string }, +) { + const header = { alg: "RS256", kid: options?.kid ?? "test-key", typ: "JWT" }; const payload = { nonce, ...claims, @@ -544,7 +548,7 @@ describe("handleCallback", () => { expect(result.error.code).toBe("missing_sub"); }); - test("accepts RS256 id tokens signed with 1024-bit RSA keys", async () => { + test("rejects RS256 id tokens signed with 1024-bit RSA keys by default", async () => { const originalPublicJwk = publicJwk; publicJwk = weakPublicJwk; @@ -573,6 +577,47 @@ describe("handleCallback", () => { 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; @@ -584,6 +629,59 @@ describe("handleCallback", () => { } }); + 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"] }),