1
13
14import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
15import { ApiError } from "@factory/shared/errors";
16import { TOKEN_LIFETIMES } from "@factory/shared/constants";
17
18
19
20
21export function parseLifetime(ttl: string): number {
22 const m = /^(\d+)\s*(ms|s|m|h|d)$/.exec(ttl.trim());
23 if (!m) throw new ApiError("VALIDATION_ERROR", { message: `invalid ttl: ${ttl}` });
24 const n = Number(m[1]);
25 const unit = m[2]!;
26 const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
27 return n * factor;
28}
29
30const b64url = (buf: Buffer | string): string =>
31 Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\
32
33const b64urlJson = (obj: unknown): string => b64url(JSON.stringify(obj));
34
35
36
37export interface AccessTokenPayload {
38
39 sub: string;
40 workspaceId?: string;
41 roles?: string[];
42 jti?: string;
43}
44
45export interface DecodedAccessToken extends AccessTokenPayload {
46 iat: number;
47 exp: number;
48 jti: string;
49}
50
51const JWT_SECRET_ENV = "JWT_SECRET";
52
53export function signAccessToken(
54 payload: AccessTokenPayload,
55 secret: string,
56 ttl: string = TOKEN_LIFETIMES.ACCESS_TOKEN
57): string {
58 if (!secret) {
59 throw new ApiError("INTERNAL", { message: `${JWT_SECRET_ENV} is not configured` });
60 }
61 const now = Math.floor(Date.now() / 1000);
62 const ttlMs = parseLifetime(ttl);
63 const jti = payload.jti ?? randomUUID().replace(/-/g, "");
64 const body: Record<string, unknown> = {
65 sub: payload.sub,
66 iat: now,
67 exp: now + Math.floor(ttlMs / 1000),
68 jti
69 };
70 if (payload.workspaceId !== undefined) body.workspaceId = payload.workspaceId;
71 if (payload.roles !== undefined) body.roles = payload.roles;
72 const header = b64urlJson({ alg: "HS256", typ: "JWT" });
73 const claims = b64urlJson(body);
74 const sig = createHmac("sha256", secret).update(`${header}.${claims}`).digest("base64")
75 .replace(/\+/g, "-").replace(/\
76 return `${header}.${claims}.${sig}`;
77}
78
79export function verifyAccessToken(token: string, secret: string, nowMs: number = Date.now()): DecodedAccessToken {
80 if (!secret) throw new ApiError("INTERNAL", { message: `${JWT_SECRET_ENV} is not configured` });
81 const parts = token.split(".");
82 if (parts.length !== 3) throw unauthorized("malformed token");
83 const [headerB64, claimsB64, sigB64] = parts as [string, string, string];
84 let header: { alg?: string };
85 try {
86 header = JSON.parse(Buffer.from(headerB64.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
87 } catch {
88 throw unauthorized("malformed token header");
89 }
90 if (header.alg !== "HS256") {
91
92 throw unauthorized("unsupported algorithm");
93 }
94 const expected = createHmac("sha256", secret).update(`${headerB64}.${claimsB64}`).digest();
95 let actual: Buffer;
96 try {
97 actual = Buffer.from(sigB64.replace(/-/g, "+").replace(/_/g, "/"), "base64");
98 } catch {
99 throw unauthorized("malformed signature");
100 }
101 if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
102 throw unauthorized("invalid signature");
103 }
104 let claims: DecodedAccessToken;
105 try {
106 claims = JSON.parse(Buffer.from(claimsB64.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
107 } catch {
108 throw unauthorized("malformed claims");
109 }
110 if (typeof claims.sub !== "string" || typeof claims.exp !== "number") {
111 throw unauthorized("malformed claims");
112 }
113 const nowSec = Math.floor(nowMs / 1000);
114 if (claims.exp <= nowSec) throw unauthorized("token expired");
115 if (typeof claims.iat === "number" && claims.iat > nowSec + 30) {
116 throw unauthorized("token not yet valid");
117 }
118 return claims;
119}
120
121const unauthorized = (message: string) =>
122 new ApiError("UNAUTHORIZED", { message, details: { reason: message } });
123
124export const generateJti = (): string => randomUUID().replace(/-/g, "");
125
126
127export const generateRefreshToken = (): string => randomBytes(64).toString("hex");
128
129
130export const hashToken = (token: string): string => createHash("sha256").update(token).digest("hex");
131
132
133
134export interface RefreshTokenRecord {
135 tokenHash: string;
136 familyId: string;
137 userId: string;
138 jti: string;
139
140 replacedBy?: string;
141
142 revoked: boolean;
143 createdAt: number;
144 expiresAt: number;
145}
146
147
151export interface RefreshTokenStore {
152 getByHash(hash: string): Promise<RefreshTokenRecord | null>;
153 getByFamily(familyId: string): Promise<RefreshTokenRecord[]>;
154 put(record: RefreshTokenRecord): Promise<void>;
155 revokeFamily(familyId: string): Promise<void>;
156}
157
158export interface RefreshTokenIssueOptions {
159 familyId?: string;
160 userId: string;
161 ttlMs?: number;
162 nowMs?: number;
163}
164
165export interface RefreshTokenIssued {
166
167 token: string;
168 familyId: string;
169 expiresAt: number;
170}
171
172export function issueRefreshToken(
173 store: RefreshTokenStore,
174 opts: RefreshTokenIssueOptions
175): Promise<RefreshTokenIssued> {
176 return (async () => {
177 const now = opts.nowMs ?? Date.now();
178 const ttl = opts.ttlMs ?? TOKEN_LIFETIMES.REFRESH_TOKEN_MS;
179 const token = generateRefreshToken();
180 const record: RefreshTokenRecord = {
181 tokenHash: hashToken(token),
182 familyId: opts.familyId ?? generateJti(),
183 userId: opts.userId,
184 jti: generateJti(),
185 revoked: false,
186 createdAt: now,
187 expiresAt: now + ttl
188 };
189 await store.put(record);
190 return { token, familyId: record.familyId, expiresAt: record.expiresAt };
191 })();
192}
193
194
200export async function rotateRefreshToken(
201 store: RefreshTokenStore,
202 presentedToken: string,
203 opts: { ttlMs?: number; nowMs?: number } = {}
204): Promise<RefreshTokenIssued> {
205 const now = opts.nowMs ?? Date.now();
206 const ttl = opts.ttlMs ?? TOKEN_LIFETIMES.REFRESH_TOKEN_MS;
207 const record = await store.getByHash(hashToken(presentedToken));
208 if (!record) throw unauthorized("unknown refresh token");
209
210
211
212 if (record.revoked || record.replacedBy) {
213 await store.revokeFamily(record.familyId);
214 throw unauthorized("refresh token reuse detected");
215 }
216 if (record.expiresAt <= now) throw unauthorized("refresh token expired");
217 const next = generateRefreshToken();
218 const nextRecord: RefreshTokenRecord = {
219 tokenHash: hashToken(next),
220 familyId: record.familyId,
221 userId: record.userId,
222 jti: generateJti(),
223 revoked: false,
224 createdAt: now,
225 expiresAt: now + ttl
226 };
227
228 await store.put({ ...record, replacedBy: nextRecord.tokenHash });
229 await store.put(nextRecord);
230 return { token: next, familyId: record.familyId, expiresAt: nextRecord.expiresAt };
231}
232