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