| 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 |
| } |
|
|
| |
|
|
| const tokenCache = new Map() |
|
|
| 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 |
| } |
|
|
| |
| |
| 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() |
| } |
| } |
|
|
| |
| 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() |
| } |
|
|
| |
| 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() |
| } |
|
|
| |
|
|
| 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) { |
| |
| 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 |
| } |
|
|
| |
| |
| |
| const pendingStates = new Map() |
| 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()}`) |
| } |
|
|
| |
| |
| |
| function cookieFlags() { |
| return OAUTH_ENABLED ? ' HttpOnly; SameSite=None; Secure' : ' HttpOnly; SameSite=Lax' |
| } |
|
|