feat: Complete Step 3 & 4 - OAuth + SurrealDB schema

Step 3: ATproto OAuth + SurrealDB JWT
- Implement database-backed OAuth state storage (lib/auth/oauth-state.ts)
- Add session helpers for JWT decoding (lib/auth/session.ts)
- Fix OAuth callback to properly handle state retrieval
- Create /chat page displaying authenticated user handle
- Configure headless mode for Magnitude testing

Step 4: SurrealDB Schema & Permissions
- Define JWT-based access control (HS512 algorithm)
- Create user table with DID-based identity
- Create node table with row-level security (users can only access their own data)
- Create links_to relation table for graph edges
- Define vector search index (1536 dimensions for gemini-embedding-001)
- Add Docker Compose for local SurrealDB development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 23:51:19 +00:00
parent bf163e2607
commit 8e14395eaf
9 changed files with 366 additions and 29 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getAuthEndpoints } from '@/lib/auth/atproto';
import { consumeOAuthState } from '@/lib/auth/oauth-state';
import { mintSurrealJwt } from '@/lib/auth/jwt';
import { AtpAgent } from '@atproto/api';
@@ -12,27 +13,23 @@ export async function GET(request: NextRequest) {
const code = searchParams.get('code');
const state = searchParams.get('state');
// Get temporary values from cookies
const cookieStore = await cookies();
const cookieState = cookieStore.get('atproto_oauth_state')?.value;
const code_verifier = cookieStore.get('atproto_pkce_verifier')?.value;
const pdsUrl = cookieStore.get('atproto_pds_url')?.value;
// Clear temporary cookies
cookieStore.delete('atproto_oauth_state');
cookieStore.delete('atproto_pkce_verifier');
cookieStore.delete('atproto_pds_url');
// 1. Validate state (CSRF protection)
if (!state || state !== cookieState) {
return NextResponse.redirect(new URL('/login?error=Invalid state', request.url));
// 1. Validate state parameter
if (!state || !code) {
return NextResponse.redirect(new URL('/login?error=Missing OAuth parameters', request.url));
}
// 2. Check for errors
if (!code || !pdsUrl || !code_verifier) {
return NextResponse.redirect(new URL('/login?error=Callback failed', request.url));
// 2. Retrieve OAuth state from database (this also deletes it for one-time use)
console.log('[OAuth Callback] Looking up state:', state);
const oauthState = await consumeOAuthState(state);
console.log('[OAuth Callback] Retrieved oauthState:', oauthState);
if (!oauthState) {
console.error('[OAuth Callback] Invalid or expired state:', state);
return NextResponse.redirect(new URL('/login?error=Invalid or expired state', request.url));
}
const { codeVerifier: code_verifier, pdsUrl } = oauthState;
try {
// 3. Get the PDS's token endpoint
const { tokenEndpoint } = await getAuthEndpoints(pdsUrl);
@@ -75,30 +72,36 @@ export async function GET(request: NextRequest) {
// 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
cookieStore.set('ponderants-auth', surrealJwt, {
// 7. Create redirect response
const response = NextResponse.redirect(new URL('/chat', request.url));
// 8. Set the SurrealDB JWT in a secure cookie on the response
response.cookies.set('ponderants-auth', surrealJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
// Store the ATproto tokens for later use
cookieStore.set('atproto_access_token', access_token, {
response.cookies.set('atproto_access_token', access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60,
path: '/',
});
cookieStore.set('atproto_refresh_token', refresh_token, {
response.cookies.set('atproto_refresh_token', refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
// 8. Redirect to the main application
return NextResponse.redirect(new URL('/chat', request.url));
// 9. Redirect to the main application
return response;
} catch (error) {
console.error('Auth callback error:', error);
return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url));

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto';
import { storeOAuthState } from '@/lib/auth/oauth-state';
import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client';
const CLIENT_ID = process.env.BLUESKY_CLIENT_ID;
@@ -30,11 +30,8 @@ export async function GET(request: NextRequest) {
const code_verifier = randomPKCECodeVerifier();
const code_challenge = await calculatePKCECodeChallenge(code_verifier);
// 4. Store verifier and state in a temporary cookie
const cookieStore = await cookies();
cookieStore.set('atproto_oauth_state', state, { httpOnly: true, maxAge: 600 });
cookieStore.set('atproto_pkce_verifier', code_verifier, { httpOnly: true, maxAge: 600 });
cookieStore.set('atproto_pds_url', pdsUrl, { httpOnly: true, maxAge: 600 });
// 4. Store OAuth state in SurrealDB (not cookies, as they don't survive external redirects)
await storeOAuthState(state, code_verifier, pdsUrl);
// 5. Construct the authorization URL
const authUrl = new URL(authorizationEndpoint);

30
app/chat/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth/session';
import { Center, Paper, Stack, Title, Text } from '@mantine/core';
export default async function ChatPage() {
const user = await getCurrentUser();
// Redirect to login if not authenticated
if (!user) {
redirect('/login');
}
return (
<Center h="100vh">
<Paper w={600} p="xl">
<Stack>
<Title order={1} ta="center">
Welcome to Ponderants
</Title>
<Text ta="center" c="dimmed" size="sm">
Logged in as: <Text component="span" fw={700} c="white">{user.handle}</Text>
</Text>
<Text ta="center" c="dimmed" size="xs">
DID: {user.did}
</Text>
</Stack>
</Paper>
</Center>
);
}