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 })