init
This commit is contained in:
477
docs/steps/step-03.md
Normal file
477
docs/steps/step-03.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# **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');
|
||||
});
|
||||
Reference in New Issue
Block a user