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}