| import { whoAmI, type WhoAmIUser } from "@huggingface/hub"; |
| import { randomBytes } from "crypto"; |
| import type { Request, Response } from "express"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export type AuthIssue = "no-org-grant" | "not-member"; |
|
|
| export interface AuthUser { |
| name: string; |
| fullName: string; |
| avatarUrl: string; |
| canEdit: boolean; |
| |
| |
| |
| |
| |
| |
| accessIssue?: AuthIssue; |
| |
| |
| |
| |
| |
| |
| spaceOrg?: string; |
| } |
|
|
| |
| const SPACE_ID = process.env.SPACE_ID || ""; |
| const SPACE_HOST = process.env.SPACE_HOST || ""; |
| const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID || ""; |
| const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ""; |
| const OAUTH_SCOPES = process.env.OAUTH_SCOPES || "openid profile"; |
| const OPENID_PROVIDER_URL = process.env.OPENID_PROVIDER_URL || "https://huggingface.co"; |
|
|
| const COOKIE_NAME = "hf_access_token"; |
|
|
| const IS_DEV = !SPACE_ID; |
|
|
| export function isOAuthEnabled(): boolean { |
| return Boolean(OAUTH_CLIENT_ID && OAUTH_CLIENT_SECRET); |
| } |
|
|
| function getRedirectUri(): string { |
| if (SPACE_HOST) return `https://${SPACE_HOST}/auth/callback`; |
| |
| return "http://localhost:8080/auth/callback"; |
| } |
|
|
| function getPostLoginRedirect(): string { |
| if (SPACE_HOST) return "/editor"; |
| |
| return "http://localhost:5678/"; |
| } |
|
|
| |
| const pendingStates = new Map<string, number>(); |
|
|
| function cleanupStates() { |
| const now = Date.now(); |
| for (const [state, ts] of pendingStates) { |
| if (now - ts > 10 * 60 * 1000) pendingStates.delete(state); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function handleOAuthAuthorize(req: Request, res: Response) { |
| cleanupStates(); |
| const state = randomBytes(16).toString("hex"); |
| pendingStates.set(state, Date.now()); |
|
|
| const params = new URLSearchParams({ |
| client_id: OAUTH_CLIENT_ID, |
| redirect_uri: getRedirectUri(), |
| response_type: "code", |
| scope: OAUTH_SCOPES, |
| state, |
| }); |
|
|
| if (typeof req.query.prompt === "string" && req.query.prompt === "consent") { |
| params.set("prompt", "consent"); |
| } |
|
|
| res.redirect(`${OPENID_PROVIDER_URL}/oauth/authorize?${params}`); |
| } |
|
|
| |
| |
| |
| export async function handleOAuthCallback(req: Request, res: Response) { |
| const { code, state } = req.query as { code?: string; state?: string }; |
|
|
| if (!code || !state || !pendingStates.has(state)) { |
| res.status(400).send("Invalid OAuth callback"); |
| return; |
| } |
| pendingStates.delete(state); |
|
|
| try { |
| const tokenRes = await fetch(`${OPENID_PROVIDER_URL}/oauth/token`, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/x-www-form-urlencoded", |
| Authorization: `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`).toString("base64")}`, |
| }, |
| body: new URLSearchParams({ |
| grant_type: "authorization_code", |
| code, |
| redirect_uri: getRedirectUri(), |
| }), |
| }); |
|
|
| if (!tokenRes.ok) { |
| const text = await tokenRes.text(); |
| console.error("[auth] token exchange failed:", tokenRes.status, text); |
| res.status(500).send("OAuth token exchange failed"); |
| return; |
| } |
|
|
| const tokenData = (await tokenRes.json()) as { access_token: string; expires_in?: number }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const maxAge = computeCookieMaxAge(tokenData); |
|
|
| res.cookie(COOKIE_NAME, tokenData.access_token, { |
| httpOnly: true, |
| secure: !IS_DEV, |
| sameSite: IS_DEV ? "lax" : "none", |
| maxAge, |
| path: "/", |
| }); |
|
|
| res.redirect(getPostLoginRedirect()); |
| } catch (err) { |
| console.error("[auth] callback error:", err); |
| res.status(500).send("OAuth callback error"); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function computeCookieMaxAge(tokenData: { |
| access_token: string; |
| expires_in?: number; |
| }): number { |
| const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; |
| const FOUR_HUNDRED_DAYS_MS = 400 * 24 * 60 * 60 * 1000; |
|
|
| let lifeMs = 0; |
|
|
| |
| |
| try { |
| const parts = tokenData.access_token.split("."); |
| if (parts.length === 3) { |
| |
| const padded = parts[1] |
| .replace(/-/g, "+") |
| .replace(/_/g, "/") |
| .padEnd(Math.ceil(parts[1].length / 4) * 4, "="); |
| const payload = JSON.parse( |
| Buffer.from(padded, "base64").toString("utf-8"), |
| ); |
| if (typeof payload?.exp === "number") { |
| lifeMs = Math.max(lifeMs, payload.exp * 1000 - Date.now()); |
| } |
| } |
| } catch { |
| |
| } |
|
|
| |
| if (typeof tokenData.expires_in === "number") { |
| lifeMs = Math.max(lifeMs, tokenData.expires_in * 1000); |
| } |
|
|
| |
| return Math.min(Math.max(lifeMs, THIRTY_DAYS_MS), FOUR_HUNDRED_DAYS_MS); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function handleOAuthLogout(_req: Request, res: Response) { |
| res.clearCookie(COOKIE_NAME, { |
| httpOnly: true, |
| secure: !IS_DEV, |
| sameSite: IS_DEV ? "lax" : "none", |
| path: "/", |
| }); |
| res.status(200).json({ ok: true }); |
| } |
|
|
| |
| |
| |
| export function extractToken(cookieHeader: string | undefined): string | undefined { |
| if (!cookieHeader) return undefined; |
| const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`)); |
| return match ? match[1] : undefined; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const WRITE_ROLES = new Set(["write", "admin"]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| interface OrgWithRole { |
| name: string; |
| roleInOrg?: "admin" | "write" | "contributor" | "read"; |
| resourceGroups?: Array<{ name: string; role: string }>; |
| } |
|
|
| |
| |
| |
| |
| export async function resolveUser( |
| accessToken: string | undefined |
| ): Promise<AuthUser | null> { |
| if (!accessToken) return null; |
|
|
| try { |
| const info = (await whoAmI({ |
| accessToken, |
| hubUrl: "https://huggingface.co", |
| })) as WhoAmIUser; |
|
|
| const name = info.name; |
| const fullName = info.fullname || name; |
| const avatarUrl = info.avatarUrl || ""; |
|
|
| if (IS_DEV) { |
| console.log(`[auth] user=${name} canEdit=true (dev mode)`); |
| return { name, fullName, avatarUrl, canEdit: true }; |
| } |
|
|
| const access = evaluateWriteAccess(name, (info.orgs || []) as OrgWithRole[]); |
| console.log( |
| `[auth] user=${name} canEdit=${access.canEdit}` + |
| (access.issue ? ` issue=${access.issue}` : "") + |
| (access.spaceOrg ? ` org=${access.spaceOrg}` : "") + |
| (access.role ? ` role=${access.role}` : ""), |
| ); |
|
|
| return { |
| name, |
| fullName, |
| avatarUrl, |
| canEdit: access.canEdit, |
| accessIssue: access.issue, |
| spaceOrg: access.spaceOrg, |
| }; |
| } catch (err) { |
| console.warn("[auth] whoAmI failed:", (err as Error).message); |
| return null; |
| } |
| } |
|
|
| interface WriteAccessResult { |
| canEdit: boolean; |
| issue?: AuthIssue; |
| |
| spaceOrg?: string; |
| |
| role?: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function evaluateWriteAccess( |
| username: string, |
| orgs: OrgWithRole[], |
| ): WriteAccessResult { |
| if (!SPACE_ID) return { canEdit: false }; |
|
|
| const spaceOwner = SPACE_ID.split("/")[0]; |
| if (spaceOwner === username) return { canEdit: true }; |
|
|
| const org = orgs.find((o) => o.name === spaceOwner); |
| if (!org) { |
| return { canEdit: false, issue: "no-org-grant", spaceOrg: spaceOwner }; |
| } |
|
|
| const orgRole = org.roleInOrg ?? "read"; |
| if (WRITE_ROLES.has(orgRole)) { |
| return { canEdit: true, spaceOrg: spaceOwner, role: orgRole }; |
| } |
|
|
| const writeGroup = (org.resourceGroups || []).find((g) => |
| WRITE_ROLES.has(g.role), |
| ); |
| if (writeGroup) { |
| return { |
| canEdit: true, |
| spaceOrg: spaceOwner, |
| role: `${orgRole} via ${writeGroup.name}=${writeGroup.role}`, |
| }; |
| } |
|
|
| return { |
| canEdit: false, |
| issue: "not-member", |
| spaceOrg: spaceOwner, |
| role: orgRole, |
| }; |
| } |
|
|