fix: harden OIDC weak RSA fallback

This commit is contained in:
croatialu
2026-04-22 00:07:47 +08:00
parent d110dd2bcb
commit 9e5e5a613a
7 changed files with 422 additions and 123 deletions
+21 -2
View File
@@ -14,6 +14,23 @@ export const pathSupportedKeys = [
"oidc.headscale_api_key", "oidc.headscale_api_key",
] as const; ] as const;
function normalizeStringArray(values: string[]): string[] {
const seen = new Set<string>();
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({ const serverConfig = type({
host: 'string.ip = "0.0.0.0"', host: 'string.ip = "0.0.0.0"',
port: "number.integer = 3000", port: "number.integer = 3000",
@@ -98,7 +115,8 @@ const oidcConfig = type({
.optional(), .optional(),
disable_api_key_login: "boolean = false", disable_api_key_login: "boolean = false",
scope: 'string = "openid email profile"', 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"', profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: "Record<string, string>?", extra_params: "Record<string, string>?",
@@ -121,7 +139,8 @@ const partialOidcConfig = type({
redirect_uri: "string.url?", redirect_uri: "string.url?",
disable_api_key_login: "boolean?", disable_api_key_login: "boolean?",
scope: "string?", scope: "string?",
subject_claims: "string[]?", subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
allow_weak_rsa_keys: "boolean?",
extra_params: "Record<string, string>?", extra_params: "Record<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?', profile_picture_source: '"oidc" | "gravatar"?',
+1
View File
@@ -116,6 +116,7 @@ const appLoadContext = {
usePkce: config.oidc.use_pkce, usePkce: config.oidc.use_pkce,
scope: config.oidc.scope, scope: config.oidc.scope,
subjectClaims: config.oidc.subject_claims, subjectClaims: config.oidc.subject_claims,
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params, extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source, profilePictureSource: config.oidc.profile_picture_source,
}), }),
+216 -113
View File
@@ -22,6 +22,7 @@ export interface OidcConfig {
usePkce?: boolean; usePkce?: boolean;
scope?: string; scope?: string;
subjectClaims?: string[]; subjectClaims?: string[];
allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>; extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar"; profilePictureSource?: "oidc" | "gravatar";
} }
@@ -119,6 +120,19 @@ interface JwksResponse {
keys?: Array<JsonWebKey & { kid?: string }>; keys?: Array<JsonWebKey & { kid?: string }>;
} }
interface DecodedJwtParts {
header: Record<string, unknown>;
payload: OidcClaims;
signature: Buffer;
signingInput: string;
}
interface WeakRsaContext {
alg: "RS256" | "RS384" | "RS512";
decoded: DecodedJwtParts;
candidateKeys: Array<JsonWebKey & { kid?: string }>;
}
export function createOidcService(initialConfig: OidcConfig): OidcService { export function createOidcService(initialConfig: OidcConfig): OidcService {
let config = Object.freeze({ ...initialConfig }); let config = Object.freeze({ ...initialConfig });
@@ -127,6 +141,13 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
let jwks: JwksResolver | undefined; let jwks: JwksResolver | undefined;
let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined = let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined =
initialConfig.tokenEndpointAuthMethod; initialConfig.tokenEndpointAuthMethod;
const weakJwksCache = new Map<
string,
{ expiresAt: number; keys: Array<JsonWebKey & { kid?: string }> }
>();
let hasWarnedWeakKeyMode = false;
maybeWarnWeakRsaMode(config);
function status(): ReturnType<OidcService["status"]> { function status(): ReturnType<OidcService["status"]> {
if (lastError) { if (lastError) {
@@ -547,10 +568,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return ok(payload); return ok(payload);
} catch (cause) { } catch (cause) {
if (isWeakRsaJoseError(cause)) {
return verifyIdTokenWithWeakRsa(idToken, expectedNonce);
}
if (cause instanceof joseErrors.JWTClaimValidationFailed) { if (cause instanceof joseErrors.JWTClaimValidationFailed) {
return err({ return err({
code: "invalid_id_token", 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) { if (cause instanceof joseErrors.JWSSignatureVerificationFailed) {
return err({ return err({
code: "invalid_id_token", code: "invalid_id_token",
@@ -581,81 +620,64 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
} }
async function verifyIdTokenWithWeakRsa( async function verifyIdTokenWithWeakRsa(
idToken: string, weakRsaContext: WeakRsaContext,
expectedNonce: string, expectedNonce: string,
): Promise<Result<OidcClaims, OidcError>> { ): Promise<Result<OidcClaims, OidcError>> {
if (!endpoints?.jwksUri) { for (const jwk of weakRsaContext.candidateKeys) {
return err({ try {
code: "invalid_id_token", const key = createPublicKey({ key: jwk, format: "jwk" });
message: "JWKS URI is not available for weak RSA verification fallback", const isValid = verify(
}); getNodeVerifyAlgorithm(weakRsaContext.alg),
} Buffer.from(weakRsaContext.decoded.signingInput),
key,
weakRsaContext.decoded.signature,
);
let decoded: ReturnType<typeof decodeJwtParts>; if (!isValid) {
try { continue;
decoded = decodeJwtParts(idToken); }
} catch (cause) { } 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) {
return err({ return err({
code: "invalid_id_token", code: "invalid_id_token",
message: "ID token signature verification failed", message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
}); });
} }
} catch (cause) {
return err({ if (!hasWarnedWeakKeyMode) {
code: "invalid_id_token", hasWarnedWeakKeyMode = true;
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, 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); return err({
if (claimError) { code: "invalid_id_token",
return err(claimError); message: "ID token signature verification failed",
} hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
});
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);
} }
async function enrichWithUserInfo( async function enrichWithUserInfo(
@@ -719,7 +741,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
function buildIdentity(claims: OidcClaims): OidcIdentity { function buildIdentity(claims: OidcClaims): OidcIdentity {
const subject = resolveSubject(claims); const subject = resolveSubject(claims);
if (!subject) { 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 = const name =
@@ -759,11 +781,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
function reload(newConfig: OidcConfig): void { function reload(newConfig: OidcConfig): void {
config = Object.freeze({ ...newConfig }); config = Object.freeze({ ...newConfig });
maybeWarnWeakRsaMode(config);
invalidate(); invalidate();
} }
function getSubjectClaimOrder(): string[] { 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 { function resolveSubject(claims: OidcClaims): string | undefined {
@@ -778,6 +804,71 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
} }
return { status, discover, startFlow, handleCallback, invalidate, reload }; 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<WeakRsaContext | undefined> {
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<Array<JsonWebKey & { kid?: string }>> {
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<string, unknown>, claimName: string): string | undefined { function readClaimAsString(claims: Record<string, unknown>, claimName: string): string | undefined {
@@ -798,19 +889,7 @@ function computeS256Challenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url"); return createHash("sha256").update(verifier).digest("base64url");
} }
function isWeakRsaJoseError(cause: unknown): cause is Error { function decodeJwtParts(token: string): DecodedJwtParts {
return (
cause instanceof Error &&
cause.message.includes("requires key modulusLength to be 2048 bits or larger")
);
}
function decodeJwtParts(token: string): {
header: Record<string, unknown>;
payload: OidcClaims;
signature: Buffer;
signingInput: string;
} {
const parts = token.split("."); const parts = token.split(".");
if (parts.length !== 3) { if (parts.length !== 3) {
throw new Error("JWT must have exactly 3 parts"); throw new Error("JWT must have exactly 3 parts");
@@ -834,39 +913,22 @@ function decodeJwtParts(token: string): {
}; };
} }
async function fetchSigningJwk( function selectCandidateSigningKeys(
jwksUri: string, keys: Array<JsonWebKey & { kid?: string }>,
expectedKid: unknown, expectedKid: unknown,
): Promise<JsonWebKey & { kid?: string }> { ): Array<JsonWebKey & { kid?: string }> {
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");
}
const kid = typeof expectedKid === "string" ? expectedKid : undefined; const kid = typeof expectedKid === "string" ? expectedKid : undefined;
const jwk = const rsaKeys = keys.filter((key) => key.kty === "RSA");
(kid ? keys.find((key) => key.kid === kid) : undefined) ?? if (kid) {
(keys.length === 1 ? keys[0] : undefined); const matchingKey = rsaKeys.find((key) => key.kid === kid);
if (matchingKey) {
return [matchingKey];
}
if (!jwk) { return [];
throw new Error(`No matching JWK found for kid ${kid ?? "(missing)"}`);
} }
if (jwk.kty !== "RSA") { return rsaKeys;
throw new Error(`Expected RSA signing key but received ${jwk.kty ?? "unknown"}`);
}
return jwk;
} }
function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" { 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<string>();
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( function validateOidcClaims(
payload: OidcClaims, payload: OidcClaims,
expectedIssuer: string, expectedIssuer: string,
+5
View File
@@ -224,6 +224,11 @@ integration:
# - "open_id" # - "open_id"
# - "email" # - "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 # Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind # by setting them here. This is useful for providers that require any kind
# of custom hinting. # of custom hinting.
+25 -5
View File
@@ -71,6 +71,7 @@ oidc:
# userinfo_endpoint: "" # userinfo_endpoint: ""
# scope: "openid email profile" # scope: "openid email profile"
# subject_claims: ["open_id", "email"] # subject_claims: ["open_id", "email"]
# allow_weak_rsa_keys: false
# extra_params: # extra_params:
# foo: "bar" # foo: "bar"
``` ```
@@ -95,6 +96,24 @@ oidc:
This keeps identity matching stable by preferring `open_id` and only falling This keeps identity matching stable by preferring `open_id` and only falling
back to `email` if needed. 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 ### PKCE
::: warning ::: warning
@@ -124,8 +143,9 @@ Headplane uses a two-step matching strategy:
1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for 1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane 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 extracts the last path segment and compares it to the resolved OIDC subject.
OIDC token. If they match, the user is linked. 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 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 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 - **Invalid API Key**: The `headscale.api_key` may have expired. Generate
a new one with `headscale apikeys create --expiration 999d`. a new one with `headscale apikeys create --expiration 999d`.
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the - **Missing the `sub` claim**: If your IdP omits `sub`, configure
ID token. This is required by the OIDC spec but some providers need explicit `oidc.subject_claims` with a stable fallback such as `open_id`. Only use
configuration. `email` as a fallback when it is stable for your users.
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP - **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
matches `{server.base_url}/admin/oidc/callback` exactly. matches `{server.base_url}/admin/oidc/callback` exactly.
+53
View File
@@ -130,6 +130,59 @@ describe("Configuration YAML file loading", () => {
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]); 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 () => { test("partial oidc config with enabled field can be parsed", async () => {
const filePath = "/config/oidc-partial.yaml"; const filePath = "/config/oidc-partial.yaml";
writeYaml(filePath, { writeYaml(filePath, {
+101 -3
View File
@@ -42,8 +42,12 @@ function encodeBase64Url(value: string | Buffer) {
return Buffer.from(value).toString("base64url"); return Buffer.from(value).toString("base64url");
} }
function signWeakRs256IdToken(claims: Record<string, unknown>, nonce?: string) { function signWeakRs256IdToken(
const header = { alg: "RS256", kid: "test-key", typ: "JWT" }; claims: Record<string, unknown>,
nonce?: string,
options?: { kid?: string },
) {
const header = { alg: "RS256", kid: options?.kid ?? "test-key", typ: "JWT" };
const payload = { const payload = {
nonce, nonce,
...claims, ...claims,
@@ -544,7 +548,7 @@ describe("handleCallback", () => {
expect(result.error.code).toBe("missing_sub"); 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; const originalPublicJwk = publicJwk;
publicJwk = weakPublicJwk; publicJwk = weakPublicJwk;
@@ -573,6 +577,47 @@ describe("handleCallback", () => {
const params = new URLSearchParams({ code: "test-code", state: flowState.state }); const params = new URLSearchParams({ code: "test-code", state: flowState.state });
const result = await svc.handleCallback(params, flowState); 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); expect(result.ok).toBe(true);
if (!result.ok) { if (!result.ok) {
return; 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 () => { test("uses configured open_id fallback when sub claim is missing", async () => {
const svc = createOidcService( const svc = createOidcService(
testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }), testConfig({ usePkce: false, subjectClaims: ["open_id", "email"] }),