import { whoAmI, type WhoAmIUser } from "@huggingface/hub"; import { randomBytes } from "crypto"; import type { Request, Response } from "express"; /** * Why an authenticated user can't edit the Space. * * - `no-org-grant`: the Space is owned by an org, the user authenticated * successfully but didn't tick the "Allow access to " checkbox on * HF's OAuth consent screen. The org therefore doesn't appear in * `whoAmI(...).orgs`, so we can't check membership and have to assume * permission is missing. Fix: re-authorize with `prompt=consent` and * tick the org this time. * - `not-member`: the user did grant org access (or the Space owner is a * plain user) and we can talk to the org members API, but the user * isn't in the write/admin role list. There's nothing the user can do * from inside the app - they need an org admin to add them. */ export type AuthIssue = "no-org-grant" | "not-member"; export interface AuthUser { name: string; fullName: string; avatarUrl: string; canEdit: boolean; /** * Set when the user authenticated but can't edit. Lets the login * page show a targeted message ("missing org grant" vs. "not a * member") instead of a generic "sign in" prompt that's confusing * when you're literally already signed in. */ accessIssue?: AuthIssue; /** * Login (HF handle) of the Space owner when it's an org. Surfaced * on the login page so the user knows which org to tick on the * consent screen. Undefined when the Space is owned by a regular * user. */ spaceOrg?: string; } // HF injects these env vars when hf_oauth: true is in README 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`; // In dev, the callback goes through the Vite proxy (port 5678) -> backend return "http://localhost:8080/auth/callback"; } function getPostLoginRedirect(): string { if (SPACE_HOST) return "/editor"; // In dev, redirect to Vite dev server return "http://localhost:5678/"; } // In-memory state store for CSRF protection (short-lived) const pendingStates = new Map(); function cleanupStates() { const now = Date.now(); for (const [state, ts] of pendingStates) { if (now - ts > 10 * 60 * 1000) pendingStates.delete(state); } } /** * GET /oauth/authorize - redirect user to HF login. * * If the visitor lands here with `?prompt=consent`, we forward that to * HF so the consent screen is shown again even when the user already * has a live OAuth grant. This is the recovery path from the * "no-org-grant" login page: HF skips consent for repeat sign-ins, so * a user who declined the org checkbox the first time would otherwise * loop straight back to the failure state without ever seeing the * org toggle again. */ 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}`); } /** * GET /auth/callback - exchange code for token, set cookie, redirect to editor */ 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 }; // Cookie lifetime strategy: match the underlying JWT's real // expiration so the cookie dies exactly when the token does. // HF currently emits 30-day tokens, but rather than hard-code // that we decode the JWT's `exp` claim (it's a public field, // no verification needed - we just want the timestamp) and use // it directly. Falls back to `expires_in` from the OAuth // response, then to a 30-day floor as a last resort so a // stale 8-hour fallback can never sneak back in. 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"); } } /** * Compute the cookie max-age in milliseconds for an HF access token. * * Order of preference: * 1. Decode the JWT's `exp` claim and use `exp - now` directly. * HF tokens are JWTs and their `exp` is the most authoritative * "when does this stop working" signal we have. * 2. Fall back to `expires_in` from the OAuth /token response if * decoding failed (third-party JWT lib unavailable, malformed * token, etc.). * 3. Floor at 30 days. Anything shorter than that is almost * certainly a misread - HF currently issues 30-day tokens, * so an 8-hour fallback would just teach users to mistrust * the editor when it boots them out mid-day. * 4. Cap at 400 days. That's the maximum Chrome / Chromium- * derived browsers accept since cookies-with-max-age-greater- * than-400-days were silently clamped (RFC 6265bis section * 5.5). Setting a larger value would either be silently * truncated or trigger console warnings. */ 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; // 1. JWT exp claim. We don't verify the signature - that's HF's // job at every API call - we just read the exp timestamp. try { const parts = tokenData.access_token.split("."); if (parts.length === 3) { // base64url -> base64 -> JSON 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 { // Malformed JWT, swallow and fall through to expires_in. } // 2. expires_in fallback. if (typeof tokenData.expires_in === "number") { lifeMs = Math.max(lifeMs, tokenData.expires_in * 1000); } // 3. & 4. Floor + cap. return Math.min(Math.max(lifeMs, THIRTY_DAYS_MS), FOUR_HUNDRED_DAYS_MS); } /** * Clear the HF access token cookie, signing the visitor out of the * editor. We mirror every attribute the original `res.cookie(...)` * call set in handleOAuthCallback (path, secure, sameSite) - cookie * deletion in browsers requires the Set-Cookie attributes to match * the original, otherwise the new "expired" cookie is treated as a * different cookie and the original sticks around. * * The handler is intentionally lenient about method (POST/GET): the * frontend uses POST + fetch (so the call can't be CSRF'd from a * cross-site form), but a GET fallback lets us link to /api/auth/ * logout directly during incident response. */ 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 }); } /** * Extract access token from cookie header string. */ 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; } /** * Roles in an HF org that grant write access to the org's repos. We * accept `admin` and `write` at the org level, plus `write`/`admin` * on any resource group when the user is only a `contributor` at the * org level (HF lets contributors edit specific repos via per-group * roles, and the Space might live in one of those groups). * * `read` and bare `contributor` (no write group) are read-only. */ const WRITE_ROLES = new Set(["write", "admin"]); /** * Shape of `info.orgs[i]` as actually returned by the live HF * whoami endpoint. The official `@huggingface/hub` typings strip * `roleInOrg` and `resourceGroups`, but they're documented and used * by the Python SDK. We narrow to a local interface so the TS * compiler lets us read them without `any`. */ interface OrgWithRole { name: string; roleInOrg?: "admin" | "write" | "contributor" | "read"; resourceGroups?: Array<{ name: string; role: string }>; } /** * Resolve a HF access token into user info + edit permission. * Returns null when the token is missing or invalid. */ export async function resolveUser( accessToken: string | undefined ): Promise { 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; /** Login of the org owning the Space, when applicable. */ spaceOrg?: string; /** Resolved org role, for logging. */ role?: string; } /** * Decide whether the authenticated user can edit this Space, using * only data we got from `whoAmI` (one API round-trip, already done). * * Cases: * 1. SPACE_ID unset (running outside a Space) -> no access. * 2. User owns the Space directly -> write access. * 3. Space owned by an org NOT in `user.orgs` -> the OAuth grant * is missing the org checkbox; recoverable via re-consent. * 4. Space owned by an org in `user.orgs`, with role * `write` or `admin` at the org level -> write access. * 5. Same as 4 but role is `contributor`, AND the user has at * least one `resourceGroups[i].role` of `write`/`admin` * -> write access * (the Space probably lives in that group; HF will reject the * eventual push if not, but the UX is much better than * blocking up-front). * 6. Anything else (read-only role, no write group) * -> not-member. * * Previously this function called `/api/organizations/{name}/members` * to read the role, but that endpoint doesn't return a `role` field * for any caller - it returns plain user records. Everyone was * therefore stuck in case 6 even with full write access. Reading * `roleInOrg` from whoami is the documented path. */ 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, }; }