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,63 +0,0 @@
import { AtpAgent } from '@atproto/api';
/**
* Resolves a Bluesky handle (e.g., "user.bsky.social") to its
* corresponding PDS (Personal Data Server) and DID (Decentralized Identifier).
* This discovery step is mandatory before initiating OAuth.
*/
export async function resolveHandle(handle: string) {
try {
const agent = new AtpAgent({ service: 'https://bsky.social' });
const response = await agent.resolveHandle({ handle });
const did = response.data.did;
// Now, get the PDS from the DID document
const didDoc = await agent.com.atproto.identity.resolveHandle({ handle });
// Get the PDS service endpoint from the DID document
const pdsService = didDoc.data;
if (!pdsService) {
throw new Error('PDS service endpoint not found in DID document.');
}
return {
did,
pdsUrl: 'https://bsky.social', // For now, all Bluesky users use the main PDS
};
} catch (error) {
console.error('Error resolving handle:', error);
throw new Error('Could not resolve Bluesky handle.');
}
}
/**
* Fetches the specific OAuth endpoints for a given PDS.
* Each PDS has its own set of endpoints.
*/
export async function getAuthEndpoints(pdsUrl: string) {
try {
const metadataUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
const response = await fetch(metadataUrl);
if (!response.ok) {
throw new Error(`Failed to fetch auth metadata from ${pdsUrl}`);
}
const metadata = await response.json();
const { authorization_endpoint, token_endpoint } = metadata;
if (!authorization_endpoint || !token_endpoint) {
throw new Error('Invalid auth metadata received from PDS.');
}
return {
authorizationEndpoint: authorization_endpoint,
tokenEndpoint: token_endpoint,
};
} catch (error) {
console.error('Error getting auth endpoints:', error);
throw new Error('Could not discover OAuth endpoints.');
}
}

75
lib/auth/oauth-client.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* OAuth Client Singleton for ATproto
*
* This module provides a singleton instance of NodeOAuthClient
* that manages OAuth flows, DPoP proofs, and session persistence.
*/
import { NodeOAuthClient } from '@atproto/oauth-client-node';
import { createStateStore } from './oauth-state-store';
import { createSessionStore } from './oauth-session-store';
let clientInstance: NodeOAuthClient | null = null;
/**
* Get or create the singleton OAuth client instance.
*
* In development, uses the localhost client exception (no keys needed).
* In production, uses backend service with private keys (TODO).
*
* The client handles:
* - OAuth authorization flow with PKCE
* - DPoP (Demonstrating Proof of Possession) for token requests
* - Automatic token refresh
* - Session persistence in SurrealDB
*/
export async function getOAuthClient(): Promise<NodeOAuthClient> {
if (clientInstance) {
return clientInstance;
}
const isDev = process.env.NODE_ENV === 'development';
const callbackUrl = process.env.BLUESKY_REDIRECT_URI;
if (!callbackUrl) {
throw new Error('BLUESKY_REDIRECT_URI environment variable is required');
}
if (isDev) {
// Development: Use localhost exception
// Per ATproto spec, client_id must be exactly "http://localhost"
// (no port number) with metadata in query parameters
const clientId = `http://localhost?${new URLSearchParams({
redirect_uri: callbackUrl,
scope: 'atproto',
})}`;
console.log('[OAuth] Initializing development client with localhost exception');
console.log('[OAuth] client_id:', clientId);
clientInstance = await NodeOAuthClient.fromClientId({
clientId,
stateStore: createStateStore(),
sessionStore: createSessionStore(),
});
console.log('[OAuth] ✓ Development client initialized');
} else {
// Production: Backend service with keys
// TODO: Implement when deploying to production
// See plans/oauth-dpop-implementation.md for details
throw new Error(
'Production OAuth client not yet implemented. ' +
'See plans/oauth-dpop-implementation.md for production setup instructions.'
);
}
return clientInstance;
}
/**
* Clear the singleton instance (mainly for testing).
*/
export function clearOAuthClient(): void {
clientInstance = null;
}

View File

@@ -0,0 +1,84 @@
/**
* OAuth Session Store for @atproto/oauth-client-node
*
* Stores persistent OAuth sessions (access/refresh tokens, DPoP keys).
* Sessions are keyed by the user's DID.
*/
import Surreal from 'surrealdb';
import type { NodeSavedSessionStore, NodeSavedSession } from '@atproto/oauth-client-node';
/**
* Get a SurrealDB connection with root credentials.
* Used for OAuth session management.
*/
async function getDB(): Promise<Surreal> {
const db = new Surreal();
await db.connect(process.env.SURREALDB_URL!);
await db.signin({
username: process.env.SURREALDB_USER!,
password: process.env.SURREALDB_PASS!,
});
await db.use({
namespace: process.env.SURREALDB_NS!,
database: process.env.SURREALDB_DB!,
});
return db;
}
/**
* Create an OAuth session store backed by SurrealDB.
*
* The session store persists authenticated user sessions across
* server restarts. The @atproto/oauth-client-node library manages
* token refresh automatically, updating the store when tokens change.
*
* Sessions are indexed by DID (decentralized identifier).
*/
export function createSessionStore(): NodeSavedSessionStore {
return {
async set(did: string, sessionData: NodeSavedSession): Promise<void> {
const db = await getDB();
try {
// Upsert: create if doesn't exist, update if it does
await db.query(
`INSERT INTO oauth_session (did, session_data)
VALUES ($did, $session_data)
ON DUPLICATE KEY UPDATE session_data = $session_data, updated_at = time::now()`,
{ did, session_data: sessionData }
);
} finally {
await db.close();
}
},
async get(did: string): Promise<NodeSavedSession | undefined> {
const db = await getDB();
try {
const [result] = await db.query<[{ session_data: NodeSavedSession }[]]>(
'SELECT session_data FROM oauth_session WHERE did = $did',
{ did }
);
return result?.[0]?.session_data;
} finally {
await db.close();
}
},
async del(did: string): Promise<void> {
const db = await getDB();
try {
await db.query(
'DELETE oauth_session WHERE did = $did',
{ did }
);
} finally {
await db.close();
}
},
};
}

View File

@@ -0,0 +1,81 @@
/**
* OAuth State Store for @atproto/oauth-client-node
*
* Stores temporary OAuth state during the authorization flow.
* Used for CSRF protection and PKCE verification.
*/
import Surreal from 'surrealdb';
import type { NodeSavedStateStore, NodeSavedState } from '@atproto/oauth-client-node';
/**
* Get a SurrealDB connection with root credentials.
* Used for OAuth state management.
*/
async function getDB(): Promise<Surreal> {
const db = new Surreal();
await db.connect(process.env.SURREALDB_URL!);
await db.signin({
username: process.env.SURREALDB_USER!,
password: process.env.SURREALDB_PASS!,
});
await db.use({
namespace: process.env.SURREALDB_NS!,
database: process.env.SURREALDB_DB!,
});
return db;
}
/**
* Create an OAuth state store backed by SurrealDB.
*
* The state store is used during the OAuth flow to store
* temporary data (PKCE verifier, DPoP key, etc.) that is
* retrieved when the user returns from the authorization server.
*
* States expire after 1 hour (enforced by database event).
*/
export function createStateStore(): NodeSavedStateStore {
return {
async set(key: string, value: NodeSavedState): Promise<void> {
const db = await getDB();
try {
await db.query(
'CREATE oauth_state SET key = $key, value = $value',
{ key, value }
);
} finally {
await db.close();
}
},
async get(key: string): Promise<NodeSavedState | undefined> {
const db = await getDB();
try {
const [result] = await db.query<[{ value: NodeSavedState }[]]>(
'SELECT value FROM oauth_state WHERE key = $key',
{ key }
);
return result?.[0]?.value;
} finally {
await db.close();
}
},
async del(key: string): Promise<void> {
const db = await getDB();
try {
await db.query(
'DELETE oauth_state WHERE key = $key',
{ key }
);
} finally {
await db.close();
}
},
};
}

View File

@@ -1,143 +0,0 @@
import Surreal from 'surrealdb';
const SURREALDB_URL = process.env.SURREALDB_URL;
const SURREALDB_NAMESPACE = process.env.SURREALDB_NS;
const SURREALDB_DATABASE = process.env.SURREALDB_DB;
const SURREALDB_USER = process.env.SURREALDB_USER;
const SURREALDB_PASS = process.env.SURREALDB_PASS;
if (!SURREALDB_URL || !SURREALDB_NAMESPACE || !SURREALDB_DATABASE || !SURREALDB_USER || !SURREALDB_PASS) {
throw new Error('SurrealDB configuration is missing. Please set SURREALDB_URL, SURREALDB_NS, SURREALDB_DB, SURREALDB_USER, and SURREALDB_PASS in .env');
}
interface OAuthState {
state: string;
code_verifier: string;
pds_url: string;
created_at: string;
}
/**
* Gets a SurrealDB connection for OAuth state management.
* This uses a separate, unauthenticated connection since OAuth happens before user auth.
*/
async function getDb() {
const db = new Surreal();
await db.connect(SURREALDB_URL);
// Sign in with root credentials for OAuth state management
await db.signin({
username: SURREALDB_USER,
password: SURREALDB_PASS,
});
await db.use({ namespace: SURREALDB_NAMESPACE, database: SURREALDB_DATABASE });
return db;
}
/**
* Stores OAuth state parameters in SurrealDB for the duration of the OAuth flow.
* The state is used as the record ID for easy lookup.
*
* @param state - The OAuth state parameter (CSRF token)
* @param codeVerifier - The PKCE code verifier
* @param pdsUrl - The user's PDS URL
*/
export async function storeOAuthState(
state: string,
codeVerifier: string,
pdsUrl: string
): Promise<void> {
const db = await getDb();
try {
// Store with a 10 minute TTL (OAuth flows should complete quickly)
const created_at = new Date().toISOString();
console.log('[OAuth State] Storing state:', state);
// Use CREATE with CONTENT to store all fields
const result = await db.create(`oauth_state:⟨${state}`, {
state,
code_verifier: codeVerifier,
pds_url: pdsUrl,
created_at,
});
console.log('[OAuth State] Stored successfully:', JSON.stringify(result, null, 2));
} finally {
await db.close();
}
}
/**
* Retrieves and deletes OAuth state from SurrealDB.
* This ensures one-time use of the state parameter (security best practice).
*
* @param state - The OAuth state parameter to look up
* @returns The OAuth state data, or null if not found or expired
*/
export async function consumeOAuthState(
state: string
): Promise<{ codeVerifier: string; pdsUrl: string } | null> {
const db = await getDb();
try {
console.log('[OAuth State] Retrieving state:', state);
// Retrieve the state by record ID
const selectResult = await db.select<OAuthState>(`oauth_state:⟨${state}`);
console.log('[OAuth State] Select result:', JSON.stringify(selectResult, null, 2));
// db.select() returns an array when selecting a specific record ID
const result = Array.isArray(selectResult) ? selectResult[0] : selectResult;
console.log('[OAuth State] Retrieved record:', JSON.stringify(result, null, 2));
if (!result) {
console.log('[OAuth State] No result found for state:', state);
return null;
}
// Check if expired (older than 10 minutes)
const createdAt = new Date(result.created_at);
const now = new Date();
const ageMinutes = (now.getTime() - createdAt.getTime()) / 1000 / 60;
console.log('[OAuth State] State age:', ageMinutes, 'minutes');
if (ageMinutes > 10) {
console.log('[OAuth State] State expired, deleting');
// Delete expired state
await db.delete(`oauth_state:⟨${state}`);
return null;
}
// Delete the state (one-time use)
console.log('[OAuth State] Deleting state (one-time use)');
await db.delete(`oauth_state:⟨${state}`);
return {
codeVerifier: result.code_verifier,
pdsUrl: result.pds_url,
};
} finally {
await db.close();
}
}
/**
* Cleans up expired OAuth states (should be called periodically).
* In production, this would be a cron job or scheduled task.
*/
export async function cleanupExpiredOAuthStates(): Promise<number> {
const db = await getDb();
try {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const result = await db.query<[OAuthState[]]>(
'DELETE FROM oauth_state WHERE created_at < $cutoff RETURN BEFORE',
{ cutoff: tenMinutesAgo }
);
return result[0]?.length || 0;
} finally {
await db.close();
}
}

View File

@@ -1,5 +1,8 @@
import { cookies } from 'next/headers';
import { verifySurrealJwt, type UserSession } from './jwt';
import { getOAuthClient } from './oauth-client';
import { Agent } from '@atproto/api';
import jwt from 'jsonwebtoken';
/**
* Gets the current authenticated user from the session cookie.
@@ -17,3 +20,104 @@ export async function getCurrentUser(): Promise<UserSession | null> {
return verifySurrealJwt(authCookie.value);
}
/**
* Get the authenticated ATproto agent for the current user.
*
* Automatically refreshes tokens if needed. The OAuth client
* handles token refresh transparently and updates the sessionStore.
*
* @returns Agent instance for making authenticated ATproto API calls,
* or null if user is not authenticated
*/
export async function getAuthenticatedAgent(): Promise<Agent | null> {
try {
// Get user DID from our SurrealDB JWT cookie
const cookieStore = await cookies();
const authCookie = cookieStore.get('ponderants-auth');
if (!authCookie) {
return null;
}
// Decode JWT to get DID (we don't verify here since SurrealDB will verify)
const payload = jwt.decode(authCookie.value) as { sub: string } | null;
if (!payload?.sub) {
return null;
}
const did = payload.sub;
console.log('[Session] Restoring session for DID:', did);
// Restore session from OAuth client
// This will automatically refresh tokens if they're expired
const client = await getOAuthClient();
const session = await client.restore(did);
console.log('[Session] ✓ Session restored');
// Create agent with session
return new Agent(session);
} catch (error) {
console.error('[Session] Failed to restore session:', error);
return null;
}
}
/**
* Sign out the current user.
*
* Revokes tokens with the PDS and clears the session from storage.
* Also clears our app's JWT cookie.
*/
export async function signOut(): Promise<void> {
try {
const agent = await getAuthenticatedAgent();
if (agent) {
console.log('[Session] Signing out user:', agent.did);
// Revoke session (calls PDS to revoke tokens)
const session = (agent as any).session;
if (session?.signOut) {
await session.signOut();
console.log('[Session] ✓ Session revoked with PDS');
}
}
} catch (error) {
console.error('[Session] Sign out error:', error);
// Continue to clear cookie even if revocation fails
}
// Clear our app's cookie
const cookieStore = await cookies();
cookieStore.delete('ponderants-auth');
console.log('[Session] ✓ Local session cleared');
}
/**
* Get the current user's DID from the session cookie.
*
* This is a lightweight check that doesn't require restoring
* the full OAuth session. Useful for quick authorization checks.
*
* @returns User's DID or null if not authenticated
*/
export async function getCurrentUserDid(): Promise<string | null> {
try {
const cookieStore = await cookies();
const authCookie = cookieStore.get('ponderants-auth');
if (!authCookie) {
return null;
}
const payload = jwt.decode(authCookie.value) as { sub: string } | null;
return payload?.sub || null;
} catch {
return null;
}
}