A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/api/handlers/oauth" 5 "Coves/internal/atproto/identity" 6 "bytes" 7 "context" 8 "encoding/json" 9 "net/http" 10 "net/http/httptest" 11 "os" 12 "testing" 13 14 oauthCore "Coves/internal/core/oauth" 15 16 "github.com/lestrrat-go/jwx/v2/jwk" 17) 18 19// TestOAuthClientMetadata tests the /oauth/client-metadata.json endpoint 20func TestOAuthClientMetadata(t *testing.T) { 21 tests := []struct { 22 name string 23 appviewURL string 24 expectedClientID string 25 expectedJWKSURI string 26 expectedRedirect string 27 }{ 28 { 29 name: "localhost development", 30 appviewURL: "http://localhost:8081", 31 expectedClientID: "http://localhost?redirect_uri=http://localhost:8081/oauth/callback&scope=atproto%20transition:generic", 32 expectedJWKSURI: "", // No JWKS URI for localhost 33 expectedRedirect: "http://localhost:8081/oauth/callback", 34 }, 35 { 36 name: "production HTTPS", 37 appviewURL: "https://coves.social", 38 expectedClientID: "https://coves.social/oauth/client-metadata.json", 39 expectedJWKSURI: "https://coves.social/oauth/jwks.json", 40 expectedRedirect: "https://coves.social/oauth/callback", 41 }, 42 } 43 44 for _, tt := range tests { 45 t.Run(tt.name, func(t *testing.T) { 46 // Set environment 47 if err := os.Setenv("APPVIEW_PUBLIC_URL", tt.appviewURL); err != nil { 48 t.Fatalf("Failed to set APPVIEW_PUBLIC_URL: %v", err) 49 } 50 defer func() { 51 if err := os.Unsetenv("APPVIEW_PUBLIC_URL"); err != nil { 52 t.Logf("Failed to unset APPVIEW_PUBLIC_URL: %v", err) 53 } 54 }() 55 56 // Create request 57 req := httptest.NewRequest("GET", "/oauth/client-metadata.json", nil) 58 w := httptest.NewRecorder() 59 60 // Call handler 61 oauth.HandleClientMetadata(w, req) 62 63 // Check status code 64 if w.Code != http.StatusOK { 65 t.Fatalf("expected status 200, got %d", w.Code) 66 } 67 68 // Parse response 69 var metadata oauth.ClientMetadata 70 if err := json.NewDecoder(w.Body).Decode(&metadata); err != nil { 71 t.Fatalf("failed to decode response: %v", err) 72 } 73 74 // Verify client ID 75 if metadata.ClientID != tt.expectedClientID { 76 t.Errorf("expected client_id %q, got %q", tt.expectedClientID, metadata.ClientID) 77 } 78 79 // Verify JWKS URI 80 if metadata.JwksURI != tt.expectedJWKSURI { 81 t.Errorf("expected jwks_uri %q, got %q", tt.expectedJWKSURI, metadata.JwksURI) 82 } 83 84 // Verify redirect URI 85 if len(metadata.RedirectURIs) != 1 || metadata.RedirectURIs[0] != tt.expectedRedirect { 86 t.Errorf("expected redirect_uris [%q], got %v", tt.expectedRedirect, metadata.RedirectURIs) 87 } 88 89 // Verify OAuth spec compliance 90 if metadata.ClientName != "Coves" { 91 t.Errorf("expected client_name 'Coves', got %q", metadata.ClientName) 92 } 93 if metadata.TokenEndpointAuthMethod != "private_key_jwt" { 94 t.Errorf("expected token_endpoint_auth_method 'private_key_jwt', got %q", metadata.TokenEndpointAuthMethod) 95 } 96 if metadata.TokenEndpointAuthSigningAlg != "ES256" { 97 t.Errorf("expected token_endpoint_auth_signing_alg 'ES256', got %q", metadata.TokenEndpointAuthSigningAlg) 98 } 99 if !metadata.DpopBoundAccessTokens { 100 t.Error("expected dpop_bound_access_tokens to be true") 101 } 102 }) 103 } 104} 105 106// TestOAuthJWKS tests the /oauth/jwks.json endpoint 107func TestOAuthJWKS(t *testing.T) { 108 // Use the test JWK from .env.dev 109 testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}` 110 111 tests := []struct { 112 name string 113 envValue string 114 expectSuccess bool 115 }{ 116 { 117 name: "valid plain JWK", 118 envValue: testJWK, 119 expectSuccess: true, 120 }, 121 { 122 name: "missing JWK", 123 envValue: "", 124 expectSuccess: false, 125 }, 126 } 127 128 for _, tt := range tests { 129 t.Run(tt.name, func(t *testing.T) { 130 // Set environment 131 if tt.envValue != "" { 132 if err := os.Setenv("OAUTH_PRIVATE_JWK", tt.envValue); err != nil { 133 t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err) 134 } 135 defer func() { 136 if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil { 137 t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err) 138 } 139 }() 140 } 141 142 // Create request 143 req := httptest.NewRequest("GET", "/oauth/jwks.json", nil) 144 w := httptest.NewRecorder() 145 146 // Call handler 147 oauth.HandleJWKS(w, req) 148 149 // Check status code 150 if tt.expectSuccess { 151 if w.Code != http.StatusOK { 152 t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) 153 } 154 155 // Parse response 156 var jwksResp struct { 157 Keys []map[string]interface{} `json:"keys"` 158 } 159 if err := json.NewDecoder(w.Body).Decode(&jwksResp); err != nil { 160 t.Fatalf("failed to decode JWKS: %v", err) 161 } 162 163 // Verify we got a public key 164 if len(jwksResp.Keys) != 1 { 165 t.Fatalf("expected 1 key, got %d", len(jwksResp.Keys)) 166 } 167 168 key := jwksResp.Keys[0] 169 if key["kty"] != "EC" { 170 t.Errorf("expected kty 'EC', got %v", key["kty"]) 171 } 172 if key["alg"] != "ES256" { 173 t.Errorf("expected alg 'ES256', got %v", key["alg"]) 174 } 175 if key["kid"] != "oauth-client-key" { 176 t.Errorf("expected kid 'oauth-client-key', got %v", key["kid"]) 177 } 178 179 // Verify private key is NOT exposed 180 if _, hasPrivate := key["d"]; hasPrivate { 181 t.Error("SECURITY: private key 'd' should not be in JWKS!") 182 } 183 184 } else { 185 if w.Code == http.StatusOK { 186 t.Fatalf("expected error status, got 200") 187 } 188 } 189 }) 190 } 191} 192 193// TestOAuthLoginHandler tests the OAuth login initiation 194func TestOAuthLoginHandler(t *testing.T) { 195 // Skip if running in CI without database 196 if os.Getenv("SKIP_INTEGRATION") == "true" { 197 t.Skip("Skipping integration test") 198 } 199 200 // Setup test database 201 db := setupTestDB(t) 202 defer func() { 203 if err := db.Close(); err != nil { 204 t.Logf("Failed to close database: %v", err) 205 } 206 }() 207 208 // Create session store 209 sessionStore := oauthCore.NewPostgresSessionStore(db) 210 211 // Create identity resolver (mock for now - we'll test with real PDS separately) 212 // For now, just test the handler structure and validation 213 214 tests := []struct { 215 name string 216 requestBody map[string]interface{} 217 envJWK string 218 expectedStatus int 219 }{ 220 { 221 name: "missing handle", 222 requestBody: map[string]interface{}{ 223 "handle": "", 224 }, 225 envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`, 226 expectedStatus: http.StatusBadRequest, 227 }, 228 { 229 name: "invalid handle format", 230 requestBody: map[string]interface{}{ 231 "handle": "no-dots-invalid", 232 }, 233 envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`, 234 expectedStatus: http.StatusBadRequest, 235 }, 236 { 237 name: "missing OAuth JWK", 238 requestBody: map[string]interface{}{ 239 "handle": "alice.bsky.social", 240 }, 241 envJWK: "", 242 expectedStatus: http.StatusInternalServerError, 243 }, 244 } 245 246 for _, tt := range tests { 247 t.Run(tt.name, func(t *testing.T) { 248 // Set environment 249 if tt.envJWK != "" { 250 if err := os.Setenv("OAUTH_PRIVATE_JWK", tt.envJWK); err != nil { 251 t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err) 252 } 253 defer func() { 254 if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil { 255 t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err) 256 } 257 }() 258 } else { 259 if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil { 260 t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err) 261 } 262 } 263 264 // Create mock identity resolver for validation tests 265 mockResolver := &mockIdentityResolver{} 266 267 // Create handler 268 handler := oauth.NewLoginHandler(mockResolver, sessionStore) 269 270 // Create request 271 bodyBytes, marshalErr := json.Marshal(tt.requestBody) 272 if marshalErr != nil { 273 t.Fatalf("Failed to marshal request body: %v", marshalErr) 274 } 275 req := httptest.NewRequest("POST", "/oauth/login", bytes.NewReader(bodyBytes)) 276 req.Header.Set("Content-Type", "application/json") 277 w := httptest.NewRecorder() 278 279 // Call handler 280 handler.HandleLogin(w, req) 281 282 // Check status code 283 if w.Code != tt.expectedStatus { 284 t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String()) 285 } 286 }) 287 } 288} 289 290// TestOAuthCallbackHandler tests the OAuth callback handling 291func TestOAuthCallbackHandler(t *testing.T) { 292 // Skip if running in CI without database 293 if os.Getenv("SKIP_INTEGRATION") == "true" { 294 t.Skip("Skipping integration test") 295 } 296 297 // Setup test database 298 db := setupTestDB(t) 299 defer func() { 300 if err := db.Close(); err != nil { 301 t.Logf("Failed to close database: %v", err) 302 } 303 }() 304 305 // Create session store 306 sessionStore := oauthCore.NewPostgresSessionStore(db) 307 308 testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}` 309 310 tests := []struct { 311 queryParams map[string]string 312 name string 313 expectedStatus int 314 }{ 315 { 316 name: "missing code", 317 queryParams: map[string]string{ 318 "state": "test-state", 319 "iss": "https://bsky.social", 320 }, 321 expectedStatus: http.StatusBadRequest, 322 }, 323 { 324 name: "missing state", 325 queryParams: map[string]string{ 326 "code": "test-code", 327 "iss": "https://bsky.social", 328 }, 329 expectedStatus: http.StatusBadRequest, 330 }, 331 { 332 name: "missing issuer", 333 queryParams: map[string]string{ 334 "code": "test-code", 335 "state": "test-state", 336 }, 337 expectedStatus: http.StatusBadRequest, 338 }, 339 { 340 name: "OAuth error parameter", 341 queryParams: map[string]string{ 342 "error": "access_denied", 343 "error_description": "User denied access", 344 }, 345 expectedStatus: http.StatusBadRequest, 346 }, 347 } 348 349 for _, tt := range tests { 350 t.Run(tt.name, func(t *testing.T) { 351 // Set environment 352 if err := os.Setenv("OAUTH_PRIVATE_JWK", testJWK); err != nil { 353 t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err) 354 } 355 defer func() { 356 if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil { 357 t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err) 358 } 359 }() 360 361 // Create handler 362 handler := oauth.NewCallbackHandler(sessionStore) 363 364 // Build query string 365 req := httptest.NewRequest("GET", "/oauth/callback", nil) 366 q := req.URL.Query() 367 for k, v := range tt.queryParams { 368 q.Add(k, v) 369 } 370 req.URL.RawQuery = q.Encode() 371 372 w := httptest.NewRecorder() 373 374 // Call handler 375 handler.HandleCallback(w, req) 376 377 // Check status code 378 if w.Code != tt.expectedStatus { 379 t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String()) 380 } 381 }) 382 } 383} 384 385// mockIdentityResolver is a mock for testing 386type mockIdentityResolver struct{} 387 388func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 389 // Return a mock resolved identity 390 return &identity.Identity{ 391 DID: "did:plc:test123", 392 Handle: identifier, 393 PDSURL: "https://test.pds.example", 394 }, nil 395} 396 397func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) { 398 return "did:plc:test123", "https://test.pds.example", nil 399} 400 401func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 402 return &identity.DIDDocument{ 403 DID: did, 404 Service: []identity.Service{ 405 { 406 ID: "#atproto_pds", 407 Type: "AtprotoPersonalDataServer", 408 ServiceEndpoint: "https://test.pds.example", 409 }, 410 }, 411 }, nil 412} 413 414func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error { 415 return nil 416} 417 418// TestJWKParsing tests that we can parse JWKs correctly 419func TestJWKParsing(t *testing.T) { 420 testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}` 421 422 // Parse the JWK 423 key, err := jwk.ParseKey([]byte(testJWK)) 424 if err != nil { 425 t.Fatalf("failed to parse JWK: %v", err) 426 } 427 428 // Verify it's an EC key 429 if key.KeyType() != "EC" { 430 t.Errorf("expected key type 'EC', got %v", key.KeyType()) 431 } 432 433 // Verify we can get the public key 434 pubKey, err := key.PublicKey() 435 if err != nil { 436 t.Fatalf("failed to get public key: %v", err) 437 } 438 439 // Verify public key doesn't have private component 440 pubKeyJSON, marshalErr := json.Marshal(pubKey) 441 if marshalErr != nil { 442 t.Fatalf("failed to marshal public key: %v", marshalErr) 443 } 444 var pubKeyMap map[string]interface{} 445 if unmarshalErr := json.Unmarshal(pubKeyJSON, &pubKeyMap); unmarshalErr != nil { 446 t.Fatalf("failed to unmarshal public key: %v", unmarshalErr) 447 } 448 449 if _, hasPrivate := pubKeyMap["d"]; hasPrivate { 450 t.Error("SECURITY: public key should not contain private 'd' component!") 451 } 452}