marp-share / server /src /auth.ts
Nipun's picture
Multi-deck: unique presentation IDs + per-user list + sharing
2cd1b66 verified
Raw
History Blame Contribute Delete
6.86 kB
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('/')
})
}