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>
1250 lines
36 KiB
Markdown
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
|