File size: 6,860 Bytes
6a685c7
43370b3
 
 
 
6a685c7
 
 
 
 
 
 
 
43370b3
 
 
 
 
6a685c7
43370b3
 
 
 
 
 
 
 
6a685c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43370b3
 
6a685c7
 
 
43370b3
 
 
 
 
 
6a685c7
 
 
 
 
 
 
43370b3
6a685c7
43370b3
 
6a685c7
 
 
 
 
 
 
43370b3
 
 
 
 
 
 
 
 
 
 
2cd1b66
 
 
 
 
 
 
 
 
 
 
43370b3
 
 
 
 
 
 
 
 
 
 
 
 
 
6a685c7
43370b3
 
6a685c7
 
 
 
 
43370b3
 
 
 
 
 
 
 
6a685c7
 
43370b3
6a685c7
43370b3
 
 
 
6a685c7
 
 
43370b3
 
 
 
 
6a685c7
 
43370b3
 
 
 
 
 
 
 
 
6a685c7
 
43370b3
 
 
 
 
 
 
 
 
 
 
 
6a685c7
43370b3
 
 
6a685c7
 
43370b3
6a685c7
43370b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
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('/')
  })
}