1
21
22import { verifyAccessToken, type DecodedAccessToken } from "@factory/auth/tokens";
23
24
25export const EXPECTED_ISSUER = "factory";
26
27
28export const EXPIRY_GRACE_MS = 5 * 60 * 1000;
29
30
31export interface SessionIdentity {
32 readonly userId: string;
33 readonly workspaces: readonly string[];
34
35 readonly jti: string;
36
37 readonly exp: number;
38}
39
40export type VerifyOutcome =
41 | { readonly ok: true; readonly session: SessionIdentity }
42 | { readonly ok: false; readonly reason: "missing_token" | "invalid_token" | "wrong_issuer" | "no_workspaces" };
43
44
51export function verifyHandshakeToken(
52 token: string | null | undefined,
53 secret: string,
54 nowMs: number = Date.now(),
55): VerifyOutcome {
56 if (token === undefined || token === null || token.length === 0) {
57 return { ok: false, reason: "missing_token" };
58 }
59 let claims: DecodedAccessToken;
60 try {
61 claims = verifyAccessToken(token, secret, nowMs);
62 } catch (err) {
63
64
65 const msg = err instanceof Error ? err.message : String(err);
66 if (/expired/i.test(msg)) {
67 try {
68 claims = verifyAccessToken(token, secret, nowMs - EXPIRY_GRACE_MS);
69 } catch {
70 return { ok: false, reason: "invalid_token" };
71 }
72 } else {
73 return { ok: false, reason: "invalid_token" };
74 }
75 }
76
77 const iss = (claims as { iss?: unknown }).iss;
78 if (iss !== undefined && iss !== EXPECTED_ISSUER) {
79 return { ok: false, reason: "wrong_issuer" };
80 }
81
82
83
84 const workspacesRaw = (claims as { workspaces?: unknown }).workspaces;
85 const workspaces = Array.isArray(workspacesRaw)
86 ? (workspacesRaw as unknown[]).filter((w): w is string => typeof w === "string")
87 : [];
88 if (workspaces.length === 0) {
89
90
91 return { ok: false, reason: "no_workspaces" };
92 }
93 return {
94 ok: true,
95 session: {
96 userId: claims.sub,
97 workspaces,
98 jti: claims.jti,
99 exp: claims.exp,
100 },
101 };
102}
103