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:
@@ -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));
|
||||
|
||||
@@ -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
30
app/chat/page.tsx
Normal 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
91
db/schema.surql
Normal 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
19
docker-compose.yml
Normal 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
|
||||
@@ -1,5 +1,14 @@
|
||||
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.
|
||||
* This token is what SurrealDB will validate.
|
||||
@@ -34,3 +43,27 @@ export function mintSurrealJwt(did: string, handle: string): string {
|
||||
|
||||
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
143
lib/auth/oauth-state.ts
Normal 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
19
lib/auth/session.ts
Normal 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);
|
||||
}
|
||||
@@ -4,4 +4,6 @@ export default {
|
||||
url: 'http://localhost:3000',
|
||||
// We will configure magnitude to find tests in this directory
|
||||
tests: 'tests/magnitude/**/*.mag.ts',
|
||||
// Run tests in headless mode to avoid window focus issues
|
||||
headless: true,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user