Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { Elysia, t } from 'elysia'
2import { NodeOAuthClient } from '@atproto/oauth-client-node'
3import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db'
4import { syncSitesFromPDS } from '../lib/sync-sites'
5import { authenticateRequest } from '../lib/wisp-auth'
6import { logger } from '../lib/observability'
7
8export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
9 cookie: {
10 secrets: cookieSecret,
11 sign: ['did']
12 }
13 })
14 .post('/api/auth/signin', async (c) => {
15 let handle = 'unknown'
16 try {
17 const body = c.body as { handle: string }
18 handle = body.handle
19 logger.info('Sign-in attempt', { handle })
20 const state = crypto.randomUUID()
21 const url = await client.authorize(handle, { state })
22 logger.info('Authorization URL generated', { handle })
23 return { url: url.toString() }
24 } catch (err) {
25 logger.error('Signin error', err, { handle })
26 console.error('[Auth] Full error:', err)
27 return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) }
28 }
29 })
30 .get('/api/auth/callback', async (c) => {
31 try {
32 const params = new URLSearchParams(c.query)
33
34 // client.callback() validates the state parameter internally
35 // It will throw an error if state validation fails (CSRF protection)
36 const { session } = await client.callback(params)
37
38 if (!session) {
39 logger.error('[Auth] OAuth callback failed: no session returned')
40 c.cookie.did.remove()
41 return c.redirect('/?error=auth_failed')
42 }
43
44 const cookieSession = c.cookie
45 cookieSession.did.set({
46 value: session.did,
47 httpOnly: true,
48 secure: process.env.NODE_ENV === 'production',
49 sameSite: 'lax',
50 maxAge: 30 * 24 * 60 * 60 // 30 days
51 })
52
53 // Sync sites from PDS to database cache
54 logger.debug('[Auth] Syncing sites from PDS for', session.did)
55 try {
56 const syncResult = await syncSitesFromPDS(session.did, session)
57 logger.debug(`[Auth] Sync complete: ${syncResult.synced} sites synced`)
58 if (syncResult.errors.length > 0) {
59 logger.debug('[Auth] Sync errors:', syncResult.errors)
60 }
61 } catch (err) {
62 logger.error('[Auth] Failed to sync sites', err)
63 // Don't fail auth if sync fails, just log it
64 }
65
66 // Check if user has any sites or domain
67 const sites = await getSitesByDid(session.did)
68 const domain = await getDomainByDid(session.did)
69
70 // If no sites and no domain, redirect to onboarding
71 if (sites.length === 0 && !domain) {
72 return c.redirect('/onboarding')
73 }
74
75 return c.redirect('/editor')
76 } catch (err) {
77 // This catches state validation failures and other OAuth errors
78 logger.error('[Auth] OAuth callback error', err)
79 c.cookie.did.remove()
80 return c.redirect('/?error=auth_failed')
81 }
82 })
83 .post('/api/auth/logout', async (c) => {
84 try {
85 const cookieSession = c.cookie
86 const did = cookieSession.did?.value
87
88 // Clear the session cookie
89 cookieSession.did.remove()
90
91 // If we have a DID, try to revoke the OAuth session
92 if (did && typeof did === 'string') {
93 try {
94 await client.revoke(did)
95 logger.debug('[Auth] Revoked OAuth session for', did)
96 } catch (err) {
97 logger.error('[Auth] Failed to revoke session', err)
98 // Continue with logout even if revoke fails
99 }
100 }
101
102 return { success: true }
103 } catch (err) {
104 logger.error('[Auth] Logout error', err)
105 return { error: 'Logout failed' }
106 }
107 })
108 .get('/api/auth/status', async (c) => {
109 try {
110 const auth = await authenticateRequest(client, c.cookie)
111
112 if (!auth) {
113 c.cookie.did.remove()
114 return { authenticated: false }
115 }
116
117 return {
118 authenticated: true,
119 did: auth.did
120 }
121 } catch (err) {
122 logger.error('[Auth] Status check error', err)
123 c.cookie.did.remove()
124 return { authenticated: false }
125 }
126 })