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
+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"] }),