1import { createHmac, timingSafeEqual } from "node:crypto";
2
3
7
8export interface JwtClaims {
9
10 sub: string;
11
12 workspaces?: string[];
13
14 iat?: number;
15
16 exp?: number;
17
18 jti?: string;
19
20 sid?: string;
21 [key: string]: unknown;
22}
23
24export class TokenError extends Error {
25 constructor(
26 public readonly code: "INVALID_TOKEN" | "TOKEN_EXPIRED",
27 message: string
28 ) {
29 super(message);
30 this.name = "TokenError";
31 }
32}
33
34const b64url = (buf: Buffer): string =>
35 buf.toString("base64").replace(/\+/g, "-").replace(/\
36
37const fromB64url = (s: string): Buffer => {
38 const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
39 return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
40};
41
42
43export function parseTtl(ttl: string): number {
44 const m = /^(\d+)(ms|s|m|h|d)$/.exec(ttl.trim());
45 if (!m) throw new TokenError("INVALID_TOKEN", `invalid ttl: ${ttl}`);
46 const n = Number(m[1]);
47 switch (m[2]) {
48 case "ms":
49 return Math.max(1, Math.ceil(n / 1000));
50 case "s":
51 return n;
52 case "m":
53 return n * 60;
54 case "h":
55 return n * 3600;
56 case "d":
57 return n * 86400;
58 default:
59
60
61 return n;
62 }
63}
64
65function sign(data: string, secret: string): string {
66 return b64url(createHmac("sha256", secret).update(data).digest());
67}
68
69
73export function signToken(
74 payload: Omit<JwtClaims, "iat" | "exp"> & { iat?: number; exp?: number },
75 ttl: number | string,
76 secret: string
77): string {
78 const ttlSeconds = typeof ttl === "number" ? ttl : parseTtl(ttl);
79 const iat = payload.iat ?? Math.floor(Date.now() / 1000);
80 const exp = payload.exp ?? iat + ttlSeconds;
81 const header = b64url(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })));
82 const body = b64url(
83 Buffer.from(JSON.stringify({ ...payload, iat, exp }))
84 );
85 const sig = sign(`${header}.${body}`, secret);
86 return `${header}.${body}.${sig}`;
87}
88
89
93export function verifyToken(token: string, secret: string): JwtClaims {
94 const parts = token.split(".");
95 if (parts.length !== 3) throw new TokenError("INVALID_TOKEN", "malformed token");
96 const [headerB64, bodyB64, sigB64] = parts;
97 let header: { alg?: string };
98 let claims: JwtClaims;
99 try {
100 header = JSON.parse(fromB64url(headerB64).toString("utf8"));
101 claims = JSON.parse(fromB64url(bodyB64).toString("utf8"));
102 } catch {
103 throw new TokenError("INVALID_TOKEN", "malformed token payload");
104 }
105 if (header.alg !== "HS256") throw new TokenError("INVALID_TOKEN", "unsupported algorithm");
106 const expected = sign(`${headerB64}.${bodyB64}`, secret);
107 const a = Buffer.from(sigB64.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(sigB64.length / 4) * 4, "="), "base64");
108 const b = Buffer.from(expected.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(expected.length / 4) * 4, "="), "base64");
109 if (a.length !== b.length || !timingSafeEqual(a, b)) {
110 throw new TokenError("INVALID_TOKEN", "bad signature");
111 }
112 const now = Math.floor(Date.now() / 1000);
113 if (typeof claims.exp === "number" && claims.exp < now) {
114 throw new TokenError("TOKEN_EXPIRED", "token expired");
115 }
116 return claims;
117}
118
119
120export function decodeToken(token: string): JwtClaims | null {
121 try {
122 const parts = token.split(".");
123 if (parts.length !== 3) return null;
124 return JSON.parse(fromB64url(parts[1]).toString("utf8")) as JwtClaims;
125 } catch {
126 return null;
127 }
128}
129