WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: add OAuth implementation design for ATB-14

Complete design for AT Protocol OAuth authentication covering:
- Decentralized PDS authority model
- @atproto/oauth-client-node integration
- Pluggable session storage (in-memory → Redis migration path)
- Authentication middleware and route protection
- Client metadata configuration
- Error handling and security considerations

Includes implementation roadmap with 5 phases and testing strategy.

+370
+370
docs/plans/2026-02-07-oauth-implementation-design.md
··· 1 + # OAuth Implementation Design 2 + 3 + **Issue:** ATB-14 4 + **Author:** Claude Code 5 + **Date:** 2026-02-07 6 + **Status:** Approved 7 + 8 + ## Overview 9 + 10 + Implement AT Protocol OAuth authentication for the atBB forum. Users authenticate with their AT Protocol identity (DID) to perform write operations. The AppView acts as an OAuth client, requesting delegated access from each user's PDS (the authorization server). 11 + 12 + ## Requirements 13 + 14 + - Support both end users and admins 15 + - Pluggable session storage (in-memory default, Redis-ready) 16 + - AppView owns OAuth flow, issues session tokens to Web UI 17 + - Respect decentralized PDS authority model 18 + 19 + ## Architecture 20 + 21 + ### Authority Model 22 + 23 + **Critical:** Each user's PDS is the authorization server, NOT the AppView. 24 + 25 + - **User's PDS** = Authorization Server for that user's account 26 + - Issues access tokens for their DID 27 + - Validates credentials 28 + - User approves scopes here 29 + 30 + - **AppView** = OAuth Client 31 + - Requests authorization from user's PDS 32 + - Receives delegated access tokens 33 + - Uses tokens to write records to user's PDS on behalf 34 + 35 + **Example:** 36 + - User A on `bsky.social` → AppView redirects to `bsky.social`'s OAuth endpoint 37 + - User B on `self-hosted.example` → AppView redirects to `self-hosted.example`'s OAuth endpoint 38 + - Same AppView client, different authorization servers per user 39 + 40 + ### OAuth Package Selection 41 + 42 + **Using: `@atproto/oauth-client-node` v0.5.14** 43 + 44 + Rationale: 45 + - Official Node.js OAuth client for AT Protocol 46 + - Handles OAuth 2.1 requirements: PKCE, DPoP, PAR (Pushed Authorization Requests) 47 + - Manages token refresh automatically 48 + - Production-ready security (nonce rotation, state management) 49 + - Integrates cleanly with `@atproto/api` Agent 50 + 51 + ### Client Metadata Approach 52 + 53 + AT Protocol uses client metadata documents instead of pre-registration. 54 + 55 + The AppView will: 56 + - Serve public `/.well-known/oauth-client-metadata` endpoint 57 + - Use metadata URL as `client_id` (e.g., `https://forum.example.com/.well-known/oauth-client-metadata`) 58 + - Declare redirect URIs, scopes (`atproto`), grant types 59 + 60 + No pre-registration required—the forum's metadata URL is the identity. 61 + 62 + ### Session Token Strategy 63 + 64 + Sessions use signed, opaque tokens (not JWTs): 65 + - HTTP-only cookies prevent XSS attacks 66 + - SameSite=Lax prevents CSRF 67 + - Server-side storage maps tokens to OAuth sessions (DID, handle, access tokens) 68 + - Tokens are random UUIDs (allows instant revocation) 69 + 70 + ## Implementation Details 71 + 72 + ### OAuth Routes 73 + 74 + **`GET /api/auth/login?handle=user.bsky.social`** 75 + 1. Extract handle from query param 76 + 2. Resolve handle → DID → PDS endpoint (using `@atproto/identity`) 77 + 3. Create OAuth client for that PDS 78 + 4. Generate state + PKCE verifier (stored server-side keyed by state) 79 + 5. Build authorization URL with state, code_challenge, redirect_uri 80 + 6. Return redirect response to PDS's authorization endpoint 81 + 82 + **`GET /api/auth/callback?code=...&state=...`** 83 + 1. Validate state parameter (CSRF protection) 84 + 2. Retrieve stored PKCE verifier for this state 85 + 3. Exchange authorization code for tokens (using code_verifier) 86 + 4. Extract user DID, handle, PDS endpoint from token response 87 + 5. Create session record with tokens + metadata 88 + 6. Generate session token (UUID), set HTTP-only cookie 89 + 7. Clean up state storage 90 + 8. Redirect to Web UI homepage 91 + 92 + **`GET /api/auth/logout`** 93 + 1. Extract session token from cookie 94 + 2. Delete session from storage 95 + 3. Optionally revoke tokens at PDS (if supported) 96 + 4. Clear cookie 97 + 5. Return 200 or redirect to homepage 98 + 99 + **`GET /api/auth/session`** 100 + 1. Extract session token from cookie 101 + 2. Lookup session in storage 102 + 3. Check if access token expired 103 + 4. If expired, refresh using refresh token 104 + 5. Return: `{ did, handle, authenticated: true }` or 401 105 + 106 + ### Session Storage 107 + 108 + **Interface:** 109 + ```typescript 110 + interface SessionData { 111 + did: string; 112 + handle: string; 113 + pdsUrl: string; 114 + accessToken: string; 115 + refreshToken?: string; 116 + expiresAt: Date; 117 + createdAt: Date; 118 + } 119 + 120 + interface SessionStore { 121 + set(token: string, session: SessionData, ttl?: number): Promise<void>; 122 + get(token: string): Promise<SessionData | null>; 123 + delete(token: string): Promise<void>; 124 + cleanup?(): Promise<void>; 125 + } 126 + ``` 127 + 128 + **Implementations:** 129 + 130 + 1. **MemorySessionStore (MVP Default)** 131 + - In-memory Map storage 132 + - Auto-cleanup with TTL timers 133 + - Limitation: lost on restart, single-instance only 134 + 135 + 2. **RedisSessionStore (Future)** 136 + - Uses Redis SETEX for automatic expiration 137 + - Supports multi-instance deployment 138 + - Drop-in replacement via same interface 139 + 140 + **Factory in AppContext:** 141 + ```typescript 142 + export interface AppConfig { 143 + sessionStore?: SessionStore; // Optional injection 144 + } 145 + 146 + export async function createAppContext(config: AppConfig) { 147 + const sessionStore = config.sessionStore ?? new MemorySessionStore(); 148 + } 149 + ``` 150 + 151 + ### Authentication Middleware 152 + 153 + **`requireAuth(ctx: AppContext)`** 154 + - Validates session token from cookie 155 + - Checks access token expiration 156 + - Triggers refresh if needed 157 + - Creates Agent with user's tokens 158 + - Attaches `AuthenticatedUser` to Hono context: `c.get('user')` 159 + - Returns 401 if invalid/expired 160 + 161 + **`optionalAuth(ctx: AppContext)`** 162 + - Same logic but doesn't return 401 163 + - Sets `c.get('user')` only if valid session exists 164 + - Allows endpoints to work for both authenticated and unauthenticated users 165 + 166 + **Usage:** 167 + ```typescript 168 + app.post('/api/posts', requireAuth(ctx), async (c) => { 169 + const user = c.get('user'); // Type: AuthenticatedUser 170 + const agent = user.agent; 171 + 172 + await agent.com.atproto.repo.createRecord({ 173 + repo: user.did, 174 + collection: 'space.atbb.post', 175 + record: { /* ... */ } 176 + }); 177 + }); 178 + ``` 179 + 180 + ### Error Handling 181 + 182 + **Categories:** 183 + 184 + 1. **User-Initiated Errors (4xx)** 185 + - User denies authorization → Redirect with `?error=access_denied` 186 + - Invalid handle → "Handle not found" 187 + - No PDS found → "Could not locate your AT Protocol server" 188 + 189 + 2. **Integration Errors (transient)** 190 + - PDS unreachable → "Your server is not responding, try again" 191 + - Token exchange timeout → "Authorization timed out, try again" 192 + - Refresh fails → Log out, "Session expired, please log in again" 193 + 194 + 3. **Configuration Errors (5xx)** 195 + - Invalid client metadata → Log error, show generic message 196 + - Missing env vars → Fail fast at startup 197 + - PKCE state not found → Log warning, "Authorization expired" 198 + 199 + **Logging Strategy:** 200 + - Structured JSON logs for all OAuth events 201 + - Never log tokens or secrets 202 + - Include event type, timestamp, relevant IDs 203 + - Dev mode shows error details, production shows generic messages 204 + 205 + **Security:** 206 + - Never log access_token, refresh_token, PKCE verifier 207 + - Validate redirect_uri matches client metadata 208 + - Constant-time state comparison (prevent timing attacks) 209 + - Clear expired state regularly (prevent memory leaks) 210 + 211 + ### Client Metadata Configuration 212 + 213 + **Endpoint:** `GET /.well-known/oauth-client-metadata` 214 + 215 + Dynamic generation based on environment: 216 + ```json 217 + { 218 + "client_id": "https://forum.example.com/.well-known/oauth-client-metadata", 219 + "client_name": "atBB Forum", 220 + "redirect_uris": ["https://forum.example.com/api/auth/callback"], 221 + "scope": "atproto transition:generic", 222 + "grant_types": ["authorization_code", "refresh_token"], 223 + "response_types": ["code"], 224 + "token_endpoint_auth_method": "none", 225 + "application_type": "web", 226 + "dpop_bound_access_tokens": true 227 + } 228 + ``` 229 + 230 + **Environment Variables:** 231 + ```bash 232 + OAUTH_PUBLIC_URL=https://forum.example.com 233 + SESSION_SECRET=generate-random-32-byte-hex 234 + SESSION_TTL_DAYS=7 235 + REDIS_URL=redis://localhost:6379 # Optional 236 + ``` 237 + 238 + **Startup Validation:** 239 + - SESSION_SECRET must be ≥32 characters 240 + - OAUTH_PUBLIC_URL required in production 241 + - Warn if using in-memory sessions in production 242 + 243 + ## File Structure 244 + 245 + **New Files:** 246 + ``` 247 + apps/appview/src/ 248 + ├── lib/ 249 + │ ├── session-store.ts 250 + │ ├── oauth-service.ts 251 + │ └── state-store.ts 252 + ├── middleware/ 253 + │ └── auth.ts 254 + └── routes/ 255 + └── auth.ts 256 + ``` 257 + 258 + **Modified Files:** 259 + ``` 260 + apps/appview/src/ 261 + ├── lib/ 262 + │ ├── app-context.ts 263 + │ └── config.ts 264 + ├── index.ts 265 + └── types.ts (new) 266 + 267 + apps/appview/package.json 268 + .env.example 269 + ``` 270 + 271 + ## Implementation Order 272 + 273 + ### Phase 1: Foundation 274 + 1. Create session-store.ts (interface + in-memory implementation) 275 + 2. Create state-store.ts (OAuth state storage) 276 + 3. Update app-context.ts (add sessionStore) 277 + 4. Add OAuth env vars to config.ts 278 + 5. Test: Session store CRUD operations 279 + 280 + ### Phase 2: OAuth Service 281 + 6. Install @atproto/oauth-client-node + @atproto/identity 282 + 7. Create oauth-service.ts (wrap NodeOAuthClient) 283 + 8. Implement PDS discovery (handle → DID → PDS) 284 + 9. Test: Generate authorization URLs 285 + 286 + ### Phase 3: Auth Routes 287 + 10. Create routes/auth.ts (all four endpoints) 288 + 11. Add /.well-known/oauth-client-metadata endpoint 289 + 12. Test: Manual OAuth flow with bsky.social 290 + 13. Verify: Callback receives tokens, creates session 291 + 292 + ### Phase 4: Middleware 293 + 14. Create middleware/auth.ts (requireAuth, optionalAuth) 294 + 15. Add Hono context types 295 + 16. Test: Protected endpoints require session 296 + 17. Test: Token refresh works 297 + 298 + ### Phase 5: Polish 299 + 18. Add structured logging 300 + 19. Implement error handling 301 + 20. Test error cases (deny, expired state, invalid tokens) 302 + 21. Document testing in Linear issue 303 + 304 + ## Testing Strategy 305 + 306 + **Manual Testing:** 307 + - Happy path: login → approve → callback → session → logout 308 + - Error cases: deny, invalid handle, expired state, token expiration 309 + - Token refresh: wait for expiration, verify auto-refresh 310 + - Multi-PDS: test with bsky.social + self-hosted PDS 311 + 312 + **Test Accounts:** 313 + - bsky.social account (official PDS) 314 + - Optional: self-hosted PDS for multi-PDS validation 315 + 316 + **Development Setup:** 317 + - ngrok/cloudflare tunnel for public redirect URI 318 + - Or: use localhost redirect (some PDSs allow) 319 + - Update client metadata to match deployment URL 320 + 321 + **Verification:** 322 + ```bash 323 + # Before login 324 + curl -c cookies.txt http://localhost:3000/api/auth/session # → 401 325 + 326 + # After manual login 327 + curl -b cookies.txt http://localhost:3000/api/auth/session # → { did, handle, authenticated: true } 328 + 329 + # Protected endpoint 330 + curl -b cookies.txt -X POST http://localhost:3000/api/posts -d '{...}' # → Success 331 + 332 + # Logout 333 + curl -b cookies.txt http://localhost:3000/api/auth/logout 334 + curl -b cookies.txt http://localhost:3000/api/auth/session # → 401 335 + ``` 336 + 337 + ## Security Considerations 338 + 339 + 1. **HTTP-only cookies** prevent XSS 340 + 2. **SameSite=Lax** prevents CSRF 341 + 3. **State parameter** validated for CSRF protection 342 + 4. **PKCE** prevents authorization code interception 343 + 5. **DPoP** binds tokens to client key (required by spec) 344 + 6. **Constant-time comparison** for state validation 345 + 7. **Never log tokens** in production or development 346 + 347 + ## Edge Cases 348 + 349 + - **User changes handle:** Session valid (keyed by DID) 350 + - **User migrates PDS:** Session invalid, must re-authenticate 351 + - **Concurrent refresh:** NodeOAuthClient handles locking 352 + - **Session cookie stolen:** Mitigated by HTTP-only + SameSite 353 + 354 + ## Complexity Estimate 355 + 356 + - **Lines of code:** ~600-800 357 + - **Time:** 1-2 days 358 + - **Risk areas:** PDS discovery, token refresh timing, state management 359 + 360 + ## Dependencies 361 + 362 + - **Blocks:** ATB-12, ATB-15, ATB-16, ATB-17 (all need authenticated users) 363 + - **Blocked by:** None (can start immediately) 364 + 365 + ## References 366 + 367 + - [AT Protocol OAuth Spec](https://atproto.com/specs/oauth) 368 + - [@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node) 369 + - [@atproto/api OAUTH.md](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md) 370 + - [OAuth Client Implementation | Bluesky](https://docs.bsky.app/docs/advanced-guides/oauth-client)