Files
app/lib/auth/oauth-state.ts
Albert 93ebb0948c 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>
2025-11-08 23:51:19 +00:00

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