forked from EduCraft/curriculum-project-hub
162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
/**
|
|
* 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<Record<string, unknown>>(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<OAuthStatePayload, "exp">,
|
|
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<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;
|
|
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<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);
|
|
}
|