From d7f3bcd338b1adbff64c088d19713a5225f69e95 Mon Sep 17 00:00:00 2001 From: Albert Date: Sun, 9 Nov 2025 01:40:04 +0000 Subject: [PATCH] feat: Implement OAuth with DPoP using @atproto/oauth-client-node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual OAuth implementation with official @atproto/oauth-client-node library to properly support DPoP (Demonstrating Proof of Possession) authentication. Changes: - Added @atproto/oauth-client-node dependency - Created OAuth state store (SurrealDB-backed) for CSRF protection - Created OAuth session store (SurrealDB-backed) for token persistence - Created OAuth client singleton with localhost exception for development - Rewrote /api/auth/login to use client.authorize() - Rewrote /api/auth/callback to use client.callback() with DPoP - Updated lib/auth/session.ts with getAuthenticatedAgent() for ATproto API calls - Updated db/schema.surql with oauth_state and oauth_session tables - Added scripts/apply-schema.js for database schema management - Created plans/oauth-dpop-implementation.md with detailed implementation plan - Removed legacy lib/auth/atproto.ts and lib/auth/oauth-state.ts - Updated .env to use localhost exception (removed BLUESKY_CLIENT_ID) The OAuth client now handles: - PKCE code generation and verification - DPoP proof generation and signing - Automatic token refresh - Session persistence across server restarts πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/api/auth/callback/route.ts | 188 +++-- app/api/auth/login/route.ts | 98 ++- db/schema.surql | 51 ++ lib/auth/atproto.ts | 63 -- lib/auth/oauth-client.ts | 75 ++ lib/auth/oauth-session-store.ts | 84 ++ lib/auth/oauth-state-store.ts | 81 ++ lib/auth/oauth-state.ts | 143 ---- lib/auth/session.ts | 104 +++ package.json | 1 + plans/oauth-dpop-implementation.md | 1249 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 182 ++++ scripts/apply-schema.js | 118 +++ 13 files changed, 2104 insertions(+), 333 deletions(-) delete mode 100644 lib/auth/atproto.ts create mode 100644 lib/auth/oauth-client.ts create mode 100644 lib/auth/oauth-session-store.ts create mode 100644 lib/auth/oauth-state-store.ts delete mode 100644 lib/auth/oauth-state.ts create mode 100644 plans/oauth-dpop-implementation.md create mode 100755 scripts/apply-schema.js diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts index 17c5e97..ede36ec 100644 --- a/app/api/auth/callback/route.ts +++ b/app/api/auth/callback/route.ts @@ -1,81 +1,71 @@ 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 { getOAuthClient } from '@/lib/auth/oauth-client'; import { mintSurrealJwt } from '@/lib/auth/jwt'; -import { AtpAgent } from '@atproto/api'; - -const CLIENT_ID = process.env.BLUESKY_CLIENT_ID; -const REDIRECT_URI = process.env.BLUESKY_REDIRECT_URI; +import { Agent } from '@atproto/api'; +import Surreal from 'surrealdb'; +/** + * GET /api/auth/callback + * + * OAuth callback endpoint. The user is redirected here from the + * ATproto PDS after authorizing the application. + * + * Query parameters (set by PDS): + * - code: Authorization code + * - state: CSRF protection token + * + * This endpoint: + * 1. Exchanges the code for an access token (with DPoP) + * 2. Retrieves the user's profile + * 3. Upserts the user in SurrealDB + * 4. Mints our app's JWT + * 5. Redirects to the chat page + */ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const code = searchParams.get('code'); - const state = searchParams.get('state'); - // 1. Validate state parameter - if (!state || !code) { - return NextResponse.redirect(new URL('/login?error=Missing OAuth parameters', request.url)); + // Check for error from OAuth provider + const error = searchParams.get('error'); + if (error) { + const errorDescription = searchParams.get('error_description') || 'Unknown error'; + console.error('[OAuth Callback] Error from provider:', error, errorDescription); + return NextResponse.redirect( + new URL(`/login?error=${encodeURIComponent(errorDescription)}`, 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); + console.log('[OAuth Callback] Processing callback...'); - // 4. Exchange the code for an ATproto access token - const tokenResponse = await fetch(tokenEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - redirect_uri: REDIRECT_URI!, - client_id: CLIENT_ID!, - code_verifier: code_verifier, - }), - }); + // Get OAuth client + const client = await getOAuthClient(); - if (!tokenResponse.ok) { - throw new Error('Failed to exchange code for token'); + // Exchange authorization code for session + // The library handles: + // - PKCE verification (using stored code_verifier) + // - DPoP proof generation for token request + // - Token exchange with PDS + // - Token validation + // - Session storage in sessionStore + const { session, state } = await client.callback(searchParams); + + console.log('[OAuth Callback] βœ“ Successfully authenticated user:', session.did); + + // Create ATproto agent with session + const agent = new Agent(session); + + // Fetch user profile + const profileResponse = await agent.getProfile({ actor: session.did }); + + if (!profileResponse.success) { + throw new Error('Failed to fetch user profile'); } - const { access_token, refresh_token } = await tokenResponse.json(); + const { did, handle } = profileResponse.data; - // 5. Use the ATproto token to get the user's session info (did, handle) - const agent = new AtpAgent({ service: pdsUrl }); + console.log('[OAuth Callback] User profile:', { did, handle }); - // Set the session with the tokens we just received - agent.resumeSession({ - accessJwt: access_token, - refreshJwt: refresh_token, - did: '', // Will be populated by getSession call - handle: '', // Will be populated by getSession call - }); - - // Fetch the actual session info from the server - const sessionResponse = await agent.api.com.atproto.server.getSession(); - - if (!sessionResponse.success || !sessionResponse.data.did || !sessionResponse.data.handle) { - throw new Error('Failed to retrieve user session details'); - } - - const { did, handle } = sessionResponse.data; - - // 6. Create or update user in SurrealDB - // We use root credentials here since the user doesn't have a JWT yet - const Surreal = (await import('surrealdb')).default; + // Upsert user in SurrealDB const db = new Surreal(); await db.connect(process.env.SURREALDB_URL!); await db.signin({ @@ -87,20 +77,36 @@ export async function GET(request: NextRequest) { database: process.env.SURREALDB_DB!, }); - // Upsert the user (create if doesn't exist, update handle if it does) await db.query( - 'INSERT INTO user (did, handle) VALUES ($did, $handle) ON DUPLICATE KEY UPDATE handle = $handle', + `INSERT INTO user (did, handle) + VALUES ($did, $handle) + ON DUPLICATE KEY UPDATE handle = $handle`, { did, handle } ); + await db.close(); - // 7. Mint OUR app's SurrealDB JWT + console.log('[OAuth Callback] βœ“ Created/updated user in SurrealDB'); + + // Mint our app's SurrealDB JWT const surrealJwt = mintSurrealJwt(did, handle); - // 8. Create redirect response - const response = NextResponse.redirect(new URL('/chat', request.url)); + // Parse custom state to determine redirect URL + let returnTo = '/chat'; + try { + const customState = JSON.parse(state); + if (customState.returnTo) { + returnTo = customState.returnTo; + } + } catch { + // Invalid state JSON, use default + console.warn('[OAuth Callback] Could not parse custom state, using default redirect'); + } - // 9. Set the SurrealDB JWT in a secure cookie on the response + // Create redirect response + const response = NextResponse.redirect(new URL(returnTo, request.url)); + + // Set SurrealDB JWT cookie (for our app's authorization) response.cookies.set('ponderants-auth', surrealJwt, { httpOnly: true, secure: process.env.NODE_ENV === 'production', @@ -109,26 +115,34 @@ export async function GET(request: NextRequest) { path: '/', }); - // Store the ATproto tokens for later use - response.cookies.set('atproto_access_token', access_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60, - path: '/', - }); - response.cookies.set('atproto_refresh_token', refresh_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, - path: '/', - }); + console.log('[OAuth Callback] βœ“ Authentication complete, redirecting to:', returnTo); + + // Note: We do NOT store ATproto tokens in cookies + // The oauth-client library manages them in sessionStore + // and will automatically refresh them when needed - // 10. 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)); + console.error('[OAuth Callback] Error:', error); + + // Check for specific OAuth errors + if (error instanceof Error) { + if (error.message.includes('Invalid state')) { + return NextResponse.redirect( + new URL('/login?error=Invalid or expired session', request.url) + ); + } + + if (error.message.includes('DPoP')) { + console.error('[OAuth Callback] DPoP error - this should not happen with the library!', error); + return NextResponse.redirect( + new URL('/login?error=Authentication protocol error', request.url) + ); + } + } + + return NextResponse.redirect( + new URL('/login?error=Authentication failed', request.url) + ); } } diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 0b89f16..9994227 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,52 +1,70 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto'; -import { storeOAuthState } from '@/lib/auth/oauth-state'; -import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client'; +import { getOAuthClient } from '@/lib/auth/oauth-client'; +import { z } from 'zod'; -const CLIENT_ID = process.env.BLUESKY_CLIENT_ID; -const REDIRECT_URI = process.env.BLUESKY_REDIRECT_URI; - -export async function GET(request: NextRequest) { - if (!CLIENT_ID || !REDIRECT_URI) { - throw new Error('Bluesky client configuration is missing.'); - } - - const { searchParams } = new URL(request.url); - const handle = searchParams.get('handle'); - - if (!handle) { - return NextResponse.redirect(new URL('/login?error=Handle missing', request.url)); - } +const LoginRequestSchema = z.object({ + handle: z.string().min(1, 'Handle is required'), +}); +/** + * POST /api/auth/login + * + * Initiates the OAuth flow for a given ATproto handle. + * + * Request body: { handle: "user.bsky.social" } + * Response: { url: "https://bsky.social/oauth/authorize?..." } + * + * The client should redirect the user to the returned URL. + */ +export async function POST(request: NextRequest) { try { - // 1. Resolve handle to get PDS - const { pdsUrl } = await resolveHandle(handle); + // Parse and validate request body + const body = await request.json(); + const { handle } = LoginRequestSchema.parse(body); - // 2. Discover PDS-specific auth endpoints - const { authorizationEndpoint } = await getAuthEndpoints(pdsUrl); + console.log('[OAuth Login] Initiating OAuth flow for handle:', handle); - // 3. Generate PKCE challenge and state - const state = randomState(); - const code_verifier = randomPKCECodeVerifier(); - const code_challenge = await calculatePKCECodeChallenge(code_verifier); + // Get OAuth client + const client = await getOAuthClient(); - // 4. Store OAuth state in SurrealDB (not cookies, as they don't survive external redirects) - await storeOAuthState(state, code_verifier, pdsUrl); + // Generate authorization URL + // The library handles: + // - Handle resolution to PDS + // - Authorization endpoint discovery + // - PKCE code generation (code_verifier, code_challenge) + // - DPoP key generation + // - State storage + const authUrl = await client.authorize(handle, { + // Custom state that will be returned in callback + state: JSON.stringify({ + timestamp: Date.now(), + returnTo: '/chat', + }), + }); - // 5. Construct the authorization URL - const authUrl = new URL(authorizationEndpoint); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('client_id', CLIENT_ID); - authUrl.searchParams.set('redirect_uri', REDIRECT_URI); - authUrl.searchParams.set('scope', 'atproto'); - authUrl.searchParams.set('code_challenge', code_challenge); - authUrl.searchParams.set('code_challenge_method', 'S256'); - authUrl.searchParams.set('state', state); + console.log('[OAuth Login] βœ“ Generated authorization URL'); - // 6. Redirect user to the PDS login screen - return NextResponse.redirect(authUrl); + return NextResponse.json({ url: authUrl }); } catch (error) { - console.error('Auth login error:', error); - return NextResponse.redirect(new URL('/login?error=Invalid handle or PDS', request.url)); + console.error('[OAuth Login] Error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message.includes('Could not resolve handle')) { + return NextResponse.json( + { error: 'Invalid handle or PDS not found' }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to initiate OAuth flow' }, + { status: 500 } + ); } } diff --git a/db/schema.surql b/db/schema.surql index be04859..e229cda 100644 --- a/db/schema.surql +++ b/db/schema.surql @@ -89,3 +89,54 @@ DEFINE TABLE links_to SCHEMAFULL -- (No fields needed, it's a simple relation) -- Example usage: RELATE (node:1)-[links_to]->(node:2); + +-- -------------------------------------------------- +-- Table: oauth_state +-- -------------------------------------------------- + +-- Stores temporary OAuth state during the authorization flow. +-- Used for CSRF protection. States should expire after 1 hour. +DEFINE TABLE oauth_state SCHEMAFULL; + +-- The state key (random string generated during authorize) +DEFINE FIELD key ON TABLE oauth_state TYPE string + ASSERT $value != NONE; + +-- The state value (contains PKCE verifier, DPoP key, etc.) +DEFINE FIELD value ON TABLE oauth_state TYPE object + ASSERT $value != NONE; + +-- Timestamp for cleanup +DEFINE FIELD created_at ON TABLE oauth_state TYPE datetime + DEFAULT time::now(); + +-- Index for fast lookups by key +DEFINE INDEX oauth_state_key_idx ON TABLE oauth_state COLUMNS key UNIQUE; + +-- Event to auto-delete expired states (older than 1 hour) +DEFINE EVENT oauth_state_cleanup ON TABLE oauth_state WHEN time::now() - created_at > 1h THEN ( + DELETE oauth_state WHERE id = $event.id +); + +-- -------------------------------------------------- +-- Table: oauth_session +-- -------------------------------------------------- + +-- Stores persistent OAuth sessions (access/refresh tokens). +-- Sessions are managed by the @atproto/oauth-client-node library. +DEFINE TABLE oauth_session SCHEMAFULL; + +-- The user's DID (unique identifier) +DEFINE FIELD did ON TABLE oauth_session TYPE string + ASSERT $value != NONE; + +-- The session data (contains tokens, DPoP key, etc.) +DEFINE FIELD session_data ON TABLE oauth_session TYPE object + ASSERT $value != NONE; + +-- Timestamp for last update (useful for debugging) +DEFINE FIELD updated_at ON TABLE oauth_session TYPE datetime + DEFAULT time::now(); + +-- Index for fast lookups by DID +DEFINE INDEX oauth_session_did_idx ON TABLE oauth_session COLUMNS did UNIQUE; diff --git a/lib/auth/atproto.ts b/lib/auth/atproto.ts deleted file mode 100644 index f3dbb02..0000000 --- a/lib/auth/atproto.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { AtpAgent } from '@atproto/api'; - -/** - * Resolves a Bluesky handle (e.g., "user.bsky.social") to its - * corresponding PDS (Personal Data Server) and DID (Decentralized Identifier). - * This discovery step is mandatory before initiating OAuth. - */ -export async function resolveHandle(handle: string) { - try { - const agent = new AtpAgent({ service: 'https://bsky.social' }); - const response = await agent.resolveHandle({ handle }); - const did = response.data.did; - - // Now, get the PDS from the DID document - const didDoc = await agent.com.atproto.identity.resolveHandle({ handle }); - - // Get the PDS service endpoint from the DID document - const pdsService = didDoc.data; - - if (!pdsService) { - throw new Error('PDS service endpoint not found in DID document.'); - } - - return { - did, - pdsUrl: 'https://bsky.social', // For now, all Bluesky users use the main PDS - }; - } catch (error) { - console.error('Error resolving handle:', error); - throw new Error('Could not resolve Bluesky handle.'); - } -} - -/** - * Fetches the specific OAuth endpoints for a given PDS. - * Each PDS has its own set of endpoints. - */ -export async function getAuthEndpoints(pdsUrl: string) { - try { - const metadataUrl = `${pdsUrl}/.well-known/oauth-authorization-server`; - const response = await fetch(metadataUrl); - - if (!response.ok) { - throw new Error(`Failed to fetch auth metadata from ${pdsUrl}`); - } - - const metadata = await response.json(); - - const { authorization_endpoint, token_endpoint } = metadata; - - if (!authorization_endpoint || !token_endpoint) { - throw new Error('Invalid auth metadata received from PDS.'); - } - - return { - authorizationEndpoint: authorization_endpoint, - tokenEndpoint: token_endpoint, - }; - } catch (error) { - console.error('Error getting auth endpoints:', error); - throw new Error('Could not discover OAuth endpoints.'); - } -} diff --git a/lib/auth/oauth-client.ts b/lib/auth/oauth-client.ts new file mode 100644 index 0000000..d3f1693 --- /dev/null +++ b/lib/auth/oauth-client.ts @@ -0,0 +1,75 @@ +/** + * OAuth Client Singleton for ATproto + * + * This module provides a singleton instance of NodeOAuthClient + * that manages OAuth flows, DPoP proofs, and session persistence. + */ + +import { NodeOAuthClient } from '@atproto/oauth-client-node'; +import { createStateStore } from './oauth-state-store'; +import { createSessionStore } from './oauth-session-store'; + +let clientInstance: NodeOAuthClient | null = null; + +/** + * Get or create the singleton OAuth client instance. + * + * In development, uses the localhost client exception (no keys needed). + * In production, uses backend service with private keys (TODO). + * + * The client handles: + * - OAuth authorization flow with PKCE + * - DPoP (Demonstrating Proof of Possession) for token requests + * - Automatic token refresh + * - Session persistence in SurrealDB + */ +export async function getOAuthClient(): Promise { + if (clientInstance) { + return clientInstance; + } + + const isDev = process.env.NODE_ENV === 'development'; + const callbackUrl = process.env.BLUESKY_REDIRECT_URI; + + if (!callbackUrl) { + throw new Error('BLUESKY_REDIRECT_URI environment variable is required'); + } + + if (isDev) { + // Development: Use localhost exception + // Per ATproto spec, client_id must be exactly "http://localhost" + // (no port number) with metadata in query parameters + const clientId = `http://localhost?${new URLSearchParams({ + redirect_uri: callbackUrl, + scope: 'atproto', + })}`; + + console.log('[OAuth] Initializing development client with localhost exception'); + console.log('[OAuth] client_id:', clientId); + + clientInstance = await NodeOAuthClient.fromClientId({ + clientId, + stateStore: createStateStore(), + sessionStore: createSessionStore(), + }); + + console.log('[OAuth] βœ“ Development client initialized'); + } else { + // Production: Backend service with keys + // TODO: Implement when deploying to production + // See plans/oauth-dpop-implementation.md for details + throw new Error( + 'Production OAuth client not yet implemented. ' + + 'See plans/oauth-dpop-implementation.md for production setup instructions.' + ); + } + + return clientInstance; +} + +/** + * Clear the singleton instance (mainly for testing). + */ +export function clearOAuthClient(): void { + clientInstance = null; +} diff --git a/lib/auth/oauth-session-store.ts b/lib/auth/oauth-session-store.ts new file mode 100644 index 0000000..e83dbf6 --- /dev/null +++ b/lib/auth/oauth-session-store.ts @@ -0,0 +1,84 @@ +/** + * OAuth Session Store for @atproto/oauth-client-node + * + * Stores persistent OAuth sessions (access/refresh tokens, DPoP keys). + * Sessions are keyed by the user's DID. + */ + +import Surreal from 'surrealdb'; +import type { NodeSavedSessionStore, NodeSavedSession } from '@atproto/oauth-client-node'; + +/** + * Get a SurrealDB connection with root credentials. + * Used for OAuth session management. + */ +async function getDB(): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + return db; +} + +/** + * Create an OAuth session store backed by SurrealDB. + * + * The session store persists authenticated user sessions across + * server restarts. The @atproto/oauth-client-node library manages + * token refresh automatically, updating the store when tokens change. + * + * Sessions are indexed by DID (decentralized identifier). + */ +export function createSessionStore(): NodeSavedSessionStore { + return { + async set(did: string, sessionData: NodeSavedSession): Promise { + const db = await getDB(); + + try { + // Upsert: create if doesn't exist, update if it does + await db.query( + `INSERT INTO oauth_session (did, session_data) + VALUES ($did, $session_data) + ON DUPLICATE KEY UPDATE session_data = $session_data, updated_at = time::now()`, + { did, session_data: sessionData } + ); + } finally { + await db.close(); + } + }, + + async get(did: string): Promise { + const db = await getDB(); + + try { + const [result] = await db.query<[{ session_data: NodeSavedSession }[]]>( + 'SELECT session_data FROM oauth_session WHERE did = $did', + { did } + ); + + return result?.[0]?.session_data; + } finally { + await db.close(); + } + }, + + async del(did: string): Promise { + const db = await getDB(); + + try { + await db.query( + 'DELETE oauth_session WHERE did = $did', + { did } + ); + } finally { + await db.close(); + } + }, + }; +} diff --git a/lib/auth/oauth-state-store.ts b/lib/auth/oauth-state-store.ts new file mode 100644 index 0000000..a17aa89 --- /dev/null +++ b/lib/auth/oauth-state-store.ts @@ -0,0 +1,81 @@ +/** + * OAuth State Store for @atproto/oauth-client-node + * + * Stores temporary OAuth state during the authorization flow. + * Used for CSRF protection and PKCE verification. + */ + +import Surreal from 'surrealdb'; +import type { NodeSavedStateStore, NodeSavedState } from '@atproto/oauth-client-node'; + +/** + * Get a SurrealDB connection with root credentials. + * Used for OAuth state management. + */ +async function getDB(): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + return db; +} + +/** + * Create an OAuth state store backed by SurrealDB. + * + * The state store is used during the OAuth flow to store + * temporary data (PKCE verifier, DPoP key, etc.) that is + * retrieved when the user returns from the authorization server. + * + * States expire after 1 hour (enforced by database event). + */ +export function createStateStore(): NodeSavedStateStore { + return { + async set(key: string, value: NodeSavedState): Promise { + const db = await getDB(); + + try { + await db.query( + 'CREATE oauth_state SET key = $key, value = $value', + { key, value } + ); + } finally { + await db.close(); + } + }, + + async get(key: string): Promise { + const db = await getDB(); + + try { + const [result] = await db.query<[{ value: NodeSavedState }[]]>( + 'SELECT value FROM oauth_state WHERE key = $key', + { key } + ); + + return result?.[0]?.value; + } finally { + await db.close(); + } + }, + + async del(key: string): Promise { + const db = await getDB(); + + try { + await db.query( + 'DELETE oauth_state WHERE key = $key', + { key } + ); + } finally { + await db.close(); + } + }, + }; +} diff --git a/lib/auth/oauth-state.ts b/lib/auth/oauth-state.ts deleted file mode 100644 index f6e6706..0000000 --- a/lib/auth/oauth-state.ts +++ /dev/null @@ -1,143 +0,0 @@ -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 { - 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(`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 { - 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(); - } -} diff --git a/lib/auth/session.ts b/lib/auth/session.ts index b8be2f6..04ef354 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -1,5 +1,8 @@ import { cookies } from 'next/headers'; import { verifySurrealJwt, type UserSession } from './jwt'; +import { getOAuthClient } from './oauth-client'; +import { Agent } from '@atproto/api'; +import jwt from 'jsonwebtoken'; /** * Gets the current authenticated user from the session cookie. @@ -17,3 +20,104 @@ export async function getCurrentUser(): Promise { return verifySurrealJwt(authCookie.value); } + +/** + * Get the authenticated ATproto agent for the current user. + * + * Automatically refreshes tokens if needed. The OAuth client + * handles token refresh transparently and updates the sessionStore. + * + * @returns Agent instance for making authenticated ATproto API calls, + * or null if user is not authenticated + */ +export async function getAuthenticatedAgent(): Promise { + try { + // Get user DID from our SurrealDB JWT cookie + const cookieStore = await cookies(); + const authCookie = cookieStore.get('ponderants-auth'); + + if (!authCookie) { + return null; + } + + // Decode JWT to get DID (we don't verify here since SurrealDB will verify) + const payload = jwt.decode(authCookie.value) as { sub: string } | null; + + if (!payload?.sub) { + return null; + } + + const did = payload.sub; + + console.log('[Session] Restoring session for DID:', did); + + // Restore session from OAuth client + // This will automatically refresh tokens if they're expired + const client = await getOAuthClient(); + const session = await client.restore(did); + + console.log('[Session] βœ“ Session restored'); + + // Create agent with session + return new Agent(session); + } catch (error) { + console.error('[Session] Failed to restore session:', error); + return null; + } +} + +/** + * Sign out the current user. + * + * Revokes tokens with the PDS and clears the session from storage. + * Also clears our app's JWT cookie. + */ +export async function signOut(): Promise { + try { + const agent = await getAuthenticatedAgent(); + + if (agent) { + console.log('[Session] Signing out user:', agent.did); + + // Revoke session (calls PDS to revoke tokens) + const session = (agent as any).session; + if (session?.signOut) { + await session.signOut(); + console.log('[Session] βœ“ Session revoked with PDS'); + } + } + } catch (error) { + console.error('[Session] Sign out error:', error); + // Continue to clear cookie even if revocation fails + } + + // Clear our app's cookie + const cookieStore = await cookies(); + cookieStore.delete('ponderants-auth'); + + console.log('[Session] βœ“ Local session cleared'); +} + +/** + * Get the current user's DID from the session cookie. + * + * This is a lightweight check that doesn't require restoring + * the full OAuth session. Useful for quick authorization checks. + * + * @returns User's DID or null if not authenticated + */ +export async function getCurrentUserDid(): Promise { + try { + const cookieStore = await cookies(); + const authCookie = cookieStore.get('ponderants-auth'); + + if (!authCookie) { + return null; + } + + const payload = jwt.decode(authCookie.value) as { sub: string } | null; + return payload?.sub || null; + } catch { + return null; + } +} diff --git a/package.json b/package.json index ca190e7..3f33665 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@ai-sdk/google": "latest", "@ai-sdk/react": "latest", "@atproto/api": "latest", + "@atproto/oauth-client-node": "^0.3.10", "@deepgram/sdk": "latest", "@google/generative-ai": "^0.24.1", "@mantine/core": "latest", diff --git a/plans/oauth-dpop-implementation.md b/plans/oauth-dpop-implementation.md new file mode 100644 index 0000000..9278124 --- /dev/null +++ b/plans/oauth-dpop-implementation.md @@ -0,0 +1,1249 @@ +# OAuth with DPoP Implementation Plan + +## Executive Summary + +This plan outlines the complete refactoring of our ATproto OAuth implementation to use the official `@atproto/oauth-client-node` library. The current manual OAuth implementation fails because ATproto requires DPoP (Demonstrating Proof of Possession) for token exchange, which cannot be implemented manually in a secure way. The official library handles all DPoP complexity automatically. + +**Timeline**: 2-3 hours +**Risk Level**: Low (library is production-ready and well-documented) +**Impact**: Complete OAuth flow working with proper security + +## Current State Analysis + +### What's Working +- βœ… OAuth initiation flow (`/api/auth/login`) +- βœ… PKCE code generation (code_verifier, code_challenge) +- βœ… OAuth state storage in SurrealDB +- βœ… User handle resolution to PDS URL +- βœ… Authorization endpoint discovery +- βœ… Redirect to PDS authorization page +- βœ… Callback route structure (`/api/auth/callback`) + +### What's Broken +- ❌ **Token exchange fails with 401 "DPoP proof required"** +- ❌ No DPoP proof generation in token requests +- ❌ No DPoP key management +- ❌ No session refresh capability +- ❌ No token rotation +- ❌ No session persistence across restarts + +### Root Cause + +ATproto's OAuth implementation requires **DPoP (Demonstrating Proof of Possession)** for all token requests. This is a security mechanism that binds access tokens to a specific client by requiring cryptographic proof that the client possesses a private key. + +From the error we encountered: +```json +{ + "error": "invalid_dpop_proof", + "error_description": "DPoP proof required" +} +``` + +DPoP requires: +1. Generating a cryptographic key pair (typically RSA or EC) +2. Creating a JWT signed with the private key for each token request +3. Including the public key in the JWT header +4. Proper nonce handling from the server +5. Binding the proof to the specific HTTP request + +**This is too complex and security-sensitive to implement manually.** The official library handles all of this. + +## Solution Overview + +Replace our manual OAuth implementation with `@atproto/oauth-client-node`, which: + +1. βœ… Automatically generates and manages DPoP keys +2. βœ… Handles all DPoP proof creation and signing +3. βœ… Manages token refresh transparently +4. βœ… Provides session persistence +5. βœ… Implements proper security best practices +6. βœ… Handles edge cases (token expiry, revocation, etc.) + +## Technical Design + +### Architecture Pattern + +We'll use the **Singleton OAuth Client** pattern with **SurrealDB-backed stores**: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Next.js Application β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ /api/auth/login β”‚ β”‚ /api/auth/callbackβ”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”‚ +β”‚ └───►│ NodeOAuthClient (Singleton)β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ StateStore β”‚ β”‚ SessionStore β”‚ β”‚ +β”‚ β”‚ (SurrealDB) β”‚ β”‚ (SurrealDB) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ OAuth + DPoP + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ ATproto PDS β”‚ + β”‚ (Bluesky) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Development vs. Production Configuration + +#### Development (Localhost) + +For local development, ATproto has a **special exception** for `http://localhost` client IDs: + +**Client ID**: `http://localhost?redirect_uri=http://localhost:3000/api/auth/callback&scope=atproto` + +This creates a "virtual" client with: +- `token_endpoint_auth_method`: `none` (no private keys needed!) +- `application_type`: `native` +- `dpop_bound_access_tokens`: `true` + +**Advantages**: +- No need to host client metadata JSON +- No need to generate and manage private keys +- No need for JWKS endpoint +- Faster iteration during development + +**Limitations**: +- Only works with `http://localhost` (exact string) +- Cannot use `127.0.0.1` or `localhost:3000` +- Port numbers are flexible in redirect URIs but not in client_id + +#### Production (Deployed) + +For production, we'll use the **Backend Service** pattern: + +**Client ID**: `https://ponderants.com/client-metadata.json` + +This requires: +- Hosting `/client-metadata.json` endpoint +- Hosting `/jwks.json` endpoint (JSON Web Key Set) +- Generating and securely storing private keys +- `token_endpoint_auth_method`: `private_key_jwt` + +We'll implement this later when deploying to production. + +### Store Implementations + +Both stores follow the `SimpleStore` interface: + +```typescript +interface SimpleStore { + set(key: K, value: V): Promise + get(key: K): Promise + del(key: K): Promise +} +``` + +#### StateStore + +**Purpose**: Store OAuth authorization state during the OAuth flow (CSRF protection) + +**Type**: `SimpleStore` + +**NodeSavedState** contains: +- `iss`: Issuer (PDS URL) +- `dpopJwk`: DPoP key as JWK (JSON Web Key) +- `verifier`: PKCE code verifier +- `appState`: Custom state data (e.g., user ID, return URL) + +**Storage**: `oauth_state` table in SurrealDB + +**Lifecycle**: +- Created during `authorize()` +- Retrieved and deleted during `callback()` +- Should auto-expire after 1 hour (TTL) + +#### SessionStore + +**Purpose**: Store authenticated user sessions (access/refresh tokens) + +**Type**: `SimpleStore` + +**Key**: User's DID (e.g., `did:plc:abcd1234`) + +**NodeSavedSession** contains: +- `sub`: Subject (user DID) +- `aud`: Audience (client_id) +- `scope`: OAuth scopes +- `tokenSet`: Access token, refresh token, expiry +- `dpopJwk`: DPoP key for this session + +**Storage**: `oauth_session` table in SurrealDB + +**Lifecycle**: +- Created during successful `callback()` +- Retrieved during `restore(did)` +- Updated during token refresh (automatic) +- Deleted during `signOut()` or token revocation + +### OAuth Client Singleton + +We'll create a singleton instance of `NodeOAuthClient` that lives for the lifetime of the Next.js server: + +**File**: `lib/auth/oauth-client.ts` + +```typescript +import { NodeOAuthClient } from '@atproto/oauth-client-node'; +import { createStateStore } from './oauth-state-store'; +import { createSessionStore } from './oauth-session-store'; + +let clientInstance: NodeOAuthClient | null = null; + +export async function getOAuthClient(): Promise { + if (clientInstance) { + return clientInstance; + } + + const isDev = process.env.NODE_ENV === 'development'; + + if (isDev) { + // Development: Use localhost client + clientInstance = await NodeOAuthClient.fromClientId({ + clientId: `http://localhost?redirect_uri=${encodeURIComponent( + 'http://localhost:3000/api/auth/callback' + )}&scope=atproto`, + stateStore: createStateStore(), + sessionStore: createSessionStore(), + }); + } else { + // Production: Use backend service client + // TODO: Implement when deploying + throw new Error('Production OAuth client not yet implemented'); + } + + return clientInstance; +} +``` + +### API Routes + +#### `/api/auth/login` (Updated) + +```typescript +import { getOAuthClient } from '@/lib/auth/oauth-client'; + +export async function POST(req: Request) { + const { handle } = await req.json(); + + const client = await getOAuthClient(); + + // Generate authorization URL + const authUrl = await client.authorize(handle, { + // Custom state that will be returned in callback + state: JSON.stringify({ + timestamp: Date.now(), + returnTo: '/chat', + }), + }); + + return Response.json({ url: authUrl }); +} +``` + +**Key Changes**: +- Use `client.authorize()` instead of manual URL construction +- Library handles PKCE, DPoP key generation, state storage +- Returns authorization URL to redirect user to + +#### `/api/auth/callback` (Complete Rewrite) + +```typescript +import { getOAuthClient } from '@/lib/auth/oauth-client'; +import { mintSurrealJwt } from '@/lib/auth/jwt'; + +export async function GET(req: Request) { + const client = await getOAuthClient(); + const params = new URLSearchParams(new URL(req.url).search); + + try { + // Exchange code for session (handles DPoP automatically) + const { session, state } = await client.callback(params); + + // session.did is the user's DID + // session is now stored in sessionStore automatically + + // Get user profile from ATproto + const agent = new Agent(session); + const profile = await agent.getProfile({ actor: session.did }); + + // Upsert user in SurrealDB + const db = await getSurrealDB(); + await db.query( + 'INSERT INTO user (did, handle) VALUES ($did, $handle) ON DUPLICATE KEY UPDATE handle = $handle', + { did: session.did, handle: profile.data.handle } + ); + + // Mint our app's JWT + const jwt = mintSurrealJwt(session.did, profile.data.handle); + + // Set cookie and redirect + const response = NextResponse.redirect(new URL('/chat', req.url)); + response.cookies.set('ponderants-auth', jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + + return response; + } catch (error) { + console.error('OAuth callback error:', error); + return NextResponse.redirect( + new URL('/login?error=auth_failed', req.url) + ); + } +} +``` + +**Key Changes**: +- Use `client.callback()` to handle token exchange +- Library handles all DPoP proofs, token validation, session storage +- Remove manual token exchange code +- Remove ATproto token storage in cookies (library handles it) +- Keep SurrealDB JWT for our app's authorization + +### Session Restoration + +For authenticated API requests, we can restore sessions: + +```typescript +import { getOAuthClient } from '@/lib/auth/oauth-client'; +import { Agent } from '@atproto/api'; + +export async function getAuthenticatedAgent(did: string): Promise { + const client = await getOAuthClient(); + + // Restore session from sessionStore (refreshes token if needed) + const session = await client.restore(did); + + // Create agent with session + return new Agent(session); +} +``` + +**Benefits**: +- Automatic token refresh +- No need to manually handle refresh tokens +- Sessions persist across server restarts (stored in SurrealDB) + +## Implementation Steps + +### Phase 1: Database Schema (30 min) + +**File**: `lib/db/schema.surql` (update) + +```sql +-- OAuth state storage (temporary, for CSRF protection) +DEFINE TABLE oauth_state SCHEMAFULL; +DEFINE FIELD key ON oauth_state TYPE string ASSERT $value != NONE; +DEFINE FIELD value ON oauth_state TYPE object ASSERT $value != NONE; +DEFINE FIELD created_at ON oauth_state TYPE datetime DEFAULT time::now(); + +-- Index for fast lookups +DEFINE INDEX oauth_state_key ON oauth_state FIELDS key UNIQUE; + +-- Auto-delete after 1 hour (cleanup stale states) +DEFINE EVENT oauth_state_cleanup ON TABLE oauth_state WHEN time::now() - created_at > 1h THEN ( + DELETE oauth_state WHERE id = $event.id +); + +-- OAuth session storage (persistent) +DEFINE TABLE oauth_session SCHEMAFULL; +DEFINE FIELD did ON oauth_session TYPE string ASSERT $value != NONE; +DEFINE FIELD session_data ON oauth_session TYPE object ASSERT $value != NONE; +DEFINE FIELD updated_at ON oauth_session TYPE datetime DEFAULT time::now(); + +-- Index for DID lookups +DEFINE INDEX oauth_session_did ON oauth_session FIELDS did UNIQUE; + +-- Sessions belong to users +DEFINE FIELD owner ON oauth_session TYPE record; +DEFINE SCOPE user ...; +DEFINE TABLE user ...; +``` + +**Test Command**: +```bash +# Apply schema +surreal import --conn http://localhost:8000 \ + --user root --pass root \ + --ns ponderants --db ponderants \ + lib/db/schema.surql +``` + +### Phase 2: Store Implementations (45 min) + +#### File: `lib/auth/oauth-state-store.ts` + +```typescript +import Surreal from 'surrealdb'; +import type { NodeSavedStateStore, NodeSavedState } from '@atproto/oauth-client-node'; + +export function createStateStore(): NodeSavedStateStore { + return { + async set(key: string, value: NodeSavedState): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + await db.query( + 'CREATE oauth_state SET key = $key, value = $value', + { key, value } + ); + + await db.close(); + }, + + async get(key: string): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + const [result] = await db.query<[{ value: NodeSavedState }[]]>( + 'SELECT value FROM oauth_state WHERE key = $key', + { key } + ); + + await db.close(); + + return result?.[0]?.value; + }, + + async del(key: string): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + await db.query( + 'DELETE oauth_state WHERE key = $key', + { key } + ); + + await db.close(); + }, + }; +} +``` + +#### File: `lib/auth/oauth-session-store.ts` + +```typescript +import Surreal from 'surrealdb'; +import type { NodeSavedSessionStore, NodeSavedSession } from '@atproto/oauth-client-node'; + +export function createSessionStore(): NodeSavedSessionStore { + return { + async set(did: string, sessionData: NodeSavedSession): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + // Upsert: create if doesn't exist, update if it does + await db.query( + `INSERT INTO oauth_session (did, session_data) + VALUES ($did, $session_data) + ON DUPLICATE KEY UPDATE session_data = $session_data, updated_at = time::now()`, + { did, session_data: sessionData } + ); + + await db.close(); + }, + + async get(did: string): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + const [result] = await db.query<[{ session_data: NodeSavedSession }[]]>( + 'SELECT session_data FROM oauth_session WHERE did = $did', + { did } + ); + + await db.close(); + + return result?.[0]?.session_data; + }, + + async del(did: string): Promise { + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + await db.query( + 'DELETE oauth_session WHERE did = $did', + { did } + ); + + await db.close(); + }, + }; +} +``` + +**Tests**: Create unit tests for both stores with in-memory SurrealDB. + +### Phase 3: OAuth Client Singleton (30 min) + +**File**: `lib/auth/oauth-client.ts` + +```typescript +import { NodeOAuthClient } from '@atproto/oauth-client-node'; +import { createStateStore } from './oauth-state-store'; +import { createSessionStore } from './oauth-session-store'; + +let clientInstance: NodeOAuthClient | null = null; + +/** + * Get or create the singleton OAuth client instance. + * + * In development, uses the localhost client (no keys needed). + * In production, uses backend service with private keys. + */ +export async function getOAuthClient(): Promise { + if (clientInstance) { + return clientInstance; + } + + const isDev = process.env.NODE_ENV === 'development'; + const callbackUrl = process.env.BLUESKY_REDIRECT_URI; + + if (!callbackUrl) { + throw new Error('BLUESKY_REDIRECT_URI environment variable is required'); + } + + if (isDev) { + // Development: Use localhost exception + // client_id must be exactly "http://localhost" with query params + const clientId = `http://localhost?${new URLSearchParams({ + redirect_uri: callbackUrl, + scope: 'atproto', + })}`; + + clientInstance = await NodeOAuthClient.fromClientId({ + clientId, + stateStore: createStateStore(), + sessionStore: createSessionStore(), + }); + + console.log('[OAuth] Initialized development client with localhost exception'); + } else { + // Production: Backend service with keys + // TODO: Implement when deploying + throw new Error('Production OAuth client not yet implemented. See plans/oauth-dpop-implementation.md'); + } + + return clientInstance; +} + +/** + * Clear the singleton instance (mainly for testing). + */ +export function clearOAuthClient(): void { + clientInstance = null; +} +``` + +**Tests**: Mock the stores and verify client initialization. + +### Phase 4: Update API Routes (45 min) + +#### File: `app/api/auth/login/route.ts` (Complete Rewrite) + +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { getOAuthClient } from '@/lib/auth/oauth-client'; +import { z } from 'zod'; + +const LoginRequestSchema = z.object({ + handle: z.string().min(1, 'Handle is required'), +}); + +export async function POST(request: NextRequest) { + try { + // Parse and validate request body + const body = await request.json(); + const { handle } = LoginRequestSchema.parse(body); + + // Get OAuth client + const client = await getOAuthClient(); + + // Generate authorization URL + // The library handles: + // - PKCE code generation + // - DPoP key generation + // - State storage + // - PDS discovery + // - Authorization endpoint resolution + const authUrl = await client.authorize(handle, { + state: JSON.stringify({ + timestamp: Date.now(), + returnTo: '/chat', + }), + }); + + console.log('[OAuth] Generated authorization URL:', authUrl); + + return NextResponse.json({ url: authUrl }); + } catch (error) { + console.error('[OAuth] Login error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to initiate OAuth flow' }, + { status: 500 } + ); + } +} +``` + +#### File: `app/api/auth/callback/route.ts` (Complete Rewrite) + +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { getOAuthClient } from '@/lib/auth/oauth-client'; +import { mintSurrealJwt } from '@/lib/auth/jwt'; +import { Agent } from '@atproto/api'; +import Surreal from 'surrealdb'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + try { + // Get OAuth client + const client = await getOAuthClient(); + + // Exchange authorization code for session + // The library handles: + // - PKCE verification + // - DPoP proof generation + // - Token exchange + // - Token validation + // - Session storage + const { session, state } = await client.callback(searchParams); + + console.log('[OAuth] Successfully authenticated user:', session.did); + + // Create ATproto agent with session + const agent = new Agent(session); + + // Fetch user profile + const profileResponse = await agent.getProfile({ actor: session.did }); + + if (!profileResponse.success) { + throw new Error('Failed to fetch user profile'); + } + + const { did, handle } = profileResponse.data; + + // Upsert user in SurrealDB + const db = new Surreal(); + await db.connect(process.env.SURREALDB_URL!); + await db.signin({ + username: process.env.SURREALDB_USER!, + password: process.env.SURREALDB_PASS!, + }); + await db.use({ + namespace: process.env.SURREALDB_NS!, + database: process.env.SURREALDB_DB!, + }); + + await db.query( + `INSERT INTO user (did, handle) + VALUES ($did, $handle) + ON DUPLICATE KEY UPDATE handle = $handle`, + { did, handle } + ); + + await db.close(); + + console.log('[OAuth] Created/updated user in SurrealDB:', did); + + // Mint our app's SurrealDB JWT + const surrealJwt = mintSurrealJwt(did, handle); + + // Parse custom state + let returnTo = '/chat'; + try { + const customState = JSON.parse(state); + if (customState.returnTo) { + returnTo = customState.returnTo; + } + } catch { + // Invalid state JSON, use default + } + + // Create redirect response + const response = NextResponse.redirect(new URL(returnTo, request.url)); + + // Set SurrealDB JWT cookie (for our app's authorization) + response.cookies.set('ponderants-auth', surrealJwt, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + + // Note: We do NOT store ATproto tokens in cookies + // The oauth-client library manages them in sessionStore + + return response; + } catch (error) { + console.error('[OAuth] Callback error:', error); + + return NextResponse.redirect( + new URL('/login?error=Authentication failed', request.url) + ); + } +} +``` + +**Key Points**: +- Remove all manual token exchange code +- Remove manual DPoP code (library handles it) +- Remove ATproto token cookies (library manages sessions) +- Keep SurrealDB JWT for our app's authorization +- Session is automatically stored in `sessionStore` by the library + +### Phase 5: Session Management Utilities (30 min) + +**File**: `lib/auth/session.ts` + +```typescript +import { getOAuthClient } from './oauth-client'; +import { Agent } from '@atproto/api'; +import { cookies } from 'next/headers'; +import jwt from 'jsonwebtoken'; + +/** + * Get the authenticated ATproto agent for a user. + * Automatically refreshes tokens if needed. + */ +export async function getAuthenticatedAgent(): Promise { + try { + // Get user DID from our SurrealDB JWT + const cookieStore = await cookies(); + const authCookie = cookieStore.get('ponderants-auth'); + + if (!authCookie) { + return null; + } + + // Decode JWT to get DID (we don't verify here since SurrealDB will verify) + const payload = jwt.decode(authCookie.value) as { sub: string } | null; + + if (!payload?.sub) { + return null; + } + + const did = payload.sub; + + // Restore session from OAuth client (refreshes if needed) + const client = await getOAuthClient(); + const session = await client.restore(did); + + // Create agent with session + return new Agent(session); + } catch (error) { + console.error('[Session] Failed to restore session:', error); + return null; + } +} + +/** + * Sign out the current user. + * Revokes tokens and clears session. + */ +export async function signOut(): Promise { + try { + const agent = await getAuthenticatedAgent(); + + if (agent) { + // Revoke session (calls PDS to revoke tokens) + const session = (agent as any).session; + if (session?.signOut) { + await session.signOut(); + } + } + } catch (error) { + console.error('[Session] Sign out error:', error); + } + + // Clear our app's cookie + const cookieStore = await cookies(); + cookieStore.delete('ponderants-auth'); +} +``` + +**Usage Example** (in API routes): + +```typescript +import { getAuthenticatedAgent } from '@/lib/auth/session'; + +export async function POST(req: Request) { + const agent = await getAuthenticatedAgent(); + + if (!agent) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Use agent to make authenticated ATproto requests + const profile = await agent.getProfile({ actor: agent.did }); + + return NextResponse.json(profile.data); +} +``` + +### Phase 6: Environment Variables (15 min) + +**File**: `.env.example` (update) + +```bash +# ATproto OAuth (Development) +# For localhost development, the callback URL can use any port +BLUESKY_REDIRECT_URI=http://localhost:3000/api/auth/callback + +# Note: We use the localhost exception, so no client_id is needed in .env +# The client_id is constructed as: http://localhost?redirect_uri=...&scope=atproto + +# ATproto OAuth (Production - TODO) +# BLUESKY_CLIENT_ID=https://ponderants.com/client-metadata.json +# BLUESKY_PRIVATE_KEY_1=... +# BLUESKY_PRIVATE_KEY_2=... +# BLUESKY_PRIVATE_KEY_3=... + +# SurrealDB +SURREALDB_URL=ws://localhost:8000/rpc +SURREALDB_NS=ponderants +SURREALDB_DB=ponderants +SURREALDB_USER=root +SURREALDB_PASS=root + +# JWT Secret (for our app's SurrealDB JWTs) +JWT_SECRET=your-secret-key-here +JWT_ALGORITHM=HS512 + +# AI +GOOGLE_AI_API_KEY=... + +# Voice +DEEPGRAM_API_KEY=... +``` + +**File**: `.env` (update) + +Add the redirect URI: +```bash +BLUESKY_REDIRECT_URI=http://localhost:3000/api/auth/callback +``` + +Remove the old `BLUESKY_CLIENT_ID` variable (we don't need it anymore for localhost). + +### Phase 7: Remove Legacy Code (15 min) + +**Files to Delete**: +- `lib/auth/atproto.ts` (old manual OAuth functions) +- `lib/auth/oauth-state.ts` (old state management - replaced by stores) + +**Files to Update**: +- Remove `BLUESKY_CLIENT_ID` from `.env` (not needed for localhost) +- Update any imports that reference deleted files + +**Verify**: +```bash +# Search for references to deleted files +pnpm grep "auth/atproto" --type ts +pnpm grep "auth/oauth-state" --type ts +``` + +## Testing Strategy + +### Unit Tests + +**File**: `tests/unit/oauth-stores.test.ts` + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createStateStore } from '@/lib/auth/oauth-state-store'; +import { createSessionStore } from '@/lib/auth/oauth-session-store'; + +describe('OAuth State Store', () => { + let store: ReturnType; + + beforeEach(() => { + store = createStateStore(); + }); + + it('should set and get state', async () => { + const key = 'test-state-key'; + const value = { + iss: 'https://bsky.social', + dpopJwk: { kty: 'EC', crv: 'P-256', x: '...', y: '...' }, + verifier: 'code-verifier-123', + appState: { foo: 'bar' }, + }; + + await store.set(key, value); + const retrieved = await store.get(key); + + expect(retrieved).toEqual(value); + }); + + it('should delete state', async () => { + const key = 'test-state-key'; + const value = { /* ... */ }; + + await store.set(key, value); + await store.del(key); + const retrieved = await store.get(key); + + expect(retrieved).toBeUndefined(); + }); + + it('should return undefined for non-existent key', async () => { + const retrieved = await store.get('non-existent-key'); + expect(retrieved).toBeUndefined(); + }); +}); + +describe('OAuth Session Store', () => { + let store: ReturnType; + + beforeEach(() => { + store = createSessionStore(); + }); + + it('should set and get session', async () => { + const did = 'did:plc:test123'; + const session = { + sub: did, + aud: 'http://localhost', + scope: 'atproto', + tokenSet: { + access_token: 'at_123', + refresh_token: 'rt_123', + expires_at: Date.now() + 3600000, + }, + dpopJwk: { kty: 'EC', crv: 'P-256', x: '...', y: '...' }, + }; + + await store.set(did, session); + const retrieved = await store.get(did); + + expect(retrieved).toEqual(session); + }); + + it('should update existing session', async () => { + const did = 'did:plc:test123'; + const session1 = { /* ... */ access_token: 'at_1' }; + const session2 = { /* ... */ access_token: 'at_2' }; + + await store.set(did, session1); + await store.set(did, session2); + const retrieved = await store.get(did); + + expect(retrieved.tokenSet.access_token).toBe('at_2'); + }); +}); +``` + +### Integration Tests (Magnitude) + +**File**: `tests/magnitude/03-auth.mag.ts` (update) + +```typescript +import { test } from 'magnitude-test'; + +test('[Happy Path] User can log in with Bluesky', async (agent) => { + await agent.open('http://localhost:3000/login'); + + // Enter handle + await agent.act('Type "aprongecko.bsky.social" into the handle input field'); + await agent.act('Click the "Continue" button'); + + // User is redirected to Bluesky OAuth page + await agent.check('The page URL contains "bsky.social"'); + await agent.check('The page contains a login form'); + + // Enter credentials (from .env) + await agent.act('Type "aprongecko.bsky.social" into the username field'); + await agent.act('Type "Candles1" into the password field'); + await agent.act('Click the "Sign in" button'); + + // Should redirect back to our app + await agent.check('The page URL is "http://localhost:3000/chat"'); + await agent.check('The page contains "Ponderants Interview"'); +}); + +test('[Unhappy Path] Invalid handle shows error', async (agent) => { + await agent.open('http://localhost:3000/login'); + + await agent.act('Type "invalid-handle" into the handle input field'); + await agent.act('Click the "Continue" button'); + + await agent.check('An error message is displayed'); + await agent.check('The page URL is still "/login"'); +}); + +test('[Unhappy Path] OAuth callback with invalid state shows error', async (agent) => { + // Directly navigate to callback with invalid state + await agent.open('http://localhost:3000/api/auth/callback?code=test&state=invalid'); + + await agent.check('The page URL contains "error=Authentication failed"'); +}); +``` + +### Manual Testing Checklist + +- [ ] Start dev server: `pnpm dev` +- [ ] Navigate to `/login` +- [ ] Enter test handle: `aprongecko.bsky.social` +- [ ] Click "Continue" +- [ ] Verify redirect to `bsky.social` OAuth page +- [ ] Enter test credentials: `aprongecko.bsky.social` / `Candles1` +- [ ] Click "Sign in" +- [ ] Verify redirect to `/chat` +- [ ] Verify user is authenticated (can see chat interface) +- [ ] Verify SurrealDB has user record +- [ ] Verify SurrealDB has oauth_session record +- [ ] Close browser and reopen +- [ ] Navigate to `/chat` (should still be authenticated) +- [ ] Wait for token expiry (1 hour) and verify automatic refresh + +## Migration Path + +### Step 1: Deploy New Schema +```bash +# Apply new oauth_state and oauth_session tables +surreal import --conn http://localhost:8000 \ + --user root --pass root \ + --ns ponderants --db ponderants \ + lib/db/schema.surql +``` + +### Step 2: Deploy Code Changes +1. Create store implementations +2. Create OAuth client singleton +3. Update API routes +4. Remove legacy code + +### Step 3: Test End-to-End +1. Run unit tests: `pnpm test:unit` +2. Run magnitude tests: `pnpm test` +3. Manual testing with Playwright MCP + +### Step 4: Clean Up +1. Delete old `oauth_state` table (if different schema) +2. Remove environment variables: `BLUESKY_CLIENT_ID` (for localhost) +3. Archive old code files + +## Risks & Mitigations + +### Risk 1: Store Implementation Bugs + +**Impact**: OAuth flow fails silently + +**Mitigation**: +- Comprehensive unit tests for both stores +- Logging at each store operation +- Test with real SurrealDB instance + +### Risk 2: Localhost Exception Not Working + +**Impact**: Token exchange still fails + +**Mitigation**: +- Follow ATproto spec exactly: `http://localhost` (no port) +- Test with official Bluesky PDS +- Fall back to hosted client metadata if needed + +### Risk 3: Session Restoration Failures + +**Impact**: Users logged out after token expiry + +**Mitigation**: +- Library handles token refresh automatically +- Add event listeners for session updates/deletions +- Graceful error handling with redirect to login + +### Risk 4: SurrealDB Connection Issues + +**Impact**: Store operations fail + +**Mitigation**: +- Connection pooling (implement singleton DB connection) +- Retry logic with exponential backoff +- Health checks before store operations + +## Success Metrics + +βœ… **OAuth flow completes without errors** +- No "DPoP proof required" errors +- Token exchange succeeds +- Session stored in SurrealDB + +βœ… **Sessions persist across restarts** +- User stays logged in after server restart +- Sessions retrieved from sessionStore + +βœ… **Tokens refresh automatically** +- No manual refresh token handling +- Library manages token lifecycle + +βœ… **All tests pass** +- Unit tests: 100% coverage on stores +- Integration tests: auth flow happy path +- Manual tests: complete flow with test account + +## Future Enhancements + +### Production Client Setup + +When deploying to production, implement: + +1. **Generate Private Keys**: +```bash +# Generate 3 RSA private keys for key rotation +openssl genrsa -out key1.pem 2048 +openssl genrsa -out key2.pem 2048 +openssl genrsa -out key3.pem 2048 +``` + +2. **Create Client Metadata Endpoint**: +```typescript +// app/client-metadata.json/route.ts +export async function GET() { + const client = await getOAuthClient(); + return Response.json(client.clientMetadata); +} +``` + +3. **Create JWKS Endpoint**: +```typescript +// app/jwks.json/route.ts +export async function GET() { + const client = await getOAuthClient(); + return Response.json(client.jwks); +} +``` + +4. **Update Environment Variables**: +```bash +BLUESKY_CLIENT_ID=https://ponderants.com/client-metadata.json +BLUESKY_PRIVATE_KEY_1= +BLUESKY_PRIVATE_KEY_2= +BLUESKY_PRIVATE_KEY_3= +``` + +### Session Event Listeners + +Add listeners for session lifecycle events: + +```typescript +client.addEventListener('updated', (event) => { + console.log('Session refreshed:', event.detail.did); + // Update analytics, send notification, etc. +}); + +client.addEventListener('deleted', (event) => { + console.log('Session deleted:', event.detail.sub); + if (event.detail.cause instanceof TokenRefreshError) { + // Handle refresh failure + } else if (event.detail.cause instanceof TokenRevokedError) { + // Handle revocation + } +}); +``` + +### Connection Pooling + +Optimize SurrealDB connections: + +```typescript +// lib/db/connection.ts +let dbPool: Surreal | null = null; + +export async function getDB(): Promise { + if (dbPool) { + return dbPool; + } + + dbPool = new Surreal(); + await dbPool.connect(process.env.SURREALDB_URL!); + // ... configure connection + + return dbPool; +} +``` + +## Conclusion + +This implementation plan provides a complete path from our current broken OAuth implementation to a fully functional, production-ready system using the official `@atproto/oauth-client-node` library. The library handles all DPoP complexity, token management, and session persistence automatically. + +**Estimated Total Time**: 3-4 hours (including testing) + +**Next Steps**: +1. Review and approve this plan +2. Begin Phase 1 (database schema) +3. Implement sequentially through Phase 7 +4. Test thoroughly with Playwright MCP +5. Verify Magnitude tests pass +6. Deploy to staging for final validation diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f43970..ea15df0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@atproto/api': specifier: latest version: 0.18.0 + '@atproto/oauth-client-node': + specifier: ^0.3.10 + version: 0.3.10 '@deepgram/sdk': specifier: latest version: 4.11.2(bufferutil@4.0.9)(utf-8-validate@6.0.5) @@ -146,15 +149,66 @@ packages: zod: optional: true + '@atproto-labs/did-resolver@0.2.2': + resolution: {integrity: sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ==} + + '@atproto-labs/fetch-node@0.2.0': + resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/fetch@0.2.3': + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} + + '@atproto-labs/handle-resolver-node@0.1.21': + resolution: {integrity: sha512-fuJy5Px5pGF3lJX/ATdurbT8tbmaFWtf+PPxAQDFy7ot2no3t+iaAgymhyxYymrssOuWs6BwOP8tyF3VrfdwtQ==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/handle-resolver@0.3.2': + resolution: {integrity: sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A==} + + '@atproto-labs/identity-resolver@0.3.2': + resolution: {integrity: sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw==} + + '@atproto-labs/pipe@0.1.1': + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} + + '@atproto-labs/simple-store-memory@0.1.4': + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} + + '@atproto-labs/simple-store@0.3.0': + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} + '@atproto/api@0.18.0': resolution: {integrity: sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA==} '@atproto/common-web@0.4.3': resolution: {integrity: sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==} + '@atproto/did@0.2.1': + resolution: {integrity: sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA==} + + '@atproto/jwk-jose@0.1.11': + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} + + '@atproto/jwk-webcrypto@0.2.0': + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} + + '@atproto/jwk@0.6.0': + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} + '@atproto/lexicon@0.5.1': resolution: {integrity: sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==} + '@atproto/oauth-client-node@0.3.10': + resolution: {integrity: sha512-6khKlJqu1Ed5rt3rzcTD5hymB6JUjKdOHWYXwiphw4inkAIo6GxLCighI4eGOqZorYk2j8ueeTNB6KsgH0kcRw==} + engines: {node: '>=18.7.0'} + + '@atproto/oauth-client@0.5.8': + resolution: {integrity: sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg==} + + '@atproto/oauth-types@0.5.0': + resolution: {integrity: sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ==} + '@atproto/syntax@0.4.1': resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==} @@ -1460,6 +1514,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.46.0: + resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} @@ -2028,6 +2085,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + is-any-array@0.1.1: resolution: {integrity: sha512-qTiELO+kpTKqPgxPYbshMERlzaFu29JDnpB8s3bjg+JkxBpw29/qqSaOdKv2pCdaG92rLGeG/zG2GauX58hfoA==} @@ -2182,6 +2243,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.0: resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} @@ -2293,6 +2357,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.2: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} @@ -3126,6 +3193,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@6.22.0: + resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} + engines: {node: '>=18.17'} + undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} @@ -3378,6 +3449,53 @@ snapshots: optionalDependencies: zod: 4.1.12 + '@atproto-labs/did-resolver@0.2.2': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.2.1 + zod: 3.25.76 + + '@atproto-labs/fetch-node@0.2.0': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + ipaddr.js: 2.2.0 + undici: 6.22.0 + + '@atproto-labs/fetch@0.2.3': + dependencies: + '@atproto-labs/pipe': 0.1.1 + + '@atproto-labs/handle-resolver-node@0.1.21': + dependencies: + '@atproto-labs/fetch-node': 0.2.0 + '@atproto-labs/handle-resolver': 0.3.2 + '@atproto/did': 0.2.1 + + '@atproto-labs/handle-resolver@0.3.2': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.2.1 + zod: 3.25.76 + + '@atproto-labs/identity-resolver@0.3.2': + dependencies: + '@atproto-labs/did-resolver': 0.2.2 + '@atproto-labs/handle-resolver': 0.3.2 + + '@atproto-labs/pipe@0.1.1': {} + + '@atproto-labs/simple-store-memory@0.1.4': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + lru-cache: 10.4.3 + + '@atproto-labs/simple-store@0.3.0': {} + '@atproto/api@0.18.0': dependencies: '@atproto/common-web': 0.4.3 @@ -3396,6 +3514,26 @@ snapshots: uint8arrays: 3.0.0 zod: 3.25.76 + '@atproto/did@0.2.1': + dependencies: + zod: 3.25.76 + + '@atproto/jwk-jose@0.1.11': + dependencies: + '@atproto/jwk': 0.6.0 + jose: 5.10.0 + + '@atproto/jwk-webcrypto@0.2.0': + dependencies: + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + zod: 3.25.76 + + '@atproto/jwk@0.6.0': + dependencies: + multiformats: 9.9.0 + zod: 3.25.76 + '@atproto/lexicon@0.5.1': dependencies: '@atproto/common-web': 0.4.3 @@ -3404,6 +3542,40 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/oauth-client-node@0.3.10': + dependencies: + '@atproto-labs/did-resolver': 0.2.2 + '@atproto-labs/handle-resolver-node': 0.1.21 + '@atproto-labs/simple-store': 0.3.0 + '@atproto/did': 0.2.1 + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + '@atproto/jwk-webcrypto': 0.2.0 + '@atproto/oauth-client': 0.5.8 + '@atproto/oauth-types': 0.5.0 + + '@atproto/oauth-client@0.5.8': + dependencies: + '@atproto-labs/did-resolver': 0.2.2 + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/handle-resolver': 0.3.2 + '@atproto-labs/identity-resolver': 0.3.2 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.2.1 + '@atproto/jwk': 0.6.0 + '@atproto/oauth-types': 0.5.0 + '@atproto/xrpc': 0.7.5 + core-js: 3.46.0 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-types@0.5.0': + dependencies: + '@atproto/did': 0.2.1 + '@atproto/jwk': 0.6.0 + zod: 3.25.76 + '@atproto/syntax@0.4.1': {} '@atproto/xrpc@0.7.5': @@ -4626,6 +4798,8 @@ snapshots: convert-source-map@2.0.0: {} + core-js@3.46.0: {} + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -5345,6 +5519,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ipaddr.js@2.2.0: {} + is-any-array@0.1.1: {} is-any-array@2.0.1: {} @@ -5503,6 +5679,8 @@ snapshots: jiti@2.6.1: {} + jose@5.10.0: {} + jose@6.1.0: {} joycon@3.1.1: {} @@ -5613,6 +5791,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + lru-cache@11.2.2: {} lru-cache@5.1.1: @@ -6594,6 +6774,8 @@ snapshots: undici-types@7.16.0: {} + undici@6.22.0: {} + undici@7.16.0: {} unrs-resolver@1.11.1: diff --git a/scripts/apply-schema.js b/scripts/apply-schema.js new file mode 100755 index 0000000..b34b939 --- /dev/null +++ b/scripts/apply-schema.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +/** + * Apply SurrealDB schema from db/schema.surql + */ + +const Surreal = require('surrealdb').default; +const fs = require('fs'); +const path = require('path'); + +async function applySchema() { + const db = new Surreal(); + + try { + console.log('[Schema] Connecting to SurrealDB...'); + await db.connect('http://localhost:8000/rpc'); + + console.log('[Schema] Signing in...'); + await db.signin({ + username: 'root', + password: 'root', + }); + + console.log('[Schema] Using namespace and database...'); + await db.use({ + namespace: 'ponderants', + database: 'main', + }); + + console.log('[Schema] Reading schema file...'); + const schemaPath = path.join(__dirname, '..', 'db', 'schema.surql'); + let schema = fs.readFileSync(schemaPath, 'utf-8'); + + // Load environment variables from .env file manually + const envPath = path.join(__dirname, '..', '.env'); + const envContent = fs.readFileSync(envPath, 'utf-8'); + const envVars = {}; + envContent.split('\n').forEach(line => { + const match = line.match(/^([^#][^=]*)=(.*)$/); + if (match) { + envVars[match[1].trim()] = match[2].trim(); + } + }); + + // Replace $env.SURREALDB_JWT_SECRET with actual value + const jwtSecret = envVars.SURREALDB_JWT_SECRET; + if (!jwtSecret) { + throw new Error('SURREALDB_JWT_SECRET not found in .env file'); + } + + schema = schema.replace('$env.SURREALDB_JWT_SECRET', `'${jwtSecret}'`); + + console.log('[Schema] Executing schema...'); + let result; + try { + result = await db.query(schema); + } catch (error) { + // If error contains "already exists", it's OK - schema was already applied + if (error.message.includes('already exists')) { + console.log('[Schema] ⚠ Some schema elements already exist (this is OK)'); + console.log('[Schema] Continuing to ensure new tables are created...'); + + // Try to create just the new OAuth tables + const oauthSchema = ` + DEFINE TABLE oauth_state SCHEMAFULL; + DEFINE FIELD key ON TABLE oauth_state TYPE string ASSERT $value != NONE; + DEFINE FIELD value ON TABLE oauth_state TYPE object ASSERT $value != NONE; + DEFINE FIELD created_at ON TABLE oauth_state TYPE datetime DEFAULT time::now(); + DEFINE INDEX oauth_state_key_idx ON TABLE oauth_state COLUMNS key UNIQUE; + DEFINE EVENT oauth_state_cleanup ON TABLE oauth_state WHEN time::now() - created_at > 1h THEN ( + DELETE oauth_state WHERE id = $event.id + ); + + DEFINE TABLE oauth_session SCHEMAFULL; + DEFINE FIELD did ON TABLE oauth_session TYPE string ASSERT $value != NONE; + DEFINE FIELD session_data ON TABLE oauth_session TYPE object ASSERT $value != NONE; + DEFINE FIELD updated_at ON TABLE oauth_session TYPE datetime DEFAULT time::now(); + DEFINE INDEX oauth_session_did_idx ON TABLE oauth_session COLUMNS did UNIQUE; + `; + + try { + result = await db.query(oauthSchema); + } catch (oauthError) { + if (oauthError.message.includes('already exists')) { + console.log('[Schema] βœ“ OAuth tables already exist'); + console.log('[Schema] βœ“ Schema is up to date!'); + return; + } + throw oauthError; + } + } else { + throw error; + } + } + + if (result) { + console.log(`[Schema] Executed ${result.length} queries`); + + // Log any errors + result.forEach((r, i) => { + if (r.status === 'ERR') { + console.error(`[Schema] Error in query ${i + 1}:`, r.result); + } else { + console.log(`[Schema] βœ“ Query ${i + 1} succeeded`); + } + }); + } + + console.log('[Schema] βœ“ Schema applied successfully!'); + } catch (error) { + console.error('[Schema] βœ— Failed to apply schema:', error); + process.exit(1); + } finally { + await db.close(); + } +} + +applySchema();