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>
36 KiB
OAuth with DPoP Implementation Plan
Executive Summary
This plan outlines the complete refactoring of our ATproto OAuth implementation to use the official @atproto/oauth-client-node library. The current manual OAuth implementation fails because ATproto requires DPoP (Demonstrating Proof of Possession) for token exchange, which cannot be implemented manually in a secure way. The official library handles all DPoP complexity automatically.
Timeline: 2-3 hours Risk Level: Low (library is production-ready and well-documented) Impact: Complete OAuth flow working with proper security
Current State Analysis
What's Working
- ✅ OAuth initiation flow (
/api/auth/login) - ✅ PKCE code generation (code_verifier, code_challenge)
- ✅ OAuth state storage in SurrealDB
- ✅ User handle resolution to PDS URL
- ✅ Authorization endpoint discovery
- ✅ Redirect to PDS authorization page
- ✅ Callback route structure (
/api/auth/callback)
What's Broken
- ❌ Token exchange fails with 401 "DPoP proof required"
- ❌ No DPoP proof generation in token requests
- ❌ No DPoP key management
- ❌ No session refresh capability
- ❌ No token rotation
- ❌ No session persistence across restarts
Root Cause
ATproto's OAuth implementation requires DPoP (Demonstrating Proof of Possession) for all token requests. This is a security mechanism that binds access tokens to a specific client by requiring cryptographic proof that the client possesses a private key.
From the error we encountered:
{
"error": "invalid_dpop_proof",
"error_description": "DPoP proof required"
}
DPoP requires:
- Generating a cryptographic key pair (typically RSA or EC)
- Creating a JWT signed with the private key for each token request
- Including the public key in the JWT header
- Proper nonce handling from the server
- Binding the proof to the specific HTTP request
This is too complex and security-sensitive to implement manually. The official library handles all of this.
Solution Overview
Replace our manual OAuth implementation with @atproto/oauth-client-node, which:
- ✅ Automatically generates and manages DPoP keys
- ✅ Handles all DPoP proof creation and signing
- ✅ Manages token refresh transparently
- ✅ Provides session persistence
- ✅ Implements proper security best practices
- ✅ Handles edge cases (token expiry, revocation, etc.)
Technical Design
Architecture Pattern
We'll use the Singleton OAuth Client pattern with SurrealDB-backed stores:
┌─────────────────────────────────────────────────────────────┐
│ Next.js Application │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ /api/auth/login │ │ /api/auth/callback│ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ ┌───────────────────────▼─────┐ │
│ └───►│ NodeOAuthClient (Singleton)│ │
│ └───────────┬───────────────┬──┘ │
│ │ │ │
│ ┌─────────▼─────┐ ┌──────▼────────┐ │
│ │ StateStore │ │ SessionStore │ │
│ │ (SurrealDB) │ │ (SurrealDB) │ │
│ └───────────────┘ └───────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
│
│ OAuth + DPoP
│
┌────────▼────────┐
│ ATproto PDS │
│ (Bluesky) │
└─────────────────┘
Development vs. Production Configuration
Development (Localhost)
For local development, ATproto has a special exception for http://localhost client IDs:
Client ID: http://localhost?redirect_uri=http://localhost:3000/api/auth/callback&scope=atproto
This creates a "virtual" client with:
token_endpoint_auth_method:none(no private keys needed!)application_type:nativedpop_bound_access_tokens:true
Advantages:
- No need to host client metadata JSON
- No need to generate and manage private keys
- No need for JWKS endpoint
- Faster iteration during development
Limitations:
- Only works with
http://localhost(exact string) - Cannot use
127.0.0.1orlocalhost:3000 - Port numbers are flexible in redirect URIs but not in client_id
Production (Deployed)
For production, we'll use the Backend Service pattern:
Client ID: https://ponderants.com/client-metadata.json
This requires:
- Hosting
/client-metadata.jsonendpoint - Hosting
/jwks.jsonendpoint (JSON Web Key Set) - Generating and securely storing private keys
token_endpoint_auth_method:private_key_jwt
We'll implement this later when deploying to production.
Store Implementations
Both stores follow the SimpleStore<K, V> interface:
interface SimpleStore<K, V> {
set(key: K, value: V): Promise<void>
get(key: K): Promise<V | undefined>
del(key: K): Promise<void>
}
StateStore
Purpose: Store OAuth authorization state during the OAuth flow (CSRF protection)
Type: SimpleStore<string, NodeSavedState>
NodeSavedState contains:
iss: Issuer (PDS URL)dpopJwk: DPoP key as JWK (JSON Web Key)verifier: PKCE code verifierappState: Custom state data (e.g., user ID, return URL)
Storage: oauth_state table in SurrealDB
Lifecycle:
- Created during
authorize() - Retrieved and deleted during
callback() - Should auto-expire after 1 hour (TTL)
SessionStore
Purpose: Store authenticated user sessions (access/refresh tokens)
Type: SimpleStore<string, NodeSavedSession>
Key: User's DID (e.g., did:plc:abcd1234)
NodeSavedSession contains:
sub: Subject (user DID)aud: Audience (client_id)scope: OAuth scopestokenSet: Access token, refresh token, expirydpopJwk: DPoP key for this session
Storage: oauth_session table in SurrealDB
Lifecycle:
- Created during successful
callback() - Retrieved during
restore(did) - Updated during token refresh (automatic)
- Deleted during
signOut()or token revocation
OAuth Client Singleton
We'll create a singleton instance of NodeOAuthClient that lives for the lifetime of the Next.js server:
File: lib/auth/oauth-client.ts
import { NodeOAuthClient } from '@atproto/oauth-client-node';
import { createStateStore } from './oauth-state-store';
import { createSessionStore } from './oauth-session-store';
let clientInstance: NodeOAuthClient | null = null;
export async function getOAuthClient(): Promise<NodeOAuthClient> {
if (clientInstance) {
return clientInstance;
}
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
// Development: Use localhost client
clientInstance = await NodeOAuthClient.fromClientId({
clientId: `http://localhost?redirect_uri=${encodeURIComponent(
'http://localhost:3000/api/auth/callback'
)}&scope=atproto`,
stateStore: createStateStore(),
sessionStore: createSessionStore(),
});
} else {
// Production: Use backend service client
// TODO: Implement when deploying
throw new Error('Production OAuth client not yet implemented');
}
return clientInstance;
}
API Routes
/api/auth/login (Updated)
import { getOAuthClient } from '@/lib/auth/oauth-client';
export async function POST(req: Request) {
const { handle } = await req.json();
const client = await getOAuthClient();
// Generate authorization URL
const authUrl = await client.authorize(handle, {
// Custom state that will be returned in callback
state: JSON.stringify({
timestamp: Date.now(),
returnTo: '/chat',
}),
});
return Response.json({ url: authUrl });
}
Key Changes:
- Use
client.authorize()instead of manual URL construction - Library handles PKCE, DPoP key generation, state storage
- Returns authorization URL to redirect user to
/api/auth/callback (Complete Rewrite)
import { getOAuthClient } from '@/lib/auth/oauth-client';
import { mintSurrealJwt } from '@/lib/auth/jwt';
export async function GET(req: Request) {
const client = await getOAuthClient();
const params = new URLSearchParams(new URL(req.url).search);
try {
// Exchange code for session (handles DPoP automatically)
const { session, state } = await client.callback(params);
// session.did is the user's DID
// session is now stored in sessionStore automatically
// Get user profile from ATproto
const agent = new Agent(session);
const profile = await agent.getProfile({ actor: session.did });
// Upsert user in SurrealDB
const db = await getSurrealDB();
await db.query(
'INSERT INTO user (did, handle) VALUES ($did, $handle) ON DUPLICATE KEY UPDATE handle = $handle',
{ did: session.did, handle: profile.data.handle }
);
// Mint our app's JWT
const jwt = mintSurrealJwt(session.did, profile.data.handle);
// Set cookie and redirect
const response = NextResponse.redirect(new URL('/chat', req.url));
response.cookies.set('ponderants-auth', jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
return response;
} catch (error) {
console.error('OAuth callback error:', error);
return NextResponse.redirect(
new URL('/login?error=auth_failed', req.url)
);
}
}
Key Changes:
- Use
client.callback()to handle token exchange - Library handles all DPoP proofs, token validation, session storage
- Remove manual token exchange code
- Remove ATproto token storage in cookies (library handles it)
- Keep SurrealDB JWT for our app's authorization
Session Restoration
For authenticated API requests, we can restore sessions:
import { getOAuthClient } from '@/lib/auth/oauth-client';
import { Agent } from '@atproto/api';
export async function getAuthenticatedAgent(did: string): Promise<Agent> {
const client = await getOAuthClient();
// Restore session from sessionStore (refreshes token if needed)
const session = await client.restore(did);
// Create agent with session
return new Agent(session);
}
Benefits:
- Automatic token refresh
- No need to manually handle refresh tokens
- Sessions persist across server restarts (stored in SurrealDB)
Implementation Steps
Phase 1: Database Schema (30 min)
File: lib/db/schema.surql (update)
-- OAuth state storage (temporary, for CSRF protection)
DEFINE TABLE oauth_state SCHEMAFULL;
DEFINE FIELD key ON oauth_state TYPE string ASSERT $value != NONE;
DEFINE FIELD value ON oauth_state TYPE object ASSERT $value != NONE;
DEFINE FIELD created_at ON oauth_state TYPE datetime DEFAULT time::now();
-- Index for fast lookups
DEFINE INDEX oauth_state_key ON oauth_state FIELDS key UNIQUE;
-- Auto-delete after 1 hour (cleanup stale states)
DEFINE EVENT oauth_state_cleanup ON TABLE oauth_state WHEN time::now() - created_at > 1h THEN (
DELETE oauth_state WHERE id = $event.id
);
-- OAuth session storage (persistent)
DEFINE TABLE oauth_session SCHEMAFULL;
DEFINE FIELD did ON oauth_session TYPE string ASSERT $value != NONE;
DEFINE FIELD session_data ON oauth_session TYPE object ASSERT $value != NONE;
DEFINE FIELD updated_at ON oauth_session TYPE datetime DEFAULT time::now();
-- Index for DID lookups
DEFINE INDEX oauth_session_did ON oauth_session FIELDS did UNIQUE;
-- Sessions belong to users
DEFINE FIELD owner ON oauth_session TYPE record<user>;
DEFINE SCOPE user ...;
DEFINE TABLE user ...;
Test Command:
# Apply schema
surreal import --conn http://localhost:8000 \
--user root --pass root \
--ns ponderants --db ponderants \
lib/db/schema.surql
Phase 2: Store Implementations (45 min)
File: lib/auth/oauth-state-store.ts
import Surreal from 'surrealdb';
import type { NodeSavedStateStore, NodeSavedState } from '@atproto/oauth-client-node';
export function createStateStore(): NodeSavedStateStore {
return {
async set(key: string, value: NodeSavedState): Promise<void> {
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!,
});
await db.query(
'CREATE oauth_state SET key = $key, value = $value',
{ key, value }
);
await db.close();
},
async get(key: string): Promise<NodeSavedState | undefined> {
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!,
});
const [result] = await db.query<[{ value: NodeSavedState }[]]>(
'SELECT value FROM oauth_state WHERE key = $key',
{ key }
);
await db.close();
return result?.[0]?.value;
},
async del(key: string): Promise<void> {
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!,
});
await db.query(
'DELETE oauth_state WHERE key = $key',
{ key }
);
await db.close();
},
};
}
File: lib/auth/oauth-session-store.ts
import Surreal from 'surrealdb';
import type { NodeSavedSessionStore, NodeSavedSession } from '@atproto/oauth-client-node';
export function createSessionStore(): NodeSavedSessionStore {
return {
async set(did: string, sessionData: NodeSavedSession): Promise<void> {
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!,
});
// 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 }
);
await db.close();
},
async get(did: string): Promise<NodeSavedSession | undefined> {
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!,
});
const [result] = await db.query<[{ session_data: NodeSavedSession }[]]>(
'SELECT session_data FROM oauth_session WHERE did = $did',
{ did }
);
await db.close();
return result?.[0]?.session_data;
},
async del(did: string): Promise<void> {
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!,
});
await db.query(
'DELETE oauth_session WHERE did = $did',
{ did }
);
await db.close();
},
};
}
Tests: Create unit tests for both stores with in-memory SurrealDB.
Phase 3: OAuth Client Singleton (30 min)
File: lib/auth/oauth-client.ts
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 (no keys needed).
* In production, uses backend service with private keys.
*/
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
// client_id must be exactly "http://localhost" with query params
const clientId = `http://localhost?${new URLSearchParams({
redirect_uri: callbackUrl,
scope: 'atproto',
})}`;
clientInstance = await NodeOAuthClient.fromClientId({
clientId,
stateStore: createStateStore(),
sessionStore: createSessionStore(),
});
console.log('[OAuth] Initialized development client with localhost exception');
} else {
// Production: Backend service with keys
// TODO: Implement when deploying
throw new Error('Production OAuth client not yet implemented. See plans/oauth-dpop-implementation.md');
}
return clientInstance;
}
/**
* Clear the singleton instance (mainly for testing).
*/
export function clearOAuthClient(): void {
clientInstance = null;
}
Tests: Mock the stores and verify client initialization.
Phase 4: Update API Routes (45 min)
File: app/api/auth/login/route.ts (Complete Rewrite)
import { NextRequest, NextResponse } from 'next/server';
import { getOAuthClient } from '@/lib/auth/oauth-client';
import { z } from 'zod';
const LoginRequestSchema = z.object({
handle: z.string().min(1, 'Handle is required'),
});
export async function POST(request: NextRequest) {
try {
// Parse and validate request body
const body = await request.json();
const { handle } = LoginRequestSchema.parse(body);
// Get OAuth client
const client = await getOAuthClient();
// Generate authorization URL
// The library handles:
// - PKCE code generation
// - DPoP key generation
// - State storage
// - PDS discovery
// - Authorization endpoint resolution
const authUrl = await client.authorize(handle, {
state: JSON.stringify({
timestamp: Date.now(),
returnTo: '/chat',
}),
});
console.log('[OAuth] Generated authorization URL:', authUrl);
return NextResponse.json({ url: authUrl });
} catch (error) {
console.error('[OAuth] Login error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to initiate OAuth flow' },
{ status: 500 }
);
}
}
File: app/api/auth/callback/route.ts (Complete Rewrite)
import { NextRequest, NextResponse } from 'next/server';
import { getOAuthClient } from '@/lib/auth/oauth-client';
import { mintSurrealJwt } from '@/lib/auth/jwt';
import { Agent } from '@atproto/api';
import Surreal from 'surrealdb';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
try {
// Get OAuth client
const client = await getOAuthClient();
// Exchange authorization code for session
// The library handles:
// - PKCE verification
// - DPoP proof generation
// - Token exchange
// - Token validation
// - Session storage
const { session, state } = await client.callback(searchParams);
console.log('[OAuth] 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 { did, handle } = profileResponse.data;
// Upsert user in SurrealDB
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!,
});
await db.query(
`INSERT INTO user (did, handle)
VALUES ($did, $handle)
ON DUPLICATE KEY UPDATE handle = $handle`,
{ did, handle }
);
await db.close();
console.log('[OAuth] Created/updated user in SurrealDB:', did);
// Mint our app's SurrealDB JWT
const surrealJwt = mintSurrealJwt(did, handle);
// Parse custom state
let returnTo = '/chat';
try {
const customState = JSON.parse(state);
if (customState.returnTo) {
returnTo = customState.returnTo;
}
} catch {
// Invalid state JSON, use default
}
// 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',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
// Note: We do NOT store ATproto tokens in cookies
// The oauth-client library manages them in sessionStore
return response;
} catch (error) {
console.error('[OAuth] Callback error:', error);
return NextResponse.redirect(
new URL('/login?error=Authentication failed', request.url)
);
}
}
Key Points:
- Remove all manual token exchange code
- Remove manual DPoP code (library handles it)
- Remove ATproto token cookies (library manages sessions)
- Keep SurrealDB JWT for our app's authorization
- Session is automatically stored in
sessionStoreby the library
Phase 5: Session Management Utilities (30 min)
File: lib/auth/session.ts
import { getOAuthClient } from './oauth-client';
import { Agent } from '@atproto/api';
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
/**
* Get the authenticated ATproto agent for a user.
* Automatically refreshes tokens if needed.
*/
export async function getAuthenticatedAgent(): Promise<Agent | null> {
try {
// Get user DID from our SurrealDB JWT
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;
// Restore session from OAuth client (refreshes if needed)
const client = await getOAuthClient();
const session = await client.restore(did);
// 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 and clears session.
*/
export async function signOut(): Promise<void> {
try {
const agent = await getAuthenticatedAgent();
if (agent) {
// Revoke session (calls PDS to revoke tokens)
const session = (agent as any).session;
if (session?.signOut) {
await session.signOut();
}
}
} catch (error) {
console.error('[Session] Sign out error:', error);
}
// Clear our app's cookie
const cookieStore = await cookies();
cookieStore.delete('ponderants-auth');
}
Usage Example (in API routes):
import { getAuthenticatedAgent } from '@/lib/auth/session';
export async function POST(req: Request) {
const agent = await getAuthenticatedAgent();
if (!agent) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Use agent to make authenticated ATproto requests
const profile = await agent.getProfile({ actor: agent.did });
return NextResponse.json(profile.data);
}
Phase 6: Environment Variables (15 min)
File: .env.example (update)
# ATproto OAuth (Development)
# For localhost development, the callback URL can use any port
BLUESKY_REDIRECT_URI=http://localhost:3000/api/auth/callback
# Note: We use the localhost exception, so no client_id is needed in .env
# The client_id is constructed as: http://localhost?redirect_uri=...&scope=atproto
# ATproto OAuth (Production - TODO)
# BLUESKY_CLIENT_ID=https://ponderants.com/client-metadata.json
# BLUESKY_PRIVATE_KEY_1=...
# BLUESKY_PRIVATE_KEY_2=...
# BLUESKY_PRIVATE_KEY_3=...
# SurrealDB
SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_NS=ponderants
SURREALDB_DB=ponderants
SURREALDB_USER=root
SURREALDB_PASS=root
# JWT Secret (for our app's SurrealDB JWTs)
JWT_SECRET=your-secret-key-here
JWT_ALGORITHM=HS512
# AI
GOOGLE_AI_API_KEY=...
# Voice
DEEPGRAM_API_KEY=...
File: .env (update)
Add the redirect URI:
BLUESKY_REDIRECT_URI=http://localhost:3000/api/auth/callback
Remove the old BLUESKY_CLIENT_ID variable (we don't need it anymore for localhost).
Phase 7: Remove Legacy Code (15 min)
Files to Delete:
lib/auth/atproto.ts(old manual OAuth functions)lib/auth/oauth-state.ts(old state management - replaced by stores)
Files to Update:
- Remove
BLUESKY_CLIENT_IDfrom.env(not needed for localhost) - Update any imports that reference deleted files
Verify:
# Search for references to deleted files
pnpm grep "auth/atproto" --type ts
pnpm grep "auth/oauth-state" --type ts
Testing Strategy
Unit Tests
File: tests/unit/oauth-stores.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createStateStore } from '@/lib/auth/oauth-state-store';
import { createSessionStore } from '@/lib/auth/oauth-session-store';
describe('OAuth State Store', () => {
let store: ReturnType<typeof createStateStore>;
beforeEach(() => {
store = createStateStore();
});
it('should set and get state', async () => {
const key = 'test-state-key';
const value = {
iss: 'https://bsky.social',
dpopJwk: { kty: 'EC', crv: 'P-256', x: '...', y: '...' },
verifier: 'code-verifier-123',
appState: { foo: 'bar' },
};
await store.set(key, value);
const retrieved = await store.get(key);
expect(retrieved).toEqual(value);
});
it('should delete state', async () => {
const key = 'test-state-key';
const value = { /* ... */ };
await store.set(key, value);
await store.del(key);
const retrieved = await store.get(key);
expect(retrieved).toBeUndefined();
});
it('should return undefined for non-existent key', async () => {
const retrieved = await store.get('non-existent-key');
expect(retrieved).toBeUndefined();
});
});
describe('OAuth Session Store', () => {
let store: ReturnType<typeof createSessionStore>;
beforeEach(() => {
store = createSessionStore();
});
it('should set and get session', async () => {
const did = 'did:plc:test123';
const session = {
sub: did,
aud: 'http://localhost',
scope: 'atproto',
tokenSet: {
access_token: 'at_123',
refresh_token: 'rt_123',
expires_at: Date.now() + 3600000,
},
dpopJwk: { kty: 'EC', crv: 'P-256', x: '...', y: '...' },
};
await store.set(did, session);
const retrieved = await store.get(did);
expect(retrieved).toEqual(session);
});
it('should update existing session', async () => {
const did = 'did:plc:test123';
const session1 = { /* ... */ access_token: 'at_1' };
const session2 = { /* ... */ access_token: 'at_2' };
await store.set(did, session1);
await store.set(did, session2);
const retrieved = await store.get(did);
expect(retrieved.tokenSet.access_token).toBe('at_2');
});
});
Integration Tests (Magnitude)
File: tests/magnitude/03-auth.mag.ts (update)
import { test } from 'magnitude-test';
test('[Happy Path] User can log in with Bluesky', async (agent) => {
await agent.open('http://localhost:3000/login');
// Enter handle
await agent.act('Type "aprongecko.bsky.social" into the handle input field');
await agent.act('Click the "Continue" button');
// User is redirected to Bluesky OAuth page
await agent.check('The page URL contains "bsky.social"');
await agent.check('The page contains a login form');
// Enter credentials (from .env)
await agent.act('Type "aprongecko.bsky.social" into the username field');
await agent.act('Type "Candles1" into the password field');
await agent.act('Click the "Sign in" button');
// Should redirect back to our app
await agent.check('The page URL is "http://localhost:3000/chat"');
await agent.check('The page contains "Ponderants Interview"');
});
test('[Unhappy Path] Invalid handle shows error', async (agent) => {
await agent.open('http://localhost:3000/login');
await agent.act('Type "invalid-handle" into the handle input field');
await agent.act('Click the "Continue" button');
await agent.check('An error message is displayed');
await agent.check('The page URL is still "/login"');
});
test('[Unhappy Path] OAuth callback with invalid state shows error', async (agent) => {
// Directly navigate to callback with invalid state
await agent.open('http://localhost:3000/api/auth/callback?code=test&state=invalid');
await agent.check('The page URL contains "error=Authentication failed"');
});
Manual Testing Checklist
- Start dev server:
pnpm dev - Navigate to
/login - Enter test handle:
aprongecko.bsky.social - Click "Continue"
- Verify redirect to
bsky.socialOAuth page - Enter test credentials:
aprongecko.bsky.social/Candles1 - Click "Sign in"
- Verify redirect to
/chat - Verify user is authenticated (can see chat interface)
- Verify SurrealDB has user record
- Verify SurrealDB has oauth_session record
- Close browser and reopen
- Navigate to
/chat(should still be authenticated) - Wait for token expiry (1 hour) and verify automatic refresh
Migration Path
Step 1: Deploy New Schema
# Apply new oauth_state and oauth_session tables
surreal import --conn http://localhost:8000 \
--user root --pass root \
--ns ponderants --db ponderants \
lib/db/schema.surql
Step 2: Deploy Code Changes
- Create store implementations
- Create OAuth client singleton
- Update API routes
- Remove legacy code
Step 3: Test End-to-End
- Run unit tests:
pnpm test:unit - Run magnitude tests:
pnpm test - Manual testing with Playwright MCP
Step 4: Clean Up
- Delete old
oauth_statetable (if different schema) - Remove environment variables:
BLUESKY_CLIENT_ID(for localhost) - Archive old code files
Risks & Mitigations
Risk 1: Store Implementation Bugs
Impact: OAuth flow fails silently
Mitigation:
- Comprehensive unit tests for both stores
- Logging at each store operation
- Test with real SurrealDB instance
Risk 2: Localhost Exception Not Working
Impact: Token exchange still fails
Mitigation:
- Follow ATproto spec exactly:
http://localhost(no port) - Test with official Bluesky PDS
- Fall back to hosted client metadata if needed
Risk 3: Session Restoration Failures
Impact: Users logged out after token expiry
Mitigation:
- Library handles token refresh automatically
- Add event listeners for session updates/deletions
- Graceful error handling with redirect to login
Risk 4: SurrealDB Connection Issues
Impact: Store operations fail
Mitigation:
- Connection pooling (implement singleton DB connection)
- Retry logic with exponential backoff
- Health checks before store operations
Success Metrics
✅ OAuth flow completes without errors
- No "DPoP proof required" errors
- Token exchange succeeds
- Session stored in SurrealDB
✅ Sessions persist across restarts
- User stays logged in after server restart
- Sessions retrieved from sessionStore
✅ Tokens refresh automatically
- No manual refresh token handling
- Library manages token lifecycle
✅ All tests pass
- Unit tests: 100% coverage on stores
- Integration tests: auth flow happy path
- Manual tests: complete flow with test account
Future Enhancements
Production Client Setup
When deploying to production, implement:
- Generate Private Keys:
# Generate 3 RSA private keys for key rotation
openssl genrsa -out key1.pem 2048
openssl genrsa -out key2.pem 2048
openssl genrsa -out key3.pem 2048
- Create Client Metadata Endpoint:
// app/client-metadata.json/route.ts
export async function GET() {
const client = await getOAuthClient();
return Response.json(client.clientMetadata);
}
- Create JWKS Endpoint:
// app/jwks.json/route.ts
export async function GET() {
const client = await getOAuthClient();
return Response.json(client.jwks);
}
- Update Environment Variables:
BLUESKY_CLIENT_ID=https://ponderants.com/client-metadata.json
BLUESKY_PRIVATE_KEY_1=<base64-encoded-key1>
BLUESKY_PRIVATE_KEY_2=<base64-encoded-key2>
BLUESKY_PRIVATE_KEY_3=<base64-encoded-key3>
Session Event Listeners
Add listeners for session lifecycle events:
client.addEventListener('updated', (event) => {
console.log('Session refreshed:', event.detail.did);
// Update analytics, send notification, etc.
});
client.addEventListener('deleted', (event) => {
console.log('Session deleted:', event.detail.sub);
if (event.detail.cause instanceof TokenRefreshError) {
// Handle refresh failure
} else if (event.detail.cause instanceof TokenRevokedError) {
// Handle revocation
}
});
Connection Pooling
Optimize SurrealDB connections:
// lib/db/connection.ts
let dbPool: Surreal | null = null;
export async function getDB(): Promise<Surreal> {
if (dbPool) {
return dbPool;
}
dbPool = new Surreal();
await dbPool.connect(process.env.SURREALDB_URL!);
// ... configure connection
return dbPool;
}
Conclusion
This implementation plan provides a complete path from our current broken OAuth implementation to a fully functional, production-ready system using the official @atproto/oauth-client-node library. The library handles all DPoP complexity, token management, and session persistence automatically.
Estimated Total Time: 3-4 hours (including testing)
Next Steps:
- Review and approve this plan
- Begin Phase 1 (database schema)
- Implement sequentially through Phase 7
- Test thoroughly with Playwright MCP
- Verify Magnitude tests pass
- Deploy to staging for final validation