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",
] 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({
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<string, string>?",
@@ -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<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?',
+1
View File
@@ -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,
}),
+216 -113
View File
@@ -22,6 +22,7 @@ export interface OidcConfig {
usePkce?: boolean;
scope?: string;
subjectClaims?: string[];
allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar";
}
@@ -119,6 +120,19 @@ interface JwksResponse {
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 {
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<JsonWebKey & { kid?: string }> }
>();
let hasWarnedWeakKeyMode = false;
maybeWarnWeakRsaMode(config);
function status(): ReturnType<OidcService["status"]> {
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<Result<OidcClaims, OidcError>> {
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<typeof decodeJwtParts>;
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<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 {
@@ -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<string, unknown>;
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<JsonWebKey & { kid?: string }>,
expectedKid: unknown,
): Promise<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");
}
): Array<JsonWebKey & { kid?: string }> {
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<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(
payload: OidcClaims,
expectedIssuer: string,
+5
View File
@@ -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.
+25 -5
View File
@@ -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.
+53
View File
@@ -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, {
+101 -3
View File
@@ -42,8 +42,12 @@ function encodeBase64Url(value: string | Buffer) {
return Buffer.from(value).toString("base64url");
}
function signWeakRs256IdToken(claims: Record<string, unknown>, nonce?: string) {
const header = { alg: "RS256", kid: "test-key", typ: "JWT" };
function signWeakRs256IdToken(
claims: Record<string, unknown>,
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"] }),