# **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 (
\
\
\
\
\
);
}
**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');
});