Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at redirects 3.8 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 { 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 })