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 --- export function registerAuthRoutes(app) { app.get('/api/me', (req, res) => { res.json({ user: req.user || null, oauth: OAUTH_ENABLED, is_admin: req.user ? isAdmin(req.user.username) : false, build_id: BUILD_ID }) }) 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' }