1import { describe, expect, it } from "vitest";
2import {
3 generateJti,
4 generateRefreshToken,
5 hashToken,
6 issueRefreshToken,
7 parseLifetime,
8 rotateRefreshToken,
9 signAccessToken,
10 verifyAccessToken,
11 type RefreshTokenRecord,
12 type RefreshTokenStore
13} from "./tokens.js";
14
15class MemStore implements RefreshTokenStore {
16 records = new Map<string, RefreshTokenRecord>();
17 async getByHash(hash: string) {
18 return this.records.get(hash) ?? null;
19 }
20 async getByFamily(familyId: string) {
21 return [...this.records.values()].filter((r) => r.familyId === familyId);
22 }
23 async put(record: RefreshTokenRecord) {
24 this.records.set(record.tokenHash, record);
25 }
26 async revokeFamily(familyId: string) {
27 for (const r of await this.getByFamily(familyId)) this.records.set(r.tokenHash, { ...r, revoked: true });
28 }
29}
30
31const SECRET = "test-secret-0123456789";
32
33describe("parseLifetime", () => {
34 it("parses units", () => {
35 expect(parseLifetime("15m")).toBe(900_000);
36 expect(parseLifetime("30d")).toBe(30 * 86_400_000);
37 expect(parseLifetime("90s")).toBe(90_000);
38 expect(parseLifetime("1h")).toBe(3_600_000);
39 expect(parseLifetime("5ms")).toBe(5);
40 });
41 it("rejects garbage", () => {
42 expect(() => parseLifetime("abc")).toThrow();
43 expect(() => parseLifetime("5")).toThrow();
44 });
45});
46
47describe("HS256 access tokens", () => {
48 it("roundtrips", () => {
49 const t = signAccessToken({ sub: "usr_1", workspaceId: "wsp_1", roles: ["owner"] }, SECRET);
50 const d = verifyAccessToken(t, SECRET);
51 expect(d.sub).toBe("usr_1");
52 expect(d.workspaceId).toBe("wsp_1");
53 expect(d.roles).toEqual(["owner"]);
54 expect(d.exp - d.iat).toBe(15 * 60);
55 expect(d.jti).toHaveLength(32);
56 });
57 it("rejects tampered payload", () => {
58 const t = signAccessToken({ sub: "usr_1" }, SECRET);
59 const [h, , s] = t.split(".");
60 const evil = Buffer.from(JSON.stringify({ sub: "usr_2", iat: 1, exp: 9999999999, jti: "x" }))
61 .toString("base64url");
62 expect(() => verifyAccessToken(`${h}.${evil}.${s}`, SECRET)).toThrow(/signature/i);
63 });
64 it("rejects wrong secret", () => {
65 const t = signAccessToken({ sub: "usr_1" }, SECRET);
66 expect(() => verifyAccessToken(t, "other")).toThrow();
67 });
68 it("rejects expired", () => {
69 const now = Date.now();
70 const t = signAccessToken({ sub: "usr_1" }, SECRET, "1s");
71 expect(() => verifyAccessToken(t, SECRET, now + 2000)).toThrow(/expired/i);
72 });
73 it("rejects alg none / non-HS256", () => {
74 const h = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
75 const c = Buffer.from(JSON.stringify({ sub: "x", iat: 1, exp: 9999999999 })).toString("base64url");
76 expect(() => verifyAccessToken(`${h}.${c}.`, SECRET)).toThrow(/algorithm/i);
77 });
78 it("rejects malformed", () => {
79 expect(() => verifyAccessToken("not-a-jwt", SECRET)).toThrow();
80 });
81 it("jti is stable when provided", () => {
82 const t = signAccessToken({ sub: "u", jti: "fixed" }, SECRET);
83 expect(verifyAccessToken(t, SECRET).jti).toBe("fixed");
84 });
85});
86
87describe("refresh rotation", () => {
88 it("issues and rotates in the same family", async () => {
89 const store = new MemStore();
90 const first = await issueRefreshToken(store, { userId: "usr_1" });
91 const rotated = await rotateRefreshToken(store, first.token, { nowMs: first.expiresAt - 1000 });
92 expect(rotated.familyId).toBe(first.familyId);
93 expect(rotated.token).not.toBe(first.token);
94
95 const old = await store.getByHash(hashToken(first.token));
96 expect(old?.replacedBy).toBe(hashToken(rotated.token));
97 });
98 it("detects reuse and revokes the family", async () => {
99 const store = new MemStore();
100 const first = await issueRefreshToken(store, { userId: "usr_1" });
101 const rotated = await rotateRefreshToken(store, first.token);
102
103 await expect(rotateRefreshToken(store, first.token)).rejects.toThrow(/reuse|unauthorized/i);
104 const all = await store.getByFamily(rotated.familyId);
105 expect(all.every((r) => r.revoked)).toBe(true);
106
107 await expect(rotateRefreshToken(store, rotated.token)).rejects.toThrow();
108 });
109 it("rejects unknown tokens", async () => {
110 const store = new MemStore();
111 await expect(rotateRefreshToken(store, generateRefreshToken())).rejects.toThrow(/unknown/i);
112 });
113 it("rejects expired", async () => {
114 const store = new MemStore();
115 const now = 1_000_000;
116 const first = await issueRefreshToken(store, { userId: "u", nowMs: now, ttlMs: 1000 });
117 await expect(rotateRefreshToken(store, first.token, { nowMs: now + 5000 })).rejects.toThrow(/expired/i);
118 });
119});
120
121describe("hashing helpers", () => {
122 it("jti is 32 hex chars", () => expect(generateJti()).toMatch(/^[0-9a-f]{32}$/));
123 it("refresh token is 128 hex chars", () => expect(generateRefreshToken()).toMatch(/^[0-9a-f]{128}$/));
124 it("hashToken is sha256", () => {
125 expect(hashToken("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
126 });
127});
128