Spaces:
Running
Running
| import crypto from 'node:crypto' | |
| import fs from 'node:fs' | |
| import path from 'node:path' | |
| import { fileURLToPath } from 'node:url' | |
| import { signSession, verifySession, parseCookies } from './util.js' | |
| import { sessionSecret } from './store.js' | |
| const __an_dirname = path.dirname(fileURLToPath(import.meta.url)) | |
| export const BUILD_ID = (() => { | |
| try { | |
| return fs.readFileSync(path.join(__an_dirname, '..', 'public', 'build-id'), 'utf8').trim() || 'dev' | |
| } catch { | |
| return 'dev' | |
| } | |
| })() | |
| const SECRET = sessionSecret() | |
| const PROVIDER = process.env.OPENID_PROVIDER_URL || 'https://huggingface.co' | |
| const CLIENT_ID = process.env.OAUTH_CLIENT_ID | |
| const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET | |
| export const OAUTH_ENABLED = Boolean(CLIENT_ID && CLIENT_SECRET) | |
| export const HOST = process.env.SPACE_HOST | |
| ? `https://${process.env.SPACE_HOST}` | |
| : `http://localhost:${process.env.PORT || 3000}` | |
| const COOKIE = 'ie_session' | |
| export function sessionFromCookieHeader(cookieHeader) { | |
| const cookies = parseCookies(cookieHeader) | |
| const session = verifySession(cookies[COOKIE], SECRET) | |
| if (session?.u) return { username: session.u, name: session.n || session.u, avatar: session.p || null } | |
| return null | |
| } | |
| // --- HF token -> username (agents authenticate with a personal HF token) --- | |
| const tokenCache = new Map() // token -> { user, exp } | |
| export async function userFromToken(token) { | |
| if (!token) return null | |
| if (!OAUTH_ENABLED && token.startsWith('dev:')) { | |
| const u = token.slice(4) | |
| return u ? { username: u, name: u, avatar: null } : null | |
| } | |
| const cached = tokenCache.get(token) | |
| if (cached && cached.exp > Date.now()) return cached.user | |
| try { | |
| const res = await fetch('https://huggingface.co/api/whoami-v2', { | |
| headers: { authorization: `Bearer ${token}` }, | |
| }) | |
| if (!res.ok) return null | |
| const body = await res.json() | |
| if (!body?.name) return null | |
| const user = { username: body.name, name: body.fullname || body.name, avatar: body.avatarUrl || null } | |
| tokenCache.set(token, { user, exp: Date.now() + 5 * 60 * 1000 }) | |
| return user | |
| } catch { | |
| return null | |
| } | |
| } | |
| export function bearerFromRequest(req) { | |
| const auth = req.headers['authorization'] | |
| if (auth?.startsWith('Bearer ')) return auth.slice(7) | |
| if (req.headers['x-hf-token']) return req.headers['x-hf-token'] | |
| return null | |
| } | |
| // Express middleware: resolves req.user from session cookie, app-issued agent | |
| // key (Bearer ak_...), or HF token. Agent keys also set req.agent = { handle }. | |
| export function resolveUser() { | |
| return async (req, res, next) => { | |
| const session = sessionFromCookieHeader(req.headers.cookie) | |
| if (session) { | |
| req.user = session | |
| return next() | |
| } | |
| const bearer = bearerFromRequest(req) | |
| if (bearer?.startsWith('ak_')) { | |
| const { agentByKey } = await import('./store.js') | |
| const agent = agentByKey(bearer) | |
| if (agent) { | |
| req.user = { username: agent.owner, name: agent.owner, avatar: null } | |
| req.agent = { handle: agent.handle } | |
| } | |
| return next() | |
| } | |
| req.user = await userFromToken(bearer) | |
| next() | |
| } | |
| } | |
| // Some routes are for humans only (accepting suggestions, creating docs, sharing…) | |
| export function requireHuman(req, res, next) { | |
| if (req.agent) return res.status(403).json({ error: 'agent keys cannot perform this action — it requires a signed-in human' }) | |
| next() | |
| } | |
| // The Space owner is the instance admin (SPACE_AUTHOR_NAME is set by HF Spaces). | |
| const ADMIN_USER = process.env.SPACE_AUTHOR_NAME || process.env.ADMIN_USER || null | |
| export function isAdmin(username) { | |
| return Boolean(ADMIN_USER && username === ADMIN_USER) | |
| } | |
| export function requireUser(req, res, next) { | |
| if (!req.user) return res.status(401).json({ error: 'authentication required (sign in, or pass an HF token as Authorization: Bearer)' }) | |
| next() | |
| } | |
| // --- routes --- | |
| // What the client needs before it can render anything. The HTML shell carries | |
| // this inline (window.__BOOT), so a page load no longer spends a round trip | |
| // asking who you are; /api/me stays as the fallback for a shell without it. | |
| export function bootPayload(req) { | |
| return { user: req.user || null, oauth: OAUTH_ENABLED, is_admin: req.user ? isAdmin(req.user.username) : false, build_id: BUILD_ID } | |
| } | |
| export function registerAuthRoutes(app) { | |
| app.get('/api/me', (req, res) => { | |
| res.json(bootPayload(req)) | |
| }) | |
| app.get('/auth/logout', (req, res) => { | |
| res.setHeader('set-cookie', `${COOKIE}=; Path=/; Max-Age=0;${cookieFlags()}`) | |
| res.redirect('/') | |
| }) | |
| if (!OAUTH_ENABLED) { | |
| // Dev-only login: /auth/dev?u=alice | |
| app.get('/auth/dev', (req, res) => { | |
| const u = String(req.query.u || '').trim() | |
| if (!/^[a-zA-Z0-9_.-]{1,40}$/.test(u)) return res.status(400).send('bad username') | |
| setSessionCookie(res, { u, n: u, p: null }) | |
| res.redirect(String(req.query.next || '/')) | |
| }) | |
| app.get('/auth/login', (req, res) => { | |
| res.redirect('/auth/dev?u=dev-user&next=' + encodeURIComponent(String(req.query.next || '/'))) | |
| }) | |
| return | |
| } | |
| // CSRF state lives server-side (like tfrere/research-article-template-editor): | |
| // a state cookie would be third-party inside the huggingface.co iframe and | |
| // silently dropped by the browser, breaking the callback check. | |
| const pendingStates = new Map() // state -> { ts, next } | |
| const cleanupStates = () => { | |
| const now = Date.now() | |
| for (const [state, v] of pendingStates) { | |
| if (now - v.ts > 10 * 60 * 1000) pendingStates.delete(state) | |
| } | |
| } | |
| app.get('/auth/login', (req, res) => { | |
| cleanupStates() | |
| const state = crypto.randomBytes(16).toString('hex') | |
| pendingStates.set(state, { ts: Date.now(), next: safeNext(req.query.next) }) | |
| const url = new URL(`${PROVIDER}/oauth/authorize`) | |
| url.searchParams.set('client_id', CLIENT_ID) | |
| url.searchParams.set('redirect_uri', `${HOST}/auth/callback`) | |
| url.searchParams.set('response_type', 'code') | |
| url.searchParams.set('scope', 'openid profile') | |
| url.searchParams.set('state', state) | |
| res.redirect(url.toString()) | |
| }) | |
| app.get('/auth/callback', async (req, res) => { | |
| try { | |
| const state = String(req.query.state || '') | |
| const pending = pendingStates.get(state) | |
| if (!req.query.code || !pending) { | |
| return res.status(400).send('Sign-in session expired or unknown — please go back and sign in again.') | |
| } | |
| pendingStates.delete(state) | |
| const tokenRes = await fetch(`${PROVIDER}/oauth/token`, { | |
| method: 'POST', | |
| headers: { 'content-type': 'application/x-www-form-urlencoded' }, | |
| body: new URLSearchParams({ | |
| grant_type: 'authorization_code', | |
| code: String(req.query.code || ''), | |
| redirect_uri: `${HOST}/auth/callback`, | |
| client_id: CLIENT_ID, | |
| client_secret: CLIENT_SECRET, | |
| }), | |
| }) | |
| if (!tokenRes.ok) return res.status(502).send('token exchange failed: ' + (await tokenRes.text()).slice(0, 200)) | |
| const { access_token } = await tokenRes.json() | |
| const infoRes = await fetch(`${PROVIDER}/oauth/userinfo`, { headers: { authorization: `Bearer ${access_token}` } }) | |
| if (!infoRes.ok) return res.status(502).send('userinfo failed') | |
| const info = await infoRes.json() | |
| const username = info.preferred_username || info.sub | |
| setSessionCookie(res, { u: username, n: info.name || username, p: info.picture || null }) | |
| res.redirect(pending.next || '/') | |
| } catch (err) { | |
| res.status(500).send('auth error: ' + err.message) | |
| } | |
| }) | |
| } | |
| function safeNext(next) { | |
| const s = String(next || '/') | |
| return s.startsWith('/') && !s.startsWith('//') ? s : '/' | |
| } | |
| function setSessionCookie(res, payload) { | |
| const value = signSession(payload, SECRET) | |
| res.setHeader('set-cookie', `${COOKIE}=${value}; Path=/; Max-Age=${30 * 24 * 3600};${cookieFlags()}`) | |
| } | |
| // The Space is often used embedded in an iframe on huggingface.co, where the app | |
| // origin (hf.space) is cross-site: SameSite=Lax cookies are never sent there. | |
| // SameSite=None lets the session cookie work embedded. | |
| function cookieFlags() { | |
| return OAUTH_ENABLED ? ' HttpOnly; SameSite=None; Secure' : ' HttpOnly; SameSite=Lax' | |
| } | |