266 lines
8.6 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|