Files
2026-05-27 23:29:53 +01:00

266 lines
8.6 KiB
TypeScript

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<string, unknown>;
readonly signingKey: RsaPrivateKey;
}
function genRsaKeyPair(kid: string): KeyPair {
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const publicJwk = {
...(publicKey.export({ format: "jwk" }) as Record<string, unknown>),
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<ServerHandle> {
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<void>((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<void> {
await new Promise<void>((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);
}
});
});