// @vitest-environment node import { describe, it, expect, beforeAll } from "vitest"; import { SignJWT, jwtVerify, decodeProtectedHeader, decodeJwt } from "jose"; import { mintAccessToken, verifyAccessToken } from "@/lib/auth/jwt"; const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes"; beforeAll(() => { process.env.JWT_SECRET = SECRET; }); const sub = "00000000-0000-0000-0000-000000000abc"; const session_id = "11111111-1111-1111-1111-111111111111"; describe("mintAccessToken / verifyAccessToken", () => { it("roundtrips and preserves iat_original", async () => { const iat_original = Math.floor(Date.now() / 1000) - 60; const token = await mintAccessToken({ sub, session_id, iat_original }); const claims = await verifyAccessToken(token); expect(claims).not.toBeNull(); expect(claims!.sub).toBe(sub); expect(claims!.session_id).toBe(session_id); expect(claims!.iat_original).toBe(iat_original); }); it("sets the protected header (alg=HS256, typ=JWT, kid=v1)", async () => { const token = await mintAccessToken({ sub, session_id, iat_original: Math.floor(Date.now() / 1000), }); const header = decodeProtectedHeader(token); expect(header.alg).toBe("HS256"); expect(header.typ).toBe("JWT"); expect(header.kid).toBe("v1"); }); it("sets iss=moviedice and aud=authenticated", async () => { const token = await mintAccessToken({ sub, session_id, iat_original: Math.floor(Date.now() / 1000), }); const payload = decodeJwt(token); expect(payload.iss).toBe("moviedice"); expect(payload.aud).toBe("authenticated"); expect(payload.role).toBe("authenticated"); expect(payload.is_anonymous).toBe(true); }); it("rejects an expired token", async () => { const secret = new TextEncoder().encode(SECRET); const past = Math.floor(Date.now() / 1000) - 7200; // 2h ago const token = await new SignJWT({ sub, session_id, iat_original: past, role: "authenticated", }) .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" }) .setIssuedAt(past) .setNotBefore(past - 10) .setExpirationTime(past + 3600) // expired 1h ago .setIssuer("moviedice") .setAudience("authenticated") .sign(secret); expect(await verifyAccessToken(token)).toBeNull(); }); it("rejects a token whose nbf is in the future (beyond skew)", async () => { const secret = new TextEncoder().encode(SECRET); const future = Math.floor(Date.now() / 1000) + 600; // +10min const token = await new SignJWT({ sub, session_id, iat_original: future, role: "authenticated", }) .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" }) .setIssuedAt(future) .setNotBefore(future) .setExpirationTime(future + 3600) .setIssuer("moviedice") .setAudience("authenticated") .sign(secret); expect(await verifyAccessToken(token)).toBeNull(); }); it("rejects a token signed with the wrong secret", async () => { const wrong = new TextEncoder().encode("wrong-secret-also-32-characters-long-yes-yes!"); const now = Math.floor(Date.now() / 1000); const token = await new SignJWT({ sub, session_id, iat_original: now, role: "authenticated", }) .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" }) .setIssuedAt() .setNotBefore("-10s") .setExpirationTime("1h") .setIssuer("moviedice") .setAudience("authenticated") .sign(wrong); expect(await verifyAccessToken(token)).toBeNull(); }); it("rejects a token with wrong issuer or audience", async () => { const secret = new TextEncoder().encode(SECRET); const now = Math.floor(Date.now() / 1000); const wrongIss = await new SignJWT({ sub, session_id, iat_original: now }) .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" }) .setIssuedAt() .setNotBefore("-10s") .setExpirationTime("1h") .setIssuer("not-moviedice") .setAudience("authenticated") .sign(secret); expect(await verifyAccessToken(wrongIss)).toBeNull(); const wrongAud = await new SignJWT({ sub, session_id, iat_original: now }) .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" }) .setIssuedAt() .setNotBefore("-10s") .setExpirationTime("1h") .setIssuer("moviedice") .setAudience("not-authenticated") .sign(secret); expect(await verifyAccessToken(wrongAud)).toBeNull(); }); it("verifies a token signed seconds in the future thanks to nbf skew", async () => { // mintAccessToken sets nbf = -10s; jose's jwtVerify uses clockTolerance:0 // by default but our nbf is already 10s in the past, so it should verify // immediately. const token = await mintAccessToken({ sub, session_id, iat_original: Math.floor(Date.now() / 1000), }); const claims = await verifyAccessToken(token); expect(claims).not.toBeNull(); }); });