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