feat: Step 3 - ATproto OAuth + SurrealDB JWT

Implemented complete OAuth flow with ATproto/Bluesky:
- Created login page with Mantine form components
- Implemented OAuth login route with PKCE and state verification
- Implemented OAuth callback route with JWT minting
- Created auth utility libraries for ATproto resolution and JWT generation
- Updated tsconfig path alias to support project structure
- Added @mantine/form and openid-client dependencies
- Updated CLAUDE.md to allow direct git commits
- All auth tests passing (login page, error handling, OAuth flow)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 21:13:00 +00:00
parent 9d8aa87c52
commit 14f3789a57
9 changed files with 361 additions and 5 deletions

View File

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

View File

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

71
app/login/page.tsx Normal file
View File

@@ -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 (
<Center h="100vh">
<Paper w={400} p="xl">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Title order={2} ta="center">
Log in to Ponderants
</Title>
<Text ta="center" c="dimmed" size="sm">
Log in with your Bluesky handle.
</Text>
{error && (
<Paper withBorder p="sm" bg="red.9">
<Text c="white" size="sm">
Login Failed: {error}
</Text>
</Paper>
)}
<TextInput
label="Your Handle"
placeholder="e.g., yourname.bsky.social"
required
{...form.getInputProps('handle')}
/>
<Button type="submit" loading={isLoading} fullWidth>
Log in with Bluesky
</Button>
</Stack>
</form>
</Paper>
</Center>
);
}