feat: Implement OAuth with DPoP using @atproto/oauth-client-node
Replace manual OAuth implementation with official @atproto/oauth-client-node library to properly support DPoP (Demonstrating Proof of Possession) authentication. Changes: - Added @atproto/oauth-client-node dependency - Created OAuth state store (SurrealDB-backed) for CSRF protection - Created OAuth session store (SurrealDB-backed) for token persistence - Created OAuth client singleton with localhost exception for development - Rewrote /api/auth/login to use client.authorize() - Rewrote /api/auth/callback to use client.callback() with DPoP - Updated lib/auth/session.ts with getAuthenticatedAgent() for ATproto API calls - Updated db/schema.surql with oauth_state and oauth_session tables - Added scripts/apply-schema.js for database schema management - Created plans/oauth-dpop-implementation.md with detailed implementation plan - Removed legacy lib/auth/atproto.ts and lib/auth/oauth-state.ts - Updated .env to use localhost exception (removed BLUESKY_CLIENT_ID) The OAuth client now handles: - PKCE code generation and verification - DPoP proof generation and signing - Automatic token refresh - Session persistence across server restarts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifySurrealJwt, type UserSession } from './jwt';
|
||||
import { getOAuthClient } from './oauth-client';
|
||||
import { Agent } from '@atproto/api';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Gets the current authenticated user from the session cookie.
|
||||
@@ -17,3 +20,104 @@ export async function getCurrentUser(): Promise<UserSession | null> {
|
||||
|
||||
return verifySurrealJwt(authCookie.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated ATproto agent for the current user.
|
||||
*
|
||||
* Automatically refreshes tokens if needed. The OAuth client
|
||||
* handles token refresh transparently and updates the sessionStore.
|
||||
*
|
||||
* @returns Agent instance for making authenticated ATproto API calls,
|
||||
* or null if user is not authenticated
|
||||
*/
|
||||
export async function getAuthenticatedAgent(): Promise<Agent | null> {
|
||||
try {
|
||||
// Get user DID from our SurrealDB JWT cookie
|
||||
const cookieStore = await cookies();
|
||||
const authCookie = cookieStore.get('ponderants-auth');
|
||||
|
||||
if (!authCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode JWT to get DID (we don't verify here since SurrealDB will verify)
|
||||
const payload = jwt.decode(authCookie.value) as { sub: string } | null;
|
||||
|
||||
if (!payload?.sub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const did = payload.sub;
|
||||
|
||||
console.log('[Session] Restoring session for DID:', did);
|
||||
|
||||
// Restore session from OAuth client
|
||||
// This will automatically refresh tokens if they're expired
|
||||
const client = await getOAuthClient();
|
||||
const session = await client.restore(did);
|
||||
|
||||
console.log('[Session] ✓ Session restored');
|
||||
|
||||
// Create agent with session
|
||||
return new Agent(session);
|
||||
} catch (error) {
|
||||
console.error('[Session] Failed to restore session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out the current user.
|
||||
*
|
||||
* Revokes tokens with the PDS and clears the session from storage.
|
||||
* Also clears our app's JWT cookie.
|
||||
*/
|
||||
export async function signOut(): Promise<void> {
|
||||
try {
|
||||
const agent = await getAuthenticatedAgent();
|
||||
|
||||
if (agent) {
|
||||
console.log('[Session] Signing out user:', agent.did);
|
||||
|
||||
// Revoke session (calls PDS to revoke tokens)
|
||||
const session = (agent as any).session;
|
||||
if (session?.signOut) {
|
||||
await session.signOut();
|
||||
console.log('[Session] ✓ Session revoked with PDS');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Session] Sign out error:', error);
|
||||
// Continue to clear cookie even if revocation fails
|
||||
}
|
||||
|
||||
// Clear our app's cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('ponderants-auth');
|
||||
|
||||
console.log('[Session] ✓ Local session cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's DID from the session cookie.
|
||||
*
|
||||
* This is a lightweight check that doesn't require restoring
|
||||
* the full OAuth session. Useful for quick authorization checks.
|
||||
*
|
||||
* @returns User's DID or null if not authenticated
|
||||
*/
|
||||
export async function getCurrentUserDid(): Promise<string | null> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const authCookie = cookieStore.get('ponderants-auth');
|
||||
|
||||
if (!authCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = jwt.decode(authCookie.value) as { sub: string } | null;
|
||||
return payload?.sub || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user