From 4bf9eb93a5a2f54d026cc580c5135ec8e582505c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 23 Apr 2026 21:41:34 +0200 Subject: [PATCH] fix(server): suppress HS256 JWKS entry when SIGNING_KEY is unbound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keys array hardcoded the HS256 entry, so deployments that retired HS256 by removing the SIGNING_KEY binding (per the RS256 migration step 3 runbook) still advertised HS256 in JWKS. Cosmetic — the entry never contained `k`, so no verifier could validate HS256 tokens against it — but it violated the migration exit criteria and confused operators reading the runbook literally. Now: HS256 entry only appears when SIGNING_KEY is actually bound. New regression test covers the post-sunset case (HS256 unbound, RSA present → only the RSA key) and the empty-config case. Co-Authored-By: Claude Opus 4.7 --- .../server/src/__tests__/jwks-rsa.test.ts | 49 +++++++++++++++++++ packages/server/src/routes/jwks.ts | 23 +++++---- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/server/src/__tests__/jwks-rsa.test.ts b/packages/server/src/__tests__/jwks-rsa.test.ts index b3fcb8d..ffc673a 100644 --- a/packages/server/src/__tests__/jwks-rsa.test.ts +++ b/packages/server/src/__tests__/jwks-rsa.test.ts @@ -98,6 +98,55 @@ test("JWKS returns both the legacy HS256 metadata and the RSA public JWK during assert.equal("d" in (rsaKey ?? {}), false, "the RSA JWK must not contain private key material"); }); +test("JWKS suppresses the HS256 metadata once SIGNING_KEY is unbound (post-sunset)", async () => { + const storage = createTestStorage(); + const app = createApp({ storage }); + try { + // Mirror a deployment that has retired HS256 by removing the SIGNING_KEY + // worker binding while keeping SIGNING_KEY_ID for legacy kid accounting. + const response = await app.request( + createTestRequest("GET", "/.well-known/jwks.json"), + undefined, + { + SIGNING_KEY_ID: "legacy-production", + INTERNAL_SECRET: "internal-test-secret", + RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: createPublicKeyPem(), + } as AppEnv["Bindings"], + ); + const body = await assertJsonResponse<{ keys: JsonWebKey[] }>(response, 200); + + assert.equal(body.keys.length, 1, "expected only the RSA key once HS256 is unbound"); + assert.equal(body.keys[0]?.kty, "RSA"); + assert.equal(body.keys[0]?.alg, "RS256"); + assert.equal( + body.keys.some((key) => key.alg === "HS256"), + false, + "JWKS must not advertise HS256 when no HS256 secret is configured", + ); + } finally { + await storage.close(); + } +}); + +test("JWKS returns an empty key set when neither HS256 nor RS256 material is configured", async () => { + const storage = createTestStorage(); + const app = createApp({ storage }); + try { + const response = await app.request( + createTestRequest("GET", "/.well-known/jwks.json"), + undefined, + { + SIGNING_KEY_ID: "legacy-production", + INTERNAL_SECRET: "internal-test-secret", + } as AppEnv["Bindings"], + ); + const body = await assertJsonResponse<{ keys: JsonWebKey[] }>(response, 200); + assert.deepEqual(body.keys, []); + } finally { + await storage.close(); + } +}); + test("JWKS RSA `kid` is the RFC 7638 JWK thumbprint (deterministic, no YYYY-MM component)", async () => { const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); diff --git a/packages/server/src/routes/jwks.ts b/packages/server/src/routes/jwks.ts index 131b93e..4d0e177 100644 --- a/packages/server/src/routes/jwks.ts +++ b/packages/server/src/routes/jwks.ts @@ -17,19 +17,24 @@ type PublishedJwk = JsonWebKey & { const jwks = new Hono(); jwks.get("/jwks.json", async (c) => { - const keyId = c.env.SIGNING_KEY_ID; - const keys: PublishedJwk[] = [ - { + const keys: PublishedJwk[] = []; + + // HS256 is published as algorithm metadata (kid only — never the secret; + // publishing the symmetric key would allow token forgery). It only appears + // when the deployment actually has an HS256 signing secret bound; otherwise + // the entry is misleading because no token can be signed or verified with + // an algorithm the server isn't configured for. This gate is what lets a + // deployment retire HS256 by simply unbinding SIGNING_KEY. + const hs256Secret = c.env.SIGNING_KEY?.trim(); + if (hs256Secret) { + keys.push({ kty: "oct", use: "sig", alg: "HS256", - kid: keyId, - }, - ]; + kid: c.env.SIGNING_KEY_ID, + }); + } - // Only expose key metadata (kid, alg), never the symmetric key material. - // HS256 is a symmetric algorithm — publishing the secret would allow token forgery. - // Clients needing to verify tokens should use a server-side introspection endpoint. const rsaPublicPem = c.env.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC?.trim(); if (rsaPublicPem) { const rsaKeyWithPlaceholderKid = await rsaPublicJwkFromPem(rsaPublicPem, "");