feat: Implement OAuth with DPoP using @atproto/oauth-client-node

Replace manual OAuth implementation with official @atproto/oauth-client-node library to properly support DPoP (Demonstrating Proof of Possession) authentication.

Changes:
- Added @atproto/oauth-client-node dependency
- Created OAuth state store (SurrealDB-backed) for CSRF protection
- Created OAuth session store (SurrealDB-backed) for token persistence
- Created OAuth client singleton with localhost exception for development
- Rewrote /api/auth/login to use client.authorize()
- Rewrote /api/auth/callback to use client.callback() with DPoP
- Updated lib/auth/session.ts with getAuthenticatedAgent() for ATproto API calls
- Updated db/schema.surql with oauth_state and oauth_session tables
- Added scripts/apply-schema.js for database schema management
- Created plans/oauth-dpop-implementation.md with detailed implementation plan
- Removed legacy lib/auth/atproto.ts and lib/auth/oauth-state.ts
- Updated .env to use localhost exception (removed BLUESKY_CLIENT_ID)

The OAuth client now handles:
- PKCE code generation and verification
- DPoP proof generation and signing
- Automatic token refresh
- Session persistence across server restarts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 01:40:04 +00:00
parent bc9bbe12de
commit d7f3bcd338
13 changed files with 2104 additions and 333 deletions

View File

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