tfrere HF Staff commited on
Commit
b3d2294
·
1 Parent(s): 5ad12dd

feat: add HF OAuth authentication middleware

Browse files

- Create auth.ts: resolveUser via whoAmI, extractToken from cookie,
checkWriteAccess against Space repo settings
- Add GET /api/auth/status endpoint returning user info + canEdit flag
- Add onAuthenticate hook to Hocuspocus to protect WebSocket
- Enable hf_oauth in README.md Space config
- Graceful fallback: all users can edit when SPACE_ID is unset

Made-with: Cursor

Files changed (3) hide show
  1. README.md +1 -0
  2. backend/src/auth.ts +86 -0
  3. backend/src/server.ts +44 -0
README.md CHANGED
@@ -6,6 +6,7 @@ colorTo: blue
6
  sdk: docker
7
  app_port: 8080
8
  pinned: false
 
9
  ---
10
 
11
  # Collab Editor
 
6
  sdk: docker
7
  app_port: 8080
8
  pinned: false
9
+ hf_oauth: true
10
  ---
11
 
12
  # Collab Editor
backend/src/auth.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { whoAmI, type WhoAmIUser } from "@huggingface/hub";
2
+
3
+ export interface AuthUser {
4
+ name: string;
5
+ fullName: string;
6
+ avatarUrl: string;
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
+ /**
17
+ * Resolve a HF access token into user info + edit permission.
18
+ * Returns null when the token is missing or invalid.
19
+ */
20
+ export async function resolveUser(
21
+ accessToken: string | undefined
22
+ ): Promise<AuthUser | null> {
23
+ if (!accessToken) return null;
24
+
25
+ try {
26
+ const info = (await whoAmI({
27
+ accessToken,
28
+ hubUrl: "https://huggingface.co",
29
+ })) as WhoAmIUser;
30
+
31
+ const name = info.name;
32
+ const fullName = info.fullname || name;
33
+ const avatarUrl = info.avatarUrl || "";
34
+
35
+ const canEdit = await checkWriteAccess(accessToken, name);
36
+
37
+ return { name, fullName, avatarUrl, canEdit };
38
+ } catch (err) {
39
+ console.warn("[auth] whoAmI failed:", (err as Error).message);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Check if user has write access to the Space repo.
46
+ * Falls back to false on any error.
47
+ */
48
+ async function checkWriteAccess(
49
+ accessToken: string,
50
+ _username: string
51
+ ): Promise<boolean> {
52
+ if (!SPACE_ID) return false;
53
+
54
+ try {
55
+ const res = await fetch(
56
+ `https://huggingface.co/api/spaces/${SPACE_ID}`,
57
+ { headers: { Authorization: `Bearer ${accessToken}` } }
58
+ );
59
+
60
+ if (!res.ok) return false;
61
+
62
+ const data = (await res.json()) as Record<string, unknown>;
63
+
64
+ if (data.private === false) {
65
+ const authorOrg = SPACE_ID.split("/")[0];
66
+ const res2 = await fetch(
67
+ `https://huggingface.co/api/spaces/${SPACE_ID}/settings`,
68
+ { headers: { Authorization: `Bearer ${accessToken}` } }
69
+ );
70
+ return res2.ok;
71
+ }
72
+
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Extract access token from cookie header string.
81
+ */
82
+ export function extractToken(cookieHeader: string | undefined): string | undefined {
83
+ if (!cookieHeader) return undefined;
84
+ const match = cookieHeader.match(/(?:^|;\s*)access-token=([^;]+)/);
85
+ return match ? match[1] : undefined;
86
+ }
backend/src/server.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
  schedulePush,
18
  flushAll,
19
  } from "./hf-storage.js";
 
20
 
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
  const DATA_DIR = join(__dirname, "..", "data");
@@ -36,7 +37,23 @@ if (hfEnabled) {
36
  console.log("[server] falling back to local file persistence");
37
  }
38
 
 
 
 
 
 
 
 
39
  const hocuspocus = new Hocuspocus({
 
 
 
 
 
 
 
 
 
40
  extensions: [
41
  new Database({
42
  fetch: async ({ documentName }: { documentName: string }) => {
@@ -79,6 +96,33 @@ const { app } = expressWebsockets(express());
79
 
80
  app.use(express.json({ limit: "1mb" }));
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  // ---------- Collab WebSocket ----------
83
 
84
  app.ws("/collab/:doc", (ws, req) => {
 
17
  schedulePush,
18
  flushAll,
19
  } from "./hf-storage.js";
20
+ import { resolveUser, extractToken, isOAuthEnabled } from "./auth.js";
21
 
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
  const DATA_DIR = join(__dirname, "..", "data");
 
37
  console.log("[server] falling back to local file persistence");
38
  }
39
 
40
+ const oauthEnabled = isOAuthEnabled();
41
+ if (oauthEnabled) {
42
+ console.log("[server] HF OAuth enabled (SPACE_ID set)");
43
+ } else {
44
+ console.log("[server] HF OAuth disabled (no SPACE_ID), all users can edit");
45
+ }
46
+
47
  const hocuspocus = new Hocuspocus({
48
+ async onAuthenticate({ token }: { token: string }) {
49
+ if (!oauthEnabled) return;
50
+
51
+ const user = await resolveUser(token);
52
+ if (!user || !user.canEdit) {
53
+ throw new Error("Unauthorized: no write access");
54
+ }
55
+ return { user };
56
+ },
57
  extensions: [
58
  new Database({
59
  fetch: async ({ documentName }: { documentName: string }) => {
 
96
 
97
  app.use(express.json({ limit: "1mb" }));
98
 
99
+ // ---------- Auth ----------
100
+
101
+ app.get("/api/auth/status", async (req, res) => {
102
+ if (!oauthEnabled) {
103
+ res.json({ authenticated: true, canEdit: true, user: null });
104
+ return;
105
+ }
106
+
107
+ const token = extractToken(req.headers.cookie);
108
+ const user = await resolveUser(token);
109
+
110
+ if (!user) {
111
+ res.json({ authenticated: false, canEdit: false, user: null });
112
+ return;
113
+ }
114
+
115
+ res.json({
116
+ authenticated: true,
117
+ canEdit: user.canEdit,
118
+ user: {
119
+ name: user.name,
120
+ fullName: user.fullName,
121
+ avatarUrl: user.avatarUrl,
122
+ },
123
+ });
124
+ });
125
+
126
  // ---------- Collab WebSocket ----------
127
 
128
  app.ws("/collab/:doc", (ws, req) => {