diff --git a/.example.env b/.example.env index b6bdace..e7a8e23 100644 --- a/.example.env +++ b/.example.env @@ -6,7 +6,7 @@ SURREALDB_USER=root SURREALDB_PASS=root # JWT Secret for SurrealDB token minting -JWT_SECRET=your-secret-key-here-change-in-production +SURREALDB_JWT_SECRET=your-secret-key-here-change-in-production # Google AI API Key (for Gemini embeddings and chat) GOOGLE_AI_API_KEY=your-google-ai-api-key @@ -14,9 +14,9 @@ GOOGLE_AI_API_KEY=your-google-ai-api-key # Deepgram API Key (for voice-to-text) DEEPGRAM_API_KEY=your-deepgram-api-key -# ATproto OAuth Configuration -ATPROTO_CLIENT_ID=http://localhost:3000/client-metadata.json -ATPROTO_REDIRECT_URI=http://localhost:3000/api/auth/callback +# Bluesky/ATproto OAuth Configuration +BLUESKY_CLIENT_ID=http://localhost:3000/client-metadata.json +BLUESKY_REDIRECT_URI=http://localhost:3000/api/auth/callback # Anthropic API Key (for Magnitude testing) ANTHROPIC_API_KEY=your-anthropic-api-key diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts new file mode 100644 index 0000000..088e688 --- /dev/null +++ b/app/api/auth/callback/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { getAuthEndpoints } from '@/lib/auth/atproto'; +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; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + // Get temporary values from cookies + const cookieStore = await cookies(); + const cookieState = cookieStore.get('atproto_oauth_state')?.value; + const code_verifier = cookieStore.get('atproto_pkce_verifier')?.value; + const pdsUrl = cookieStore.get('atproto_pds_url')?.value; + + // Clear temporary cookies + cookieStore.delete('atproto_oauth_state'); + cookieStore.delete('atproto_pkce_verifier'); + cookieStore.delete('atproto_pds_url'); + + // 1. Validate state (CSRF protection) + if (!state || state !== cookieState) { + return NextResponse.redirect(new URL('/login?error=Invalid state', request.url)); + } + + // 2. Check for errors + if (!code || !pdsUrl || !code_verifier) { + return NextResponse.redirect(new URL('/login?error=Callback failed', request.url)); + } + + try { + // 3. Get the PDS's token endpoint + const { tokenEndpoint } = await getAuthEndpoints(pdsUrl); + + // 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, + }), + }); + + if (!tokenResponse.ok) { + throw new Error('Failed to exchange code for token'); + } + + const { access_token, refresh_token } = await tokenResponse.json(); + + // 5. Use the ATproto token to get the user's session info (did, handle) + const agent = new AtpAgent({ service: pdsUrl }); + agent.resumeSession({ + accessJwt: access_token, + refreshJwt: refresh_token, + did: '', + handle: '', + }); + + // getSession will populate the agent with the correct did/handle + const session = await agent.getSession(); + + if (!session.did || !session.handle) { + throw new Error('Failed to retrieve user session details'); + } + + // 6. Mint OUR app's SurrealDB JWT + const surrealJwt = mintSurrealJwt(session.did, session.handle); + + // 7. Set the SurrealDB JWT in a secure cookie for our app + cookieStore.set('ponderants-auth', surrealJwt, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + + // Store the ATproto tokens for later use + cookieStore.set('atproto_access_token', access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60, + path: '/', + }); + cookieStore.set('atproto_refresh_token', refresh_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 30, + path: '/', + }); + + // 8. Redirect to the main application + return NextResponse.redirect(new URL('/chat', request.url)); + } catch (error) { + console.error('Auth callback error:', error); + return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url)); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..9f902f5 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto'; +import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client'; + +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)); + } + + try { + // 1. Resolve handle to get PDS + const { pdsUrl } = await resolveHandle(handle); + + // 2. Discover PDS-specific auth endpoints + const { authorizationEndpoint } = await getAuthEndpoints(pdsUrl); + + // 3. Generate PKCE challenge and state + const state = randomState(); + const code_verifier = randomPKCECodeVerifier(); + const code_challenge = await calculatePKCECodeChallenge(code_verifier); + + // 4. Store verifier and state in a temporary cookie + const cookieStore = await cookies(); + cookieStore.set('atproto_oauth_state', state, { httpOnly: true, maxAge: 600 }); + cookieStore.set('atproto_pkce_verifier', code_verifier, { httpOnly: true, maxAge: 600 }); + cookieStore.set('atproto_pds_url', pdsUrl, { httpOnly: true, maxAge: 600 }); + + // 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); + + // 6. Redirect user to the PDS login screen + return NextResponse.redirect(authUrl); + } catch (error) { + console.error('Auth login error:', error); + return NextResponse.redirect(new URL('/login?error=Invalid handle or PDS', request.url)); + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..c6e37c9 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { + Button, + Center, + Paper, + Stack, + TextInput, + Title, + Text, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +export default function LoginPage() { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + const error = searchParams.get('error'); + + const form = useForm({ + initialValues: { + handle: '', + }, + }); + + 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. + router.push(`/api/auth/login?handle=${values.handle}`); + }; + + return ( +