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>
144 lines
4.4 KiB
TypeScript
144 lines
4.4 KiB
TypeScript
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();
|
|
}
|
|
}
|