A community based topic aggregation platform built on atproto

Merge branch 'fix/dpop-security-improvements'

DPoP security improvements addressing PR review findings:

- feat(auth): comprehensive DPoP security improvements
- Access token hash (ath) validation per RFC 9449
- Proxy header support (X-Forwarded-Host, RFC 7239 Forwarded)
- EscapedPath for percent-encoded URLs
- Case-insensitive DPoP scheme per RFC 7235

- fix(auth): prevent goroutine leak from DPoP replay cache
- Graceful server shutdown with signal handling
- Proper cleanup in integration tests

- docs: update authentication documentation for DPoP scheme

+680 -127
+37 -3
cmd/server/main.go
··· 25 "log" 26 "net/http" 27 "os" 28 "strings" 29 "time" 30 31 "github.com/go-chi/chi/v5" ··· 511 port = "8080" 512 } 513 514 - fmt.Printf("Coves AppView starting on port %s\n", port) 515 - fmt.Printf("Default PDS: %s\n", defaultPDS) 516 - log.Fatal(http.ListenAndServe(":"+port, r)) 517 } 518 519 // authenticateWithPDS creates a session on the PDS and returns an access token
··· 25 "log" 26 "net/http" 27 "os" 28 + "os/signal" 29 "strings" 30 + "syscall" 31 "time" 32 33 "github.com/go-chi/chi/v5" ··· 513 port = "8080" 514 } 515 516 + // Create HTTP server for graceful shutdown 517 + server := &http.Server{ 518 + Addr: ":" + port, 519 + Handler: r, 520 + } 521 + 522 + // Channel to listen for shutdown signals 523 + stop := make(chan os.Signal, 1) 524 + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) 525 + 526 + // Start server in goroutine 527 + go func() { 528 + fmt.Printf("Coves AppView starting on port %s\n", port) 529 + fmt.Printf("Default PDS: %s\n", defaultPDS) 530 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 531 + log.Fatalf("Server error: %v", err) 532 + } 533 + }() 534 + 535 + // Wait for shutdown signal 536 + <-stop 537 + log.Println("Shutting down server...") 538 + 539 + // Graceful shutdown with timeout 540 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 541 + defer cancel() 542 + 543 + // Stop auth middleware background goroutines (DPoP replay cache cleanup) 544 + authMiddleware.Stop() 545 + log.Println("Auth middleware stopped") 546 + 547 + if err := server.Shutdown(ctx); err != nil { 548 + log.Fatalf("Server shutdown error: %v", err) 549 + } 550 + log.Println("Server stopped gracefully") 551 } 552 553 // authenticateWithPDS creates a session on the PDS and returns an access token
+5 -5
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
··· 47 - Lexicon definitions: `social.coves.community.comment.defs` and `getComments` 48 - Database query methods with Lemmy hot ranking algorithm 49 - Service layer with iterative loading strategy for nested replies 50 - - XRPC HTTP handler with optional authentication 51 - Comprehensive integration test suite (11 test scenarios) 52 53 **What works:** ··· 55 - Nested replies up to configurable depth (default 10, max 100) 56 - Lemmy hot ranking: `log(greatest(2, score + 2)) / power(time_decay, 1.8)` 57 - Cursor-based pagination for stable scrolling 58 - - Optional authentication for viewer state (stubbed for Phase 2B) 59 - Timeframe filtering for "top" sort (hour/day/week/month/year/all) 60 61 **Endpoints:** ··· 63 - Required: `post` (AT-URI) 64 - Optional: `sort` (hot/top/new), `depth` (0-100), `limit` (1-100), `cursor`, `timeframe` 65 - Returns: Array of `threadViewComment` with nested replies + post context 66 - - Supports Bearer token for authenticated requests (viewer state) 67 68 **Files created (9):** 69 1. `internal/atproto/lexicon/social/coves/community/comment/defs.json` - View definitions ··· 976 **8. Viewer Authentication Validation (Non-Issue - Architecture Working as Designed)** 977 - **Initial Concern:** ViewerDID field trusted without verification in service layer 978 - **Investigation:** Authentication IS properly validated at middleware layer 979 - - `OptionalAuth` middleware extracts and validates JWT Bearer tokens 980 - Uses PDS public keys (JWKS) for signature verification 981 - - Validates token expiration, DID format, issuer 982 - Only injects verified DIDs into request context 983 - Handler extracts DID using `middleware.GetUserDID(r)` 984 - **Architecture:** Follows industry best practices (authentication at perimeter)
··· 47 - Lexicon definitions: `social.coves.community.comment.defs` and `getComments` 48 - Database query methods with Lemmy hot ranking algorithm 49 - Service layer with iterative loading strategy for nested replies 50 + - XRPC HTTP handler with optional DPoP authentication 51 - Comprehensive integration test suite (11 test scenarios) 52 53 **What works:** ··· 55 - Nested replies up to configurable depth (default 10, max 100) 56 - Lemmy hot ranking: `log(greatest(2, score + 2)) / power(time_decay, 1.8)` 57 - Cursor-based pagination for stable scrolling 58 + - Optional DPoP authentication for viewer state (stubbed for Phase 2B) 59 - Timeframe filtering for "top" sort (hour/day/week/month/year/all) 60 61 **Endpoints:** ··· 63 - Required: `post` (AT-URI) 64 - Optional: `sort` (hot/top/new), `depth` (0-100), `limit` (1-100), `cursor`, `timeframe` 65 - Returns: Array of `threadViewComment` with nested replies + post context 66 + - Supports DPoP-bound access token for authenticated requests (viewer state) 67 68 **Files created (9):** 69 1. `internal/atproto/lexicon/social/coves/community/comment/defs.json` - View definitions ··· 976 **8. Viewer Authentication Validation (Non-Issue - Architecture Working as Designed)** 977 - **Initial Concern:** ViewerDID field trusted without verification in service layer 978 - **Investigation:** Authentication IS properly validated at middleware layer 979 + - `OptionalAuth` middleware extracts and validates DPoP-bound access tokens 980 - Uses PDS public keys (JWKS) for signature verification 981 + - Validates DPoP proof, token expiration, DID format, issuer 982 - Only injects verified DIDs into request context 983 - Handler extracts DID using `middleware.GetUserDID(r)` 984 - **Architecture:** Follows industry best practices (authentication at perimeter)
+7 -4
docs/FEED_SYSTEM_IMPLEMENTATION.md
··· 203 # Get personalized timeline (hot posts from subscriptions) 204 curl -X GET \ 205 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=hot&limit=15' \ 206 - -H 'Authorization: Bearer eyJhbGc...' 207 208 # Get top posts from last week 209 curl -X GET \ 210 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=week&limit=20' \ 211 - -H 'Authorization: Bearer eyJhbGc...' 212 213 # Get newest posts with pagination 214 curl -X GET \ 215 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=new&limit=10&cursor=<cursor>' \ 216 - -H 'Authorization: Bearer eyJhbGc...' 217 ``` 218 219 **Response:** ··· 313 - ✅ Context timeout support 314 315 ### Authentication (Timeline) 316 - - ✅ JWT Bearer token required 317 - ✅ DID extracted from auth context 318 - ✅ Validates token signature (when AUTH_SKIP_VERIFY=false) 319 - ✅ Returns 401 on auth failure
··· 203 # Get personalized timeline (hot posts from subscriptions) 204 curl -X GET \ 205 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=hot&limit=15' \ 206 + -H 'Authorization: DPoP eyJhbGc...' \ 207 + -H 'DPoP: eyJhbGc...' 208 209 # Get top posts from last week 210 curl -X GET \ 211 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=week&limit=20' \ 212 + -H 'Authorization: DPoP eyJhbGc...' \ 213 + -H 'DPoP: eyJhbGc...' 214 215 # Get newest posts with pagination 216 curl -X GET \ 217 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=new&limit=10&cursor=<cursor>' \ 218 + -H 'Authorization: DPoP eyJhbGc...' \ 219 + -H 'DPoP: eyJhbGc...' 220 ``` 221 222 **Response:** ··· 316 - ✅ Context timeout support 317 318 ### Authentication (Timeline) 319 + - ✅ DPoP-bound access token required 320 - ✅ DID extracted from auth context 321 - ✅ Validates token signature (when AUTH_SKIP_VERIFY=false) 322 - ✅ Returns 401 on auth failure
+3 -3
docs/PRD_OAUTH.md
··· 10 - ✅ Auth middleware protecting community endpoints 11 - ✅ Handlers updated to use `GetUserDID(r)` 12 - ✅ Comprehensive middleware auth tests (11 test cases) 13 - - ✅ E2E tests updated to use Bearer tokens 14 - ✅ Security logging with IP, method, path, issuer 15 - ✅ Scope validation (atproto required) 16 - ✅ Issuer HTTPS validation ··· 163 Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ... 164 ``` 165 166 - Format: `DPoP <access_token>` 167 168 The access token is a JWT containing: 169 ```json ··· 753 - [x] All community endpoints reject requests without valid JWT structure 754 - [x] Integration tests pass with mock tokens (11/11 middleware tests passing) 755 - [x] Zero security regressions from X-User-DID (JWT validation is strictly better) 756 - - [x] E2E tests updated to use proper Bearer token authentication 757 - [x] Build succeeds without compilation errors 758 759 ### Phase 2 (Beta) - ✅ READY FOR TESTING
··· 10 - ✅ Auth middleware protecting community endpoints 11 - ✅ Handlers updated to use `GetUserDID(r)` 12 - ✅ Comprehensive middleware auth tests (11 test cases) 13 + - ✅ E2E tests updated to use DPoP-bound tokens 14 - ✅ Security logging with IP, method, path, issuer 15 - ✅ Scope validation (atproto required) 16 - ✅ Issuer HTTPS validation ··· 163 Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ... 164 ``` 165 166 + Format: `DPoP <access_token>` (note: uses "DPoP" scheme, not "Bearer") 167 168 The access token is a JWT containing: 169 ```json ··· 753 - [x] All community endpoints reject requests without valid JWT structure 754 - [x] Integration tests pass with mock tokens (11/11 middleware tests passing) 755 - [x] Zero security regressions from X-User-DID (JWT validation is strictly better) 756 + - [x] E2E tests updated to use proper DPoP token authentication 757 - [x] Build succeeds without compilation errors 758 759 ### Phase 2 (Beta) - ✅ READY FOR TESTING
+3 -1
docs/aggregators/SETUP_GUIDE.md
··· 256 257 **Request**: 258 ```bash 259 curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \ 260 -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ 261 -H "Content-Type: application/json" \ ··· 354 355 **Request**: 356 ```bash 357 curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 358 - -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ 359 -H "Content-Type: application/json" \ 360 -d '{ 361 "communityDid": "did:plc:community123...",
··· 256 257 **Request**: 258 ```bash 259 + # Note: This calls the PDS directly, so it uses Bearer authorization (not DPoP) 260 curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \ 261 -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ 262 -H "Content-Type: application/json" \ ··· 355 356 **Request**: 357 ```bash 358 + # Note: This calls the Coves API, so it uses DPoP authorization 359 curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 360 + -H "Authorization: DPoP YOUR_ACCESS_TOKEN" \ 361 -H "Content-Type: application/json" \ 362 -d '{ 363 "communityDid": "did:plc:community123...",
+8 -2
docs/federation-prd.md
··· 263 req, _ := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) 264 265 // Use service auth token instead of community credentials 266 req.Header.Set("Authorization", "Bearer "+serviceAuthToken) 267 req.Header.Set("Content-Type", "application/json") 268 ··· 726 **Request to Remote PDS:** 727 ```http 728 POST https://covesinstance.com/xrpc/com.atproto.server.getServiceAuth 729 - Authorization: Bearer {coves-social-instance-jwt} 730 Content-Type: application/json 731 732 { ··· 749 **Using Token to Create Post:** 750 ```http 751 POST https://covesinstance.com/xrpc/com.atproto.repo.createRecord 752 - Authorization: Bearer {service-auth-token} 753 Content-Type: application/json 754 755 {
··· 263 req, _ := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) 264 265 // Use service auth token instead of community credentials 266 + // NOTE: Auth scheme depends on target PDS implementation: 267 + // - Standard atproto service auth uses "Bearer" scheme 268 + // - Our AppView uses "DPoP" scheme when DPoP-bound tokens are required 269 + // For server-to-server with standard PDS, use Bearer; adjust based on target. 270 req.Header.Set("Authorization", "Bearer "+serviceAuthToken) 271 req.Header.Set("Content-Type", "application/json") 272 ··· 730 **Request to Remote PDS:** 731 ```http 732 POST https://covesinstance.com/xrpc/com.atproto.server.getServiceAuth 733 + Authorization: DPoP {coves-social-instance-jwt} 734 + DPoP: {coves-social-dpop-proof} 735 Content-Type: application/json 736 737 { ··· 754 **Using Token to Create Post:** 755 ```http 756 POST https://covesinstance.com/xrpc/com.atproto.repo.createRecord 757 + Authorization: DPoP {service-auth-token} 758 + DPoP: {service-auth-dpop-proof} 759 Content-Type: application/json 760 761 {
+124 -31
internal/api/middleware/auth.go
··· 53 // RequireAuth middleware ensures the user is authenticated with a valid JWT 54 // If not authenticated, returns 401 55 // If authenticated, injects user DID and JWT claims into context 56 func (m *AtProtoAuthMiddleware) RequireAuth(next http.Handler) http.Handler { 57 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 // Extract Authorization header ··· 62 return 63 } 64 65 - // Must be Bearer token 66 - if !strings.HasPrefix(authHeader, "Bearer ") { 67 - writeAuthError(w, "Invalid Authorization header format. Expected: Bearer <token>") 68 return 69 } 70 - 71 - token := strings.TrimPrefix(authHeader, "Bearer ") 72 - token = strings.TrimSpace(token) 73 74 var claims *auth.Claims 75 var err error ··· 116 return 117 } 118 119 - proof, err := m.verifyDPoPBinding(r, claims, dpopHeader) 120 if err != nil { 121 log.Printf("[AUTH_FAILURE] type=dpop_verification_failed ip=%s method=%s path=%s error=%v", 122 r.RemoteAddr, r.Method, r.URL.Path, err) ··· 154 155 // OptionalAuth middleware loads user info if authenticated, but doesn't require it 156 // Useful for endpoints that work for both authenticated and anonymous users 157 func (m *AtProtoAuthMiddleware) OptionalAuth(next http.Handler) http.Handler { 158 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 159 // Extract Authorization header 160 authHeader := r.Header.Get("Authorization") 161 - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { 162 - // Not authenticated - continue without user context 163 next.ServeHTTP(w, r) 164 return 165 } 166 - 167 - token := strings.TrimPrefix(authHeader, "Bearer ") 168 - token = strings.TrimSpace(token) 169 170 var claims *auth.Claims 171 var err error ··· 202 return 203 } 204 205 - proof, err := m.verifyDPoPBinding(r, claims, dpopHeader) 206 if err != nil { 207 // DPoP verification failed - cannot trust this token 208 log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err) ··· 280 // 281 // This prevents token theft attacks by proving the client possesses the private key 282 // corresponding to the public key thumbprint in the token's cnf.jkt claim. 283 - func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader string) (*auth.DPoPProof, error) { 284 // Extract the cnf.jkt claim from the already-verified token 285 jkt, err := auth.ExtractCnfJkt(claims) 286 if err != nil { ··· 288 } 289 290 // Build the HTTP URI for DPoP verification 291 - // Use the full URL including scheme and host 292 - scheme := strings.TrimSpace(r.URL.Scheme) 293 if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" { 294 - // Forwarded proto may contain a comma-separated list; use the first entry 295 parts := strings.Split(forwardedProto, ",") 296 if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" { 297 scheme = strings.ToLower(strings.TrimSpace(parts[0])) 298 } 299 } 300 if scheme == "" { 301 if r.TLS != nil { 302 scheme = "https" ··· 304 scheme = "http" 305 } 306 } 307 - scheme = strings.ToLower(scheme) 308 - httpURI := scheme + "://" + r.Host + r.URL.Path 309 310 - // Verify the DPoP proof 311 - proof, err := m.dpopVerifier.VerifyDPoPProof(dpopProofHeader, r.Method, httpURI) 312 - if err != nil { 313 - return nil, fmt.Errorf("DPoP proof verification failed: %w", err) 314 - } 315 - 316 - // Verify the binding between the proof and the token 317 - if err := m.dpopVerifier.VerifyTokenBinding(proof, jkt); err != nil { 318 - return nil, fmt.Errorf("DPoP binding verification failed: %w", err) 319 - } 320 - 321 - return proof, nil 322 } 323 324 // writeAuthError writes a JSON error response for authentication failures ··· 331 log.Printf("Failed to write auth error response: %v", err) 332 } 333 }
··· 53 // RequireAuth middleware ensures the user is authenticated with a valid JWT 54 // If not authenticated, returns 401 55 // If authenticated, injects user DID and JWT claims into context 56 + // 57 + // Only accepts DPoP authorization scheme per RFC 9449: 58 + // - Authorization: DPoP <token> (DPoP-bound tokens) 59 func (m *AtProtoAuthMiddleware) RequireAuth(next http.Handler) http.Handler { 60 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 // Extract Authorization header ··· 65 return 66 } 67 68 + // Only accept DPoP scheme per RFC 9449 69 + // HTTP auth schemes are case-insensitive per RFC 7235 70 + token, ok := extractDPoPToken(authHeader) 71 + if !ok { 72 + writeAuthError(w, "Invalid Authorization header format. Expected: DPoP <token>") 73 return 74 } 75 76 var claims *auth.Claims 77 var err error ··· 118 return 119 } 120 121 + proof, err := m.verifyDPoPBinding(r, claims, dpopHeader, token) 122 if err != nil { 123 log.Printf("[AUTH_FAILURE] type=dpop_verification_failed ip=%s method=%s path=%s error=%v", 124 r.RemoteAddr, r.Method, r.URL.Path, err) ··· 156 157 // OptionalAuth middleware loads user info if authenticated, but doesn't require it 158 // Useful for endpoints that work for both authenticated and anonymous users 159 + // 160 + // Only accepts DPoP authorization scheme per RFC 9449: 161 + // - Authorization: DPoP <token> (DPoP-bound tokens) 162 func (m *AtProtoAuthMiddleware) OptionalAuth(next http.Handler) http.Handler { 163 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 164 // Extract Authorization header 165 authHeader := r.Header.Get("Authorization") 166 + 167 + // Only accept DPoP scheme per RFC 9449 168 + // HTTP auth schemes are case-insensitive per RFC 7235 169 + token, ok := extractDPoPToken(authHeader) 170 + if !ok { 171 + // Not authenticated or invalid format - continue without user context 172 next.ServeHTTP(w, r) 173 return 174 } 175 176 var claims *auth.Claims 177 var err error ··· 208 return 209 } 210 211 + proof, err := m.verifyDPoPBinding(r, claims, dpopHeader, token) 212 if err != nil { 213 // DPoP verification failed - cannot trust this token 214 log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err) ··· 286 // 287 // This prevents token theft attacks by proving the client possesses the private key 288 // corresponding to the public key thumbprint in the token's cnf.jkt claim. 289 + func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader, accessToken string) (*auth.DPoPProof, error) { 290 // Extract the cnf.jkt claim from the already-verified token 291 jkt, err := auth.ExtractCnfJkt(claims) 292 if err != nil { ··· 294 } 295 296 // Build the HTTP URI for DPoP verification 297 + // Use the full URL including scheme and host, respecting proxy headers 298 + scheme, host := extractSchemeAndHost(r) 299 + 300 + // Use EscapedPath to preserve percent-encoding (P3 fix) 301 + // r.URL.Path is decoded, but DPoP proofs contain the raw encoded path 302 + path := r.URL.EscapedPath() 303 + if path == "" { 304 + path = r.URL.Path // Fallback if EscapedPath returns empty 305 + } 306 + 307 + httpURI := scheme + "://" + host + path 308 + 309 + // Verify the DPoP proof 310 + proof, err := m.dpopVerifier.VerifyDPoPProof(dpopProofHeader, r.Method, httpURI) 311 + if err != nil { 312 + return nil, fmt.Errorf("DPoP proof verification failed: %w", err) 313 + } 314 + 315 + // Verify the binding between the proof and the token (cnf.jkt) 316 + if err := m.dpopVerifier.VerifyTokenBinding(proof, jkt); err != nil { 317 + return nil, fmt.Errorf("DPoP binding verification failed: %w", err) 318 + } 319 + 320 + // Verify the access token hash (ath) if present in the proof 321 + // Per RFC 9449 section 4.2, if ath is present, it MUST match the access token 322 + if err := m.dpopVerifier.VerifyAccessTokenHash(proof, accessToken); err != nil { 323 + return nil, fmt.Errorf("DPoP ath verification failed: %w", err) 324 + } 325 + 326 + return proof, nil 327 + } 328 + 329 + // extractSchemeAndHost extracts the scheme and host from the request, 330 + // respecting proxy headers (X-Forwarded-Proto, X-Forwarded-Host, Forwarded). 331 + // This is critical for DPoP verification when behind TLS-terminating proxies. 332 + func extractSchemeAndHost(r *http.Request) (scheme, host string) { 333 + // Start with request defaults 334 + scheme = r.URL.Scheme 335 + host = r.Host 336 + 337 + // Check X-Forwarded-Proto for scheme (most common) 338 if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" { 339 parts := strings.Split(forwardedProto, ",") 340 if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" { 341 scheme = strings.ToLower(strings.TrimSpace(parts[0])) 342 } 343 } 344 + 345 + // Check X-Forwarded-Host for host (common with nginx/traefik) 346 + if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" { 347 + parts := strings.Split(forwardedHost, ",") 348 + if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" { 349 + host = strings.TrimSpace(parts[0]) 350 + } 351 + } 352 + 353 + // Check standard Forwarded header (RFC 7239) - takes precedence if present 354 + // Format: Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43;host=example.com 355 + // RFC 7239 allows: mixed-case keys (Proto, PROTO), quoted values (host="example.com") 356 + if forwarded := r.Header.Get("Forwarded"); forwarded != "" { 357 + // Parse the first entry (comma-separated list) 358 + firstEntry := strings.Split(forwarded, ",")[0] 359 + for _, part := range strings.Split(firstEntry, ";") { 360 + part = strings.TrimSpace(part) 361 + // Split on first '=' to properly handle key=value pairs 362 + if idx := strings.Index(part, "="); idx != -1 { 363 + key := strings.ToLower(strings.TrimSpace(part[:idx])) 364 + value := strings.TrimSpace(part[idx+1:]) 365 + // Strip optional quotes per RFC 7239 section 4 366 + value = strings.Trim(value, "\"") 367 + 368 + switch key { 369 + case "proto": 370 + scheme = strings.ToLower(value) 371 + case "host": 372 + host = value 373 + } 374 + } 375 + } 376 + } 377 + 378 + // Fallback scheme detection from TLS 379 if scheme == "" { 380 if r.TLS != nil { 381 scheme = "https" ··· 383 scheme = "http" 384 } 385 } 386 387 + return strings.ToLower(scheme), host 388 } 389 390 // writeAuthError writes a JSON error response for authentication failures ··· 397 log.Printf("Failed to write auth error response: %v", err) 398 } 399 } 400 + 401 + // extractDPoPToken extracts the token from a DPoP Authorization header. 402 + // HTTP auth schemes are case-insensitive per RFC 7235, so "DPoP", "dpop", "DPOP" are all valid. 403 + // Returns the token and true if valid DPoP scheme, empty string and false otherwise. 404 + func extractDPoPToken(authHeader string) (string, bool) { 405 + if authHeader == "" { 406 + return "", false 407 + } 408 + 409 + // Split on first space: "DPoP <token>" -> ["DPoP", "<token>"] 410 + parts := strings.SplitN(authHeader, " ", 2) 411 + if len(parts) != 2 { 412 + return "", false 413 + } 414 + 415 + // Case-insensitive scheme comparison per RFC 7235 416 + if !strings.EqualFold(parts[0], "DPoP") { 417 + return "", false 418 + } 419 + 420 + token := strings.TrimSpace(parts[1]) 421 + if token == "" { 422 + return "", false 423 + } 424 + 425 + return token, true 426 + }
+378 -16
internal/api/middleware/auth_test.go
··· 6 "crypto/ecdsa" 7 "crypto/elliptic" 8 "crypto/rand" 9 "encoding/base64" 10 "fmt" 11 "net/http" 12 "net/http/httptest" 13 "testing" 14 "time" 15 ··· 45 return tokenString 46 } 47 48 - // TestRequireAuth_ValidToken tests that valid tokens are accepted (Phase 1) 49 func TestRequireAuth_ValidToken(t *testing.T) { 50 fetcher := &mockJWKSFetcher{} 51 middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true ··· 75 76 token := createTestToken("did:plc:test123") 77 req := httptest.NewRequest("GET", "/test", nil) 78 - req.Header.Set("Authorization", "Bearer "+token) 79 w := httptest.NewRecorder() 80 81 handler.ServeHTTP(w, req) ··· 109 } 110 } 111 112 - // TestRequireAuth_InvalidAuthHeaderFormat tests that non-Bearer tokens are rejected 113 func TestRequireAuth_InvalidAuthHeaderFormat(t *testing.T) { 114 fetcher := &mockJWKSFetcher{} 115 middleware := NewAtProtoAuthMiddleware(fetcher, true) 116 117 handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 t.Error("handler should not be called") 119 })) 120 121 req := httptest.NewRequest("GET", "/test", nil) 122 - req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0") // Wrong format 123 w := httptest.NewRecorder() 124 125 handler.ServeHTTP(w, req) 126 127 if w.Code != http.StatusUnauthorized { 128 t.Errorf("expected status 401, got %d", w.Code) 129 } 130 } 131 ··· 139 })) 140 141 req := httptest.NewRequest("GET", "/test", nil) 142 - req.Header.Set("Authorization", "Bearer not-a-valid-jwt") 143 w := httptest.NewRecorder() 144 145 handler.ServeHTTP(w, req) ··· 171 tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) 172 173 req := httptest.NewRequest("GET", "/test", nil) 174 - req.Header.Set("Authorization", "Bearer "+tokenString) 175 w := httptest.NewRecorder() 176 177 handler.ServeHTTP(w, req) ··· 203 tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) 204 205 req := httptest.NewRequest("GET", "/test", nil) 206 - req.Header.Set("Authorization", "Bearer "+tokenString) 207 w := httptest.NewRecorder() 208 209 handler.ServeHTTP(w, req) ··· 213 } 214 } 215 216 - // TestOptionalAuth_WithToken tests that OptionalAuth accepts valid tokens 217 func TestOptionalAuth_WithToken(t *testing.T) { 218 fetcher := &mockJWKSFetcher{} 219 middleware := NewAtProtoAuthMiddleware(fetcher, true) ··· 233 234 token := createTestToken("did:plc:test123") 235 req := httptest.NewRequest("GET", "/test", nil) 236 - req.Header.Set("Authorization", "Bearer "+token) 237 w := httptest.NewRecorder() 238 239 handler.ServeHTTP(w, req) ··· 299 })) 300 301 req := httptest.NewRequest("GET", "/test", nil) 302 - req.Header.Set("Authorization", "Bearer not-a-valid-jwt") 303 w := httptest.NewRecorder() 304 305 handler.ServeHTTP(w, req) ··· 393 })) 394 395 req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil) 396 - req.Header.Set("Authorization", "Bearer "+tokenString) 397 req.Header.Set("DPoP", dpopProof) 398 w := httptest.NewRecorder() 399 ··· 437 })) 438 439 req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil) 440 - req.Header.Set("Authorization", "Bearer "+tokenString) 441 // No DPoP header 442 w := httptest.NewRecorder() 443 ··· 488 })) 489 490 req := httptest.NewRequest("POST", "https://api.example.com/protected", nil) 491 - req.Header.Set("Authorization", "Bearer "+tokenString) 492 req.Header.Set("DPoP", dpopProof) 493 w := httptest.NewRecorder() 494 ··· 537 req.Host = "api.example.com" 538 req.Header.Set("X-Forwarded-Proto", "https") 539 540 - proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof) 541 if err != nil { 542 t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err) 543 } ··· 547 } 548 } 549 550 // TestMiddlewareStop tests that the middleware can be stopped properly 551 func TestMiddlewareStop(t *testing.T) { 552 fetcher := &mockJWKSFetcher{} ··· 607 })) 608 609 req := httptest.NewRequest("GET", "/test", nil) 610 - req.Header.Set("Authorization", "Bearer "+tokenString) 611 // Deliberately NOT setting DPoP header 612 w := httptest.NewRecorder() 613 ··· 639 })) 640 641 req := httptest.NewRequest("GET", "/test", nil) 642 - req.Header.Set("Authorization", "Bearer "+tokenString) 643 // No DPoP header 644 w := httptest.NewRecorder() 645 ··· 709 return signedToken 710 } 711 712 // Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map 713 func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} { 714 // Get curve name ··· 742 "y": base64.RawURLEncoding.EncodeToString(yPadded), 743 } 744 }
··· 6 "crypto/ecdsa" 7 "crypto/elliptic" 8 "crypto/rand" 9 + "crypto/sha256" 10 "encoding/base64" 11 "fmt" 12 "net/http" 13 "net/http/httptest" 14 + "strings" 15 "testing" 16 "time" 17 ··· 47 return tokenString 48 } 49 50 + // TestRequireAuth_ValidToken tests that valid tokens are accepted with DPoP scheme (Phase 1) 51 func TestRequireAuth_ValidToken(t *testing.T) { 52 fetcher := &mockJWKSFetcher{} 53 middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true ··· 77 78 token := createTestToken("did:plc:test123") 79 req := httptest.NewRequest("GET", "/test", nil) 80 + req.Header.Set("Authorization", "DPoP "+token) 81 w := httptest.NewRecorder() 82 83 handler.ServeHTTP(w, req) ··· 111 } 112 } 113 114 + // TestRequireAuth_InvalidAuthHeaderFormat tests that non-DPoP tokens are rejected (including Bearer) 115 func TestRequireAuth_InvalidAuthHeaderFormat(t *testing.T) { 116 fetcher := &mockJWKSFetcher{} 117 middleware := NewAtProtoAuthMiddleware(fetcher, true) 118 119 + tests := []struct { 120 + name string 121 + header string 122 + }{ 123 + {"Basic auth", "Basic dGVzdDp0ZXN0"}, 124 + {"Bearer scheme", "Bearer some-token"}, 125 + {"Invalid format", "InvalidFormat"}, 126 + } 127 + 128 + for _, tt := range tests { 129 + t.Run(tt.name, func(t *testing.T) { 130 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 131 + t.Error("handler should not be called") 132 + })) 133 + 134 + req := httptest.NewRequest("GET", "/test", nil) 135 + req.Header.Set("Authorization", tt.header) 136 + w := httptest.NewRecorder() 137 + 138 + handler.ServeHTTP(w, req) 139 + 140 + if w.Code != http.StatusUnauthorized { 141 + t.Errorf("expected status 401, got %d", w.Code) 142 + } 143 + }) 144 + } 145 + } 146 + 147 + // TestRequireAuth_BearerRejectionErrorMessage verifies that Bearer tokens are rejected 148 + // with a helpful error message guiding users to use DPoP scheme 149 + func TestRequireAuth_BearerRejectionErrorMessage(t *testing.T) { 150 + fetcher := &mockJWKSFetcher{} 151 + middleware := NewAtProtoAuthMiddleware(fetcher, true) 152 + 153 handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 154 t.Error("handler should not be called") 155 })) 156 157 req := httptest.NewRequest("GET", "/test", nil) 158 + req.Header.Set("Authorization", "Bearer some-token") 159 w := httptest.NewRecorder() 160 161 handler.ServeHTTP(w, req) 162 163 if w.Code != http.StatusUnauthorized { 164 t.Errorf("expected status 401, got %d", w.Code) 165 + } 166 + 167 + // Verify error message guides user to use DPoP 168 + body := w.Body.String() 169 + if !strings.Contains(body, "Expected: DPoP") { 170 + t.Errorf("error message should guide user to use DPoP, got: %s", body) 171 + } 172 + } 173 + 174 + // TestRequireAuth_CaseInsensitiveScheme verifies that DPoP scheme matching is case-insensitive 175 + // per RFC 7235 which states HTTP auth schemes are case-insensitive 176 + func TestRequireAuth_CaseInsensitiveScheme(t *testing.T) { 177 + fetcher := &mockJWKSFetcher{} 178 + middleware := NewAtProtoAuthMiddleware(fetcher, true) 179 + 180 + // Create a valid JWT for testing 181 + validToken := createValidJWT(t, "did:plc:test123", time.Hour) 182 + 183 + testCases := []struct { 184 + name string 185 + scheme string 186 + }{ 187 + {"lowercase", "dpop"}, 188 + {"uppercase", "DPOP"}, 189 + {"mixed_case", "DpOp"}, 190 + {"standard", "DPoP"}, 191 + } 192 + 193 + for _, tc := range testCases { 194 + t.Run(tc.name, func(t *testing.T) { 195 + handlerCalled := false 196 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 197 + handlerCalled = true 198 + w.WriteHeader(http.StatusOK) 199 + })) 200 + 201 + req := httptest.NewRequest("GET", "/test", nil) 202 + req.Header.Set("Authorization", tc.scheme+" "+validToken) 203 + w := httptest.NewRecorder() 204 + 205 + handler.ServeHTTP(w, req) 206 + 207 + if !handlerCalled { 208 + t.Errorf("scheme %q should be accepted (case-insensitive per RFC 7235), got status %d: %s", 209 + tc.scheme, w.Code, w.Body.String()) 210 + } 211 + }) 212 } 213 } 214 ··· 222 })) 223 224 req := httptest.NewRequest("GET", "/test", nil) 225 + req.Header.Set("Authorization", "DPoP not-a-valid-jwt") 226 w := httptest.NewRecorder() 227 228 handler.ServeHTTP(w, req) ··· 254 tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) 255 256 req := httptest.NewRequest("GET", "/test", nil) 257 + req.Header.Set("Authorization", "DPoP "+tokenString) 258 w := httptest.NewRecorder() 259 260 handler.ServeHTTP(w, req) ··· 286 tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) 287 288 req := httptest.NewRequest("GET", "/test", nil) 289 + req.Header.Set("Authorization", "DPoP "+tokenString) 290 w := httptest.NewRecorder() 291 292 handler.ServeHTTP(w, req) ··· 296 } 297 } 298 299 + // TestOptionalAuth_WithToken tests that OptionalAuth accepts valid DPoP tokens 300 func TestOptionalAuth_WithToken(t *testing.T) { 301 fetcher := &mockJWKSFetcher{} 302 middleware := NewAtProtoAuthMiddleware(fetcher, true) ··· 316 317 token := createTestToken("did:plc:test123") 318 req := httptest.NewRequest("GET", "/test", nil) 319 + req.Header.Set("Authorization", "DPoP "+token) 320 w := httptest.NewRecorder() 321 322 handler.ServeHTTP(w, req) ··· 382 })) 383 384 req := httptest.NewRequest("GET", "/test", nil) 385 + req.Header.Set("Authorization", "DPoP not-a-valid-jwt") 386 w := httptest.NewRecorder() 387 388 handler.ServeHTTP(w, req) ··· 476 })) 477 478 req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil) 479 + req.Header.Set("Authorization", "DPoP "+tokenString) 480 req.Header.Set("DPoP", dpopProof) 481 w := httptest.NewRecorder() 482 ··· 520 })) 521 522 req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil) 523 + req.Header.Set("Authorization", "DPoP "+tokenString) 524 // No DPoP header 525 w := httptest.NewRecorder() 526 ··· 571 })) 572 573 req := httptest.NewRequest("POST", "https://api.example.com/protected", nil) 574 + req.Header.Set("Authorization", "DPoP "+tokenString) 575 req.Header.Set("DPoP", dpopProof) 576 w := httptest.NewRecorder() 577 ··· 620 req.Host = "api.example.com" 621 req.Header.Set("X-Forwarded-Proto", "https") 622 623 + // Pass a fake access token - ath verification will pass since we don't include ath in the DPoP proof 624 + fakeAccessToken := "fake-access-token-for-testing" 625 + proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken) 626 if err != nil { 627 t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err) 628 } ··· 632 } 633 } 634 635 + // TestVerifyDPoPBinding_UsesForwardedHost ensures we honor X-Forwarded-Host header 636 + // when behind a TLS-terminating proxy that rewrites the Host header. 637 + func TestVerifyDPoPBinding_UsesForwardedHost(t *testing.T) { 638 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 639 + if err != nil { 640 + t.Fatalf("failed to generate key: %v", err) 641 + } 642 + 643 + jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey) 644 + thumbprint, err := auth.CalculateJWKThumbprint(jwk) 645 + if err != nil { 646 + t.Fatalf("failed to calculate thumbprint: %v", err) 647 + } 648 + 649 + claims := &auth.Claims{ 650 + RegisteredClaims: jwt.RegisteredClaims{ 651 + Subject: "did:plc:test123", 652 + Issuer: "https://test.pds.local", 653 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), 654 + IssuedAt: jwt.NewNumericDate(time.Now()), 655 + }, 656 + Scope: "atproto", 657 + Confirmation: map[string]interface{}{ 658 + "jkt": thumbprint, 659 + }, 660 + } 661 + 662 + middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false) 663 + defer middleware.Stop() 664 + 665 + // External URI that the client uses 666 + externalURI := "https://api.example.com/protected/resource" 667 + dpopProof := createDPoPProof(t, privateKey, "GET", externalURI) 668 + 669 + // Request hits internal service with internal hostname, but X-Forwarded-Host has public hostname 670 + req := httptest.NewRequest("GET", "http://internal-service:8080/protected/resource", nil) 671 + req.Host = "internal-service:8080" // Internal host after proxy 672 + req.Header.Set("X-Forwarded-Proto", "https") 673 + req.Header.Set("X-Forwarded-Host", "api.example.com") // Original public host 674 + 675 + fakeAccessToken := "fake-access-token-for-testing" 676 + proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken) 677 + if err != nil { 678 + t.Fatalf("expected DPoP verification to succeed with X-Forwarded-Host, got %v", err) 679 + } 680 + 681 + if proof == nil || proof.Claims == nil { 682 + t.Fatal("expected DPoP proof to be returned") 683 + } 684 + } 685 + 686 + // TestVerifyDPoPBinding_UsesStandardForwardedHeader tests RFC 7239 Forwarded header parsing 687 + func TestVerifyDPoPBinding_UsesStandardForwardedHeader(t *testing.T) { 688 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 689 + if err != nil { 690 + t.Fatalf("failed to generate key: %v", err) 691 + } 692 + 693 + jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey) 694 + thumbprint, err := auth.CalculateJWKThumbprint(jwk) 695 + if err != nil { 696 + t.Fatalf("failed to calculate thumbprint: %v", err) 697 + } 698 + 699 + claims := &auth.Claims{ 700 + RegisteredClaims: jwt.RegisteredClaims{ 701 + Subject: "did:plc:test123", 702 + Issuer: "https://test.pds.local", 703 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), 704 + IssuedAt: jwt.NewNumericDate(time.Now()), 705 + }, 706 + Scope: "atproto", 707 + Confirmation: map[string]interface{}{ 708 + "jkt": thumbprint, 709 + }, 710 + } 711 + 712 + middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false) 713 + defer middleware.Stop() 714 + 715 + // External URI 716 + externalURI := "https://api.example.com/protected/resource" 717 + dpopProof := createDPoPProof(t, privateKey, "GET", externalURI) 718 + 719 + // Request with standard Forwarded header (RFC 7239) 720 + req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil) 721 + req.Host = "internal-service" 722 + req.Header.Set("Forwarded", "for=192.0.2.60;proto=https;host=api.example.com") 723 + 724 + fakeAccessToken := "fake-access-token-for-testing" 725 + proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken) 726 + if err != nil { 727 + t.Fatalf("expected DPoP verification to succeed with Forwarded header, got %v", err) 728 + } 729 + 730 + if proof == nil { 731 + t.Fatal("expected DPoP proof to be returned") 732 + } 733 + } 734 + 735 + // TestVerifyDPoPBinding_ForwardedMixedCaseAndQuotes tests RFC 7239 edge cases: 736 + // mixed-case keys (Proto vs proto) and quoted values (host="example.com") 737 + func TestVerifyDPoPBinding_ForwardedMixedCaseAndQuotes(t *testing.T) { 738 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 739 + if err != nil { 740 + t.Fatalf("failed to generate key: %v", err) 741 + } 742 + 743 + jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey) 744 + thumbprint, err := auth.CalculateJWKThumbprint(jwk) 745 + if err != nil { 746 + t.Fatalf("failed to calculate thumbprint: %v", err) 747 + } 748 + 749 + claims := &auth.Claims{ 750 + RegisteredClaims: jwt.RegisteredClaims{ 751 + Subject: "did:plc:test123", 752 + Issuer: "https://test.pds.local", 753 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), 754 + IssuedAt: jwt.NewNumericDate(time.Now()), 755 + }, 756 + Scope: "atproto", 757 + Confirmation: map[string]interface{}{ 758 + "jkt": thumbprint, 759 + }, 760 + } 761 + 762 + middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false) 763 + defer middleware.Stop() 764 + 765 + // External URI that the client uses 766 + externalURI := "https://api.example.com/protected/resource" 767 + dpopProof := createDPoPProof(t, privateKey, "GET", externalURI) 768 + 769 + // Request with RFC 7239 Forwarded header using: 770 + // - Mixed-case keys: "Proto" instead of "proto", "Host" instead of "host" 771 + // - Quoted value: Host="api.example.com" (legal per RFC 7239 section 4) 772 + req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil) 773 + req.Host = "internal-service" 774 + req.Header.Set("Forwarded", `for=192.0.2.60;Proto=https;Host="api.example.com"`) 775 + 776 + fakeAccessToken := "fake-access-token-for-testing" 777 + proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken) 778 + if err != nil { 779 + t.Fatalf("expected DPoP verification to succeed with mixed-case/quoted Forwarded header, got %v", err) 780 + } 781 + 782 + if proof == nil { 783 + t.Fatal("expected DPoP proof to be returned") 784 + } 785 + } 786 + 787 + // TestVerifyDPoPBinding_AthValidation tests access token hash (ath) claim validation 788 + func TestVerifyDPoPBinding_AthValidation(t *testing.T) { 789 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 790 + if err != nil { 791 + t.Fatalf("failed to generate key: %v", err) 792 + } 793 + 794 + jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey) 795 + thumbprint, err := auth.CalculateJWKThumbprint(jwk) 796 + if err != nil { 797 + t.Fatalf("failed to calculate thumbprint: %v", err) 798 + } 799 + 800 + claims := &auth.Claims{ 801 + RegisteredClaims: jwt.RegisteredClaims{ 802 + Subject: "did:plc:test123", 803 + Issuer: "https://test.pds.local", 804 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), 805 + IssuedAt: jwt.NewNumericDate(time.Now()), 806 + }, 807 + Scope: "atproto", 808 + Confirmation: map[string]interface{}{ 809 + "jkt": thumbprint, 810 + }, 811 + } 812 + 813 + middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false) 814 + defer middleware.Stop() 815 + 816 + accessToken := "real-access-token-12345" 817 + 818 + t.Run("ath_matches_access_token", func(t *testing.T) { 819 + // Create DPoP proof with ath claim matching the access token 820 + dpopProof := createDPoPProofWithAth(t, privateKey, "GET", "https://api.example.com/resource", accessToken) 821 + 822 + req := httptest.NewRequest("GET", "https://api.example.com/resource", nil) 823 + req.Host = "api.example.com" 824 + 825 + proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, accessToken) 826 + if err != nil { 827 + t.Fatalf("expected verification to succeed with matching ath, got %v", err) 828 + } 829 + if proof == nil { 830 + t.Fatal("expected proof to be returned") 831 + } 832 + }) 833 + 834 + t.Run("ath_mismatch_rejected", func(t *testing.T) { 835 + // Create DPoP proof with ath for a DIFFERENT token 836 + differentToken := "different-token-67890" 837 + dpopProof := createDPoPProofWithAth(t, privateKey, "POST", "https://api.example.com/resource", differentToken) 838 + 839 + req := httptest.NewRequest("POST", "https://api.example.com/resource", nil) 840 + req.Host = "api.example.com" 841 + 842 + // Try to use with the original access token - should fail 843 + _, err := middleware.verifyDPoPBinding(req, claims, dpopProof, accessToken) 844 + if err == nil { 845 + t.Fatal("SECURITY: expected verification to fail when ath doesn't match access token") 846 + } 847 + if !strings.Contains(err.Error(), "ath") { 848 + t.Errorf("error should mention ath mismatch, got: %v", err) 849 + } 850 + }) 851 + } 852 + 853 // TestMiddlewareStop tests that the middleware can be stopped properly 854 func TestMiddlewareStop(t *testing.T) { 855 fetcher := &mockJWKSFetcher{} ··· 910 })) 911 912 req := httptest.NewRequest("GET", "/test", nil) 913 + req.Header.Set("Authorization", "DPoP "+tokenString) 914 // Deliberately NOT setting DPoP header 915 w := httptest.NewRecorder() 916 ··· 942 })) 943 944 req := httptest.NewRequest("GET", "/test", nil) 945 + req.Header.Set("Authorization", "DPoP "+tokenString) 946 // No DPoP header 947 w := httptest.NewRecorder() 948 ··· 1012 return signedToken 1013 } 1014 1015 + // Helper: createDPoPProofWithAth creates a DPoP proof JWT with ath (access token hash) claim 1016 + func createDPoPProofWithAth(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri, accessToken string) string { 1017 + // Create JWK from public key 1018 + jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey) 1019 + 1020 + // Calculate ath: base64url(SHA-256(access_token)) 1021 + hash := sha256.Sum256([]byte(accessToken)) 1022 + ath := base64.RawURLEncoding.EncodeToString(hash[:]) 1023 + 1024 + // Create DPoP claims with ath 1025 + claims := auth.DPoPClaims{ 1026 + RegisteredClaims: jwt.RegisteredClaims{ 1027 + IssuedAt: jwt.NewNumericDate(time.Now()), 1028 + ID: uuid.New().String(), 1029 + }, 1030 + HTTPMethod: method, 1031 + HTTPURI: uri, 1032 + AccessTokenHash: ath, 1033 + } 1034 + 1035 + // Create token with custom header 1036 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1037 + token.Header["typ"] = "dpop+jwt" 1038 + token.Header["jwk"] = jwk 1039 + 1040 + // Sign with private key 1041 + signedToken, err := token.SignedString(privateKey) 1042 + if err != nil { 1043 + t.Fatalf("failed to sign DPoP proof: %v", err) 1044 + } 1045 + 1046 + return signedToken 1047 + } 1048 + 1049 // Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map 1050 func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} { 1051 // Get curve name ··· 1079 "y": base64.RawURLEncoding.EncodeToString(yPadded), 1080 } 1081 } 1082 + 1083 + // Helper: createValidJWT creates a valid unsigned JWT token for testing 1084 + // This is used with skipVerify=true middleware where signature verification is skipped 1085 + func createValidJWT(t *testing.T, subject string, expiry time.Duration) string { 1086 + t.Helper() 1087 + 1088 + claims := auth.Claims{ 1089 + RegisteredClaims: jwt.RegisteredClaims{ 1090 + Subject: subject, 1091 + Issuer: "https://test.pds.local", 1092 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), 1093 + IssuedAt: jwt.NewNumericDate(time.Now()), 1094 + }, 1095 + Scope: "atproto", 1096 + } 1097 + 1098 + // Create unsigned token (for skipVerify=true tests) 1099 + token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) 1100 + signedToken, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) 1101 + if err != nil { 1102 + t.Fatalf("failed to create test JWT: %v", err) 1103 + } 1104 + 1105 + return signedToken 1106 + }
+12 -8
internal/atproto/auth/README.md
··· 1 # atProto OAuth Authentication 2 3 - This package implements third-party OAuth authentication for Coves, validating JWT Bearer tokens from mobile apps and other atProto clients. 4 5 ## Architecture 6 ··· 17 ``` 18 Client Request 19 20 - Authorization: Bearer <jwt> 21 22 Auth Middleware 23 24 - Extract JWT → Parse Claims → Verify Signature (via JWKS) 25 26 Inject DID into Context → Call Handler 27 ``` ··· 71 72 ```bash 73 curl -X POST https://coves.social/xrpc/social.coves.community.create \ 74 - -H "Authorization: Bearer eyJhbGc..." \ 75 -H "Content-Type: application/json" \ 76 -d '{"name":"Gaming","hostedByDid":"did:plc:..."}' 77 ``` ··· 141 │ │ │ (Coves) │ 142 └─────────────┘ └─────────────┘ 143 │ │ 144 - │ 1. Authorization: Bearer <token> │ 145 │ DPoP: <proof-jwt> │ 146 │───────────────────────────────────────>│ 147 │ │ ··· 276 # Create a test JWT (use jwt.io or a tool) 277 export AUTH_SKIP_VERIFY=true 278 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \ 279 - -H "Authorization: Bearer <test-jwt>" \ 280 -d '{"name":"Test","hostedByDid":"did:plc:test"}' 281 ``` 282 ··· 285 # Use a real JWT from a PDS 286 export AUTH_SKIP_VERIFY=false 287 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \ 288 - -H "Authorization: Bearer <real-jwt>" \ 289 -d '{"name":"Test","hostedByDid":"did:plc:test"}' 290 ``` 291 ··· 311 312 ### Common Issues 313 314 - 1. **Missing Authorization header** → Add `Authorization: Bearer <token>` 315 2. **Token expired** → Get a new token from PDS 316 3. **Invalid signature** → Ensure token is from a valid PDS 317 4. **JWKS fetch fails** → Check PDS availability and network connectivity
··· 1 # atProto OAuth Authentication 2 3 + This package implements third-party OAuth authentication for Coves, validating DPoP-bound access tokens from mobile apps and other atProto clients. 4 5 ## Architecture 6 ··· 17 ``` 18 Client Request 19 20 + Authorization: DPoP <access_token> 21 + DPoP: <proof-jwt> 22 23 Auth Middleware 24 25 + Extract JWT → Parse Claims → Verify Signature (via JWKS) → Verify DPoP Proof 26 27 Inject DID into Context → Call Handler 28 ``` ··· 72 73 ```bash 74 curl -X POST https://coves.social/xrpc/social.coves.community.create \ 75 + -H "Authorization: DPoP eyJhbGc..." \ 76 + -H "DPoP: eyJhbGc..." \ 77 -H "Content-Type: application/json" \ 78 -d '{"name":"Gaming","hostedByDid":"did:plc:..."}' 79 ``` ··· 143 │ │ │ (Coves) │ 144 └─────────────┘ └─────────────┘ 145 │ │ 146 + │ 1. Authorization: DPoP <token> │ 147 │ DPoP: <proof-jwt> │ 148 │───────────────────────────────────────>│ 149 │ │ ··· 278 # Create a test JWT (use jwt.io or a tool) 279 export AUTH_SKIP_VERIFY=true 280 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \ 281 + -H "Authorization: DPoP <test-jwt>" \ 282 + -H "DPoP: <test-dpop-proof>" \ 283 -d '{"name":"Test","hostedByDid":"did:plc:test"}' 284 ``` 285 ··· 288 # Use a real JWT from a PDS 289 export AUTH_SKIP_VERIFY=false 290 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \ 291 + -H "Authorization: DPoP <real-jwt>" \ 292 + -H "DPoP: <real-dpop-proof>" \ 293 -d '{"name":"Test","hostedByDid":"did:plc:test"}' 294 ``` 295 ··· 315 316 ### Common Issues 317 318 + 1. **Missing Authorization header** → Add `Authorization: DPoP <token>` and `DPoP: <proof>` 319 2. **Token expired** → Get a new token from PDS 320 3. **Invalid signature** → Ensure token is from a valid PDS 321 4. **JWKS fetch fails** → Check PDS availability and network connectivity
+21
internal/atproto/auth/dpop.go
··· 314 return nil 315 } 316 317 // CalculateJWKThumbprint calculates the JWK thumbprint per RFC 7638 318 // The thumbprint is the base64url-encoded SHA-256 hash of the canonical JWK representation 319 func CalculateJWKThumbprint(jwk map[string]interface{}) (string, error) {
··· 314 return nil 315 } 316 317 + // VerifyAccessTokenHash verifies the DPoP proof's ath (access token hash) claim 318 + // matches the SHA-256 hash of the presented access token. 319 + // Per RFC 9449 section 4.2, if ath is present, the RS MUST verify it. 320 + func (v *DPoPVerifier) VerifyAccessTokenHash(proof *DPoPProof, accessToken string) error { 321 + // If ath claim is not present, that's acceptable per RFC 9449 322 + // (ath is only required when the RS mandates it) 323 + if proof.Claims.AccessTokenHash == "" { 324 + return nil 325 + } 326 + 327 + // Calculate the expected ath: base64url(SHA-256(access_token)) 328 + hash := sha256.Sum256([]byte(accessToken)) 329 + expectedAth := base64.RawURLEncoding.EncodeToString(hash[:]) 330 + 331 + if proof.Claims.AccessTokenHash != expectedAth { 332 + return fmt.Errorf("DPoP proof ath mismatch: proof bound to different access token") 333 + } 334 + 335 + return nil 336 + } 337 + 338 // CalculateJWKThumbprint calculates the JWK thumbprint per RFC 7638 339 // The thumbprint is the base64url-encoded SHA-256 hash of the canonical JWK representation 340 func CalculateJWKThumbprint(jwk map[string]interface{}) (string, error) {
+1 -1
scripts/aggregator-setup/README.md
··· 175 176 ```bash 177 curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 178 - -H "Authorization: Bearer $AGGREGATOR_ACCESS_JWT" \ 179 -H "Content-Type: application/json" \ 180 -d '{ 181 "communityDid": "did:plc:...",
··· 175 176 ```bash 177 curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 178 + -H "Authorization: DPoP $AGGREGATOR_ACCESS_JWT" \ 179 -H "Content-Type: application/json" \ 180 -d '{ 181 "communityDid": "did:plc:...",
+7 -6
tests/integration/aggregator_e2e_test.go
··· 83 listForCommunityHandler := aggregator.NewListForCommunityHandler(aggregatorService) 84 createPostHandler := post.NewCreateHandler(postService) 85 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing 86 87 ctx := context.Background() 88 ··· 337 338 // Create JWT for aggregator (not a user) 339 aggregatorJWT := createSimpleTestJWT(aggregatorDID) 340 - req.Header.Set("Authorization", "Bearer "+aggregatorJWT) 341 342 // Execute request through auth middleware + handler 343 rr := httptest.NewRecorder() ··· 424 425 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 426 req.Header.Set("Content-Type", "application/json") 427 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 428 429 rr := httptest.NewRecorder() 430 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 446 447 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 448 req.Header.Set("Content-Type", "application/json") 449 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 450 451 rr := httptest.NewRecorder() 452 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 467 468 req = httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 469 req.Header.Set("Content-Type", "application/json") 470 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 471 472 rr = httptest.NewRecorder() 473 handler = authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 659 660 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 661 req.Header.Set("Content-Type", "application/json") 662 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(unauthorizedAggDID)) 663 664 rr := httptest.NewRecorder() 665 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 783 784 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 785 req.Header.Set("Content-Type", "application/json") 786 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 787 788 rr := httptest.NewRecorder() 789 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
··· 83 listForCommunityHandler := aggregator.NewListForCommunityHandler(aggregatorService) 84 createPostHandler := post.NewCreateHandler(postService) 85 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing 86 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 87 88 ctx := context.Background() 89 ··· 338 339 // Create JWT for aggregator (not a user) 340 aggregatorJWT := createSimpleTestJWT(aggregatorDID) 341 + req.Header.Set("Authorization", "DPoP "+aggregatorJWT) 342 343 // Execute request through auth middleware + handler 344 rr := httptest.NewRecorder() ··· 425 426 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 427 req.Header.Set("Content-Type", "application/json") 428 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 429 430 rr := httptest.NewRecorder() 431 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 447 448 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 449 req.Header.Set("Content-Type", "application/json") 450 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 451 452 rr := httptest.NewRecorder() 453 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 468 469 req = httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 470 req.Header.Set("Content-Type", "application/json") 471 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 472 473 rr = httptest.NewRecorder() 474 handler = authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 660 661 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 662 req.Header.Set("Content-Type", "application/json") 663 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(unauthorizedAggDID)) 664 665 rr := httptest.NewRecorder() 666 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 784 785 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 786 req.Header.Set("Content-Type", "application/json") 787 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 788 789 rr := httptest.NewRecorder() 790 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
+1
tests/integration/blob_upload_e2e_test.go
··· 466 assert.Equal(t, "POST", r.Method, "Should be POST request") 467 assert.Equal(t, "/xrpc/com.atproto.repo.uploadBlob", r.URL.Path, "Should hit uploadBlob endpoint") 468 assert.Equal(t, "image/png", r.Header.Get("Content-Type"), "Should have correct content type") 469 assert.Contains(t, r.Header.Get("Authorization"), "Bearer ", "Should have auth header") 470 471 // Return mock blob reference
··· 466 assert.Equal(t, "POST", r.Method, "Should be POST request") 467 assert.Equal(t, "/xrpc/com.atproto.repo.uploadBlob", r.URL.Path, "Should hit uploadBlob endpoint") 468 assert.Equal(t, "image/png", r.Header.Get("Content-Type"), "Should have correct content type") 469 + // Note: This is a PDS call, so it uses Bearer (not DPoP) 470 assert.Contains(t, r.Header.Get("Authorization"), "Bearer ", "Should have auth header") 471 472 // Return mock blob reference
+13 -8
tests/integration/community_e2e_test.go
··· 108 109 t.Logf("✅ Authenticated - Instance DID: %s", instanceDID) 110 111 - // Initialize auth middleware (skipVerify=true for E2E tests) 112 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) 113 114 // V2.0: Extract instance domain for community provisioning 115 var instanceDomain string ··· 383 } 384 req.Header.Set("Content-Type", "application/json") 385 // Use real PDS access token for E2E authentication 386 - req.Header.Set("Authorization", "Bearer "+accessToken) 387 388 resp, err := http.DefaultClient.Do(req) 389 if err != nil { ··· 769 } 770 req.Header.Set("Content-Type", "application/json") 771 // Use real PDS access token for E2E authentication 772 - req.Header.Set("Authorization", "Bearer "+accessToken) 773 774 resp, err := http.DefaultClient.Do(req) 775 if err != nil { ··· 1004 } 1005 req.Header.Set("Content-Type", "application/json") 1006 // Use real PDS access token for E2E authentication 1007 - req.Header.Set("Authorization", "Bearer "+accessToken) 1008 1009 resp, err := http.DefaultClient.Do(req) 1010 if err != nil { ··· 1136 t.Fatalf("Failed to create block request: %v", err) 1137 } 1138 req.Header.Set("Content-Type", "application/json") 1139 - req.Header.Set("Authorization", "Bearer "+accessToken) 1140 1141 resp, err := http.DefaultClient.Do(req) 1142 if err != nil { ··· 1256 t.Fatalf("Failed to create block request: %v", err) 1257 } 1258 blockHttpReq.Header.Set("Content-Type", "application/json") 1259 - blockHttpReq.Header.Set("Authorization", "Bearer "+accessToken) 1260 1261 blockResp, err := http.DefaultClient.Do(blockHttpReq) 1262 if err != nil { ··· 1316 t.Fatalf("Failed to create unblock request: %v", err) 1317 } 1318 req.Header.Set("Content-Type", "application/json") 1319 - req.Header.Set("Authorization", "Bearer "+accessToken) 1320 1321 resp, err := http.DefaultClient.Do(req) 1322 if err != nil { ··· 1473 } 1474 req.Header.Set("Content-Type", "application/json") 1475 // Use real PDS access token for E2E authentication 1476 - req.Header.Set("Authorization", "Bearer "+accessToken) 1477 1478 resp, err := http.DefaultClient.Do(req) 1479 if err != nil {
··· 108 109 t.Logf("✅ Authenticated - Instance DID: %s", instanceDID) 110 111 + // Initialize auth middleware with skipVerify=true 112 + // IMPORTANT: PDS password authentication returns Bearer tokens (not DPoP-bound tokens). 113 + // E2E tests use these Bearer tokens with the DPoP scheme header, which only works 114 + // because skipVerify=true bypasses signature and DPoP binding verification. 115 + // In production, skipVerify=false requires proper DPoP-bound tokens from OAuth flow. 116 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) 117 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 118 119 // V2.0: Extract instance domain for community provisioning 120 var instanceDomain string ··· 388 } 389 req.Header.Set("Content-Type", "application/json") 390 // Use real PDS access token for E2E authentication 391 + req.Header.Set("Authorization", "DPoP "+accessToken) 392 393 resp, err := http.DefaultClient.Do(req) 394 if err != nil { ··· 774 } 775 req.Header.Set("Content-Type", "application/json") 776 // Use real PDS access token for E2E authentication 777 + req.Header.Set("Authorization", "DPoP "+accessToken) 778 779 resp, err := http.DefaultClient.Do(req) 780 if err != nil { ··· 1009 } 1010 req.Header.Set("Content-Type", "application/json") 1011 // Use real PDS access token for E2E authentication 1012 + req.Header.Set("Authorization", "DPoP "+accessToken) 1013 1014 resp, err := http.DefaultClient.Do(req) 1015 if err != nil { ··· 1141 t.Fatalf("Failed to create block request: %v", err) 1142 } 1143 req.Header.Set("Content-Type", "application/json") 1144 + req.Header.Set("Authorization", "DPoP "+accessToken) 1145 1146 resp, err := http.DefaultClient.Do(req) 1147 if err != nil { ··· 1261 t.Fatalf("Failed to create block request: %v", err) 1262 } 1263 blockHttpReq.Header.Set("Content-Type", "application/json") 1264 + blockHttpReq.Header.Set("Authorization", "DPoP "+accessToken) 1265 1266 blockResp, err := http.DefaultClient.Do(blockHttpReq) 1267 if err != nil { ··· 1321 t.Fatalf("Failed to create unblock request: %v", err) 1322 } 1323 req.Header.Set("Content-Type", "application/json") 1324 + req.Header.Set("Authorization", "DPoP "+accessToken) 1325 1326 resp, err := http.DefaultClient.Do(req) 1327 if err != nil { ··· 1478 } 1479 req.Header.Set("Content-Type", "application/json") 1480 // Use real PDS access token for E2E authentication 1481 + req.Header.Set("Authorization", "DPoP "+accessToken) 1482 1483 resp, err := http.DefaultClient.Do(req) 1484 if err != nil {
+4 -2
tests/integration/jwt_verification_test.go
··· 104 t.Log("Testing auth middleware with skipVerify=true (dev mode)...") 105 106 authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) // skipVerify=true for dev PDS 107 108 handlerCalled := false 109 var extractedDID string ··· 116 })) 117 118 req := httptest.NewRequest("GET", "/test", nil) 119 - req.Header.Set("Authorization", "Bearer "+accessToken) 120 w := httptest.NewRecorder() 121 122 testHandler.ServeHTTP(w, req) ··· 166 // Tampered payload should fail JWT parsing even without signature check 167 jwksFetcher := auth.NewCachedJWKSFetcher(1 * time.Hour) 168 authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) 169 170 handlerCalled := false 171 testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ··· 174 })) 175 176 req := httptest.NewRequest("GET", "/test", nil) 177 - req.Header.Set("Authorization", "Bearer "+tamperedToken) 178 w := httptest.NewRecorder() 179 180 testHandler.ServeHTTP(w, req)
··· 104 t.Log("Testing auth middleware with skipVerify=true (dev mode)...") 105 106 authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) // skipVerify=true for dev PDS 107 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 108 109 handlerCalled := false 110 var extractedDID string ··· 117 })) 118 119 req := httptest.NewRequest("GET", "/test", nil) 120 + req.Header.Set("Authorization", "DPoP "+accessToken) 121 w := httptest.NewRecorder() 122 123 testHandler.ServeHTTP(w, req) ··· 167 // Tampered payload should fail JWT parsing even without signature check 168 jwksFetcher := auth.NewCachedJWKSFetcher(1 * time.Hour) 169 authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) 170 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 171 172 handlerCalled := false 173 testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ··· 176 })) 177 178 req := httptest.NewRequest("GET", "/test", nil) 179 + req.Header.Set("Authorization", "DPoP "+tamperedToken) 180 w := httptest.NewRecorder() 181 182 testHandler.ServeHTTP(w, req)
+2 -1
tests/integration/post_e2e_test.go
··· 407 408 // Setup auth middleware (skip JWT verification for testing) 409 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) 410 411 // Setup HTTP handler 412 createHandler := post.NewCreateHandler(postService) ··· 478 // Create a simple JWT for testing (Phase 1: no signature verification) 479 // In production, this would be a real OAuth token from PDS 480 testJWT := createSimpleTestJWT(author.DID) 481 - req.Header.Set("Authorization", "Bearer "+testJWT) 482 483 // Execute request through auth middleware + handler 484 rr := httptest.NewRecorder()
··· 407 408 // Setup auth middleware (skip JWT verification for testing) 409 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) 410 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 411 412 // Setup HTTP handler 413 createHandler := post.NewCreateHandler(postService) ··· 479 // Create a simple JWT for testing (Phase 1: no signature verification) 480 // In production, this would be a real OAuth token from PDS 481 testJWT := createSimpleTestJWT(author.DID) 482 + req.Header.Set("Authorization", "DPoP "+testJWT) 483 484 // Execute request through auth middleware + handler 485 rr := httptest.NewRecorder()
+54 -36
tests/integration/user_journey_e2e_test.go
··· 116 userService := users.NewUserService(userRepo, identityResolver, pdsURL) 117 118 // Extract instance domain and DID 119 instanceDID := os.Getenv("INSTANCE_DID") 120 if instanceDID == "" { 121 - instanceDID = "did:web:test.coves.social" 122 } 123 var instanceDomain string 124 if strings.HasPrefix(instanceDID, "did:web:") { ··· 139 voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) 140 141 // Setup HTTP server with all routes 142 - authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing 143 r := chi.NewRouter() 144 routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators 145 routes.RegisterPostRoutes(r, postService, authMiddleware) ··· 149 150 // Cleanup test data from previous runs (clean up ALL journey test data) 151 timestamp := time.Now().Unix() 152 - // Clean up previous test runs - use pattern that matches ANY journey test data 153 - _, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE '%alice-journey-%' OR voter_did LIKE '%bob-journey-%'") 154 - _, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE '%alice-journey-%' OR author_did LIKE '%bob-journey-%'") 155 - _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE '%gaming-journey-%'") 156 - _, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE '%alice-journey-%' OR user_did LIKE '%bob-journey-%'") 157 - _, _ = db.Exec("DELETE FROM communities WHERE handle LIKE 'gaming-journey-%'") 158 - _, _ = db.Exec("DELETE FROM users WHERE handle LIKE '%alice-journey-%' OR handle LIKE '%bob-journey-%'") 159 160 // Defer cleanup for current test run using specific timestamp pattern 161 defer func() { 162 - pattern := fmt.Sprintf("%%journey-%d%%", timestamp) 163 - _, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE $1", pattern) 164 - _, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE $1", pattern) 165 - _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE $1", pattern) 166 - _, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE $1", pattern) 167 - _, _ = db.Exec("DELETE FROM communities WHERE did LIKE $1 OR handle LIKE $1", pattern, pattern) 168 - _, _ = db.Exec("DELETE FROM users WHERE did LIKE $1 OR handle LIKE $1", pattern, pattern) 169 }() 170 171 // Test variables to track state across steps ··· 190 t.Run("1. User A - Signup and Authenticate", func(t *testing.T) { 191 t.Log("\n👤 Part 1: User A creates account and authenticates...") 192 193 - userAHandle = fmt.Sprintf("alice-journey-%d.local.coves.dev", timestamp) 194 - email := fmt.Sprintf("alice-journey-%d@test.com", timestamp) 195 password := "test-password-alice-123" 196 197 // Create account on PDS ··· 215 t.Run("2. User A - Create Community", func(t *testing.T) { 216 t.Log("\n🏘️ Part 2: User A creates a community...") 217 218 - communityName := fmt.Sprintf("gaming-journey-%d", timestamp%10000) // Keep name short 219 220 createReq := map[string]interface{}{ 221 "name": communityName, ··· 230 httpServer.URL+"/xrpc/social.coves.community.create", 231 bytes.NewBuffer(reqBody)) 232 req.Header.Set("Content-Type", "application/json") 233 - req.Header.Set("Authorization", "Bearer "+userAToken) 234 235 resp, err := http.DefaultClient.Do(req) 236 require.NoError(t, err) ··· 314 httpServer.URL+"/xrpc/social.coves.community.post.create", 315 bytes.NewBuffer(reqBody)) 316 req.Header.Set("Content-Type", "application/json") 317 - req.Header.Set("Authorization", "Bearer "+userAToken) 318 319 resp, err := http.DefaultClient.Do(req) 320 require.NoError(t, err) ··· 381 t.Run("4. User B - Signup and Authenticate", func(t *testing.T) { 382 t.Log("\n👤 Part 4: User B creates account and authenticates...") 383 384 - userBHandle = fmt.Sprintf("bob-journey-%d.local.coves.dev", timestamp) 385 - email := fmt.Sprintf("bob-journey-%d@test.com", timestamp) 386 password := "test-password-bob-123" 387 388 // Create account on PDS ··· 421 httpServer.URL+"/xrpc/social.coves.community.subscribe", 422 bytes.NewBuffer(reqBody)) 423 req.Header.Set("Content-Type", "application/json") 424 - req.Header.Set("Authorization", "Bearer "+userBToken) 425 426 resp, err := http.DefaultClient.Do(req) 427 require.NoError(t, err) ··· 653 t.Run("9. User B - Verify Timeline Feed Shows Subscribed Community Posts", func(t *testing.T) { 654 t.Log("\n📰 Part 9: User B checks timeline feed...") 655 656 - req := httptest.NewRequest(http.MethodGet, 657 - "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil) 658 - req = req.WithContext(middleware.SetTestUserDID(req.Context(), userBDID)) 659 - rec := httptest.NewRecorder() 660 661 - // Call timeline handler directly 662 - timelineHandler := httpServer.Config.Handler 663 - timelineHandler.ServeHTTP(rec, req) 664 665 - require.Equal(t, http.StatusOK, rec.Code, "Timeline request should succeed") 666 667 var response timelineCore.TimelineResponse 668 - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) 669 670 // User B should see the post from the community they subscribed to 671 require.NotEmpty(t, response.Feed, "Timeline should contain posts") ··· 679 "Post author should be User A") 680 assert.Equal(t, communityDID, feedPost.Post.Community.DID, 681 "Post community should match") 682 - assert.Equal(t, 1, feedPost.Post.UpvoteCount, 683 "Post should show 1 upvote from User B") 684 - assert.Equal(t, 1, feedPost.Post.CommentCount, 685 "Post should show 1 comment from User B") 686 break 687 } ··· 788 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) 789 ON CONFLICT (did) DO NOTHING 790 `, did, handle, strings.Split(handle, ".")[0], "Test Community", did, ownerDID, 791 - "did:web:test.coves.social", "public", "moderator", 792 fmt.Sprintf("at://%s/social.coves.community.profile/self", did), "fakecid") 793 794 require.NoError(t, err, "Failed to simulate community indexing")
··· 116 userService := users.NewUserService(userRepo, identityResolver, pdsURL) 117 118 // Extract instance domain and DID 119 + // IMPORTANT: Instance domain must match PDS_SERVICE_HANDLE_DOMAINS config (.community.coves.social) 120 instanceDID := os.Getenv("INSTANCE_DID") 121 if instanceDID == "" { 122 + instanceDID = "did:web:coves.social" // Must match PDS handle domain config 123 } 124 var instanceDomain string 125 if strings.HasPrefix(instanceDID, "did:web:") { ··· 140 voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) 141 142 // Setup HTTP server with all routes 143 + // IMPORTANT: skipVerify=true because PDS password auth returns Bearer tokens (not DPoP-bound). 144 + // E2E tests use Bearer tokens with DPoP scheme header, which only works with skipVerify=true. 145 + // In production, skipVerify=false requires proper DPoP-bound tokens from OAuth flow. 146 + authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) 147 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 148 r := chi.NewRouter() 149 routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators 150 routes.RegisterPostRoutes(r, postService, authMiddleware) ··· 154 155 // Cleanup test data from previous runs (clean up ALL journey test data) 156 timestamp := time.Now().Unix() 157 + // Clean up previous test runs - use pattern that matches journey test data 158 + // Handles are now shorter: alice{4-digit}.local.coves.dev, bob{4-digit}.local.coves.dev 159 + _, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE '%alice%.local.coves.dev%' OR voter_did LIKE '%bob%.local.coves.dev%'") 160 + _, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE '%alice%.local.coves.dev%' OR author_did LIKE '%bob%.local.coves.dev%'") 161 + _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE '%gj%'") 162 + _, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE '%alice%.local.coves.dev%' OR user_did LIKE '%bob%.local.coves.dev%'") 163 + _, _ = db.Exec("DELETE FROM communities WHERE handle LIKE 'gj%'") 164 + _, _ = db.Exec("DELETE FROM users WHERE handle LIKE 'alice%.local.coves.dev' OR handle LIKE 'bob%.local.coves.dev'") 165 166 // Defer cleanup for current test run using specific timestamp pattern 167 defer func() { 168 + shortTS := timestamp % 10000 169 + alicePattern := fmt.Sprintf("%%alice%d%%", shortTS) 170 + bobPattern := fmt.Sprintf("%%bob%d%%", shortTS) 171 + gjPattern := fmt.Sprintf("%%gj%d%%", shortTS) 172 + _, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE $1 OR voter_did LIKE $2", alicePattern, bobPattern) 173 + _, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE $1 OR author_did LIKE $2", alicePattern, bobPattern) 174 + _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE $1", gjPattern) 175 + _, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE $1 OR user_did LIKE $2", alicePattern, bobPattern) 176 + _, _ = db.Exec("DELETE FROM communities WHERE handle LIKE $1", gjPattern) 177 + _, _ = db.Exec("DELETE FROM users WHERE handle LIKE $1 OR handle LIKE $2", alicePattern, bobPattern) 178 }() 179 180 // Test variables to track state across steps ··· 199 t.Run("1. User A - Signup and Authenticate", func(t *testing.T) { 200 t.Log("\n👤 Part 1: User A creates account and authenticates...") 201 202 + // Use short handle format to stay under PDS 34-char limit 203 + shortTS := timestamp % 10000 // Use last 4 digits 204 + userAHandle = fmt.Sprintf("alice%d.local.coves.dev", shortTS) 205 + email := fmt.Sprintf("alice%d@test.com", shortTS) 206 password := "test-password-alice-123" 207 208 // Create account on PDS ··· 226 t.Run("2. User A - Create Community", func(t *testing.T) { 227 t.Log("\n🏘️ Part 2: User A creates a community...") 228 229 + // Community handle will be {name}.community.coves.social 230 + // Max 34 chars total, so name must be short (34 - 23 = 11 chars max) 231 + shortTS := timestamp % 10000 232 + communityName := fmt.Sprintf("gj%d", shortTS) // "gj9261" = 6 chars -> handle = 29 chars 233 234 createReq := map[string]interface{}{ 235 "name": communityName, ··· 244 httpServer.URL+"/xrpc/social.coves.community.create", 245 bytes.NewBuffer(reqBody)) 246 req.Header.Set("Content-Type", "application/json") 247 + req.Header.Set("Authorization", "DPoP "+userAToken) 248 249 resp, err := http.DefaultClient.Do(req) 250 require.NoError(t, err) ··· 328 httpServer.URL+"/xrpc/social.coves.community.post.create", 329 bytes.NewBuffer(reqBody)) 330 req.Header.Set("Content-Type", "application/json") 331 + req.Header.Set("Authorization", "DPoP "+userAToken) 332 333 resp, err := http.DefaultClient.Do(req) 334 require.NoError(t, err) ··· 395 t.Run("4. User B - Signup and Authenticate", func(t *testing.T) { 396 t.Log("\n👤 Part 4: User B creates account and authenticates...") 397 398 + // Use short handle format to stay under PDS 34-char limit 399 + shortTS := timestamp % 10000 // Use last 4 digits 400 + userBHandle = fmt.Sprintf("bob%d.local.coves.dev", shortTS) 401 + email := fmt.Sprintf("bob%d@test.com", shortTS) 402 password := "test-password-bob-123" 403 404 // Create account on PDS ··· 437 httpServer.URL+"/xrpc/social.coves.community.subscribe", 438 bytes.NewBuffer(reqBody)) 439 req.Header.Set("Content-Type", "application/json") 440 + req.Header.Set("Authorization", "DPoP "+userBToken) 441 442 resp, err := http.DefaultClient.Do(req) 443 require.NoError(t, err) ··· 669 t.Run("9. User B - Verify Timeline Feed Shows Subscribed Community Posts", func(t *testing.T) { 670 t.Log("\n📰 Part 9: User B checks timeline feed...") 671 672 + // Use HTTP client to properly go through auth middleware with DPoP token 673 + req, _ := http.NewRequest(http.MethodGet, 674 + httpServer.URL+"/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil) 675 + req.Header.Set("Authorization", "DPoP "+userBToken) 676 677 + resp, err := http.DefaultClient.Do(req) 678 + require.NoError(t, err) 679 + defer func() { _ = resp.Body.Close() }() 680 681 + require.Equal(t, http.StatusOK, resp.StatusCode, "Timeline request should succeed") 682 683 var response timelineCore.TimelineResponse 684 + require.NoError(t, json.NewDecoder(resp.Body).Decode(&response)) 685 686 // User B should see the post from the community they subscribed to 687 require.NotEmpty(t, response.Feed, "Timeline should contain posts") ··· 695 "Post author should be User A") 696 assert.Equal(t, communityDID, feedPost.Post.Community.DID, 697 "Post community should match") 698 + // Check stats (counts are in Stats struct, not direct fields) 699 + require.NotNil(t, feedPost.Post.Stats, "Post should have stats") 700 + assert.Equal(t, 1, feedPost.Post.Stats.Upvotes, 701 "Post should show 1 upvote from User B") 702 + assert.Equal(t, 1, feedPost.Post.Stats.CommentCount, 703 "Post should show 1 comment from User B") 704 break 705 } ··· 806 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) 807 ON CONFLICT (did) DO NOTHING 808 `, did, handle, strings.Split(handle, ".")[0], "Test Community", did, ownerDID, 809 + "did:web:coves.social", "public", "moderator", 810 fmt.Sprintf("at://%s/social.coves.community.profile/self", did), "fakecid") 811 812 require.NoError(t, err, "Failed to simulate community indexing")