/** * Signed cookie session for org admin web login (ADR-0021). * * Payload is HMAC-SHA256 signed (HUB_SESSION_SECRET). No server-side session * table in v1 — logout clears the cookie; stolen-cookie revoke is deferred. */ import { createHmac, timingSafeEqual } from "node:crypto"; export const SESSION_COOKIE_NAME = "cph_session"; export const OAUTH_STATE_COOKIE_NAME = "cph_oauth_state"; /** Default session TTL: 7 days. */ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; /** OAuth state cookie TTL: 10 minutes. */ export const OAUTH_STATE_TTL_SECONDS = 10 * 60; interface SessionTimes { readonly iat: number; readonly exp: number; } export type SessionIdentity = | { readonly userId: string; readonly feishuOpenId: string; } | { readonly userId: string; readonly feishuIdentityId: string; readonly feishuConnectionId: string; readonly feishuOrganizationId: string; }; export type SessionPayload = SessionIdentity & SessionTimes; export interface OAuthStatePayload { readonly nonce: string; readonly returnTo: string; readonly organizationId?: string | undefined; readonly connectionId?: string | undefined; readonly exp: number; } export function signSession( payload: SessionIdentity, secret: string, ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS, nowSeconds: number = Math.floor(Date.now() / 1000), ): string { const full: SessionPayload = { ...payload, iat: nowSeconds, exp: nowSeconds + ttlSeconds, }; return signJson(full, secret); } export function verifySession( token: string, secret: string, nowSeconds: number = Math.floor(Date.now() / 1000), ): SessionPayload | null { const payload = verifyJson>(token, secret); if (payload === null) return null; if (typeof payload["userId"] !== "string" || payload["userId"] === "") return null; if (typeof payload["iat"] !== "number" || typeof payload["exp"] !== "number") return null; if (payload["exp"] <= nowSeconds) return null; const legacy = typeof payload["feishuOpenId"] === "string" && payload["feishuOpenId"] !== ""; const scoped = typeof payload["feishuIdentityId"] === "string" && payload["feishuIdentityId"] !== "" && typeof payload["feishuConnectionId"] === "string" && payload["feishuConnectionId"] !== "" && typeof payload["feishuOrganizationId"] === "string" && payload["feishuOrganizationId"] !== ""; if (legacy === scoped) return null; if (legacy) { return { userId: payload["userId"], feishuOpenId: payload["feishuOpenId"] as string, iat: payload["iat"], exp: payload["exp"], }; } return { userId: payload["userId"], feishuIdentityId: payload["feishuIdentityId"] as string, feishuConnectionId: payload["feishuConnectionId"] as string, feishuOrganizationId: payload["feishuOrganizationId"] as string, iat: payload["iat"], exp: payload["exp"], }; } /* * OAuth state may be legacy/unscoped or bind both an Organization and its * intended Feishu connection. A half-scoped state is always invalid. */ export function signOAuthState( payload: Omit, secret: string, ttlSeconds: number = OAUTH_STATE_TTL_SECONDS, nowSeconds: number = Math.floor(Date.now() / 1000), ): string { const full: OAuthStatePayload = { ...payload, exp: nowSeconds + ttlSeconds, }; return signJson(full, secret); } export function verifyOAuthState( token: string, secret: string, nowSeconds: number = Math.floor(Date.now() / 1000), ): OAuthStatePayload | null { const payload = verifyJson(token, secret); if (payload === null) return null; if (typeof payload.nonce !== "string" || payload.nonce === "") return null; if (typeof payload.returnTo !== "string") return null; if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null; const hasOrganization = typeof payload.organizationId === "string" && payload.organizationId !== ""; const hasConnection = typeof payload.connectionId === "string" && payload.connectionId !== ""; if (hasOrganization !== hasConnection) return null; return { nonce: payload.nonce, returnTo: payload.returnTo, ...(hasOrganization ? { organizationId: payload.organizationId!, connectionId: payload.connectionId! } : {}), exp: payload.exp, }; } function signJson(value: unknown, secret: string): string { const body = Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); const sig = hmac(body, secret); return `${body}.${sig}`; } function verifyJson(token: string, secret: string): T | null { const dot = token.indexOf("."); if (dot <= 0 || dot === token.length - 1) return null; const body = token.slice(0, dot); const sig = token.slice(dot + 1); const expected = hmac(body, secret); if (!safeEqual(sig, expected)) return null; try { return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T; } catch { return null; } } function hmac(body: string, secret: string): string { return createHmac("sha256", secret).update(body).digest("base64url"); } function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a); const bb = Buffer.from(b); if (ba.length !== bb.length) return false; return timingSafeEqual(ba, bb); }