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,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
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);
}