Merge pull request #537 from croatialu/feat/oidc-weak-rsa-fallback

This commit is contained in:
Aarnav Tale
2026-04-26 16:10:05 -04:00
committed by GitHub
7 changed files with 768 additions and 19 deletions
+21
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,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<string, string>?",
@@ -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<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?',
+2
View File
@@ -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,
}),
+368 -12
View File
@@ -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<string, string>;
profilePictureSource?: "oidc" | "gravatar";
}
@@ -114,6 +116,23 @@ interface TokenErrorResponse {
error_description?: string;
}
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 });
@@ -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<JsonWebKey & { kid?: string }> }
>();
let hasWarnedWeakKeyMode = false;
maybeWarnWeakRsaMode(config);
function status(): ReturnType<OidcService["status"]> {
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<Result<OidcClaims, OidcError>> {
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<OidcClaims> {
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<string, unknown>;
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<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 {
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<JsonWebKey & { kid?: string }>,
expectedKid: unknown,
): Array<JsonWebKey & { kid?: string }> {
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<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,
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;
}
+12
View File
@@ -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.
+42 -5
View File
@@ -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.
+71
View File
@@ -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, {
+252 -2
View File
@@ -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<string, unknown>;
let weakPrivateKey: KeyObject;
let weakPublicJwk: Record<string, unknown>;
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<string, unknown>, nonce?: string) {
async function signIdToken(
claims: Record<string, unknown>,
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<string, unknown>, 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<string, unknown>,
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();