import { generateKeyPairSync } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse, } from "node:http"; import { parseRsaPrivateKeyJwk, type RsaPrivateKey, } from "@suaveplan/crypto-rsa"; import { compactSign } from "@suaveplan/jws"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { AUTH_JWT_CODES, AuthJwtError, } from "../../src/error-codes/error-codes.js"; import { createJwtSessionResolver } from "../../src/resolver/resolver.js"; interface KeyPair { readonly publicJwk: Record; readonly signingKey: RsaPrivateKey; } function genRsaKeyPair(kid: string): KeyPair { const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048, }); const publicJwk = { ...(publicKey.export({ format: "jwk" }) as Record), kid, alg: "RS256", use: "sig", }; const privateJwk = privateKey.export({ format: "jwk" }) as Record< string, unknown >; const signingKey = parseRsaPrivateKeyJwk(privateJwk as never); return { publicJwk, signingKey }; } interface ServerHandle { readonly url: string; readonly server: Server; fetchCount(): number; setBody(body: string | (() => string)): void; setStatus(status: number): void; } async function startJwksServer(initial: () => string): Promise { let count = 0; let bodyFn: () => string = initial; let status = 200; const server = createServer( (_req: IncomingMessage, res: ServerResponse) => { count++; res.statusCode = status; res.setHeader("content-type", "application/json"); res.end(bodyFn()); } ); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve) ); const addr = server.address(); if (addr === null || typeof addr === "string") { throw new Error("server.listen returned no address"); } return { url: `http://127.0.0.1:${addr.port}/jwks.json`, server, fetchCount: () => count, setBody(body) { bodyFn = typeof body === "function" ? body : () => body; }, setStatus(s) { status = s; }, }; } async function closeServer(server: Server): Promise { await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }); } const ISSUER = "https://idp.example"; const AUDIENCE = "https://api.example"; describe("integration: RS256 + real JWKS server", () => { it("end-to-end signs with @suaveplan/jws and verifies via JWKS endpoint", { timeout: 30000, }, async () => { const pair = genRsaKeyPair("kid-1"); const handle = await startJwksServer(() => JSON.stringify({ keys: [pair.publicJwk] }) ); try { const token = await compactSign( JSON.stringify({ iss: ISSUER, aud: AUDIENCE, sub: "u_integration", scope: "a b", }), pair.signingKey, "RS256" ); const resolver = createJwtSessionResolver({ jwksUrl: handle.url, issuer: ISSUER, audience: AUDIENCE, algorithms: ["RS256"], }); const principal = await resolver.resolve( new Request("https://api.example/me", { headers: { authorization: `Bearer ${token}` }, }) ); expect(principal?.id).toBe("u_integration"); expect(principal?.scopes).toEqual(["a", "b"]); await resolver.dispose(); expect(handle.fetchCount()).toBe(1); } finally { await closeServer(handle.server); } }); it("refetches JWKS when kid rotates", { timeout: 30000 }, async () => { const oldPair = genRsaKeyPair("kid-old"); const newPair = genRsaKeyPair("kid-new"); const handle = await startJwksServer(() => JSON.stringify({ keys: [oldPair.publicJwk] }) ); try { const resolver = createJwtSessionResolver({ jwksUrl: handle.url, issuer: ISSUER, audience: AUDIENCE, algorithms: ["RS256"], }); const oldToken = await compactSign( JSON.stringify({ iss: ISSUER, aud: AUDIENCE, sub: "old-user", }), oldPair.signingKey, "RS256", { protectedHeader: { kid: "kid-old" } } ); const p1 = await resolver.resolve( new Request("https://api.example/me", { headers: { authorization: `Bearer ${oldToken}` }, }) ); expect(p1?.id).toBe("old-user"); // Rotate the JWKS document. handle.setBody(JSON.stringify({ keys: [newPair.publicJwk] })); const newToken = await compactSign( JSON.stringify({ iss: ISSUER, aud: AUDIENCE, sub: "new-user", }), newPair.signingKey, "RS256", { protectedHeader: { kid: "kid-new" } } ); const p2 = await resolver.resolve( new Request("https://api.example/me", { headers: { authorization: `Bearer ${newToken}` }, }) ); // Two fetches: once on first call, once on kid miss for newToken. expect(handle.fetchCount()).toBe(2); expect(p2?.id).toBe("new-user"); await resolver.dispose(); } finally { await closeServer(handle.server); } }); it("returns null when JWKS has no matching kid and document is fresh", { timeout: 30000, }, async () => { const pair = genRsaKeyPair("kid-a"); const otherPair = genRsaKeyPair("kid-b"); const handle = await startJwksServer(() => JSON.stringify({ keys: [pair.publicJwk] }) ); try { const token = await compactSign( JSON.stringify({ iss: ISSUER, aud: AUDIENCE, sub: "u", }), otherPair.signingKey, "RS256", { protectedHeader: { kid: "kid-not-in-jwks" } } ); const resolver = createJwtSessionResolver({ jwksUrl: handle.url, issuer: ISSUER, audience: AUDIENCE, algorithms: ["RS256"], }); const p = await resolver.resolve( new Request("https://api.example/me", { headers: { authorization: `Bearer ${token}` }, }) ); expect(p).toBeNull(); await resolver.dispose(); } finally { await closeServer(handle.server); } }); it("propagates JWKS_FETCH_FAILED when endpoint returns 500", { timeout: 30000, }, async () => { const handle = await startJwksServer(() => "boom"); handle.setStatus(500); try { const resolver = createJwtSessionResolver({ jwksUrl: handle.url, issuer: ISSUER, audience: AUDIENCE, algorithms: ["RS256"], }); // Construct a token with a kid so the JWKS fetch is attempted. const pair = genRsaKeyPair("kid-x"); const token = await compactSign( JSON.stringify({ iss: ISSUER, aud: AUDIENCE, sub: "u", }), pair.signingKey, "RS256" ); try { await resolver.resolve( new Request("https://api.example/me", { headers: { authorization: `Bearer ${token}` }, }) ); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(AuthJwtError); expect((err as AuthJwtError).code).toBe( AUTH_JWT_CODES.JWKS_FETCH_FAILED ); } await resolver.dispose(); } finally { await closeServer(handle.server); } }); });