mirror of
https://github.com/tale/headplane.git
synced 2026-08-19 09:26:18 +00:00
fix: harden OIDC weak RSA fallback
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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"] }),
|
||||
|
||||
Reference in New Issue
Block a user