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:
106
app/api/auth/callback/route.ts
Normal file
106
app/api/auth/callback/route.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
55
app/api/auth/login/route.ts
Normal file
55
app/api/auth/login/route.ts
Normal 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
71
app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user