tfrere HF Staff commited on
Commit
bafd8cb
·
1 Parent(s): 6c9458d

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

Files changed (2) hide show
  1. backend/src/auth.ts +105 -21
  2. 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) => {