forked from bai/curriculum-project-hub
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:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Feishu web OAuth (user login) for org admin panel.
|
||||
*
|
||||
* Pilot uses the process-global FEISHU_APP_ID/SECRET (same app as the bot).
|
||||
* Per-org encrypted Feishu app secrets are deferred (ADR-0021).
|
||||
*
|
||||
* Flow:
|
||||
* 1. GET authorize URL → user consents
|
||||
* 2. callback code → user_access_token (authen/v2/oauth/token)
|
||||
* 3. user_access_token → user info (authen/v1/user_info) → open_id
|
||||
*/
|
||||
|
||||
export interface FeishuOAuthConfig {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly redirectUri: string;
|
||||
/** Space-separated scopes requested at authorize time. */
|
||||
readonly scope: string;
|
||||
readonly authorizeBaseUrl?: string;
|
||||
readonly tokenUrl?: string;
|
||||
readonly userInfoUrl?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface FeishuOAuthUser {
|
||||
readonly openId: string;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUTHORIZE_BASE = "https://accounts.feishu.cn/open-apis/authen/v1/authorize";
|
||||
const DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
|
||||
const DEFAULT_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
|
||||
|
||||
/** Minimal scopes for open_id + name/avatar on user_info. */
|
||||
export const DEFAULT_OAUTH_SCOPE = "contact:user.base:readonly";
|
||||
|
||||
export function buildAuthorizeUrl(config: FeishuOAuthConfig, state: string): string {
|
||||
const base = config.authorizeBaseUrl ?? DEFAULT_AUTHORIZE_BASE;
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("client_id", config.appId);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", config.redirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
if (config.scope.trim() !== "") {
|
||||
url.searchParams.set("scope", config.scope);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeCodeForUser(
|
||||
config: FeishuOAuthConfig,
|
||||
code: string,
|
||||
): Promise<FeishuOAuthUser> {
|
||||
const accessToken = await exchangeCodeForAccessToken(config, code);
|
||||
return fetchUserInfo(config, accessToken);
|
||||
}
|
||||
|
||||
async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> {
|
||||
const fetchImpl = config.fetchImpl ?? fetch;
|
||||
const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
|
||||
const res = await fetchImpl(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.appId,
|
||||
client_secret: config.appSecret,
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
}),
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
code?: number;
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
msg?: string;
|
||||
};
|
||||
if (!res.ok || body.code !== 0 || typeof body.access_token !== "string" || body.access_token === "") {
|
||||
const detail = body.error_description ?? body.error ?? body.msg ?? `HTTP ${res.status}`;
|
||||
throw new Error(`Feishu OAuth token exchange failed: ${detail}`);
|
||||
}
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise<FeishuOAuthUser> {
|
||||
const fetchImpl = config.fetchImpl ?? fetch;
|
||||
const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL;
|
||||
const res = await fetchImpl(userInfoUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${userAccessToken}` },
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
open_id?: string;
|
||||
name?: string;
|
||||
en_name?: string;
|
||||
avatar_url?: string;
|
||||
avatar_middle?: string;
|
||||
};
|
||||
};
|
||||
if (!res.ok || body.code !== 0 || body.data === undefined) {
|
||||
throw new Error(`Feishu user_info failed: ${body.msg ?? `HTTP ${res.status}`}`);
|
||||
}
|
||||
const openId = body.data.open_id;
|
||||
if (typeof openId !== "string" || openId === "") {
|
||||
throw new Error("Feishu user_info response missing open_id");
|
||||
}
|
||||
const displayName =
|
||||
(typeof body.data.name === "string" && body.data.name !== "" ? body.data.name : null) ??
|
||||
(typeof body.data.en_name === "string" && body.data.en_name !== "" ? body.data.en_name : null) ??
|
||||
openId;
|
||||
const avatar =
|
||||
(typeof body.data.avatar_url === "string" && body.data.avatar_url !== ""
|
||||
? body.data.avatar_url
|
||||
: null) ??
|
||||
(typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== ""
|
||||
? body.data.avatar_middle
|
||||
: null);
|
||||
return { openId, displayName, avatarUrl: avatar };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* HTTP guards for org admin APIs (ADR-0021).
|
||||
*
|
||||
* Platform admin is a separate control plane — not modeled here.
|
||||
*/
|
||||
import type { Organization, OrganizationMemberRole, PrismaClient, User } from "@prisma/client";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
verifySession,
|
||||
type SessionPayload,
|
||||
} from "./session.js";
|
||||
|
||||
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
|
||||
|
||||
export interface AuthContext {
|
||||
readonly session: SessionPayload;
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export interface OrgAuthContext extends AuthContext {
|
||||
readonly organization: Organization;
|
||||
readonly membershipRole: OrganizationMemberRole;
|
||||
}
|
||||
|
||||
export interface GuardDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly statusCode: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(statusCode: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireSession(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: GuardDeps,
|
||||
): Promise<AuthContext | null> {
|
||||
const raw = request.cookies[SESSION_COOKIE_NAME];
|
||||
if (raw === undefined || raw === "") {
|
||||
await sendError(reply, 401, "unauthenticated", "login required");
|
||||
return null;
|
||||
}
|
||||
const session = verifySession(raw, deps.sessionSecret);
|
||||
if (session === null) {
|
||||
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
|
||||
return null;
|
||||
}
|
||||
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
|
||||
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
|
||||
await sendError(reply, 401, "unauthenticated", "session user not found");
|
||||
return null;
|
||||
}
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export async function requireOrgRole(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: GuardDeps,
|
||||
options: {
|
||||
readonly orgSlug: string;
|
||||
readonly roles?: readonly OrganizationMemberRole[];
|
||||
},
|
||||
): Promise<OrgAuthContext | null> {
|
||||
const auth = await requireSession(request, reply, deps);
|
||||
if (auth === null) return null;
|
||||
|
||||
const allowed = options.roles ?? ORG_ADMIN_ROLES;
|
||||
const organization = await deps.prisma.organization.findUnique({
|
||||
where: { slug: options.orgSlug },
|
||||
});
|
||||
if (organization === null) {
|
||||
await sendError(reply, 404, "org_not_found", `organization not found: ${options.orgSlug}`);
|
||||
return null;
|
||||
}
|
||||
if (organization.status !== "ACTIVE") {
|
||||
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const membership = await deps.prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
userId: auth.user.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
await sendError(reply, 403, "forbidden", "not a member of this organization");
|
||||
return null;
|
||||
}
|
||||
if (!allowed.includes(membership.role)) {
|
||||
await sendError(reply, 403, "forbidden", `requires role: ${allowed.join("|")}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...auth,
|
||||
organization,
|
||||
membershipRole: membership.role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireOrgProject(
|
||||
deps: GuardDeps,
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
): Promise<{ readonly id: string; readonly organizationId: string; readonly name: string }> {
|
||||
const project = await deps.prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, organizationId: true, name: true, archivedAt: true },
|
||||
});
|
||||
if (project === null || project.organizationId !== organizationId) {
|
||||
throw new HttpError(404, "project_not_found", `project not found: ${projectId}`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function sendError(
|
||||
reply: FastifyReply,
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await reply.status(statusCode).send({ error: { code, message } });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user