478 lines
16 KiB
Markdown
478 lines
16 KiB
Markdown
# **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');
|
|
});
|