Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/server/src/__tests__/jwks-rsa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
23 changes: 14 additions & 9 deletions packages/server/src/routes/jwks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,24 @@ type PublishedJwk = JsonWebKey & {
const jwks = new Hono<AppEnv>();

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, "");
Expand Down
Loading