feat: implement proper HF OAuth flow
Browse files- Add /oauth/authorize route that builds the correct HF OAuth URL
with client_id, redirect_uri, state (CSRF protection)
- Add /auth/callback route that exchanges code for access token,
stores it in httpOnly cookie, redirects to /editor
- Use OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OPENID_PROVIDER_URL
env vars injected by HF when hf_oauth: true
- isOAuthEnabled now checks for both SPACE_ID and OAUTH_CLIENT_ID
Made-with: Cursor
- backend/src/auth.ts +105 -21
- backend/src/server.ts +8 -1
backend/src/auth.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import { whoAmI, type WhoAmIUser } from "@huggingface/hub";
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export interface AuthUser {
|
| 4 |
name: string;
|
|
@@ -7,10 +9,112 @@ export interface AuthUser {
|
|
| 7 |
canEdit: boolean;
|
| 8 |
}
|
| 9 |
|
|
|
|
| 10 |
const SPACE_ID = process.env.SPACE_ID || "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
export function isOAuthEnabled(): boolean {
|
| 13 |
-
return Boolean(SPACE_ID);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
/**
|
|
@@ -44,15 +148,6 @@ export async function resolveUser(
|
|
| 44 |
|
| 45 |
/**
|
| 46 |
* Check if user has write access to the Space repo.
|
| 47 |
-
*
|
| 48 |
-
* Strategy:
|
| 49 |
-
* 1. If the user owns the Space namespace, grant access.
|
| 50 |
-
* 2. If the user belongs to the org that owns the Space, check via
|
| 51 |
-
* the /api/spaces/{id} endpoint which returns the user's role
|
| 52 |
-
* when authenticated (the "role" field is present for members).
|
| 53 |
-
* 3. For public Spaces, try the /settings endpoint as a write-access probe.
|
| 54 |
-
*
|
| 55 |
-
* Falls back to false on any error.
|
| 56 |
*/
|
| 57 |
async function checkWriteAccess(
|
| 58 |
accessToken: string,
|
|
@@ -63,10 +158,8 @@ async function checkWriteAccess(
|
|
| 63 |
try {
|
| 64 |
const spaceOwner = SPACE_ID.split("/")[0];
|
| 65 |
|
| 66 |
-
// Owner of the Space namespace has write access
|
| 67 |
if (spaceOwner === username) return true;
|
| 68 |
|
| 69 |
-
// Check if user is a member of the org with write/admin role
|
| 70 |
const res = await fetch(
|
| 71 |
`https://huggingface.co/api/organizations/${spaceOwner}/members`,
|
| 72 |
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
@@ -85,12 +178,3 @@ async function checkWriteAccess(
|
|
| 85 |
return false;
|
| 86 |
}
|
| 87 |
}
|
| 88 |
-
|
| 89 |
-
/**
|
| 90 |
-
* Extract access token from cookie header string.
|
| 91 |
-
*/
|
| 92 |
-
export function extractToken(cookieHeader: string | undefined): string | undefined {
|
| 93 |
-
if (!cookieHeader) return undefined;
|
| 94 |
-
const match = cookieHeader.match(/(?:^|;\s*)access-token=([^;]+)/);
|
| 95 |
-
return match ? match[1] : undefined;
|
| 96 |
-
}
|
|
|
|
| 1 |
import { whoAmI, type WhoAmIUser } from "@huggingface/hub";
|
| 2 |
+
import { randomBytes } from "crypto";
|
| 3 |
+
import type { Request, Response, NextFunction } from "express";
|
| 4 |
|
| 5 |
export interface AuthUser {
|
| 6 |
name: string;
|
|
|
|
| 9 |
canEdit: boolean;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
// HF injects these env vars when hf_oauth: true is in README
|
| 13 |
const SPACE_ID = process.env.SPACE_ID || "";
|
| 14 |
+
const SPACE_HOST = process.env.SPACE_HOST || "";
|
| 15 |
+
const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID || "";
|
| 16 |
+
const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || "";
|
| 17 |
+
const OAUTH_SCOPES = process.env.OAUTH_SCOPES || "openid profile";
|
| 18 |
+
const OPENID_PROVIDER_URL = process.env.OPENID_PROVIDER_URL || "https://huggingface.co";
|
| 19 |
+
|
| 20 |
+
const COOKIE_NAME = "hf_access_token";
|
| 21 |
|
| 22 |
export function isOAuthEnabled(): boolean {
|
| 23 |
+
return Boolean(SPACE_ID && OAUTH_CLIENT_ID);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function getRedirectUri(): string {
|
| 27 |
+
if (SPACE_HOST) return `https://${SPACE_HOST}/auth/callback`;
|
| 28 |
+
return "http://localhost:8080/auth/callback";
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// In-memory state store for CSRF protection (short-lived)
|
| 32 |
+
const pendingStates = new Map<string, number>();
|
| 33 |
+
|
| 34 |
+
function cleanupStates() {
|
| 35 |
+
const now = Date.now();
|
| 36 |
+
for (const [state, ts] of pendingStates) {
|
| 37 |
+
if (now - ts > 10 * 60 * 1000) pendingStates.delete(state);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* GET /oauth/authorize - redirect user to HF login
|
| 43 |
+
*/
|
| 44 |
+
export function handleOAuthAuthorize(_req: Request, res: Response) {
|
| 45 |
+
cleanupStates();
|
| 46 |
+
const state = randomBytes(16).toString("hex");
|
| 47 |
+
pendingStates.set(state, Date.now());
|
| 48 |
+
|
| 49 |
+
const params = new URLSearchParams({
|
| 50 |
+
client_id: OAUTH_CLIENT_ID,
|
| 51 |
+
redirect_uri: getRedirectUri(),
|
| 52 |
+
response_type: "code",
|
| 53 |
+
scope: OAUTH_SCOPES,
|
| 54 |
+
state,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
res.redirect(`${OPENID_PROVIDER_URL}/oauth/authorize?${params}`);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* GET /auth/callback - exchange code for token, set cookie, redirect to editor
|
| 62 |
+
*/
|
| 63 |
+
export async function handleOAuthCallback(req: Request, res: Response) {
|
| 64 |
+
const { code, state } = req.query as { code?: string; state?: string };
|
| 65 |
+
|
| 66 |
+
if (!code || !state || !pendingStates.has(state)) {
|
| 67 |
+
res.status(400).send("Invalid OAuth callback");
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
pendingStates.delete(state);
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
const tokenRes = await fetch(`${OPENID_PROVIDER_URL}/oauth/token`, {
|
| 74 |
+
method: "POST",
|
| 75 |
+
headers: {
|
| 76 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 77 |
+
Authorization: `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`).toString("base64")}`,
|
| 78 |
+
},
|
| 79 |
+
body: new URLSearchParams({
|
| 80 |
+
grant_type: "authorization_code",
|
| 81 |
+
code,
|
| 82 |
+
redirect_uri: getRedirectUri(),
|
| 83 |
+
}),
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (!tokenRes.ok) {
|
| 87 |
+
const text = await tokenRes.text();
|
| 88 |
+
console.error("[auth] token exchange failed:", tokenRes.status, text);
|
| 89 |
+
res.status(500).send("OAuth token exchange failed");
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const tokenData = (await tokenRes.json()) as { access_token: string; expires_in?: number };
|
| 94 |
+
const maxAge = (tokenData.expires_in || 28800) * 1000;
|
| 95 |
+
|
| 96 |
+
res.cookie(COOKIE_NAME, tokenData.access_token, {
|
| 97 |
+
httpOnly: true,
|
| 98 |
+
secure: true,
|
| 99 |
+
sameSite: "none",
|
| 100 |
+
maxAge,
|
| 101 |
+
path: "/",
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
res.redirect("/editor");
|
| 105 |
+
} catch (err) {
|
| 106 |
+
console.error("[auth] callback error:", err);
|
| 107 |
+
res.status(500).send("OAuth callback error");
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Extract access token from cookie header string.
|
| 113 |
+
*/
|
| 114 |
+
export function extractToken(cookieHeader: string | undefined): string | undefined {
|
| 115 |
+
if (!cookieHeader) return undefined;
|
| 116 |
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
|
| 117 |
+
return match ? match[1] : undefined;
|
| 118 |
}
|
| 119 |
|
| 120 |
/**
|
|
|
|
| 148 |
|
| 149 |
/**
|
| 150 |
* Check if user has write access to the Space repo.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
*/
|
| 152 |
async function checkWriteAccess(
|
| 153 |
accessToken: string,
|
|
|
|
| 158 |
try {
|
| 159 |
const spaceOwner = SPACE_ID.split("/")[0];
|
| 160 |
|
|
|
|
| 161 |
if (spaceOwner === username) return true;
|
| 162 |
|
|
|
|
| 163 |
const res = await fetch(
|
| 164 |
`https://huggingface.co/api/organizations/${spaceOwner}/members`,
|
| 165 |
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
|
|
| 178 |
return false;
|
| 179 |
}
|
| 180 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/src/server.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
| 17 |
schedulePush,
|
| 18 |
flushAll,
|
| 19 |
} from "./hf-storage.js";
|
| 20 |
-
import { resolveUser, extractToken, isOAuthEnabled } from "./auth.js";
|
| 21 |
import { publishDocument } from "./publisher/index.js";
|
| 22 |
|
| 23 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -100,6 +100,13 @@ const { app } = expressWebsockets(express());
|
|
| 100 |
|
| 101 |
app.use(express.json({ limit: "1mb" }));
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
// ---------- Auth ----------
|
| 104 |
|
| 105 |
app.get("/api/auth/status", async (req, res) => {
|
|
|
|
| 17 |
schedulePush,
|
| 18 |
flushAll,
|
| 19 |
} from "./hf-storage.js";
|
| 20 |
+
import { resolveUser, extractToken, isOAuthEnabled, handleOAuthAuthorize, handleOAuthCallback } from "./auth.js";
|
| 21 |
import { publishDocument } from "./publisher/index.js";
|
| 22 |
|
| 23 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
| 100 |
|
| 101 |
app.use(express.json({ limit: "1mb" }));
|
| 102 |
|
| 103 |
+
// ---------- OAuth routes ----------
|
| 104 |
+
|
| 105 |
+
if (oauthEnabled) {
|
| 106 |
+
app.get("/oauth/authorize", handleOAuthAuthorize);
|
| 107 |
+
app.get("/auth/callback", handleOAuthCallback);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
// ---------- Auth ----------
|
| 111 |
|
| 112 |
app.get("/api/auth/status", async (req, res) => {
|