Spaces:
Sleeping
Sleeping
| 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<string, Discovery>() | |
| async function getDiscovery(p: ProviderConfig): Promise<Discovery> { | |
| 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<SessionUser | null> { | |
| 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<SessionUser | null> { | |
| 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('/') | |
| }) | |
| } | |