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 25 "log" 26 26 "net/http" 27 27 "os" 28 + "os/signal" 28 29 "strings" 30 + "syscall" 29 31 "time" 30 32 31 33 "github.com/go-chi/chi/v5" ··· 511 513 port = "8080" 512 514 } 513 515 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)) 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") 517 551 } 518 552 519 553 // authenticateWithPDS creates a session on the PDS and returns an access token
+5 -5
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
··· 47 47 - Lexicon definitions: `social.coves.community.comment.defs` and `getComments` 48 48 - Database query methods with Lemmy hot ranking algorithm 49 49 - Service layer with iterative loading strategy for nested replies 50 - - XRPC HTTP handler with optional authentication 50 + - XRPC HTTP handler with optional DPoP authentication 51 51 - Comprehensive integration test suite (11 test scenarios) 52 52 53 53 **What works:** ··· 55 55 - Nested replies up to configurable depth (default 10, max 100) 56 56 - Lemmy hot ranking: `log(greatest(2, score + 2)) / power(time_decay, 1.8)` 57 57 - Cursor-based pagination for stable scrolling 58 - - Optional authentication for viewer state (stubbed for Phase 2B) 58 + - Optional DPoP authentication for viewer state (stubbed for Phase 2B) 59 59 - Timeframe filtering for "top" sort (hour/day/week/month/year/all) 60 60 61 61 **Endpoints:** ··· 63 63 - Required: `post` (AT-URI) 64 64 - Optional: `sort` (hot/top/new), `depth` (0-100), `limit` (1-100), `cursor`, `timeframe` 65 65 - Returns: Array of `threadViewComment` with nested replies + post context 66 - - Supports Bearer token for authenticated requests (viewer state) 66 + - Supports DPoP-bound access token for authenticated requests (viewer state) 67 67 68 68 **Files created (9):** 69 69 1. `internal/atproto/lexicon/social/coves/community/comment/defs.json` - View definitions ··· 976 976 **8. Viewer Authentication Validation (Non-Issue - Architecture Working as Designed)** 977 977 - **Initial Concern:** ViewerDID field trusted without verification in service layer 978 978 - **Investigation:** Authentication IS properly validated at middleware layer 979 - - `OptionalAuth` middleware extracts and validates JWT Bearer tokens 979 + - `OptionalAuth` middleware extracts and validates DPoP-bound access tokens 980 980 - Uses PDS public keys (JWKS) for signature verification 981 - - Validates token expiration, DID format, issuer 981 + - Validates DPoP proof, token expiration, DID format, issuer 982 982 - Only injects verified DIDs into request context 983 983 - Handler extracts DID using `middleware.GetUserDID(r)` 984 984 - **Architecture:** Follows industry best practices (authentication at perimeter)
+7 -4
docs/FEED_SYSTEM_IMPLEMENTATION.md
··· 203 203 # Get personalized timeline (hot posts from subscriptions) 204 204 curl -X GET \ 205 205 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=hot&limit=15' \ 206 - -H 'Authorization: Bearer eyJhbGc...' 206 + -H 'Authorization: DPoP eyJhbGc...' \ 207 + -H 'DPoP: eyJhbGc...' 207 208 208 209 # Get top posts from last week 209 210 curl -X GET \ 210 211 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=week&limit=20' \ 211 - -H 'Authorization: Bearer eyJhbGc...' 212 + -H 'Authorization: DPoP eyJhbGc...' \ 213 + -H 'DPoP: eyJhbGc...' 212 214 213 215 # Get newest posts with pagination 214 216 curl -X GET \ 215 217 'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=new&limit=10&cursor=<cursor>' \ 216 - -H 'Authorization: Bearer eyJhbGc...' 218 + -H 'Authorization: DPoP eyJhbGc...' \ 219 + -H 'DPoP: eyJhbGc...' 217 220 ``` 218 221 219 222 **Response:** ··· 313 316 - ✅ Context timeout support 314 317 315 318 ### Authentication (Timeline) 316 - - ✅ JWT Bearer token required 319 + - ✅ DPoP-bound access token required 317 320 - ✅ DID extracted from auth context 318 321 - ✅ Validates token signature (when AUTH_SKIP_VERIFY=false) 319 322 - ✅ Returns 401 on auth failure
+3 -3
docs/PRD_OAUTH.md
··· 10 10 - ✅ Auth middleware protecting community endpoints 11 11 - ✅ Handlers updated to use `GetUserDID(r)` 12 12 - ✅ Comprehensive middleware auth tests (11 test cases) 13 - - ✅ E2E tests updated to use Bearer tokens 13 + - ✅ E2E tests updated to use DPoP-bound tokens 14 14 - ✅ Security logging with IP, method, path, issuer 15 15 - ✅ Scope validation (atproto required) 16 16 - ✅ Issuer HTTPS validation ··· 163 163 Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ... 164 164 ``` 165 165 166 - Format: `DPoP <access_token>` 166 + Format: `DPoP <access_token>` (note: uses "DPoP" scheme, not "Bearer") 167 167 168 168 The access token is a JWT containing: 169 169 ```json ··· 753 753 - [x] All community endpoints reject requests without valid JWT structure 754 754 - [x] Integration tests pass with mock tokens (11/11 middleware tests passing) 755 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 756 + - [x] E2E tests updated to use proper DPoP token authentication 757 757 - [x] Build succeeds without compilation errors 758 758 759 759 ### Phase 2 (Beta) - ✅ READY FOR TESTING
+3 -1
docs/aggregators/SETUP_GUIDE.md
··· 256 256 257 257 **Request**: 258 258 ```bash 259 + # Note: This calls the PDS directly, so it uses Bearer authorization (not DPoP) 259 260 curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \ 260 261 -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ 261 262 -H "Content-Type: application/json" \ ··· 354 355 355 356 **Request**: 356 357 ```bash 358 + # Note: This calls the Coves API, so it uses DPoP authorization 357 359 curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 358 - -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ 360 + -H "Authorization: DPoP YOUR_ACCESS_TOKEN" \ 359 361 -H "Content-Type: application/json" \ 360 362 -d '{ 361 363 "communityDid": "did:plc:community123...",
+8 -2
docs/federation-prd.md
··· 263 263 req, _ := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) 264 264 265 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. 266 270 req.Header.Set("Authorization", "Bearer "+serviceAuthToken) 267 271 req.Header.Set("Content-Type", "application/json") 268 272 ··· 726 730 **Request to Remote PDS:** 727 731 ```http 728 732 POST https://covesinstance.com/xrpc/com.atproto.server.getServiceAuth 729 - Authorization: Bearer {coves-social-instance-jwt} 733 + Authorization: DPoP {coves-social-instance-jwt} 734 + DPoP: {coves-social-dpop-proof} 730 735 Content-Type: application/json 731 736 732 737 { ··· 749 754 **Using Token to Create Post:** 750 755 ```http 751 756 POST https://covesinstance.com/xrpc/com.atproto.repo.createRecord 752 - Authorization: Bearer {service-auth-token} 757 + Authorization: DPoP {service-auth-token} 758 + DPoP: {service-auth-dpop-proof} 753 759 Content-Type: application/json 754 760 755 761 {
+124 -31
internal/api/middleware/auth.go
··· 53 53 // RequireAuth middleware ensures the user is authenticated with a valid JWT 54 54 // If not authenticated, returns 401 55 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) 56 59 func (m *AtProtoAuthMiddleware) RequireAuth(next http.Handler) http.Handler { 57 60 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 61 // Extract Authorization header ··· 62 65 return 63 66 } 64 67 65 - // Must be Bearer token 66 - if !strings.HasPrefix(authHeader, "Bearer ") { 67 - writeAuthError(w, "Invalid Authorization header format. Expected: Bearer <token>") 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>") 68 73 return 69 74 } 70 - 71 - token := strings.TrimPrefix(authHeader, "Bearer ") 72 - token = strings.TrimSpace(token) 73 75 74 76 var claims *auth.Claims 75 77 var err error ··· 116 118 return 117 119 } 118 120 119 - proof, err := m.verifyDPoPBinding(r, claims, dpopHeader) 121 + proof, err := m.verifyDPoPBinding(r, claims, dpopHeader, token) 120 122 if err != nil { 121 123 log.Printf("[AUTH_FAILURE] type=dpop_verification_failed ip=%s method=%s path=%s error=%v", 122 124 r.RemoteAddr, r.Method, r.URL.Path, err) ··· 154 156 155 157 // OptionalAuth middleware loads user info if authenticated, but doesn't require it 156 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) 157 162 func (m *AtProtoAuthMiddleware) OptionalAuth(next http.Handler) http.Handler { 158 163 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 159 164 // Extract Authorization header 160 165 authHeader := r.Header.Get("Authorization") 161 - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { 162 - // Not authenticated - continue without user context 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 163 172 next.ServeHTTP(w, r) 164 173 return 165 174 } 166 - 167 - token := strings.TrimPrefix(authHeader, "Bearer ") 168 - token = strings.TrimSpace(token) 169 175 170 176 var claims *auth.Claims 171 177 var err error ··· 202 208 return 203 209 } 204 210 205 - proof, err := m.verifyDPoPBinding(r, claims, dpopHeader) 211 + proof, err := m.verifyDPoPBinding(r, claims, dpopHeader, token) 206 212 if err != nil { 207 213 // DPoP verification failed - cannot trust this token 208 214 log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err) ··· 280 286 // 281 287 // This prevents token theft attacks by proving the client possesses the private key 282 288 // 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) { 289 + func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader, accessToken string) (*auth.DPoPProof, error) { 284 290 // Extract the cnf.jkt claim from the already-verified token 285 291 jkt, err := auth.ExtractCnfJkt(claims) 286 292 if err != nil { ··· 288 294 } 289 295 290 296 // Build the HTTP URI for DPoP verification 291 - // Use the full URL including scheme and host 292 - scheme := strings.TrimSpace(r.URL.Scheme) 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) 293 338 if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" { 294 - // Forwarded proto may contain a comma-separated list; use the first entry 295 339 parts := strings.Split(forwardedProto, ",") 296 340 if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" { 297 341 scheme = strings.ToLower(strings.TrimSpace(parts[0])) 298 342 } 299 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 300 379 if scheme == "" { 301 380 if r.TLS != nil { 302 381 scheme = "https" ··· 304 383 scheme = "http" 305 384 } 306 385 } 307 - scheme = strings.ToLower(scheme) 308 - httpURI := scheme + "://" + r.Host + r.URL.Path 309 386 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 387 + return strings.ToLower(scheme), host 322 388 } 323 389 324 390 // writeAuthError writes a JSON error response for authentication failures ··· 331 397 log.Printf("Failed to write auth error response: %v", err) 332 398 } 333 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 6 "crypto/ecdsa" 7 7 "crypto/elliptic" 8 8 "crypto/rand" 9 + "crypto/sha256" 9 10 "encoding/base64" 10 11 "fmt" 11 12 "net/http" 12 13 "net/http/httptest" 14 + "strings" 13 15 "testing" 14 16 "time" 15 17 ··· 45 47 return tokenString 46 48 } 47 49 48 - // TestRequireAuth_ValidToken tests that valid tokens are accepted (Phase 1) 50 + // TestRequireAuth_ValidToken tests that valid tokens are accepted with DPoP scheme (Phase 1) 49 51 func TestRequireAuth_ValidToken(t *testing.T) { 50 52 fetcher := &mockJWKSFetcher{} 51 53 middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true ··· 75 77 76 78 token := createTestToken("did:plc:test123") 77 79 req := httptest.NewRequest("GET", "/test", nil) 78 - req.Header.Set("Authorization", "Bearer "+token) 80 + req.Header.Set("Authorization", "DPoP "+token) 79 81 w := httptest.NewRecorder() 80 82 81 83 handler.ServeHTTP(w, req) ··· 109 111 } 110 112 } 111 113 112 - // TestRequireAuth_InvalidAuthHeaderFormat tests that non-Bearer tokens are rejected 114 + // TestRequireAuth_InvalidAuthHeaderFormat tests that non-DPoP tokens are rejected (including Bearer) 113 115 func TestRequireAuth_InvalidAuthHeaderFormat(t *testing.T) { 114 116 fetcher := &mockJWKSFetcher{} 115 117 middleware := NewAtProtoAuthMiddleware(fetcher, true) 116 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 + 117 153 handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 154 t.Error("handler should not be called") 119 155 })) 120 156 121 157 req := httptest.NewRequest("GET", "/test", nil) 122 - req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0") // Wrong format 158 + req.Header.Set("Authorization", "Bearer some-token") 123 159 w := httptest.NewRecorder() 124 160 125 161 handler.ServeHTTP(w, req) 126 162 127 163 if w.Code != http.StatusUnauthorized { 128 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 + }) 129 212 } 130 213 } 131 214 ··· 139 222 })) 140 223 141 224 req := httptest.NewRequest("GET", "/test", nil) 142 - req.Header.Set("Authorization", "Bearer not-a-valid-jwt") 225 + req.Header.Set("Authorization", "DPoP not-a-valid-jwt") 143 226 w := httptest.NewRecorder() 144 227 145 228 handler.ServeHTTP(w, req) ··· 171 254 tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) 172 255 173 256 req := httptest.NewRequest("GET", "/test", nil) 174 - req.Header.Set("Authorization", "Bearer "+tokenString) 257 + req.Header.Set("Authorization", "DPoP "+tokenString) 175 258 w := httptest.NewRecorder() 176 259 177 260 handler.ServeHTTP(w, req) ··· 203 286 tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) 204 287 205 288 req := httptest.NewRequest("GET", "/test", nil) 206 - req.Header.Set("Authorization", "Bearer "+tokenString) 289 + req.Header.Set("Authorization", "DPoP "+tokenString) 207 290 w := httptest.NewRecorder() 208 291 209 292 handler.ServeHTTP(w, req) ··· 213 296 } 214 297 } 215 298 216 - // TestOptionalAuth_WithToken tests that OptionalAuth accepts valid tokens 299 + // TestOptionalAuth_WithToken tests that OptionalAuth accepts valid DPoP tokens 217 300 func TestOptionalAuth_WithToken(t *testing.T) { 218 301 fetcher := &mockJWKSFetcher{} 219 302 middleware := NewAtProtoAuthMiddleware(fetcher, true) ··· 233 316 234 317 token := createTestToken("did:plc:test123") 235 318 req := httptest.NewRequest("GET", "/test", nil) 236 - req.Header.Set("Authorization", "Bearer "+token) 319 + req.Header.Set("Authorization", "DPoP "+token) 237 320 w := httptest.NewRecorder() 238 321 239 322 handler.ServeHTTP(w, req) ··· 299 382 })) 300 383 301 384 req := httptest.NewRequest("GET", "/test", nil) 302 - req.Header.Set("Authorization", "Bearer not-a-valid-jwt") 385 + req.Header.Set("Authorization", "DPoP not-a-valid-jwt") 303 386 w := httptest.NewRecorder() 304 387 305 388 handler.ServeHTTP(w, req) ··· 393 476 })) 394 477 395 478 req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil) 396 - req.Header.Set("Authorization", "Bearer "+tokenString) 479 + req.Header.Set("Authorization", "DPoP "+tokenString) 397 480 req.Header.Set("DPoP", dpopProof) 398 481 w := httptest.NewRecorder() 399 482 ··· 437 520 })) 438 521 439 522 req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil) 440 - req.Header.Set("Authorization", "Bearer "+tokenString) 523 + req.Header.Set("Authorization", "DPoP "+tokenString) 441 524 // No DPoP header 442 525 w := httptest.NewRecorder() 443 526 ··· 488 571 })) 489 572 490 573 req := httptest.NewRequest("POST", "https://api.example.com/protected", nil) 491 - req.Header.Set("Authorization", "Bearer "+tokenString) 574 + req.Header.Set("Authorization", "DPoP "+tokenString) 492 575 req.Header.Set("DPoP", dpopProof) 493 576 w := httptest.NewRecorder() 494 577 ··· 537 620 req.Host = "api.example.com" 538 621 req.Header.Set("X-Forwarded-Proto", "https") 539 622 540 - proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof) 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) 541 626 if err != nil { 542 627 t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err) 543 628 } ··· 547 632 } 548 633 } 549 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 + 550 853 // TestMiddlewareStop tests that the middleware can be stopped properly 551 854 func TestMiddlewareStop(t *testing.T) { 552 855 fetcher := &mockJWKSFetcher{} ··· 607 910 })) 608 911 609 912 req := httptest.NewRequest("GET", "/test", nil) 610 - req.Header.Set("Authorization", "Bearer "+tokenString) 913 + req.Header.Set("Authorization", "DPoP "+tokenString) 611 914 // Deliberately NOT setting DPoP header 612 915 w := httptest.NewRecorder() 613 916 ··· 639 942 })) 640 943 641 944 req := httptest.NewRequest("GET", "/test", nil) 642 - req.Header.Set("Authorization", "Bearer "+tokenString) 945 + req.Header.Set("Authorization", "DPoP "+tokenString) 643 946 // No DPoP header 644 947 w := httptest.NewRecorder() 645 948 ··· 709 1012 return signedToken 710 1013 } 711 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 + 712 1049 // Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map 713 1050 func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} { 714 1051 // Get curve name ··· 742 1079 "y": base64.RawURLEncoding.EncodeToString(yPadded), 743 1080 } 744 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 1 # atProto OAuth Authentication 2 2 3 - This package implements third-party OAuth authentication for Coves, validating JWT Bearer tokens from mobile apps and other atProto clients. 3 + This package implements third-party OAuth authentication for Coves, validating DPoP-bound access tokens from mobile apps and other atProto clients. 4 4 5 5 ## Architecture 6 6 ··· 17 17 ``` 18 18 Client Request 19 19 20 - Authorization: Bearer <jwt> 20 + Authorization: DPoP <access_token> 21 + DPoP: <proof-jwt> 21 22 22 23 Auth Middleware 23 24 24 - Extract JWT → Parse Claims → Verify Signature (via JWKS) 25 + Extract JWT → Parse Claims → Verify Signature (via JWKS) → Verify DPoP Proof 25 26 26 27 Inject DID into Context → Call Handler 27 28 ``` ··· 71 72 72 73 ```bash 73 74 curl -X POST https://coves.social/xrpc/social.coves.community.create \ 74 - -H "Authorization: Bearer eyJhbGc..." \ 75 + -H "Authorization: DPoP eyJhbGc..." \ 76 + -H "DPoP: eyJhbGc..." \ 75 77 -H "Content-Type: application/json" \ 76 78 -d '{"name":"Gaming","hostedByDid":"did:plc:..."}' 77 79 ``` ··· 141 143 │ │ │ (Coves) │ 142 144 └─────────────┘ └─────────────┘ 143 145 │ │ 144 - │ 1. Authorization: Bearer <token> │ 146 + │ 1. Authorization: DPoP <token> │ 145 147 │ DPoP: <proof-jwt> │ 146 148 │───────────────────────────────────────>│ 147 149 │ │ ··· 276 278 # Create a test JWT (use jwt.io or a tool) 277 279 export AUTH_SKIP_VERIFY=true 278 280 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \ 279 - -H "Authorization: Bearer <test-jwt>" \ 281 + -H "Authorization: DPoP <test-jwt>" \ 282 + -H "DPoP: <test-dpop-proof>" \ 280 283 -d '{"name":"Test","hostedByDid":"did:plc:test"}' 281 284 ``` 282 285 ··· 285 288 # Use a real JWT from a PDS 286 289 export AUTH_SKIP_VERIFY=false 287 290 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \ 288 - -H "Authorization: Bearer <real-jwt>" \ 291 + -H "Authorization: DPoP <real-jwt>" \ 292 + -H "DPoP: <real-dpop-proof>" \ 289 293 -d '{"name":"Test","hostedByDid":"did:plc:test"}' 290 294 ``` 291 295 ··· 311 315 312 316 ### Common Issues 313 317 314 - 1. **Missing Authorization header** → Add `Authorization: Bearer <token>` 318 + 1. **Missing Authorization header** → Add `Authorization: DPoP <token>` and `DPoP: <proof>` 315 319 2. **Token expired** → Get a new token from PDS 316 320 3. **Invalid signature** → Ensure token is from a valid PDS 317 321 4. **JWKS fetch fails** → Check PDS availability and network connectivity
+21
internal/atproto/auth/dpop.go
··· 314 314 return nil 315 315 } 316 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 + 317 338 // CalculateJWKThumbprint calculates the JWK thumbprint per RFC 7638 318 339 // The thumbprint is the base64url-encoded SHA-256 hash of the canonical JWK representation 319 340 func CalculateJWKThumbprint(jwk map[string]interface{}) (string, error) {
+1 -1
scripts/aggregator-setup/README.md
··· 175 175 176 176 ```bash 177 177 curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 178 - -H "Authorization: Bearer $AGGREGATOR_ACCESS_JWT" \ 178 + -H "Authorization: DPoP $AGGREGATOR_ACCESS_JWT" \ 179 179 -H "Content-Type: application/json" \ 180 180 -d '{ 181 181 "communityDid": "did:plc:...",
+7 -6
tests/integration/aggregator_e2e_test.go
··· 83 83 listForCommunityHandler := aggregator.NewListForCommunityHandler(aggregatorService) 84 84 createPostHandler := post.NewCreateHandler(postService) 85 85 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing 86 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 86 87 87 88 ctx := context.Background() 88 89 ··· 337 338 338 339 // Create JWT for aggregator (not a user) 339 340 aggregatorJWT := createSimpleTestJWT(aggregatorDID) 340 - req.Header.Set("Authorization", "Bearer "+aggregatorJWT) 341 + req.Header.Set("Authorization", "DPoP "+aggregatorJWT) 341 342 342 343 // Execute request through auth middleware + handler 343 344 rr := httptest.NewRecorder() ··· 424 425 425 426 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 426 427 req.Header.Set("Content-Type", "application/json") 427 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 428 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 428 429 429 430 rr := httptest.NewRecorder() 430 431 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 446 447 447 448 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 448 449 req.Header.Set("Content-Type", "application/json") 449 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 450 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 450 451 451 452 rr := httptest.NewRecorder() 452 453 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 467 468 468 469 req = httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 469 470 req.Header.Set("Content-Type", "application/json") 470 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 471 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 471 472 472 473 rr = httptest.NewRecorder() 473 474 handler = authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 659 660 660 661 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 661 662 req.Header.Set("Content-Type", "application/json") 662 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(unauthorizedAggDID)) 663 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(unauthorizedAggDID)) 663 664 664 665 rr := httptest.NewRecorder() 665 666 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate)) ··· 783 784 784 785 req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 785 786 req.Header.Set("Content-Type", "application/json") 786 - req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 787 + req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID)) 787 788 788 789 rr := httptest.NewRecorder() 789 790 handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
+1
tests/integration/blob_upload_e2e_test.go
··· 466 466 assert.Equal(t, "POST", r.Method, "Should be POST request") 467 467 assert.Equal(t, "/xrpc/com.atproto.repo.uploadBlob", r.URL.Path, "Should hit uploadBlob endpoint") 468 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) 469 470 assert.Contains(t, r.Header.Get("Authorization"), "Bearer ", "Should have auth header") 470 471 471 472 // Return mock blob reference
+13 -8
tests/integration/community_e2e_test.go
··· 108 108 109 109 t.Logf("✅ Authenticated - Instance DID: %s", instanceDID) 110 110 111 - // Initialize auth middleware (skipVerify=true for E2E tests) 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. 112 116 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) 117 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 113 118 114 119 // V2.0: Extract instance domain for community provisioning 115 120 var instanceDomain string ··· 383 388 } 384 389 req.Header.Set("Content-Type", "application/json") 385 390 // Use real PDS access token for E2E authentication 386 - req.Header.Set("Authorization", "Bearer "+accessToken) 391 + req.Header.Set("Authorization", "DPoP "+accessToken) 387 392 388 393 resp, err := http.DefaultClient.Do(req) 389 394 if err != nil { ··· 769 774 } 770 775 req.Header.Set("Content-Type", "application/json") 771 776 // Use real PDS access token for E2E authentication 772 - req.Header.Set("Authorization", "Bearer "+accessToken) 777 + req.Header.Set("Authorization", "DPoP "+accessToken) 773 778 774 779 resp, err := http.DefaultClient.Do(req) 775 780 if err != nil { ··· 1004 1009 } 1005 1010 req.Header.Set("Content-Type", "application/json") 1006 1011 // Use real PDS access token for E2E authentication 1007 - req.Header.Set("Authorization", "Bearer "+accessToken) 1012 + req.Header.Set("Authorization", "DPoP "+accessToken) 1008 1013 1009 1014 resp, err := http.DefaultClient.Do(req) 1010 1015 if err != nil { ··· 1136 1141 t.Fatalf("Failed to create block request: %v", err) 1137 1142 } 1138 1143 req.Header.Set("Content-Type", "application/json") 1139 - req.Header.Set("Authorization", "Bearer "+accessToken) 1144 + req.Header.Set("Authorization", "DPoP "+accessToken) 1140 1145 1141 1146 resp, err := http.DefaultClient.Do(req) 1142 1147 if err != nil { ··· 1256 1261 t.Fatalf("Failed to create block request: %v", err) 1257 1262 } 1258 1263 blockHttpReq.Header.Set("Content-Type", "application/json") 1259 - blockHttpReq.Header.Set("Authorization", "Bearer "+accessToken) 1264 + blockHttpReq.Header.Set("Authorization", "DPoP "+accessToken) 1260 1265 1261 1266 blockResp, err := http.DefaultClient.Do(blockHttpReq) 1262 1267 if err != nil { ··· 1316 1321 t.Fatalf("Failed to create unblock request: %v", err) 1317 1322 } 1318 1323 req.Header.Set("Content-Type", "application/json") 1319 - req.Header.Set("Authorization", "Bearer "+accessToken) 1324 + req.Header.Set("Authorization", "DPoP "+accessToken) 1320 1325 1321 1326 resp, err := http.DefaultClient.Do(req) 1322 1327 if err != nil { ··· 1473 1478 } 1474 1479 req.Header.Set("Content-Type", "application/json") 1475 1480 // Use real PDS access token for E2E authentication 1476 - req.Header.Set("Authorization", "Bearer "+accessToken) 1481 + req.Header.Set("Authorization", "DPoP "+accessToken) 1477 1482 1478 1483 resp, err := http.DefaultClient.Do(req) 1479 1484 if err != nil {
+4 -2
tests/integration/jwt_verification_test.go
··· 104 104 t.Log("Testing auth middleware with skipVerify=true (dev mode)...") 105 105 106 106 authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) // skipVerify=true for dev PDS 107 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 107 108 108 109 handlerCalled := false 109 110 var extractedDID string ··· 116 117 })) 117 118 118 119 req := httptest.NewRequest("GET", "/test", nil) 119 - req.Header.Set("Authorization", "Bearer "+accessToken) 120 + req.Header.Set("Authorization", "DPoP "+accessToken) 120 121 w := httptest.NewRecorder() 121 122 122 123 testHandler.ServeHTTP(w, req) ··· 166 167 // Tampered payload should fail JWT parsing even without signature check 167 168 jwksFetcher := auth.NewCachedJWKSFetcher(1 * time.Hour) 168 169 authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) 170 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 169 171 170 172 handlerCalled := false 171 173 testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ··· 174 176 })) 175 177 176 178 req := httptest.NewRequest("GET", "/test", nil) 177 - req.Header.Set("Authorization", "Bearer "+tamperedToken) 179 + req.Header.Set("Authorization", "DPoP "+tamperedToken) 178 180 w := httptest.NewRecorder() 179 181 180 182 testHandler.ServeHTTP(w, req)
+2 -1
tests/integration/post_e2e_test.go
··· 407 407 408 408 // Setup auth middleware (skip JWT verification for testing) 409 409 authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) 410 + defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine 410 411 411 412 // Setup HTTP handler 412 413 createHandler := post.NewCreateHandler(postService) ··· 478 479 // Create a simple JWT for testing (Phase 1: no signature verification) 479 480 // In production, this would be a real OAuth token from PDS 480 481 testJWT := createSimpleTestJWT(author.DID) 481 - req.Header.Set("Authorization", "Bearer "+testJWT) 482 + req.Header.Set("Authorization", "DPoP "+testJWT) 482 483 483 484 // Execute request through auth middleware + handler 484 485 rr := httptest.NewRecorder()
+54 -36
tests/integration/user_journey_e2e_test.go
··· 116 116 userService := users.NewUserService(userRepo, identityResolver, pdsURL) 117 117 118 118 // Extract instance domain and DID 119 + // IMPORTANT: Instance domain must match PDS_SERVICE_HANDLE_DOMAINS config (.community.coves.social) 119 120 instanceDID := os.Getenv("INSTANCE_DID") 120 121 if instanceDID == "" { 121 - instanceDID = "did:web:test.coves.social" 122 + instanceDID = "did:web:coves.social" // Must match PDS handle domain config 122 123 } 123 124 var instanceDomain string 124 125 if strings.HasPrefix(instanceDID, "did:web:") { ··· 139 140 voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) 140 141 141 142 // Setup HTTP server with all routes 142 - authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing 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 143 148 r := chi.NewRouter() 144 149 routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators 145 150 routes.RegisterPostRoutes(r, postService, authMiddleware) ··· 149 154 150 155 // Cleanup test data from previous runs (clean up ALL journey test data) 151 156 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-%'") 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'") 159 165 160 166 // Defer cleanup for current test run using specific timestamp pattern 161 167 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) 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) 169 178 }() 170 179 171 180 // Test variables to track state across steps ··· 190 199 t.Run("1. User A - Signup and Authenticate", func(t *testing.T) { 191 200 t.Log("\n👤 Part 1: User A creates account and authenticates...") 192 201 193 - userAHandle = fmt.Sprintf("alice-journey-%d.local.coves.dev", timestamp) 194 - email := fmt.Sprintf("alice-journey-%d@test.com", timestamp) 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) 195 206 password := "test-password-alice-123" 196 207 197 208 // Create account on PDS ··· 215 226 t.Run("2. User A - Create Community", func(t *testing.T) { 216 227 t.Log("\n🏘️ Part 2: User A creates a community...") 217 228 218 - communityName := fmt.Sprintf("gaming-journey-%d", timestamp%10000) // Keep name short 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 219 233 220 234 createReq := map[string]interface{}{ 221 235 "name": communityName, ··· 230 244 httpServer.URL+"/xrpc/social.coves.community.create", 231 245 bytes.NewBuffer(reqBody)) 232 246 req.Header.Set("Content-Type", "application/json") 233 - req.Header.Set("Authorization", "Bearer "+userAToken) 247 + req.Header.Set("Authorization", "DPoP "+userAToken) 234 248 235 249 resp, err := http.DefaultClient.Do(req) 236 250 require.NoError(t, err) ··· 314 328 httpServer.URL+"/xrpc/social.coves.community.post.create", 315 329 bytes.NewBuffer(reqBody)) 316 330 req.Header.Set("Content-Type", "application/json") 317 - req.Header.Set("Authorization", "Bearer "+userAToken) 331 + req.Header.Set("Authorization", "DPoP "+userAToken) 318 332 319 333 resp, err := http.DefaultClient.Do(req) 320 334 require.NoError(t, err) ··· 381 395 t.Run("4. User B - Signup and Authenticate", func(t *testing.T) { 382 396 t.Log("\n👤 Part 4: User B creates account and authenticates...") 383 397 384 - userBHandle = fmt.Sprintf("bob-journey-%d.local.coves.dev", timestamp) 385 - email := fmt.Sprintf("bob-journey-%d@test.com", timestamp) 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) 386 402 password := "test-password-bob-123" 387 403 388 404 // Create account on PDS ··· 421 437 httpServer.URL+"/xrpc/social.coves.community.subscribe", 422 438 bytes.NewBuffer(reqBody)) 423 439 req.Header.Set("Content-Type", "application/json") 424 - req.Header.Set("Authorization", "Bearer "+userBToken) 440 + req.Header.Set("Authorization", "DPoP "+userBToken) 425 441 426 442 resp, err := http.DefaultClient.Do(req) 427 443 require.NoError(t, err) ··· 653 669 t.Run("9. User B - Verify Timeline Feed Shows Subscribed Community Posts", func(t *testing.T) { 654 670 t.Log("\n📰 Part 9: User B checks timeline feed...") 655 671 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() 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) 660 676 661 - // Call timeline handler directly 662 - timelineHandler := httpServer.Config.Handler 663 - timelineHandler.ServeHTTP(rec, req) 677 + resp, err := http.DefaultClient.Do(req) 678 + require.NoError(t, err) 679 + defer func() { _ = resp.Body.Close() }() 664 680 665 - require.Equal(t, http.StatusOK, rec.Code, "Timeline request should succeed") 681 + require.Equal(t, http.StatusOK, resp.StatusCode, "Timeline request should succeed") 666 682 667 683 var response timelineCore.TimelineResponse 668 - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) 684 + require.NoError(t, json.NewDecoder(resp.Body).Decode(&response)) 669 685 670 686 // User B should see the post from the community they subscribed to 671 687 require.NotEmpty(t, response.Feed, "Timeline should contain posts") ··· 679 695 "Post author should be User A") 680 696 assert.Equal(t, communityDID, feedPost.Post.Community.DID, 681 697 "Post community should match") 682 - assert.Equal(t, 1, feedPost.Post.UpvoteCount, 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, 683 701 "Post should show 1 upvote from User B") 684 - assert.Equal(t, 1, feedPost.Post.CommentCount, 702 + assert.Equal(t, 1, feedPost.Post.Stats.CommentCount, 685 703 "Post should show 1 comment from User B") 686 704 break 687 705 } ··· 788 806 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) 789 807 ON CONFLICT (did) DO NOTHING 790 808 `, did, handle, strings.Split(handle, ".")[0], "Test Community", did, ownerDID, 791 - "did:web:test.coves.social", "public", "moderator", 809 + "did:web:coves.social", "public", "moderator", 792 810 fmt.Sprintf("at://%s/social.coves.community.profile/self", did), "fakecid") 793 811 794 812 require.NoError(t, err, "Failed to simulate community indexing")