1import { describe, it, expect } from "vitest";
2import { createHmac } from "node:crypto";
3import { verifyHandshakeToken, EXPIRY_GRACE_MS, EXPECTED_ISSUER } from "../src/auth.js";
4
5const SECRET = "unit-test-jwt-secret-0123456789abcdef";
6
7function b64url(buf: Buffer | string): string {
8 return Buffer.from(buf, "utf8").toString("base64url");
9}
10
11
14function jwt(claims: Record<string, unknown>, secret: string = SECRET): string {
15 const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
16 const body = b64url(JSON.stringify(claims));
17 const sig = createHmac("sha256", secret)
18 .update(`${header}.${body}`)
19 .digest()
20 .toString("base64url");
21 return `${header}.${body}.${sig}`;
22}
23
24const NOW = 1_700_000_000_000;
25const nowSec = Math.floor(NOW / 1000);
26
27describe("verifyHandshakeToken (WS handshake auth, R1)", () => {
28 it("verifies a valid token with a non-empty workspaces claim", () => {
29 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, jti: "jti_1", workspaces: ["wks_1", "wks_2"] });
30 const r = verifyHandshakeToken(token, SECRET, NOW);
31 expect(r).toEqual({
32 ok: true,
33 session: { userId: "usr_1", workspaces: ["wks_1", "wks_2"], jti: "jti_1", exp: nowSec + 3600 },
34 });
35 });
36
37 it("returns missing_token for null/empty token", () => {
38 expect(verifyHandshakeToken(null, SECRET, NOW)).toEqual({ ok: false, reason: "missing_token" });
39 expect(verifyHandshakeToken("", SECRET, NOW)).toEqual({ ok: false, reason: "missing_token" });
40 expect(verifyHandshakeToken(undefined, SECRET, NOW)).toEqual({ ok: false, reason: "missing_token" });
41 });
42
43 it("returns invalid_token for a garbage (non-JWT) string", () => {
44 expect(verifyHandshakeToken("garbage-not-a-jwt", SECRET, NOW)).toEqual({ ok: false, reason: "invalid_token" });
45 });
46
47 it("returns invalid_token when the signature does not match the secret", () => {
48 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, workspaces: ["wks_1"] }, "a-different-secret-0123456789");
49 expect(verifyHandshakeToken(token, SECRET, NOW)).toEqual({ ok: false, reason: "invalid_token" });
50 });
51
52 it("returns wrong_issuer when `iss` is present but not the expected issuer", () => {
53 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, iss: "evil", workspaces: ["wks_1"] });
54 expect(verifyHandshakeToken(token, SECRET, NOW)).toEqual({ ok: false, reason: "wrong_issuer" });
55 });
56
57 it("accepts a token whose `iss` is the expected issuer", () => {
58 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, iss: EXPECTED_ISSUER, workspaces: ["wks_1"] });
59 expect(verifyHandshakeToken(token, SECRET, NOW)).toMatchObject({ ok: true, session: { userId: "usr_1" } });
60 });
61
62 it("accepts a token with no `iss` claim at all (iss is optional — note 13)", () => {
63 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, workspaces: ["wks_1"] });
64 expect(verifyHandshakeToken(token, SECRET, NOW)).toMatchObject({ ok: true });
65 });
66
67 it("returns no_workspaces when the workspaces claim is absent", () => {
68 const token = jwt({ sub: "usr_1", exp: nowSec + 3600 });
69 expect(verifyHandshakeToken(token, SECRET, NOW)).toEqual({ ok: false, reason: "no_workspaces" });
70 });
71
72 it("returns no_workspaces when the workspaces claim is an empty array", () => {
73 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, workspaces: [] });
74 expect(verifyHandshakeToken(token, SECRET, NOW)).toEqual({ ok: false, reason: "no_workspaces" });
75 });
76
77 it("filters non-string workspace entries and still succeeds when a string remains", () => {
78 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, workspaces: ["wks_1", 42, null] });
79 const r = verifyHandshakeToken(token, SECRET, NOW);
80 expect(r).toMatchObject({ ok: true, session: { workspaces: ["wks_1"] } });
81 });
82
83 it("returns no_workspaces when every workspace entry is non-string", () => {
84 const token = jwt({ sub: "usr_1", exp: nowSec + 3600, workspaces: [42, null, { a: 1 }] });
85 expect(verifyHandshakeToken(token, SECRET, NOW)).toEqual({ ok: false, reason: "no_workspaces" });
86 });
87
88 it("accepts a token expired by less than the 5-min grace (an open tab)", () => {
89
90 const token = jwt({ sub: "usr_1", exp: nowSec - 60, workspaces: ["wks_1"] });
91 const r = verifyHandshakeToken(token, SECRET, NOW);
92 expect(r).toMatchObject({ ok: true, session: { userId: "usr_1" } });
93 });
94
95 it("rejects a token expired by more than the grace", () => {
96 const token = jwt({ sub: "usr_1", exp: nowSec - (EXPIRY_GRACE_MS / 1000) - 60, workspaces: ["wks_1"] });
97 expect(verifyHandshakeToken(token, SECRET, NOW)).toEqual({ ok: false, reason: "invalid_token" });
98 });
99});
100