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,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
75
lib/auth/oauth-client.ts
Normal 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;
|
||||
}
|
||||
84
lib/auth/oauth-session-store.ts
Normal file
84
lib/auth/oauth-session-store.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
81
lib/auth/oauth-state-store.ts
Normal file
81
lib/auth/oauth-state-store.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user