import { describe, expect, it } from "vitest"; import { generateJti, generateRefreshToken, hashToken, issueRefreshToken, parseLifetime, rotateRefreshToken, signAccessToken, verifyAccessToken, type RefreshTokenRecord, type RefreshTokenStore } from "./tokens.js"; class MemStore implements RefreshTokenStore { records = new Map(); async getByHash(hash: string) { return this.records.get(hash) ?? null; } async getByFamily(familyId: string) { return [...this.records.values()].filter((r) => r.familyId === familyId); } async put(record: RefreshTokenRecord) { this.records.set(record.tokenHash, record); } async revokeFamily(familyId: string) { for (const r of await this.getByFamily(familyId)) this.records.set(r.tokenHash, { ...r, revoked: true }); } } const SECRET = "test-secret-0123456789"; describe("parseLifetime", () => { it("parses units", () => { expect(parseLifetime("15m")).toBe(900_000); expect(parseLifetime("30d")).toBe(30 * 86_400_000); expect(parseLifetime("90s")).toBe(90_000); expect(parseLifetime("1h")).toBe(3_600_000); expect(parseLifetime("5ms")).toBe(5); }); it("rejects garbage", () => { expect(() => parseLifetime("abc")).toThrow(); expect(() => parseLifetime("5")).toThrow(); }); }); describe("HS256 access tokens", () => { it("roundtrips", () => { const t = signAccessToken({ sub: "usr_1", workspaceId: "wsp_1", roles: ["owner"] }, SECRET); const d = verifyAccessToken(t, SECRET); expect(d.sub).toBe("usr_1"); expect(d.workspaceId).toBe("wsp_1"); expect(d.roles).toEqual(["owner"]); expect(d.exp - d.iat).toBe(15 * 60); expect(d.jti).toHaveLength(32); }); it("rejects tampered payload", () => { const t = signAccessToken({ sub: "usr_1" }, SECRET); const [h, , s] = t.split("."); const evil = Buffer.from(JSON.stringify({ sub: "usr_2", iat: 1, exp: 9999999999, jti: "x" })) .toString("base64url"); expect(() => verifyAccessToken(`${h}.${evil}.${s}`, SECRET)).toThrow(/signature/i); }); it("rejects wrong secret", () => { const t = signAccessToken({ sub: "usr_1" }, SECRET); expect(() => verifyAccessToken(t, "other")).toThrow(); }); it("rejects expired", () => { const now = Date.now(); const t = signAccessToken({ sub: "usr_1" }, SECRET, "1s"); expect(() => verifyAccessToken(t, SECRET, now + 2000)).toThrow(/expired/i); }); it("rejects alg none / non-HS256", () => { const h = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); const c = Buffer.from(JSON.stringify({ sub: "x", iat: 1, exp: 9999999999 })).toString("base64url"); expect(() => verifyAccessToken(`${h}.${c}.`, SECRET)).toThrow(/algorithm/i); }); it("rejects malformed", () => { expect(() => verifyAccessToken("not-a-jwt", SECRET)).toThrow(); }); it("jti is stable when provided", () => { const t = signAccessToken({ sub: "u", jti: "fixed" }, SECRET); expect(verifyAccessToken(t, SECRET).jti).toBe("fixed"); }); }); describe("refresh rotation", () => { it("issues and rotates in the same family", async () => { const store = new MemStore(); const first = await issueRefreshToken(store, { userId: "usr_1" }); const rotated = await rotateRefreshToken(store, first.token, { nowMs: first.expiresAt - 1000 }); expect(rotated.familyId).toBe(first.familyId); expect(rotated.token).not.toBe(first.token); // old token is now marked replacedBy const old = await store.getByHash(hashToken(first.token)); expect(old?.replacedBy).toBe(hashToken(rotated.token)); }); it("detects reuse and revokes the family", async () => { const store = new MemStore(); const first = await issueRefreshToken(store, { userId: "usr_1" }); const rotated = await rotateRefreshToken(store, first.token); // Present the OLD token again → reuse → whole family revoked. await expect(rotateRefreshToken(store, first.token)).rejects.toThrow(/reuse|unauthorized/i); const all = await store.getByFamily(rotated.familyId); expect(all.every((r) => r.revoked)).toBe(true); // even the fresh token is dead now await expect(rotateRefreshToken(store, rotated.token)).rejects.toThrow(); }); it("rejects unknown tokens", async () => { const store = new MemStore(); await expect(rotateRefreshToken(store, generateRefreshToken())).rejects.toThrow(/unknown/i); }); it("rejects expired", async () => { const store = new MemStore(); const now = 1_000_000; const first = await issueRefreshToken(store, { userId: "u", nowMs: now, ttlMs: 1000 }); await expect(rotateRefreshToken(store, first.token, { nowMs: now + 5000 })).rejects.toThrow(/expired/i); }); }); describe("hashing helpers", () => { it("jti is 32 hex chars", () => expect(generateJti()).toMatch(/^[0-9a-f]{32}$/)); it("refresh token is 128 hex chars", () => expect(generateRefreshToken()).toMatch(/^[0-9a-f]{128}$/)); it("hashToken is sha256", () => { expect(hashToken("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); }); });