Files
app/plans/oauth-dpop-implementation.md
Albert 6ff6bae270 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>
2025-11-09 01:40:04 +00:00

1250 lines
36 KiB
Markdown

# 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:
```json
{
"error": "invalid_dpop_proof",
"error_description": "DPoP proof required"
}
```
DPoP requires:
1. Generating a cryptographic key pair (typically RSA or EC)
2. Creating a JWT signed with the private key for each token request
3. Including the public key in the JWT header
4. Proper nonce handling from the server
5. 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:
1. ✅ Automatically generates and manages DPoP keys
2. ✅ Handles all DPoP proof creation and signing
3. ✅ Manages token refresh transparently
4. ✅ Provides session persistence
5. ✅ Implements proper security best practices
6. ✅ 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`: `native`
- `dpop_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.1` or `localhost: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.json` endpoint
- Hosting `/jwks.json` endpoint (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:
```typescript
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 verifier
- `appState`: 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 scopes
- `tokenSet`: Access token, refresh token, expiry
- `dpopJwk`: 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`
```typescript
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)
```typescript
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)
```typescript
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:
```typescript
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)
```sql
-- 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**:
```bash
# 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`
```typescript
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`
```typescript
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`
```typescript
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)
```typescript
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)
```typescript
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 `sessionStore` by the library
### Phase 5: Session Management Utilities (30 min)
**File**: `lib/auth/session.ts`
```typescript
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):
```typescript
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)
```bash
# 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:
```bash
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_ID` from `.env` (not needed for localhost)
- Update any imports that reference deleted files
**Verify**:
```bash
# 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`
```typescript
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)
```typescript
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.social` OAuth 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
```bash
# 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
1. Create store implementations
2. Create OAuth client singleton
3. Update API routes
4. Remove legacy code
### Step 3: Test End-to-End
1. Run unit tests: `pnpm test:unit`
2. Run magnitude tests: `pnpm test`
3. Manual testing with Playwright MCP
### Step 4: Clean Up
1. Delete old `oauth_state` table (if different schema)
2. Remove environment variables: `BLUESKY_CLIENT_ID` (for localhost)
3. 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:
1. **Generate Private Keys**:
```bash
# 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
```
2. **Create Client Metadata Endpoint**:
```typescript
// app/client-metadata.json/route.ts
export async function GET() {
const client = await getOAuthClient();
return Response.json(client.clientMetadata);
}
```
3. **Create JWKS Endpoint**:
```typescript
// app/jwks.json/route.ts
export async function GET() {
const client = await getOAuthClient();
return Response.json(client.jwks);
}
```
4. **Update Environment Variables**:
```bash
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:
```typescript
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:
```typescript
// 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**:
1. Review and approve this plan
2. Begin Phase 1 (database schema)
3. Implement sequentially through Phase 7
4. Test thoroughly with Playwright MCP
5. Verify Magnitude tests pass
6. Deploy to staging for final validation