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 878c3a7582
commit 93ebb0948c
9 changed files with 366 additions and 29 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { getAuthEndpoints } from '@/lib/auth/atproto'; import { getAuthEndpoints } from '@/lib/auth/atproto';
import { consumeOAuthState } from '@/lib/auth/oauth-state';
import { mintSurrealJwt } from '@/lib/auth/jwt'; import { mintSurrealJwt } from '@/lib/auth/jwt';
import { AtpAgent } from '@atproto/api'; import { AtpAgent } from '@atproto/api';
@@ -12,27 +13,23 @@ export async function GET(request: NextRequest) {
const code = searchParams.get('code'); const code = searchParams.get('code');
const state = searchParams.get('state'); const state = searchParams.get('state');
// Get temporary values from cookies // 1. Validate state parameter
const cookieStore = await cookies(); if (!state || !code) {
const cookieState = cookieStore.get('atproto_oauth_state')?.value; return NextResponse.redirect(new URL('/login?error=Missing OAuth parameters', request.url));
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));
} }
// 2. Check for errors // 2. Retrieve OAuth state from database (this also deletes it for one-time use)
if (!code || !pdsUrl || !code_verifier) { console.log('[OAuth Callback] Looking up state:', state);
return NextResponse.redirect(new URL('/login?error=Callback failed', request.url)); 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 { try {
// 3. Get the PDS's token endpoint // 3. Get the PDS's token endpoint
const { tokenEndpoint } = await getAuthEndpoints(pdsUrl); const { tokenEndpoint } = await getAuthEndpoints(pdsUrl);
@@ -75,30 +72,36 @@ export async function GET(request: NextRequest) {
// 6. Mint OUR app's SurrealDB JWT // 6. Mint OUR app's SurrealDB JWT
const surrealJwt = mintSurrealJwt(session.did, session.handle); const surrealJwt = mintSurrealJwt(session.did, session.handle);
// 7. Set the SurrealDB JWT in a secure cookie for our app // 7. Create redirect response
cookieStore.set('ponderants-auth', surrealJwt, { 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, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/', path: '/',
}); });
// Store the ATproto tokens for later use // Store the ATproto tokens for later use
cookieStore.set('atproto_access_token', access_token, { response.cookies.set('atproto_access_token', access_token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, maxAge: 60 * 60,
path: '/', path: '/',
}); });
cookieStore.set('atproto_refresh_token', refresh_token, { response.cookies.set('atproto_refresh_token', refresh_token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30,
path: '/', path: '/',
}); });
// 8. Redirect to the main application // 9. Redirect to the main application
return NextResponse.redirect(new URL('/chat', request.url)); return response;
} catch (error) { } catch (error) {
console.error('Auth callback error:', error); console.error('Auth callback error:', error);
return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url)); 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 { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto'; import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto';
import { storeOAuthState } from '@/lib/auth/oauth-state';
import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client'; import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client';
const CLIENT_ID = process.env.BLUESKY_CLIENT_ID; const CLIENT_ID = process.env.BLUESKY_CLIENT_ID;
@@ -30,11 +30,8 @@ export async function GET(request: NextRequest) {
const code_verifier = randomPKCECodeVerifier(); const code_verifier = randomPKCECodeVerifier();
const code_challenge = await calculatePKCECodeChallenge(code_verifier); const code_challenge = await calculatePKCECodeChallenge(code_verifier);
// 4. Store verifier and state in a temporary cookie // 4. Store OAuth state in SurrealDB (not cookies, as they don't survive external redirects)
const cookieStore = await cookies(); await storeOAuthState(state, code_verifier, pdsUrl);
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 });
// 5. Construct the authorization URL // 5. Construct the authorization URL
const authUrl = new URL(authorizationEndpoint); 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>
);
}

91
db/schema.surql Normal file
View File

@@ -0,0 +1,91 @@
-- --------------------------------------------------
-- Ponderants :: SurrealDB Schema
-- --------------------------------------------------
-- --------------------------------------------------
-- Access Control (JWT)
-- --------------------------------------------------
-- Define the JWT access method. This tells SurrealDB to trust
-- JWTs signed by our Next.js backend using the HS512 algorithm
-- and the secret key provided in the environment.
-- (Note: DEFINE TOKEN is deprecated as of 2.x)
DEFINE ACCESS app_jwt
ON DATABASE
TYPE JWT
ALGORITHM HS512
KEY $env.SURREALDB_JWT_SECRET;
-- --------------------------------------------------
-- Table: user
-- --------------------------------------------------
-- Stores basic user information, cached from ATproto.
DEFINE TABLE user SCHEMAFULL;
-- The user's decentralized identifier (DID) is their primary key.
DEFINE FIELD did ON TABLE user TYPE string
ASSERT $value != NONE;
DEFINE FIELD handle ON TABLE user TYPE string;
-- Ensure DIDs are unique.
DEFINE INDEX user_did_idx ON TABLE user COLUMNS did UNIQUE;
-- --------------------------------------------------
-- Table: node
-- --------------------------------------------------
-- Stores a single "thought node." This is the cache record for
-- the com.ponderants.node lexicon.
DEFINE TABLE node SCHEMAFULL
-- THIS IS THE CORE SECURITY MODEL:
-- Users can only perform actions on nodes where the
-- node's 'user_did' field matches the 'did' claim
-- from their validated JWT ('$token.did').
PERMISSIONS
FOR select, create, update, delete
WHERE user_did = $token.did;
-- Foreign key linking to the user table (via DID).
DEFINE FIELD user_did ON TABLE node TYPE string
ASSERT $value != NONE;
-- The canonical URI of the record on the ATproto PDS.
DEFINE FIELD atp_uri ON TABLE node TYPE string;
DEFINE FIELD title ON TABLE node TYPE string;
DEFINE FIELD body ON TABLE node TYPE string;
-- The AI-generated vector embedding for the 'body'.
-- We use array<number> for the vector.
DEFINE FIELD embedding ON TABLE node TYPE array<number>;
-- The 3D coordinates calculated by UMAP.
DEFINE FIELD coords_3d ON TABLE node TYPE array<number>
-- Must be a 3-point array [x, y, z] or empty.
ASSERT $value = NONE OR array::len($value) = 3;
-- Define the vector search index.
-- We use MTREE (or HNSW) for high-performance k-NN search.
-- The dimension (1536) MUST match the output of the
-- 'gemini-embedding-001' model.
DEFINE INDEX node_embedding_idx ON TABLE node FIELDS embedding MTREE DIMENSION 1536;
-- --------------------------------------------------
-- Relation: links_to
-- --------------------------------------------------
-- This is a graph edge table, relating (node)->(node).
DEFINE TABLE links_to SCHEMAFULL
-- Security for graph edges: A user can only create/view/delete
-- links between two nodes that BOTH belong to them.
PERMISSIONS
FOR select, create, delete
WHERE
(SELECT user_did FROM $from) = $token.did
AND
(SELECT user_did FROM $to) = $token.did;
-- (No fields needed, it's a simple relation)
-- Example usage: RELATE (node:1)-[links_to]->(node:2);

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
surrealdb:
image: surrealdb/surrealdb:latest
ports:
- "8000:8000"
command:
- start
- --log
- trace
- --user
- root
- --pass
- root
- memory
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5

View File

@@ -1,5 +1,14 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
export interface UserSession {
did: string;
handle: string;
iss: string;
aud: string;
exp: number;
iat: number;
}
/** /**
* Mints a new JWT for our application's session management. * Mints a new JWT for our application's session management.
* This token is what SurrealDB will validate. * This token is what SurrealDB will validate.
@@ -34,3 +43,27 @@ export function mintSurrealJwt(did: string, handle: string): string {
return token; return token;
} }
/**
* Verifies and decodes a JWT token.
*
* @param token - The JWT token to verify
* @returns The decoded user session, or null if invalid
*/
export function verifySurrealJwt(token: string): UserSession | null {
const secret = process.env.SURREALDB_JWT_SECRET;
if (!secret) {
throw new Error('SURREALDB_JWT_SECRET is not set in environment.');
}
try {
const decoded = jwt.verify(token, secret, {
algorithms: ['HS512'],
}) as UserSession;
return decoded;
} catch (error) {
console.error('JWT verification failed:', error);
return null;
}
}

143
lib/auth/oauth-state.ts Normal file
View File

@@ -0,0 +1,143 @@
import Surreal from 'surrealdb';
const SURREALDB_URL = process.env.SURREALDB_URL;
const SURREALDB_NAMESPACE = process.env.SURREALDB_NS;
const SURREALDB_DATABASE = process.env.SURREALDB_DB;
const SURREALDB_USER = process.env.SURREALDB_USER;
const SURREALDB_PASS = process.env.SURREALDB_PASS;
if (!SURREALDB_URL || !SURREALDB_NAMESPACE || !SURREALDB_DATABASE || !SURREALDB_USER || !SURREALDB_PASS) {
throw new Error('SurrealDB configuration is missing. Please set SURREALDB_URL, SURREALDB_NS, SURREALDB_DB, SURREALDB_USER, and SURREALDB_PASS in .env');
}
interface OAuthState {
state: string;
code_verifier: string;
pds_url: string;
created_at: string;
}
/**
* Gets a SurrealDB connection for OAuth state management.
* This uses a separate, unauthenticated connection since OAuth happens before user auth.
*/
async function getDb() {
const db = new Surreal();
await db.connect(SURREALDB_URL);
// Sign in with root credentials for OAuth state management
await db.signin({
username: SURREALDB_USER,
password: SURREALDB_PASS,
});
await db.use({ namespace: SURREALDB_NAMESPACE, database: SURREALDB_DATABASE });
return db;
}
/**
* Stores OAuth state parameters in SurrealDB for the duration of the OAuth flow.
* The state is used as the record ID for easy lookup.
*
* @param state - The OAuth state parameter (CSRF token)
* @param codeVerifier - The PKCE code verifier
* @param pdsUrl - The user's PDS URL
*/
export async function storeOAuthState(
state: string,
codeVerifier: string,
pdsUrl: string
): Promise<void> {
const db = await getDb();
try {
// Store with a 10 minute TTL (OAuth flows should complete quickly)
const created_at = new Date().toISOString();
console.log('[OAuth State] Storing state:', state);
// Use CREATE with CONTENT to store all fields
const result = await db.create(`oauth_state:⟨${state}`, {
state,
code_verifier: codeVerifier,
pds_url: pdsUrl,
created_at,
});
console.log('[OAuth State] Stored successfully:', JSON.stringify(result, null, 2));
} finally {
await db.close();
}
}
/**
* Retrieves and deletes OAuth state from SurrealDB.
* This ensures one-time use of the state parameter (security best practice).
*
* @param state - The OAuth state parameter to look up
* @returns The OAuth state data, or null if not found or expired
*/
export async function consumeOAuthState(
state: string
): Promise<{ codeVerifier: string; pdsUrl: string } | null> {
const db = await getDb();
try {
console.log('[OAuth State] Retrieving state:', state);
// Retrieve the state by record ID
const selectResult = await db.select<OAuthState>(`oauth_state:⟨${state}`);
console.log('[OAuth State] Select result:', JSON.stringify(selectResult, null, 2));
// db.select() returns an array when selecting a specific record ID
const result = Array.isArray(selectResult) ? selectResult[0] : selectResult;
console.log('[OAuth State] Retrieved record:', JSON.stringify(result, null, 2));
if (!result) {
console.log('[OAuth State] No result found for state:', state);
return null;
}
// Check if expired (older than 10 minutes)
const createdAt = new Date(result.created_at);
const now = new Date();
const ageMinutes = (now.getTime() - createdAt.getTime()) / 1000 / 60;
console.log('[OAuth State] State age:', ageMinutes, 'minutes');
if (ageMinutes > 10) {
console.log('[OAuth State] State expired, deleting');
// Delete expired state
await db.delete(`oauth_state:⟨${state}`);
return null;
}
// Delete the state (one-time use)
console.log('[OAuth State] Deleting state (one-time use)');
await db.delete(`oauth_state:⟨${state}`);
return {
codeVerifier: result.code_verifier,
pdsUrl: result.pds_url,
};
} finally {
await db.close();
}
}
/**
* Cleans up expired OAuth states (should be called periodically).
* In production, this would be a cron job or scheduled task.
*/
export async function cleanupExpiredOAuthStates(): Promise<number> {
const db = await getDb();
try {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const result = await db.query<[OAuthState[]]>(
'DELETE FROM oauth_state WHERE created_at < $cutoff RETURN BEFORE',
{ cutoff: tenMinutesAgo }
);
return result[0]?.length || 0;
} finally {
await db.close();
}
}

19
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,19 @@
import { cookies } from 'next/headers';
import { verifySurrealJwt, type UserSession } from './jwt';
/**
* Gets the current authenticated user from the session cookie.
* This function should be called from Server Components or API routes.
*
* @returns The user session if authenticated, null otherwise
*/
export async function getCurrentUser(): Promise<UserSession | null> {
const cookieStore = await cookies();
const authCookie = cookieStore.get('ponderants-auth');
if (!authCookie?.value) {
return null;
}
return verifySurrealJwt(authCookie.value);
}

View File

@@ -4,4 +4,6 @@ export default {
url: 'http://localhost:3000', url: 'http://localhost:3000',
// We will configure magnitude to find tests in this directory // We will configure magnitude to find tests in this directory
tests: 'tests/magnitude/**/*.mag.ts', tests: 'tests/magnitude/**/*.mag.ts',
// Run tests in headless mode to avoid window focus issues
headless: true,
}; };