OAuth Implementation Summary (ATB-14)#
Status: Complete Linear Issue: ATB-14 Implementation Date: February 2026 Tested With: bsky.social PDS
Overview#
This document summarizes the implementation of AT Protocol OAuth authentication for the atBB forum AppView. The implementation enables users to log in with their AT Protocol identity (DID) and grants the AppView delegated access to write records on the user's behalf.
What Was Built#
1. OAuth Client Integration#
Implementation: Official @atproto/oauth-client-node library (v0.3.16)
The OAuth implementation uses AT Protocol's official Node.js OAuth client library, which handles:
- Multi-PDS Discovery: Automatic handle resolution across any AT Protocol PDS (not limited to bsky.social)
- PKCE (Proof Key for Code Exchange): Secure public client flow without client secrets
- State Parameter Validation: CSRF protection during authorization callback
- Token Management: Automatic token refresh, DPoP-bound tokens, secure storage
- Handle Resolution: DNS, .well-known, and DID document resolution
Key Benefits of Using the Library:
- Battle-tested security (used by Bluesky official clients)
- Automatic token refresh eliminates expired session issues
- Multi-PDS support without manual resolution code
- DPoP implementation handled correctly
- Future-proof as AT Protocol evolves
Key Files:
apps/appview/src/routes/auth.ts— OAuth endpoints (login, callback, logout, session)apps/appview/src/lib/oauth-stores.ts— OAuth state/session storage adaptersapps/appview/src/lib/cookie-session-store.ts— HTTP cookie to DID mappingapps/appview/src/lib/app-context.ts— NodeOAuthClient initialization
2. Session Management#
Implementation: Two-layer session architecture
The session system uses a dual-store architecture:
-
OAuth Session Store: Library-managed OAuth sessions (indexed by DID)
- Stores OAuth tokens, DPoP keys, refresh tokens
- Handles automatic token refresh
- Implemented via
OAuthSessionStoreadapter (in-memory MVP)
-
Cookie Session Store: Maps HTTP cookies to DIDs
- Lightweight mapping from random cookie token → user DID
- Allows OAuth session restoration via
oauthClient.restore(did) - Stores user handle for display purposes
- Automatic cleanup of expired sessions
Session Security:
- httpOnly Cookies: JavaScript cannot access session tokens (XSS protection)
- Secure Flag: Cookies only sent over HTTPS in production
- SameSite=Lax: CSRF protection for state-changing operations
- Configurable TTL: Default 7 days, configurable via SESSION_TTL_DAYS
- Automatic Cleanup: Expired sessions removed every 5 minutes
Key Files:
apps/appview/src/lib/cookie-session-store.ts— CookieSessionStore implementationapps/appview/src/lib/oauth-stores.ts— OAuthStateStore and OAuthSessionStore adaptersapps/appview/src/lib/app-context.ts— Session store initialization
3. Authentication Middleware#
Implementation: Hono middleware for route protection
Two middleware functions provide flexible authentication:
requireAuth(ctx) — Enforce Authentication#
Requires valid session for route access:
- Extracts session cookie
- Restores OAuth session from library
- Creates authenticated Agent with DPoP
- Attaches user to context:
c.get('user') - Returns 401 if session missing/invalid
- Returns 500 on unexpected errors
optionalAuth(ctx) — Conditional Authentication#
Validates session if present, allows unauthenticated access:
- Extracts session cookie if present
- Restores OAuth session if available
- Attaches user to context if authenticated
- Cleans up invalid cookies automatically
- Never returns errors (fails silently)
- Useful for public pages with auth-dependent features
Key Files:
apps/appview/src/middleware/auth.ts— requireAuth and optionalAuth implementationsapps/appview/src/types.ts— AuthenticatedUser and Variables type definitions
4. OAuth Endpoints#
Implementation: RESTful API routes for authentication flow
Four endpoints handle the complete authentication lifecycle:
GET /api/auth/login?handle={handle}#
Initiates OAuth flow:
- Validates handle parameter (returns 400 if missing)
- Calls
oauthClient.authorize(handle)to:- Resolve handle → DID → PDS URL (multi-PDS support)
- Generate PKCE verifier and challenge
- Create state parameter for CSRF protection
- Build authorization URL
- Redirects user to their PDS authorization endpoint
- PDS presents authorization UI to user
Error Handling:
- 400 Bad Request: Invalid handle or PDS not found (client error)
- 500 Internal Server Error: Network failures, unexpected errors (server error)
- Logs all errors with structured context
GET /api/auth/callback?code={code}&state={state}&iss={issuer}#
Handles OAuth callback from PDS:
- Checks for user denial (
error=access_denied) - Parses callback parameters
- Calls
oauthClient.callback(params)to:- Validate state parameter (CSRF check)
- Verify PKCE code challenge
- Exchange authorization code for tokens with DPoP
- Store OAuth session in library's session store
- Fetches user profile to get handle
- Creates cookie session mapping (cookie token → DID + handle)
- Sets HTTP-only session cookie
- Redirects to homepage
Error Handling:
- 400 Bad Request: CSRF/PKCE validation failures (security errors logged)
- 500 Internal Server Error: Token exchange failures, network errors
- Fails login if handle fetch fails (no silent fallbacks)
GET /api/auth/session#
Returns current session information:
- Checks for session cookie
- Restores OAuth session via
oauthClient.restore(did) - Gets token info (library handles expiry checks and refresh)
- Returns
{ authenticated: true, did, sub }or{ authenticated: false }(401)
Error Handling:
- 401 Unauthorized: No cookie or session expired/invalid
- 500 Internal Server Error: Unexpected errors during restoration
- Does not delete cookie on transient errors (may be temporary)
GET /api/auth/logout#
Clears user session and revokes tokens:
- Extracts session cookie
- Restores OAuth session
- Calls
oauthSession.signOut()to revoke tokens at PDS - Deletes cookie session locally
- Clears session cookie
- Returns success or redirects
Error Handling:
- Continues with local cleanup even if PDS revocation fails
- Logs errors but does not fail the logout request
- Validates redirect parameter to prevent open redirect attacks
Key Files:
apps/appview/src/routes/auth.ts— All OAuth route handlers
Architecture#
Component Diagram#
┌─────────────────┐
│ User's PDS │
│ (any PDS) │
└────────┬────────┘
│ OAuth 2.1 Flow
│ (PKCE + DPoP)
▼
┌──────────────────────────────────────────┐
│ AppView (Port 3000) │
│ │
│ ┌────────────────────────────────────┐ │
│ │ NodeOAuthClient │ │
│ │ (@atproto/oauth-client-node) │ │
│ │ - Multi-PDS handle resolution │ │
│ │ - PKCE generation/validation │ │
│ │ - State management (CSRF) │ │
│ │ - Token exchange with DPoP │ │
│ │ - Automatic token refresh │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ OAuth Session Stores │ │
│ │ - OAuthStateStore (PKCE/state) │ │
│ │ - OAuthSessionStore (tokens) │ │
│ │ - In-memory Map (MVP) │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Cookie Session Store │ │
│ │ - Maps cookie token → DID │ │
│ │ - Stores handle for display │ │
│ │ - Automatic expiry cleanup │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Auth Middleware │ │
│ │ - requireAuth (enforce) │ │
│ │ - optionalAuth (conditional) │ │
│ │ - Creates Agent with DPoP │ │
│ │ - Injects user to context │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Auth Routes │ │
│ │ - /login (initiate) │ │
│ │ - /callback (complete) │ │
│ │ - /logout (revoke + clear) │ │
│ │ - /session (check) │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
│
│ HTTP-only session cookie
▼
┌─────────────────┐
│ Web UI │
│ (Port 3001) │
└─────────────────┘
Data Flow#
Login Flow:
- User visits Web UI, clicks "Login", enters handle
- Web UI redirects to
GET /api/auth/login?handle=user.bsky.social - AppView calls
oauthClient.authorize(handle):- Library resolves handle → DID → PDS URL (works with any PDS)
- Generates PKCE verifier (stored in OAuthStateStore)
- Creates state parameter (stored in OAuthStateStore)
- Builds authorization URL with all parameters
- AppView redirects browser to user's PDS authorization endpoint
- User approves access at their PDS
- PDS redirects to
GET /api/auth/callback?code=...&state=...&iss=... - AppView calls
oauthClient.callback(params):- Validates state parameter (CSRF check)
- Verifies PKCE code challenge
- Exchanges authorization code for access/refresh tokens with DPoP
- Stores tokens in OAuthSessionStore
- AppView fetches user profile to get handle
- AppView creates cookie session (random token → DID + handle)
- AppView sets HTTP-only session cookie
- AppView redirects to Web UI homepage
- Web UI calls
GET /api/auth/sessionto verify login
Authenticated Request Flow:
- Web UI makes request with session cookie
- Auth middleware extracts cookie token
- Middleware gets DID from CookieSessionStore
- Middleware calls
oauthClient.restore(did):- Library checks OAuthSessionStore for session
- Automatically refreshes tokens if near expiry
- Returns OAuthSession with valid tokens
- Middleware creates Agent with OAuthSession (DPoP-enabled)
- Middleware attaches AuthenticatedUser to context
- Route handler accesses
c.get('user')
Logout Flow:
- User clicks "Logout" in Web UI
- Web UI calls
GET /api/auth/logout - AppView restores OAuth session
- AppView calls
oauthSession.signOut():- Library revokes tokens at the PDS
- Cleans up local OAuth session
- AppView deletes cookie session
- AppView clears session cookie
- Web UI redirects to homepage
Security Features#
OAuth Security#
- PKCE Flow: Prevents authorization code interception attacks (S256 challenge)
- State Parameter: CSRF protection during callback
- DPoP Tokens: Token-bound to cryptographic key, prevents token theft/replay
- No Client Secrets: Public client pattern (safe for web apps)
- Multi-PDS Support: Works with any AT Protocol PDS, not hardcoded to bsky.social
- Automatic Token Rotation: Library refreshes tokens before expiry
Session Security#
- httpOnly Cookies: JavaScript cannot access session tokens (XSS protection)
- Secure Flag: Cookies only sent over HTTPS in production
- SameSite=Lax: CSRF protection for state-changing operations
- Session Expiry: Configurable TTL (default 7 days)
- Automatic Cleanup: Expired sessions removed every 5 minutes
- No Token Logging: Access tokens never written to logs
- Cookie Cleanup: Invalid cookies deleted to prevent repeated validation
Error Handling#
- Proper HTTP Status Codes: 400 for client errors, 401 for auth errors, 500 for server errors
- Security Logging: CSRF/PKCE failures logged with high severity
- No Silent Fallbacks: Errors fail explicitly rather than fabricating data
- Expected vs Unexpected: Session-not-found returns null, network errors throw
- User-Friendly Messages: Generic errors for users, detailed logs for debugging
Code Security#
- Type Safety: Full TypeScript coverage with strict types
- Input Validation: Handle and state parameters validated
- No Sensitive Data in Errors: Generic error messages for users
- Open Redirect Prevention: Redirect parameter validated in logout
Known Limitations (MVP)#
1. In-Memory Session Storage#
Limitation: Sessions stored in JavaScript Map are lost on server restart.
Impact:
- Users logged out on AppView restart
- Cannot scale to multiple AppView instances (no shared session state)
Mitigation Path: Replace with PostgreSQL or Redis-backed stores (both implement same SimpleStore<K, V> interface)
Tracked In: Post-MVP improvements (Priority 1)
2. No Session Persistence Across Deployments#
Limitation: Sessions not persisted to database.
Impact:
- Rolling deployments log out all users
- Downtime during updates requires re-authentication
Mitigation Path: Implement persistent session store
Tracked In: Post-MVP improvements (Priority 1)
3. Race Conditions in Token Refresh#
Limitation: requestLock implementation uses in-memory Map for single-instance deployments.
Impact:
- Multi-instance deployments may have race conditions during token refresh
- Multiple AppView instances could refresh tokens simultaneously
Mitigation Path: Use Redis-based distributed locking (redlock) for production
Tracked In: Code review comments (requestLock documentation)
Post-MVP Improvements#
Priority 1: Production-Ready Session Storage#
Goal: Replace in-memory sessions with persistent storage.
Options:
- PostgreSQL: Reuse existing database, add
oauth_sessionsandcookie_sessionstables - Redis: High-performance key-value store, built for sessions, supports TTL natively
- Drizzle ORM: Extend current schema with session tables
Recommendation: Redis for sessions (performance + TTL), PostgreSQL for forum data.
Implementation Steps:
- Create Redis-backed implementations of
OAuthSessionStore,OAuthStateStore,CookieSessionStore - Implement SimpleStore<K, V> interface for each
- Update
AppContextto use Redis stores when REDIS_URL is set - Test multi-instance deployment with shared session state
- Implement distributed locking for token refresh (redlock)
Estimated Effort: 2-3 days
Priority 2: Session Security Hardening#
Goal: Add production-grade session security features.
Features:
- Session rotation on authentication (prevent session fixation)
- Session fingerprinting (IP, User-Agent) for suspicious activity detection
- Session revocation API for users to logout all devices
- Rate limiting on login attempts
- Brute force protection
Estimated Effort: 2-3 days
Priority 3: Monitoring and Observability#
Goal: Track OAuth flow health and session metrics.
Metrics to Track:
- Login success/failure rates by error type
- Token refresh success/failure rates
- Session creation/deletion rates
- Active session count
- Session duration distribution
- OAuth flow latency (P50, P95, P99)
- Error types and frequencies
Tools: Prometheus metrics, Grafana dashboards, structured JSON logging
Estimated Effort: 1-2 days
Priority 4: Integration Testing#
Goal: Automated test suite for authentication flows.
Test Coverage:
- OAuth flow with mock PDS
- Session CRUD operations
- Token refresh flow
- Error scenarios (invalid codes, expired tokens, CSRF)
- Security tests (open redirect, XSS, cookie theft)
- Multi-tab session sharing
Tools: Vitest, Playwright, mock OAuth server
Estimated Effort: 3-5 days
Environment Variables#
Required Configuration#
Add these to .env:
# OAuth Configuration
OAUTH_PUBLIC_URL=http://localhost:3000 # Production: https://forum.atbb.space
SESSION_SECRET=<generate-with-openssl-rand-base64-32>
SESSION_TTL_DAYS=7 # Optional, defaults to 7
# Optional: Redis for production session storage
# REDIS_URL=redis://localhost:6379
Generating SESSION_SECRET#
Security Requirement: Must be at least 32 characters.
# Generate cryptographically secure random secret
openssl rand -base64 32
# Or use Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Warning: Never commit SESSION_SECRET to version control. The .env.example file intentionally uses an invalid value (CHANGE_ME_SEE_COMMENT_BELOW) to force generation.
Production Considerations#
For production deployments:
-
HTTPS Required:
- Set OAUTH_PUBLIC_URL to https:// URL
- Secure cookie flag automatically enabled in production
-
Public URL Must Match:
- OAUTH_PUBLIC_URL must match the actual deployment URL
- Used for OAuth client metadata and redirect URI
-
Redis Recommended:
- Set REDIS_URL for persistent sessions
- Enables multi-instance deployments
- Provides automatic TTL and cleanup
-
Security:
- Generate unique SESSION_SECRET per environment
- Rotate SESSION_SECRET periodically (logs out all users)
- Monitor failed login attempts
Migration from Password Auth#
OAuth Implementation#
OAuth flow:
Replace with OAuth flow:
// User initiates login via web UI
// AppView handles OAuth flow
// Route handlers receive authenticated user from middleware:
app.post('/api/posts', requireAuth(ctx), async (c) => {
const user = c.get('user'); // { did, handle, pdsUrl, agent }
// Agent is pre-configured with DPoP tokens
await user.agent.com.atproto.repo.createRecord({ ... });
});
Migration Steps#
- ✅ OAuth implemented — Users can now log in via web UI
- ⬜ Update write endpoints — Use OAuth sessions instead of password auth
- ⬜ Admin login — Decide: OAuth or keep password for forum service account?
Next Steps#
Completed Since This Document#
- ✅ ATB-15: Auto-Create Membership — Membership records now auto-created on first login (PR #27)
- ✅ ATB-12: Write Endpoints — Topic and reply creation endpoints implemented with OAuth sessions
Immediate (Phase 2 Continuation)#
- ATB-16: Session Management Enhancements — Redis-backed session store
- ATB-17: Permission Middleware — Role-based access control for protected routes
- ATB-18: Forum DID Agent — Dedicated agent for forum-level operations
Phase 4: Web UI Integration#
- Login/Logout UI — Add login button, logout button, user menu
- Session Display — Show logged-in user's handle in header
- Protected Actions — Show/hide compose forms based on auth status
- Error Messages — Display OAuth errors to users (denied auth, expired session)
References#
Documentation#
- AT Protocol OAuth Specification
@atproto/oauth-client-nodeDocumentation- OAuth 2.1 Specification (Draft)
- OAuth 2.0 PKCE Specification (RFC 7636)
- DPoP Specification (RFC 9449)
Code Files#
OAuth Routes:
apps/appview/src/routes/auth.ts— OAuth endpoints (login, callback, logout, session)
Session Management:
apps/appview/src/lib/oauth-stores.ts— OAuthStateStore and OAuthSessionStore adaptersapps/appview/src/lib/cookie-session-store.ts— CookieSessionStore implementation
Authentication:
apps/appview/src/middleware/auth.ts— requireAuth and optionalAuth middlewareapps/appview/src/types.ts— AuthenticatedUser and Variables types
Configuration:
apps/appview/src/lib/app-context.ts— NodeOAuthClient initializationapps/appview/src/lib/config.ts— OAuth configuration validation
Tests:
apps/appview/src/lib/__tests__/config.test.ts— Configuration validation testsapps/appview/src/lib/__tests__/test-context.ts— Test helper for OAuth context
Related Issues#
- ATB-14: Implement AT Proto OAuth flow (Complete ✅)
- ATB-15: Auto-create membership on first login (Complete ✅)
- ATB-12: Write endpoints implementation (Complete ✅)
- ATB-16: Redis-backed session storage (Pending)
- ATB-17: Permission middleware (Pending)
- ATB-18: Forum DID agent (Pending)
Contributors#
Implementation by Claude Code (Anthropic) with guidance from project maintainer.
Implementation Period: February 7-9, 2026 Code Review: February 8-9, 2026 Documentation: February 9, 2026
End of OAuth Implementation Summary