tfrere's picture
tfrere HF Staff
fix(auth): use roleInOrg from whoami, drop broken members API
7652473
Raw
History Blame Contribute Delete
10.4 kB
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 <org>" 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<string, number>();
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 };
const maxAge = (tokenData.expires_in || 28800) * 1000;
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");
}
}
/**
* 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<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;
/** 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,
};
}