Initial Commit
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user