cowrite / server /auth.js
lvwerra's picture
lvwerra HF Staff
Upload folder using huggingface_hub
704a62b verified
Raw
History Blame Contribute Delete
7.91 kB
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'
}