# **File: COMMIT\_03\_AUTH.md** ## **Commit 3: ATproto OAuth Flow & SurrealDB JWT Generation** ### **Objective** Implement the complete, high-risk ATproto OAuth flow. This is a complex, multi-step process: 1. Resolve the user's handle to their PDS.9 2. Discover the PDS's specific authorization\_endpoint.10 3. Redirect the user to that endpoint. 4. Handle the callback, exchange the code for an ATproto token.11 5. Use the ATproto token to get the user's canonical did. 6. Mint a new, *app-specific* SurrealDB JWT containing the did claim. 7. Set this SurrealDB JWT in a secure, httpOnly cookie for app session management. This commit directly addresses the user's identified **Risk 1**. ### **Implementation Specification** **1\. Create lib/auth/atproto.ts** Create a file at /lib/auth/atproto.ts to abstract the complex ATproto discovery logic: TypeScript import { AtpAgent } from '@atproto/api'; /\*\* \* Resolves a Bluesky handle (e.g., "user.bsky.social") to its \* corresponding PDS (Personal Data Server) and DID (Decentralized Identifier). \* This discovery step is mandatory before initiating OAuth. \*/ export async function resolveHandle(handle: string) { try { const agent \= new AtpAgent({ service: 'https://bsky.social' }); const response \= await agent.resolveHandle({ handle }); const did \= response.data.did; // Now, get the PDS from the DID document const didDoc \= await agent.getDidDocument({ did }); // Find the 'atproto\_pds' service endpoint const pdsService \= didDoc.service?.find( (s) \=\> s.id \=== '\#atproto\_pds' ); if (\!pdsService?.serviceEndpoint) { throw new Error('PDS service endpoint not found in DID document.'); } return { did, pdsUrl: pdsService.serviceEndpoint, }; } catch (error) { console.error('Error resolving handle:', error); throw new Error('Could not resolve Bluesky handle.'); } } /\*\* \* Fetches the specific OAuth endpoints for a given PDS. \* Each PDS has its own set of endpoints. \*/ export async function getAuthEndpoints(pdsUrl: string) { try { const metadataUrl \= \`${pdsUrl}/.well-known/oauth-authorization-server\`; const response \= await fetch(metadataUrl); if (\!response.ok) { throw new Error(\`Failed to fetch auth metadata from ${pdsUrl}\`); } const metadata \= await response.json(); const { authorization\_endpoint, token\_endpoint } \= metadata; if (\!authorization\_endpoint ||\!token\_endpoint) { throw new Error('Invalid auth metadata received from PDS.'); } return { authorizationEndpoint: authorization\_endpoint, tokenEndpoint: token\_endpoint, }; } catch (error) { console.error('Error getting auth endpoints:', error); throw new Error('Could not discover OAuth endpoints.'); } } **2\. Create lib/auth/jwt.ts** Create a file at /lib/auth/jwt.ts to mint our app's internal JWT for SurrealDB: TypeScript import jwt from 'jsonwebtoken'; /\*\* \* Mints a new JWT for our application's session management. \* This token is what SurrealDB will validate. \* \* @param did \- The user's canonical ATproto DID (e.g., "did:plc:...") \* @param handle \- The user's Bluesky handle (e.g., "user.bsky.social") \* @returns A signed JWT string. \*/ export function mintSurrealJwt(did: string, handle: string): string { const secret \= process.env.SURREALDB\_JWT\_SECRET; if (\!secret) { throw new Error('SURREALDB\_JWT\_SECRET is not set in environment.'); } // This payload is critical. The \`did\` claim will be used // in SurrealDB's PERMISSIONS clauses. const payload \= { // Standard JWT claims iss: 'Ponderants', aud: 'SurrealDB', // Custom claims did: did, handle: handle, }; // Token expires in 7 days const token \= jwt.sign(payload, secret, { algorithm: 'HS512', expiresIn: '7d', }); return token; } **3\. Create Login Page (app/login/page.tsx)** Create a file at /app/login/page.tsx: TypeScript 'use client'; import { Button, Center, Paper, Stack, TextInput, Title, Text, } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; export default function LoginPage() { const \[isLoading, setIsLoading\] \= useState(false); const router \= useRouter(); const searchParams \= useSearchParams(); const error \= searchParams.get('error'); const form \= useForm({ initialValues: { handle: '', }, }); const handleSubmit \= async (values: { handle: string }) \=\> { setIsLoading(true); // We redirect to our \*own\* API route, which will then // perform discovery and redirect to the correct Bluesky PDS. // This keeps all complex logic and secrets on the server. router.push(\`/api/auth/login?handle=${values.handle}\`); }; return ( \
\ \
\ \ Log in to Ponderants \</Title\> \<Text ta="center" c="dimmed" size="sm"\> Log in with your Bluesky handle. \</Text\> {error && ( \<Paper withBorder p="sm" bg="red.9"\> \<Text c="white" size="sm"\> Login Failed: {error} \</Text\> \</Paper\> )} \<TextInput label="Your Handle" placeholder="e.g., yourname.bsky.social" required {...form.getInputProps('handle')} /\> \<Button type="submit" loading={isLoading} fullWidth\> Log in with Bluesky \</Button\> \</Stack\> \</form\> \</Paper\> \</Center\> ); } **4\. Create Auth Start Route (app/api/auth/login/route.ts)** Create a file at /app/api/auth/login/route.ts. This route starts the OAuth dance: TypeScript import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto'; import { generators } from 'openid-client'; // For PKCE const CLIENT\_ID \= process.env.BLUESKY\_CLIENT\_ID; // e.g., 'https://ponderants.com/client-metadata.json' const REDIRECT\_URI \= process.env.BLUESKY\_REDIRECT\_URI; // e.g., 'http://localhost:3000/api/auth/callback' export async function GET(request: NextRequest) { if (\!CLIENT\_ID ||\!REDIRECT\_URI) { throw new Error('Bluesky client configuration is missing.'); } const { searchParams } \= new URL(request.url); const handle \= searchParams.get('handle'); if (\!handle) { return NextResponse.redirect(new URL('/login?error=Handle missing', request.url)); } try { // 1\. Resolve handle to get PDS const { pdsUrl } \= await resolveHandle(handle); // 2\. Discover PDS-specific auth endpoints const { authorizationEndpoint } \= await getAuthEndpoints(pdsUrl); // 3\. Generate PKCE challenge and state const state \= generators.state(); const code\_verifier \= generators.codeVerifier(); const code\_challenge \= generators.codeChallenge(code\_verifier); // 4\. Store verifier and state in a temporary cookie cookies().set('atproto\_oauth\_state', state, { httpOnly: true, maxAge: 600 }); cookies().set('atproto\_pkce\_verifier', code\_verifier, { httpOnly: true, maxAge: 600 }); cookies().set('atproto\_pds\_url', pdsUrl, { httpOnly: true, maxAge: 600 }); // 5\. Construct the authorization URL const authUrl \= new URL(authorizationEndpoint); authUrl.searchParams.set('response\_type', 'code'); authUrl.searchParams.set('client\_id', CLIENT\_ID); authUrl.searchParams.set('redirect\_uri', REDIRECT\_URI); authUrl.searchParams.set('scope', 'atproto'); // Request full access authUrl.searchParams.set('code\_challenge', code\_challenge); authUrl.searchParams.set('code\_challenge\_method', 'S256'); authUrl.searchParams.set('state', state); // 6\. Redirect user to the PDS login screen return NextResponse.redirect(authUrl); } catch (error) { console.error('Auth login error:', error); return NextResponse.redirect(new URL('/login?error=Invalid handle or PDS', request.url)); } } **5\. Create Auth Callback Route (app/api/auth/callback/route.ts)** Create a file at /app/api/auth/callback/route.ts. This route handles the user's return: TypeScript import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { getAuthEndpoints } from '@/lib/auth/atproto'; import { mintSurrealJwt } from '@/lib/auth/jwt'; import { AtpAgent } from '@atproto/api'; const CLIENT\_ID \= process.env.BLUESKY\_CLIENT\_ID; const REDIRECT\_URI \= process.env.BLUESKY\_REDIRECT\_URI; export async function GET(request: NextRequest) { const { searchParams } \= new URL(request.url); const code \= searchParams.get('code'); const state \= searchParams.get('state'); // Get temporary values from cookies const cookieState \= cookies().get('atproto\_oauth\_state')?.value; const code\_verifier \= cookies().get('atproto\_pkce\_verifier')?.value; const pdsUrl \= cookies().get('atproto\_pds\_url')?.value; // Clear temporary cookies cookies().delete('atproto\_oauth\_state'); cookies().delete('atproto\_pkce\_verifier'); cookies().delete('atproto\_pds\_url'); // 1\. Validate state (CSRF protection) if (\!state | | state\!== cookieState) { return NextResponse.redirect(new URL('/login?error=Invalid state', request.url)); } // 2\. Check for errors if (\!code ||\!pdsUrl ||\!code\_verifier) { return NextResponse.redirect(new URL('/login?error=Callback failed', request.url)); } try { // 3\. Get the PDS's token endpoint const { tokenEndpoint } \= await getAuthEndpoints(pdsUrl); // 4\. Exchange the code for an ATproto access token const tokenResponse \= await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant\_type: 'authorization\_code', code: code, redirect\_uri: REDIRECT\_URI\!, client\_id: CLIENT\_ID\!, code\_verifier: code\_verifier, }), }); if (\!tokenResponse.ok) { throw new Error('Failed to exchange code for token'); } const { access\_token, refresh\_token } \= await tokenResponse.json(); // 5\. Use the ATproto token to get the user's session info (did, handle) const agent \= new AtpAgent({ service: pdsUrl }); agent.resumeSession({ accessJwt: access\_token, refreshJwt: refresh\_token, did: '', // We don't know it yet handle: '', // We don't know it yet }); // getSession will populate the agent with the correct did/handle const session \= await agent.getSession(); if (\!session.did ||\!session.handle) { throw new Error('Failed to retrieve user session details'); } // 6\. Mint OUR app's SurrealDB JWT const surrealJwt \= mintSurrealJwt(session.did, session.handle); // 7\. Set the SurrealDB JWT in a secure cookie for our app cookies().set('ponderants-auth', surrealJwt, { httpOnly: true, secure: process.env.NODE\_ENV \=== 'production', maxAge: 60 \* 60 \* 24 \* 7, // 7 days path: '/', }); // (Hackathon Strategy: Store the ATproto tokens for later use. // In production, this would go in an encrypted DB store.) cookies().set('atproto\_access\_token', access\_token, { httpOnly: true, secure: process.env.NODE\_ENV \=== 'production', maxAge: 60 \* 60, path: '/', }); cookies().set('atproto\_refresh\_token', refresh\_token, { httpOnly: true, secure: process.env.NODE\_ENV \=== 'production', maxAge: 60 \* 60 \* 24 \* 30, path: '/', }); // 8\. Redirect to the main application return NextResponse.redirect(new URL('/chat', request.url)); } catch (error) { console.error('Auth callback error:', error); return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url)); } } ### **Test Specification** **1\. Create Test File (tests/magnitude/03-auth.mag.ts)** Create a file at /tests/magnitude/03-auth.mag.ts: TypeScript import { test } from 'magnitude-test'; test('\[Happy Path\] User can log in with Bluesky', async (agent) \=\> { // Act: Go to login page await agent.act('Navigate to /login'); // Act: Enter handle and submit await agent.act( 'Enter "testuser.bsky.social" into the "Your Handle" input' ); await agent.act('Click the "Log in with Bluesky" button'); // Check: Verify redirect to our API route // (Magnitude will follow this redirect) // We will mock the external OAuth flow. // We simulate a successful return from Bluesky to our callback. await agent.act( 'Simulate a successful OAuth callback by navigating to the /api/auth/callback route with a valid code and state', { // This requires mocking the server's API routes to bypass the // external fetch and PKCE/state verification. // For the hackathon, we can test this by // mocking the 'fetch' and 'cookies' calls. // A simpler E2E test for magnitude: // 1\. Mock '/api/auth/login' to redirect to '/api/auth/callback' // 2\. Mock '/api/auth/callback' to return a redirect to '/chat' // and set the 'ponderants-auth' cookie. // Let's assume magnitude can check for redirects and cookies: // The previous 'click' action on 'Log in' (which hits /api/auth/login) // would be mocked to redirect to the external Bluesky URL. // We can't follow that. // Let's test the callback handling directly. // We assume the user has already been to Bluesky and is now // hitting our callback. url: '/api/auth/callback?code=mockcode\&state=mockstate', // We must mock the server-side logic in the test setup // to validate 'mockstate' and 'mockcode' and return the // final redirect \+ cookie. } ); // Check: User is redirected to the main app await agent.check('The browser URL is now "http://localhost:3000/chat"'); // Check: The session cookie is set await agent.check('A secure, httpOnly cookie named "ponderants-auth" is set'); }); test('\[Unhappy Path\] User sees error on failed auth', async (agent) \=\> { // Act: Simulate a failed callback from Bluesky await agent.act( 'Navigate to the /api/auth/callback route with an error', { url: '/api/auth/callback?error=invalid\_grant', } ); // Check: User is redirected back to the login page await agent.check('The browser URL is now "http://localhost:3000/login"'); // Check: An error message is displayed await agent.check('The text "Login Failed: Callback failed" is visible'); }); test('\[Unhappy Path\] User sees error for invalid handle', async (agent) \=\> { // Act: Go to login page await agent.act('Navigate to /login'); // Act: Enter a non-existent handle await agent.act( 'Enter "nonexistent.handle.xyz" into the "Your Handle" input' ); // (We mock the /api/auth/login route to fail resolution) await agent.act('Click the "Log in with Bluesky" button'); // Check: User is redirected back to login with an error await agent.check('The browser URL is now "http://localhost:3000/login"'); await agent.check('The text "Login Failed: Invalid handle or PDS" is visible'); });