diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts index ede36ec..4abd65e 100644 --- a/app/api/auth/callback/route.ts +++ b/app/api/auth/callback/route.ts @@ -51,19 +51,14 @@ export async function GET(request: NextRequest) { console.log('[OAuth Callback] ✓ Successfully authenticated user:', session.did); - // Create ATproto agent with session - const agent = new Agent(session); + const did = session.did; - // Fetch user profile - const profileResponse = await agent.getProfile({ actor: session.did }); + // For now, we'll use the DID as the handle placeholder + // The actual handle will be fetched later when the user accesses protected routes + // This avoids needing additional OAuth scopes during the callback + const handle = did; - if (!profileResponse.success) { - throw new Error('Failed to fetch user profile'); - } - - const { did, handle } = profileResponse.data; - - console.log('[OAuth Callback] User profile:', { did, handle }); + console.log('[OAuth Callback] User DID:', did); // Upsert user in SurrealDB const db = new Surreal(); @@ -77,12 +72,18 @@ export async function GET(request: NextRequest) { database: process.env.SURREALDB_DB!, }); - await db.query( - `INSERT INTO user (did, handle) - VALUES ($did, $handle) - ON DUPLICATE KEY UPDATE handle = $handle`, - { did, handle } - ); + // Use UPSERT with record ID for better performance + await db.create(`user:⟨${did}⟩`, { + did, + handle, + created_at: new Date().toISOString(), + }).catch(async () => { + // If record exists, update it + await db.merge(`user:⟨${did}⟩`, { + handle, + updated_at: new Date().toISOString(), + }); + }); await db.close(); diff --git a/app/login/page.tsx b/app/login/page.tsx index 2843aeb..64513e4 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -27,11 +27,31 @@ export default function LoginPage() { const handleSubmit = async (values: { handle: string }) => { setIsLoading(true); - // We redirect to our *own* API route, which will then - // perform discovery and redirect to the correct Bluesky PDS. - // This keeps all complex logic and secrets on the server. - // Using window.location.href for full navigation that follows server redirects - window.location.href = `/api/auth/login?handle=${encodeURIComponent(values.handle)}`; + try { + // Call our API route to initiate OAuth flow + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handle: values.handle }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Login failed'); + } + + const { url } = await response.json(); + + // Redirect to the OAuth authorization URL + window.location.href = url; + } catch (error) { + console.error('Login error:', error); + setIsLoading(false); + // Show error to user + window.location.href = `/login?error=${encodeURIComponent( + error instanceof Error ? error.message : 'An error occurred' + )}`; + } }; return ( diff --git a/lib/auth/oauth-client.ts b/lib/auth/oauth-client.ts index d3f1693..24a7eb0 100644 --- a/lib/auth/oauth-client.ts +++ b/lib/auth/oauth-client.ts @@ -36,19 +36,27 @@ export async function getOAuthClient(): Promise { } 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({ + // Development: Use localhost loopback client + // Per ATproto spec, we encode metadata in the client_id query params + const clientId = `http://localhost/?${new URLSearchParams({ redirect_uri: callbackUrl, scope: 'atproto', - })}`; + }).toString()}`; - console.log('[OAuth] Initializing development client with localhost exception'); + console.log('[OAuth] Initializing development client with loopback exception'); console.log('[OAuth] client_id:', clientId); - clientInstance = await NodeOAuthClient.fromClientId({ - clientId, + clientInstance = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + redirect_uris: [callbackUrl], + scope: 'atproto', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + application_type: 'native', + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, + }, stateStore: createStateStore(), sessionStore: createSessionStore(), }); diff --git a/lib/auth/oauth-session-store.ts b/lib/auth/oauth-session-store.ts index e83dbf6..ed1ae47 100644 --- a/lib/auth/oauth-session-store.ts +++ b/lib/auth/oauth-session-store.ts @@ -41,13 +41,28 @@ export function createSessionStore(): NodeSavedSessionStore { 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 } - ); + // Use DID as the record ID for direct lookup + // Escape special characters in the DID for SurrealDB record ID + const recordId = `oauth_session:⟨${did}⟩`; + + // Upsert: update if exists, create if doesn't + const existing = await db.select<{ session_data: NodeSavedSession }>(recordId); + + if (Array.isArray(existing) && existing.length > 0) { + // Update existing record + await db.merge(recordId, { + session_data: sessionData, + updated_at: new Date().toISOString(), + }); + } else { + // Create new record + await db.create(recordId, { + did, + session_data: sessionData, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + } } finally { await db.close(); } @@ -57,12 +72,12 @@ export function createSessionStore(): NodeSavedSessionStore { const db = await getDB(); try { - const [result] = await db.query<[{ session_data: NodeSavedSession }[]]>( - 'SELECT session_data FROM oauth_session WHERE did = $did', - { did } - ); + // Select directly by record ID + const result = await db.select<{ session_data: NodeSavedSession }>(`oauth_session:⟨${did}⟩`); - return result?.[0]?.session_data; + // db.select() returns an array when selecting a specific record ID + const record = Array.isArray(result) ? result[0] : result; + return record?.session_data; } finally { await db.close(); } @@ -72,10 +87,8 @@ export function createSessionStore(): NodeSavedSessionStore { const db = await getDB(); try { - await db.query( - 'DELETE oauth_session WHERE did = $did', - { did } - ); + // Delete directly by record ID + await db.delete(`oauth_session:⟨${did}⟩`); } finally { await db.close(); } diff --git a/lib/auth/oauth-state-store.ts b/lib/auth/oauth-state-store.ts index a17aa89..f10dac8 100644 --- a/lib/auth/oauth-state-store.ts +++ b/lib/auth/oauth-state-store.ts @@ -41,10 +41,13 @@ export function createStateStore(): NodeSavedStateStore { const db = await getDB(); try { - await db.query( - 'CREATE oauth_state SET key = $key, value = $value', - { key, value } - ); + // Use the key as the record ID for direct lookup + // Escape special characters in the key for SurrealDB record ID + await db.create(`oauth_state:⟨${key}⟩`, { + key, + value, + created_at: new Date().toISOString(), + }); } finally { await db.close(); } @@ -54,12 +57,12 @@ export function createStateStore(): NodeSavedStateStore { const db = await getDB(); try { - const [result] = await db.query<[{ value: NodeSavedState }[]]>( - 'SELECT value FROM oauth_state WHERE key = $key', - { key } - ); + // Select directly by record ID + const result = await db.select<{ value: NodeSavedState }>(`oauth_state:⟨${key}⟩`); - return result?.[0]?.value; + // db.select() returns an array when selecting a specific record ID + const record = Array.isArray(result) ? result[0] : result; + return record?.value; } finally { await db.close(); } @@ -69,10 +72,8 @@ export function createStateStore(): NodeSavedStateStore { const db = await getDB(); try { - await db.query( - 'DELETE oauth_state WHERE key = $key', - { key } - ); + // Delete directly by record ID + await db.delete(`oauth_state:⟨${key}⟩`); } finally { await db.close(); }