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