feat: Implement OAuth with DPoP using @atproto/oauth-client-node

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-09 01:40:04 +00:00
parent bc9bbe12de
commit d7f3bcd338
13 changed files with 2104 additions and 333 deletions

View File

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

View File

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