A community based topic aggregation platform built on atproto
1package oauth 2 3import ( 4 "net/http" 5 "net/http/httptest" 6 "testing" 7 8 "github.com/stretchr/testify/assert" 9 "github.com/stretchr/testify/require" 10) 11 12// TestIsAllowedMobileRedirectURI tests the mobile redirect URI allowlist with EXACT URI matching 13// Only Universal Links (HTTPS) are allowed - custom schemes are blocked for security 14func TestIsAllowedMobileRedirectURI(t *testing.T) { 15 tests := []struct { 16 name string 17 uri string 18 expected bool 19 }{ 20 { 21 name: "allowed - Universal Link", 22 uri: "https://coves.social/app/oauth/callback", 23 expected: true, 24 }, 25 { 26 name: "rejected - custom scheme coves-app (vulnerable to interception)", 27 uri: "coves-app://oauth/callback", 28 expected: false, 29 }, 30 { 31 name: "rejected - custom scheme coves (vulnerable to interception)", 32 uri: "coves://oauth/callback", 33 expected: false, 34 }, 35 { 36 name: "rejected - evil scheme", 37 uri: "evil://callback", 38 expected: false, 39 }, 40 { 41 name: "rejected - http (not secure)", 42 uri: "http://example.com/callback", 43 expected: false, 44 }, 45 { 46 name: "rejected - https different domain", 47 uri: "https://example.com/callback", 48 expected: false, 49 }, 50 { 51 name: "rejected - https coves.social wrong path", 52 uri: "https://coves.social/wrong/path", 53 expected: false, 54 }, 55 { 56 name: "rejected - invalid URI", 57 uri: "not a uri", 58 expected: false, 59 }, 60 { 61 name: "rejected - empty string", 62 uri: "", 63 expected: false, 64 }, 65 } 66 67 for _, tt := range tests { 68 t.Run(tt.name, func(t *testing.T) { 69 result := isAllowedMobileRedirectURI(tt.uri) 70 assert.Equal(t, tt.expected, result, 71 "isAllowedMobileRedirectURI(%q) = %v, want %v", tt.uri, result, tt.expected) 72 }) 73 } 74} 75 76// TestExtractScheme tests the scheme extraction function 77func TestExtractScheme(t *testing.T) { 78 tests := []struct { 79 name string 80 uri string 81 expected string 82 }{ 83 { 84 name: "https scheme", 85 uri: "https://coves.social/app/oauth/callback", 86 expected: "https", 87 }, 88 { 89 name: "custom scheme", 90 uri: "coves-app://callback", 91 expected: "coves-app", 92 }, 93 { 94 name: "invalid URI", 95 uri: "not a uri", 96 expected: "invalid", 97 }, 98 } 99 100 for _, tt := range tests { 101 t.Run(tt.name, func(t *testing.T) { 102 result := extractScheme(tt.uri) 103 assert.Equal(t, tt.expected, result) 104 }) 105 } 106} 107 108// TestGenerateCSRFToken tests CSRF token generation 109func TestGenerateCSRFToken(t *testing.T) { 110 // Generate two tokens and verify they are different (randomness check) 111 token1, err1 := generateCSRFToken() 112 require.NoError(t, err1) 113 require.NotEmpty(t, token1) 114 115 token2, err2 := generateCSRFToken() 116 require.NoError(t, err2) 117 require.NotEmpty(t, token2) 118 119 assert.NotEqual(t, token1, token2, "CSRF tokens should be unique") 120 121 // Verify token is base64 encoded (should decode without error) 122 assert.Greater(t, len(token1), 40, "CSRF token should be reasonably long (32 bytes base64 encoded)") 123} 124 125// TestHandleMobileLogin_RedirectURIValidation tests that HandleMobileLogin validates redirect URIs 126func TestHandleMobileLogin_RedirectURIValidation(t *testing.T) { 127 // Note: This is a unit test for the validation logic only. 128 // Full integration tests with OAuth flow are in tests/integration/oauth_e2e_test.go 129 130 tests := []struct { 131 name string 132 redirectURI string 133 expectedLog string 134 expectedStatus int 135 }{ 136 { 137 name: "allowed - Universal Link", 138 redirectURI: "https://coves.social/app/oauth/callback", 139 expectedStatus: http.StatusBadRequest, // Will fail at StartAuthFlow (no OAuth client setup) 140 }, 141 { 142 name: "rejected - custom scheme coves-app (insecure)", 143 redirectURI: "coves-app://oauth/callback", 144 expectedStatus: http.StatusBadRequest, 145 expectedLog: "rejected unauthorized mobile redirect URI", 146 }, 147 { 148 name: "rejected evil scheme", 149 redirectURI: "evil://callback", 150 expectedStatus: http.StatusBadRequest, 151 expectedLog: "rejected unauthorized mobile redirect URI", 152 }, 153 { 154 name: "rejected http", 155 redirectURI: "http://evil.com/callback", 156 expectedStatus: http.StatusBadRequest, 157 expectedLog: "scheme not allowed", 158 }, 159 } 160 161 for _, tt := range tests { 162 t.Run(tt.name, func(t *testing.T) { 163 // Test the validation function directly 164 result := isAllowedMobileRedirectURI(tt.redirectURI) 165 if tt.expectedLog != "" { 166 assert.False(t, result, "Should reject %s", tt.redirectURI) 167 } 168 }) 169 } 170} 171 172// TestHandleCallback_CSRFValidation tests that HandleCallback validates CSRF tokens for mobile flow 173func TestHandleCallback_CSRFValidation(t *testing.T) { 174 // This is a conceptual test structure. Full implementation would require: 175 // 1. Mock OAuthClient 176 // 2. Mock OAuth store 177 // 3. Simulated OAuth callback with cookies 178 179 t.Run("mobile callback requires CSRF token", func(t *testing.T) { 180 // Setup: Create request with mobile_redirect_uri cookie but NO oauth_csrf cookie 181 req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test", nil) 182 req.AddCookie(&http.Cookie{ 183 Name: "mobile_redirect_uri", 184 Value: "https%3A%2F%2Fcoves.social%2Fapp%2Foauth%2Fcallback", 185 }) 186 // Missing: oauth_csrf cookie 187 188 // This would be rejected with 403 Forbidden in the actual handler 189 // (Full test in integration tests with real OAuth flow) 190 191 assert.NotNil(t, req) // Placeholder assertion 192 }) 193 194 t.Run("mobile callback with valid CSRF token", func(t *testing.T) { 195 // Setup: Create request with both cookies 196 req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test", nil) 197 req.AddCookie(&http.Cookie{ 198 Name: "mobile_redirect_uri", 199 Value: "https%3A%2F%2Fcoves.social%2Fapp%2Foauth%2Fcallback", 200 }) 201 req.AddCookie(&http.Cookie{ 202 Name: "oauth_csrf", 203 Value: "valid-csrf-token", 204 }) 205 206 // This would be accepted (assuming valid OAuth code/state) 207 // (Full test in integration tests with real OAuth flow) 208 209 assert.NotNil(t, req) // Placeholder assertion 210 }) 211} 212 213// TestHandleMobileCallback_RevalidatesRedirectURI tests that handleMobileCallback re-validates the redirect URI 214func TestHandleMobileCallback_RevalidatesRedirectURI(t *testing.T) { 215 // This is a critical security test: even if an attacker somehow bypasses the initial check, 216 // the callback handler should re-validate the redirect URI before redirecting. 217 218 tests := []struct { 219 name string 220 redirectURI string 221 shouldPass bool 222 }{ 223 { 224 name: "allowed - Universal Link", 225 redirectURI: "https://coves.social/app/oauth/callback", 226 shouldPass: true, 227 }, 228 { 229 name: "blocked - custom scheme (insecure)", 230 redirectURI: "coves-app://oauth/callback", 231 shouldPass: false, 232 }, 233 { 234 name: "blocked - evil scheme", 235 redirectURI: "evil://callback", 236 shouldPass: false, 237 }, 238 } 239 240 for _, tt := range tests { 241 t.Run(tt.name, func(t *testing.T) { 242 result := isAllowedMobileRedirectURI(tt.redirectURI) 243 assert.Equal(t, tt.shouldPass, result) 244 }) 245 } 246} 247 248// TestGenerateMobileRedirectBinding tests the binding token generation 249// The binding now includes the CSRF token for proper double-submit validation 250func TestGenerateMobileRedirectBinding(t *testing.T) { 251 csrfToken := "test-csrf-token-12345" 252 tests := []struct { 253 name string 254 redirectURI string 255 }{ 256 { 257 name: "Universal Link", 258 redirectURI: "https://coves.social/app/oauth/callback", 259 }, 260 { 261 name: "different path", 262 redirectURI: "https://coves.social/different/path", 263 }, 264 } 265 266 for _, tt := range tests { 267 t.Run(tt.name, func(t *testing.T) { 268 binding1 := generateMobileRedirectBinding(csrfToken, tt.redirectURI) 269 binding2 := generateMobileRedirectBinding(csrfToken, tt.redirectURI) 270 271 // Same CSRF token + URI should produce same binding (deterministic) 272 assert.Equal(t, binding1, binding2, "binding should be deterministic for same inputs") 273 274 // Binding should not be empty 275 assert.NotEmpty(t, binding1, "binding should not be empty") 276 277 // Binding should be base64 encoded (should decode without error) 278 assert.Greater(t, len(binding1), 20, "binding should be reasonably long") 279 }) 280 } 281 282 // Different URIs should produce different bindings 283 binding1 := generateMobileRedirectBinding(csrfToken, "https://coves.social/app/oauth/callback") 284 binding2 := generateMobileRedirectBinding(csrfToken, "https://coves.social/different/path") 285 assert.NotEqual(t, binding1, binding2, "different URIs should produce different bindings") 286 287 // Different CSRF tokens should produce different bindings 288 binding3 := generateMobileRedirectBinding("different-csrf-token", "https://coves.social/app/oauth/callback") 289 assert.NotEqual(t, binding1, binding3, "different CSRF tokens should produce different bindings") 290} 291 292// TestValidateMobileRedirectBinding tests the binding validation 293// Now validates both CSRF token and redirect URI together (double-submit pattern) 294func TestValidateMobileRedirectBinding(t *testing.T) { 295 csrfToken := "test-csrf-token-for-validation" 296 redirectURI := "https://coves.social/app/oauth/callback" 297 validBinding := generateMobileRedirectBinding(csrfToken, redirectURI) 298 299 tests := []struct { 300 name string 301 csrfToken string 302 redirectURI string 303 binding string 304 shouldPass bool 305 }{ 306 { 307 name: "valid - correct CSRF token and redirect URI", 308 csrfToken: csrfToken, 309 redirectURI: redirectURI, 310 binding: validBinding, 311 shouldPass: true, 312 }, 313 { 314 name: "invalid - wrong redirect URI", 315 csrfToken: csrfToken, 316 redirectURI: "https://coves.social/different/path", 317 binding: validBinding, 318 shouldPass: false, 319 }, 320 { 321 name: "invalid - wrong CSRF token", 322 csrfToken: "wrong-csrf-token", 323 redirectURI: redirectURI, 324 binding: validBinding, 325 shouldPass: false, 326 }, 327 { 328 name: "invalid - random binding", 329 csrfToken: csrfToken, 330 redirectURI: redirectURI, 331 binding: "random-invalid-binding", 332 shouldPass: false, 333 }, 334 { 335 name: "invalid - empty binding", 336 csrfToken: csrfToken, 337 redirectURI: redirectURI, 338 binding: "", 339 shouldPass: false, 340 }, 341 { 342 name: "invalid - empty CSRF token", 343 csrfToken: "", 344 redirectURI: redirectURI, 345 binding: validBinding, 346 shouldPass: false, 347 }, 348 } 349 350 for _, tt := range tests { 351 t.Run(tt.name, func(t *testing.T) { 352 result := validateMobileRedirectBinding(tt.csrfToken, tt.redirectURI, tt.binding) 353 assert.Equal(t, tt.shouldPass, result) 354 }) 355 } 356} 357 358// TestSessionFixationAttackPrevention tests that the binding prevents session fixation 359func TestSessionFixationAttackPrevention(t *testing.T) { 360 // Simulate attack scenario: 361 // 1. Attacker plants a cookie for evil://steal with binding for evil://steal 362 // 2. User does a web login (no mobile_redirect_binding cookie) 363 // 3. Callback should NOT redirect to evil://steal 364 365 attackerCSRF := "attacker-csrf-token" 366 attackerRedirectURI := "evil://steal" 367 attackerBinding := generateMobileRedirectBinding(attackerCSRF, attackerRedirectURI) 368 369 // Later, user's legitimate mobile login 370 userCSRF := "user-csrf-token" 371 userRedirectURI := "https://coves.social/app/oauth/callback" 372 userBinding := generateMobileRedirectBinding(userCSRF, userRedirectURI) 373 374 // The attacker's binding should NOT validate for the user's redirect URI 375 assert.False(t, validateMobileRedirectBinding(userCSRF, userRedirectURI, attackerBinding), 376 "attacker's binding should not validate for user's CSRF token and redirect URI") 377 378 // The user's binding should validate for the user's CSRF token and redirect URI 379 assert.True(t, validateMobileRedirectBinding(userCSRF, userRedirectURI, userBinding), 380 "user's binding should validate for user's CSRF token and redirect URI") 381 382 // Cross-validation should fail 383 assert.False(t, validateMobileRedirectBinding(attackerCSRF, attackerRedirectURI, userBinding), 384 "user's binding should not validate for attacker's CSRF token and redirect URI") 385} 386 387// TestCSRFTokenValidation tests that CSRF token VALUE is validated, not just presence 388func TestCSRFTokenValidation(t *testing.T) { 389 // This test verifies the fix for the P1 security issue: 390 // "The callback never validates the token... the csrfToken argument is ignored entirely" 391 // 392 // The fix ensures that the CSRF token VALUE is cryptographically bound to the 393 // binding token, so changing the CSRF token will invalidate the binding. 394 395 t.Run("CSRF token value must match", func(t *testing.T) { 396 originalCSRF := "original-csrf-token-from-login" 397 redirectURI := "https://coves.social/app/oauth/callback" 398 binding := generateMobileRedirectBinding(originalCSRF, redirectURI) 399 400 // Original CSRF token should validate 401 assert.True(t, validateMobileRedirectBinding(originalCSRF, redirectURI, binding), 402 "original CSRF token should validate") 403 404 // Different CSRF token should NOT validate (this is the key security fix) 405 differentCSRF := "attacker-forged-csrf-token" 406 assert.False(t, validateMobileRedirectBinding(differentCSRF, redirectURI, binding), 407 "different CSRF token should NOT validate - this is the security fix") 408 }) 409 410 t.Run("attacker cannot forge binding without CSRF token", func(t *testing.T) { 411 // Attacker knows the redirect URI but not the CSRF token 412 redirectURI := "https://coves.social/app/oauth/callback" 413 victimCSRF := "victim-secret-csrf-token" 414 victimBinding := generateMobileRedirectBinding(victimCSRF, redirectURI) 415 416 // Attacker tries various CSRF tokens to forge the binding 417 attackerGuesses := []string{ 418 "", 419 "guess1", 420 "attacker-csrf", 421 redirectURI, // trying the redirect URI as CSRF 422 } 423 424 for _, guess := range attackerGuesses { 425 assert.False(t, validateMobileRedirectBinding(guess, redirectURI, victimBinding), 426 "attacker's CSRF guess %q should not validate", guess) 427 } 428 }) 429} 430 431// TestConstantTimeCompare tests the timing-safe comparison function 432func TestConstantTimeCompare(t *testing.T) { 433 tests := []struct { 434 name string 435 a string 436 b string 437 expected bool 438 }{ 439 { 440 name: "equal strings", 441 a: "abc123", 442 b: "abc123", 443 expected: true, 444 }, 445 { 446 name: "different strings same length", 447 a: "abc123", 448 b: "xyz789", 449 expected: false, 450 }, 451 { 452 name: "different lengths", 453 a: "short", 454 b: "longer", 455 expected: false, 456 }, 457 { 458 name: "empty strings", 459 a: "", 460 b: "", 461 expected: true, 462 }, 463 { 464 name: "one empty", 465 a: "abc", 466 b: "", 467 expected: false, 468 }, 469 } 470 471 for _, tt := range tests { 472 t.Run(tt.name, func(t *testing.T) { 473 result := constantTimeCompare(tt.a, tt.b) 474 assert.Equal(t, tt.expected, result) 475 }) 476 } 477}