import type { Context, Hono } from 'hono' import { sign, verify } from 'hono/jwt' import { getCookie, setCookie, deleteCookie } from 'hono/cookie' /** * Pluggable OpenID Connect sign-in with a guest fallback. * * - Providers are configured from env and auto-detected: * Hugging Face → OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET (injected by `hf_oauth: true`) * Google → GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET * (any OIDC provider can be added the same way.) * - AUTH_REQUIRED=true forces sign-in. Otherwise anyone may use the app as a guest, and * signing in simply upgrades them to their real identity. */ export interface SessionUser { id: string name: string avatar?: string provider?: string } const SECRET = process.env.SESSION_SECRET || 'dev-insecure-secret-change-me' const ALG = 'HS256' as const const SESSION_COOKIE = 'ms_session' const STATE_COOKIE = 'ms_oauth_state' const WEEK = 60 * 60 * 24 * 7 interface ProviderConfig { id: string label: string clientId: string clientSecret: string discoveryUrl: string scopes: string } function loadProviders(): ProviderConfig[] { const list: ProviderConfig[] = [] const hfId = process.env.OAUTH_CLIENT_ID const hfSecret = process.env.OAUTH_CLIENT_SECRET if (hfId && hfSecret) { const base = process.env.OPENID_PROVIDER_URL || 'https://huggingface.co' list.push({ id: 'hf', label: 'Hugging Face', clientId: hfId, clientSecret: hfSecret, discoveryUrl: `${base}/.well-known/openid-configuration`, scopes: process.env.OAUTH_SCOPES || 'openid profile', }) } const gId = process.env.GOOGLE_CLIENT_ID const gSecret = process.env.GOOGLE_CLIENT_SECRET if (gId && gSecret) { list.push({ id: 'google', label: 'Google', clientId: gId, clientSecret: gSecret, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', scopes: 'openid profile email', }) } return list } const PROVIDERS = loadProviders() export const providers = PROVIDERS.map((p) => ({ id: p.id, label: p.label })) export const authRequired = process.env.AUTH_REQUIRED === 'true' interface Discovery { authorization_endpoint: string token_endpoint: string userinfo_endpoint: string } const discoveryCache = new Map() async function getDiscovery(p: ProviderConfig): Promise { let d = discoveryCache.get(p.id) if (!d) { const res = await fetch(p.discoveryUrl) d = (await res.json()) as Discovery discoveryCache.set(p.id, d) } return d } function publicOrigin(c: Context): string { if (process.env.SPACE_HOST) return `https://${process.env.SPACE_HOST}` const proto = c.req.header('x-forwarded-proto') || 'https' const host = c.req.header('x-forwarded-host') || c.req.header('host') || 'localhost' return `${proto}://${host}` } const redirectUri = (c: Context, id: string) => `${publicOrigin(c)}/api/auth/callback/${id}` export async function verifySessionToken(token?: string): Promise { if (!token) return null try { const payload = (await verify(token, SECRET, ALG)) as { user?: SessionUser } return payload.user ?? null } catch { return null } } /** Read the signed-in user from the httpOnly session cookie (for REST routes). */ export async function getSessionUser(c: Context): Promise { const session = getCookie(c, SESSION_COOKIE) if (!session) return null try { return ((await verify(session, SECRET, ALG)) as { user?: SessionUser }).user ?? null } catch { return null } } export function registerAuth(app: Hono): void { app.get('/api/auth/me', async (c) => { let user: SessionUser | null = null const session = getCookie(c, SESSION_COOKIE) if (session) { try { user = ((await verify(session, SECRET, ALG)) as { user?: SessionUser }).user ?? null } catch { /* expired / invalid */ } } const collabToken = user ? await sign({ user, exp: Math.floor(Date.now() / 1000) + 3600 }, SECRET, ALG) : null return c.json({ providers, authRequired, user, collabToken }) }) app.get('/api/auth/login/:provider', async (c) => { const p = PROVIDERS.find((x) => x.id === c.req.param('provider')) if (!p) return c.redirect('/') const d = await getDiscovery(p) const state = `${p.id}:${crypto.randomUUID()}` setCookie(c, STATE_COOKIE, state, { httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 600, path: '/', }) const url = new URL(d.authorization_endpoint) url.searchParams.set('client_id', p.clientId) url.searchParams.set('redirect_uri', redirectUri(c, p.id)) url.searchParams.set('response_type', 'code') url.searchParams.set('scope', p.scopes) url.searchParams.set('state', state) return c.redirect(url.toString()) }) app.get('/api/auth/callback/:provider', async (c) => { const p = PROVIDERS.find((x) => x.id === c.req.param('provider')) if (!p) return c.redirect('/') const code = c.req.query('code') const state = c.req.query('state') if (!code || !state || state !== getCookie(c, STATE_COOKIE)) { return c.text('Invalid OAuth state', 400) } const d = await getDiscovery(p) const basic = Buffer.from(`${p.clientId}:${p.clientSecret}`).toString('base64') const tokenRes = await fetch(d.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}`, }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri(c, p.id), client_id: p.clientId, }), }) if (!tokenRes.ok) return c.text('Token exchange failed', 401) const token = (await tokenRes.json()) as { access_token: string } const userRes = await fetch(d.userinfo_endpoint, { headers: { Authorization: `Bearer ${token.access_token}` }, }) const info = (await userRes.json()) as { sub: string name?: string preferred_username?: string email?: string picture?: string } const user: SessionUser = { id: `${p.id}:${info.sub}`, name: info.preferred_username || info.name || info.email || 'User', avatar: info.picture, provider: p.id, } deleteCookie(c, STATE_COOKIE, { path: '/' }) const session = await sign({ user, exp: Math.floor(Date.now() / 1000) + WEEK }, SECRET, ALG) setCookie(c, SESSION_COOKIE, session, { httpOnly: true, secure: true, sameSite: 'Lax', maxAge: WEEK, path: '/', }) return c.redirect('/') }) app.get('/api/auth/logout', (c) => { deleteCookie(c, SESSION_COOKIE, { path: '/' }) return c.redirect('/') }) }