release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createRemoteJwks, verifyJwtWithJwks } from "../src/index.ts";
|
||||
|
||||
function base64url(value: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of value) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function rsaKey(kid: string) {
|
||||
const pair = (await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
)) as CryptoKeyPair;
|
||||
const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
|
||||
return { pair, jwk: { ...jwk, kid, alg: "RS256", use: "sig" } };
|
||||
}
|
||||
|
||||
async function sign(privateKey: CryptoKey, kid: string, claims: Record<string, unknown>) {
|
||||
const encoder = new TextEncoder();
|
||||
const header = base64url(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT", kid })));
|
||||
const payload = base64url(encoder.encode(JSON.stringify(claims)));
|
||||
const input = `${header}.${payload}`;
|
||||
const signature = await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
privateKey,
|
||||
encoder.encode(input),
|
||||
);
|
||||
return `${input}.${base64url(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
test("remote JWKS verifies RS256 claims and refreshes immediately for key rotation", async () => {
|
||||
const first = await rsaKey("key-1");
|
||||
const second = await rsaKey("key-2");
|
||||
const documents = [{ keys: [first.jwk] }, { keys: [second.jwk] }];
|
||||
let fetches = 0;
|
||||
const jwks = createRemoteJwks("https://issuer.example/jwks", {
|
||||
now: () => 0,
|
||||
cacheTtlMs: 60_000,
|
||||
fetch: (async () =>
|
||||
Response.json(
|
||||
documents[Math.min(fetches++, documents.length - 1)],
|
||||
)) as unknown as typeof fetch,
|
||||
});
|
||||
const tokenOne = await sign(first.pair.privateKey, "key-1", {
|
||||
sub: "user-1",
|
||||
iss: "https://issuer.example",
|
||||
aud: "client-1",
|
||||
iat: 100,
|
||||
exp: 200,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await verifyJwtWithJwks(tokenOne, jwks, {
|
||||
issuer: "https://issuer.example",
|
||||
audience: "client-1",
|
||||
now: 150,
|
||||
})
|
||||
).sub,
|
||||
).toBe("user-1");
|
||||
expect(fetches).toBe(1);
|
||||
|
||||
const tokenTwo = await sign(second.pair.privateKey, "key-2", {
|
||||
sub: "user-2",
|
||||
iss: "https://issuer.example",
|
||||
aud: "client-1",
|
||||
exp: 200,
|
||||
});
|
||||
expect((await verifyJwtWithJwks(tokenTwo, jwks, { now: 150 })).sub).toBe("user-2");
|
||||
expect(fetches).toBe(2);
|
||||
expect(jwks.stats()).toMatchObject({ fetches: 2, hits: 2, keys: 1 });
|
||||
await expect(verifyJwtWithJwks(tokenOne, jwks, { now: 150 })).rejects.toThrow("Unknown key");
|
||||
});
|
||||
|
||||
test("remote JWKS rejects insecure, oversized, and incompatible key sets", async () => {
|
||||
expect(() => createRemoteJwks("http://issuer.example/jwks")).toThrow("HTTPS");
|
||||
const oversized = createRemoteJwks("https://issuer.example/jwks", {
|
||||
maxBytes: 10,
|
||||
fetch: (async () => Response.json({ keys: [] })) as unknown as typeof fetch,
|
||||
});
|
||||
await expect(oversized.refresh()).rejects.toThrow("too large");
|
||||
|
||||
const incompatible = createRemoteJwks("https://issuer.example/jwks", {
|
||||
fetch: (async () =>
|
||||
Response.json({
|
||||
keys: [{ kid: "ec", kty: "EC", alg: "ES256" }],
|
||||
})) as unknown as typeof fetch,
|
||||
});
|
||||
await incompatible.refresh();
|
||||
await expect(incompatible.resolve("ec", "RS256")).rejects.toThrow("Unknown key");
|
||||
});
|
||||
Reference in New Issue
Block a user