Files
app/docs/steps/step-03.md
2025-11-08 12:44:39 +00:00

16 KiB

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 (
<Center h="100vh">
<Paper w={400} p="xl">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Title order={2} ta="center">
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');
});