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:
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user