feat(hub): add Feishu OAuth session for org admin

Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole
guards, and the first org admin APIs (summary + project settings).
This commit is contained in:
2026-07-10 00:55:19 +08:00
parent 87e3d3f990
commit c4f052efa2
12 changed files with 1297 additions and 2 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* 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;
export interface SessionPayload {
readonly userId: string;
readonly feishuOpenId: string;
readonly iat: number;
readonly exp: number;
}
export interface OAuthStatePayload {
readonly nonce: string;
readonly returnTo: string;
readonly exp: number;
}
export function signSession(
payload: Omit<SessionPayload, "iat" | "exp">,
secret: string,
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: SessionPayload = {
userId: payload.userId,
feishuOpenId: payload.feishuOpenId,
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<SessionPayload>(token, secret);
if (payload === null) return null;
if (typeof payload.userId !== "string" || payload.userId === "") return null;
if (typeof payload.feishuOpenId !== "string" || payload.feishuOpenId === "") return null;
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
if (payload.exp <= nowSeconds) return null;
return payload;
}
export function signOAuthState(
payload: Omit<OAuthStatePayload, "exp">,
secret: string,
ttlSeconds: number = OAUTH_STATE_TTL_SECONDS,
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: OAuthStatePayload = {
nonce: payload.nonce,
returnTo: payload.returnTo,
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<OAuthStatePayload>(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;
return payload;
}
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<T>(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);
}