A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/atproto/oauth"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13
14 oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
15 "github.com/bluesky-social/indigo/atproto/syntax"
16 "github.com/go-chi/chi/v5"
17 _ "github.com/lib/pq"
18 "github.com/pressly/goose/v3"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21)
22
23// TestOAuth_Components tests OAuth component functionality without requiring PDS.
24// This validates all Coves OAuth code:
25// - Session storage and retrieval (PostgreSQL)
26// - Token sealing (AES-GCM encryption)
27// - Token unsealing (decryption + validation)
28// - Session cleanup
29//
30// NOTE: Full OAuth redirect flow testing requires both HTTPS PDS and HTTPS Coves deployment.
31// The OAuth redirect flow is handled by indigo's library and enforces OAuth 2.0 spec
32// (HTTPS required for authorization servers and redirect URIs).
33func TestOAuth_Components(t *testing.T) {
34 if testing.Short() {
35 t.Skip("Skipping OAuth component test in short mode")
36 }
37
38 // Setup test database
39 db := setupTestDB(t)
40 defer func() {
41 if err := db.Close(); err != nil {
42 t.Logf("Failed to close database: %v", err)
43 }
44 }()
45
46 // Run migrations to ensure OAuth tables exist
47 require.NoError(t, goose.SetDialect("postgres"))
48 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
49
50 t.Log("🔧 Testing OAuth Components")
51
52 ctx := context.Background()
53
54 // Setup OAuth client and store
55 store := SetupOAuthTestStore(t, db)
56 client := SetupOAuthTestClient(t, store)
57 require.NotNil(t, client, "OAuth client should be initialized")
58
59 // Use a test DID (doesn't need to exist on PDS for component tests)
60 testDID := "did:plc:componenttest123"
61
62 // Run component tests
63 testOAuthComponentsWithMockedSession(t, ctx, nil, store, client, testDID, "")
64
65 t.Log("")
66 t.Log(strings.Repeat("=", 60))
67 t.Log("✅ OAuth Component Tests Complete")
68 t.Log(strings.Repeat("=", 60))
69 t.Log("Components validated:")
70 t.Log(" ✓ Session storage (PostgreSQL)")
71 t.Log(" ✓ Token sealing (AES-GCM encryption)")
72 t.Log(" ✓ Token unsealing (decryption + validation)")
73 t.Log(" ✓ Session cleanup")
74 t.Log("")
75 t.Log("NOTE: Full OAuth redirect flow requires HTTPS PDS + HTTPS Coves")
76 t.Log(strings.Repeat("=", 60))
77}
78
79// testOAuthComponentsWithMockedSession tests OAuth components that work without PDS redirect flow.
80// This is used when testing with localhost PDS, where the indigo library rejects http:// URLs.
81func testOAuthComponentsWithMockedSession(t *testing.T, ctx context.Context, _ interface{}, store oauthlib.ClientAuthStore, client *oauth.OAuthClient, userDID, _ string) {
82 t.Helper()
83
84 t.Log("🔧 Testing OAuth components with mocked session...")
85
86 // Parse DID
87 parsedDID, err := syntax.ParseDID(userDID)
88 require.NoError(t, err, "Should parse DID")
89
90 // Component 1: Session Storage
91 t.Log(" 📦 Component 1: Testing session storage...")
92 testSession := oauthlib.ClientSessionData{
93 AccountDID: parsedDID,
94 SessionID: fmt.Sprintf("localhost-test-%d", time.Now().UnixNano()),
95 HostURL: "http://localhost:3001",
96 AccessToken: "mocked-access-token",
97 Scopes: []string{"atproto", "transition:generic"},
98 }
99
100 err = store.SaveSession(ctx, testSession)
101 require.NoError(t, err, "Should save session")
102
103 retrieved, err := store.GetSession(ctx, parsedDID, testSession.SessionID)
104 require.NoError(t, err, "Should retrieve session")
105 require.Equal(t, testSession.SessionID, retrieved.SessionID)
106 require.Equal(t, testSession.AccessToken, retrieved.AccessToken)
107 t.Log(" ✅ Session storage working")
108
109 // Component 2: Token Sealing
110 t.Log(" 🔐 Component 2: Testing token sealing...")
111 sealedToken, err := client.SealSession(parsedDID.String(), testSession.SessionID, time.Hour)
112 require.NoError(t, err, "Should seal token")
113 require.NotEmpty(t, sealedToken, "Sealed token should not be empty")
114 tokenPreview := sealedToken
115 if len(tokenPreview) > 50 {
116 tokenPreview = tokenPreview[:50]
117 }
118 t.Logf(" ✅ Token sealed: %s...", tokenPreview)
119
120 // Component 3: Token Unsealing
121 t.Log(" 🔓 Component 3: Testing token unsealing...")
122 unsealed, err := client.UnsealSession(sealedToken)
123 require.NoError(t, err, "Should unseal token")
124 require.Equal(t, userDID, unsealed.DID)
125 require.Equal(t, testSession.SessionID, unsealed.SessionID)
126 t.Log(" ✅ Token unsealing working")
127
128 // Component 4: Session Cleanup
129 t.Log(" 🧹 Component 4: Testing session cleanup...")
130 err = store.DeleteSession(ctx, parsedDID, testSession.SessionID)
131 require.NoError(t, err, "Should delete session")
132
133 _, err = store.GetSession(ctx, parsedDID, testSession.SessionID)
134 require.Error(t, err, "Session should not exist after deletion")
135 t.Log(" ✅ Session cleanup working")
136
137 t.Log("✅ All OAuth components verified!")
138 t.Log("")
139 t.Log("📝 Summary: OAuth implementation validated with mocked session")
140 t.Log(" - Session storage: ✓")
141 t.Log(" - Token sealing: ✓")
142 t.Log(" - Token unsealing: ✓")
143 t.Log(" - Session cleanup: ✓")
144 t.Log("")
145 t.Log("⚠️ To test full OAuth redirect flow, use a production PDS with HTTPS")
146}
147
148// TestOAuthE2E_TokenExpiration tests that expired sealed tokens are rejected
149func TestOAuthE2E_TokenExpiration(t *testing.T) {
150 if testing.Short() {
151 t.Skip("Skipping OAuth token expiration test in short mode")
152 }
153
154 db := setupTestDB(t)
155 defer func() { _ = db.Close() }()
156
157 // Run migrations
158 require.NoError(t, goose.SetDialect("postgres"))
159 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
160
161 ctx := context.Background()
162
163 t.Log("⏰ Testing OAuth token expiration...")
164
165 // Setup OAuth client and store
166 store := SetupOAuthTestStore(t, db)
167 client := SetupOAuthTestClient(t, store)
168 _ = oauth.NewOAuthHandler(client, store) // Handler created for completeness
169
170 // Create test session with past expiration
171 did, err := syntax.ParseDID("did:plc:expiredtest123")
172 require.NoError(t, err)
173
174 testSession := oauthlib.ClientSessionData{
175 AccountDID: did,
176 SessionID: "expired-session",
177 HostURL: "http://localhost:3001",
178 AccessToken: "expired-token",
179 Scopes: []string{"atproto"},
180 }
181
182 // Save session
183 err = store.SaveSession(ctx, testSession)
184 require.NoError(t, err)
185
186 // Manually update expiration to the past
187 _, err = db.ExecContext(ctx,
188 "UPDATE oauth_sessions SET expires_at = NOW() - INTERVAL '1 day' WHERE did = $1 AND session_id = $2",
189 did.String(), testSession.SessionID)
190 require.NoError(t, err)
191
192 // Try to retrieve expired session
193 _, err = store.GetSession(ctx, did, testSession.SessionID)
194 assert.Error(t, err, "Should not be able to retrieve expired session")
195 assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound for expired session")
196
197 // Test cleanup of expired sessions
198 cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredSessions(ctx)
199 require.NoError(t, err, "Cleanup should succeed")
200 assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one session")
201
202 t.Logf("✅ Expired session handling verified (cleaned %d sessions)", cleaned)
203}
204
205// TestOAuthE2E_InvalidToken tests that invalid/tampered tokens are rejected
206func TestOAuthE2E_InvalidToken(t *testing.T) {
207 if testing.Short() {
208 t.Skip("Skipping OAuth invalid token test in short mode")
209 }
210
211 db := setupTestDB(t)
212 defer func() { _ = db.Close() }()
213
214 // Run migrations
215 require.NoError(t, goose.SetDialect("postgres"))
216 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
217
218 t.Log("🔒 Testing OAuth invalid token rejection...")
219
220 // Setup OAuth client and store
221 store := SetupOAuthTestStore(t, db)
222 client := SetupOAuthTestClient(t, store)
223 handler := oauth.NewOAuthHandler(client, store)
224
225 // Setup test server with protected endpoint
226 r := chi.NewRouter()
227 r.Get("/api/me", func(w http.ResponseWriter, r *http.Request) {
228 sessData, err := handler.GetSessionFromRequest(r)
229 if err != nil {
230 http.Error(w, "Unauthorized", http.StatusUnauthorized)
231 return
232 }
233 w.Header().Set("Content-Type", "application/json")
234 _ = json.NewEncoder(w).Encode(map[string]string{"did": sessData.AccountDID.String()})
235 })
236
237 server := httptest.NewServer(r)
238 defer server.Close()
239
240 // Test with invalid token formats
241 testCases := []struct {
242 name string
243 token string
244 }{
245 {"Empty token", ""},
246 {"Invalid base64", "not-valid-base64!!!"},
247 {"Tampered token", "dGFtcGVyZWQtdG9rZW4tZGF0YQ=="}, // Valid base64 but invalid content
248 {"Short token", "abc"},
249 }
250
251 for _, tc := range testCases {
252 t.Run(tc.name, func(t *testing.T) {
253 req, _ := http.NewRequest("GET", server.URL+"/api/me", nil)
254 if tc.token != "" {
255 req.Header.Set("Authorization", "Bearer "+tc.token)
256 }
257
258 resp, err := http.DefaultClient.Do(req)
259 require.NoError(t, err)
260 defer func() { _ = resp.Body.Close() }()
261
262 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
263 "Invalid token should be rejected with 401")
264 })
265 }
266
267 t.Logf("✅ Invalid token rejection verified")
268}
269
270// TestOAuthE2E_SessionNotFound tests behavior when session doesn't exist in DB
271func TestOAuthE2E_SessionNotFound(t *testing.T) {
272 if testing.Short() {
273 t.Skip("Skipping OAuth session not found test in short mode")
274 }
275
276 db := setupTestDB(t)
277 defer func() { _ = db.Close() }()
278
279 // Run migrations
280 require.NoError(t, goose.SetDialect("postgres"))
281 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
282
283 ctx := context.Background()
284
285 t.Log("🔍 Testing OAuth session not found behavior...")
286
287 // Setup OAuth store
288 store := SetupOAuthTestStore(t, db)
289
290 // Try to retrieve non-existent session
291 nonExistentDID, err := syntax.ParseDID("did:plc:nonexistent123")
292 require.NoError(t, err)
293
294 _, err = store.GetSession(ctx, nonExistentDID, "nonexistent-session")
295 assert.Error(t, err, "Should return error for non-existent session")
296 assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound")
297
298 // Try to delete non-existent session
299 err = store.DeleteSession(ctx, nonExistentDID, "nonexistent-session")
300 assert.Error(t, err, "Should return error when deleting non-existent session")
301 assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound")
302
303 t.Logf("✅ Session not found handling verified")
304}
305
306// TestOAuthE2E_MultipleSessionsPerUser tests that a user can have multiple active sessions
307func TestOAuthE2E_MultipleSessionsPerUser(t *testing.T) {
308 if testing.Short() {
309 t.Skip("Skipping OAuth multiple sessions test in short mode")
310 }
311
312 db := setupTestDB(t)
313 defer func() { _ = db.Close() }()
314
315 // Run migrations
316 require.NoError(t, goose.SetDialect("postgres"))
317 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
318
319 ctx := context.Background()
320
321 t.Log("👥 Testing multiple OAuth sessions per user...")
322
323 // Setup OAuth store
324 store := SetupOAuthTestStore(t, db)
325
326 // Create a test DID
327 did, err := syntax.ParseDID("did:plc:multisession123")
328 require.NoError(t, err)
329
330 // Create multiple sessions for the same user
331 sessions := []oauthlib.ClientSessionData{
332 {
333 AccountDID: did,
334 SessionID: "session-1-web",
335 HostURL: "http://localhost:3001",
336 AccessToken: "token-1",
337 Scopes: []string{"atproto"},
338 },
339 {
340 AccountDID: did,
341 SessionID: "session-2-mobile",
342 HostURL: "http://localhost:3001",
343 AccessToken: "token-2",
344 Scopes: []string{"atproto"},
345 },
346 {
347 AccountDID: did,
348 SessionID: "session-3-tablet",
349 HostURL: "http://localhost:3001",
350 AccessToken: "token-3",
351 Scopes: []string{"atproto"},
352 },
353 }
354
355 // Save all sessions
356 for i, session := range sessions {
357 err := store.SaveSession(ctx, session)
358 require.NoError(t, err, "Should be able to save session %d", i+1)
359 }
360
361 t.Logf("✅ Created %d sessions for user", len(sessions))
362
363 // Verify all sessions can be retrieved independently
364 for i, session := range sessions {
365 retrieved, err := store.GetSession(ctx, did, session.SessionID)
366 require.NoError(t, err, "Should be able to retrieve session %d", i+1)
367 assert.Equal(t, session.SessionID, retrieved.SessionID, "Session ID should match")
368 assert.Equal(t, session.AccessToken, retrieved.AccessToken, "Access token should match")
369 }
370
371 t.Logf("✅ All sessions retrieved independently")
372
373 // Delete one session and verify others remain
374 err = store.DeleteSession(ctx, did, sessions[0].SessionID)
375 require.NoError(t, err, "Should be able to delete first session")
376
377 // Verify first session is deleted
378 _, err = store.GetSession(ctx, did, sessions[0].SessionID)
379 assert.Equal(t, oauth.ErrSessionNotFound, err, "First session should be deleted")
380
381 // Verify other sessions still exist
382 for i := 1; i < len(sessions); i++ {
383 _, err := store.GetSession(ctx, did, sessions[i].SessionID)
384 require.NoError(t, err, "Session %d should still exist", i+1)
385 }
386
387 t.Logf("✅ Multiple sessions per user verified")
388
389 // Cleanup
390 for i := 1; i < len(sessions); i++ {
391 _ = store.DeleteSession(ctx, did, sessions[i].SessionID)
392 }
393}
394
395// TestOAuthE2E_AuthRequestStorage tests OAuth auth request storage and retrieval
396func TestOAuthE2E_AuthRequestStorage(t *testing.T) {
397 if testing.Short() {
398 t.Skip("Skipping OAuth auth request storage test in short mode")
399 }
400
401 db := setupTestDB(t)
402 defer func() { _ = db.Close() }()
403
404 // Run migrations
405 require.NoError(t, goose.SetDialect("postgres"))
406 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
407
408 ctx := context.Background()
409
410 t.Log("📝 Testing OAuth auth request storage...")
411
412 // Setup OAuth store
413 store := SetupOAuthTestStore(t, db)
414
415 // Create test auth request data
416 did, err := syntax.ParseDID("did:plc:authrequest123")
417 require.NoError(t, err)
418
419 authRequest := oauthlib.AuthRequestData{
420 State: "test-state-12345",
421 AccountDID: &did,
422 PKCEVerifier: "test-pkce-verifier",
423 DPoPPrivateKeyMultibase: "test-dpop-key",
424 DPoPAuthServerNonce: "test-nonce",
425 AuthServerURL: "http://localhost:3001",
426 RequestURI: "http://localhost:3001/authorize",
427 AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
428 AuthServerRevocationEndpoint: "http://localhost:3001/oauth/revoke",
429 Scopes: []string{"atproto", "transition:generic"},
430 }
431
432 // Save auth request
433 err = store.SaveAuthRequestInfo(ctx, authRequest)
434 require.NoError(t, err, "Should be able to save auth request")
435
436 t.Logf("✅ Auth request saved")
437
438 // Retrieve auth request
439 retrieved, err := store.GetAuthRequestInfo(ctx, authRequest.State)
440 require.NoError(t, err, "Should be able to retrieve auth request")
441 assert.Equal(t, authRequest.State, retrieved.State, "State should match")
442 assert.Equal(t, authRequest.PKCEVerifier, retrieved.PKCEVerifier, "PKCE verifier should match")
443 assert.Equal(t, authRequest.AuthServerURL, retrieved.AuthServerURL, "Auth server URL should match")
444 assert.Equal(t, len(authRequest.Scopes), len(retrieved.Scopes), "Scopes length should match")
445
446 t.Logf("✅ Auth request retrieved and verified")
447
448 // Test duplicate state error
449 err = store.SaveAuthRequestInfo(ctx, authRequest)
450 assert.Error(t, err, "Should not allow duplicate state")
451 assert.Contains(t, err.Error(), "already exists", "Error should indicate duplicate")
452
453 t.Logf("✅ Duplicate state prevention verified")
454
455 // Delete auth request
456 err = store.DeleteAuthRequestInfo(ctx, authRequest.State)
457 require.NoError(t, err, "Should be able to delete auth request")
458
459 // Verify deletion
460 _, err = store.GetAuthRequestInfo(ctx, authRequest.State)
461 assert.Equal(t, oauth.ErrAuthRequestNotFound, err, "Auth request should be deleted")
462
463 t.Logf("✅ Auth request deletion verified")
464
465 // Test cleanup of expired auth requests
466 // Create an auth request and manually set created_at to the past
467 oldAuthRequest := oauthlib.AuthRequestData{
468 State: "old-state-12345",
469 PKCEVerifier: "old-verifier",
470 AuthServerURL: "http://localhost:3001",
471 Scopes: []string{"atproto"},
472 }
473
474 err = store.SaveAuthRequestInfo(ctx, oldAuthRequest)
475 require.NoError(t, err)
476
477 // Update created_at to 1 hour ago
478 _, err = db.ExecContext(ctx,
479 "UPDATE oauth_requests SET created_at = NOW() - INTERVAL '1 hour' WHERE state = $1",
480 oldAuthRequest.State)
481 require.NoError(t, err)
482
483 // Cleanup expired requests
484 cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredAuthRequests(ctx)
485 require.NoError(t, err, "Cleanup should succeed")
486 assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one auth request")
487
488 t.Logf("✅ Expired auth request cleanup verified (cleaned %d requests)", cleaned)
489}
490
491// TestOAuthE2E_TokenRefresh tests the refresh token flow
492func TestOAuthE2E_TokenRefresh(t *testing.T) {
493 if testing.Short() {
494 t.Skip("Skipping OAuth token refresh test in short mode")
495 }
496
497 db := setupTestDB(t)
498 defer func() { _ = db.Close() }()
499
500 // Run migrations
501 require.NoError(t, goose.SetDialect("postgres"))
502 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
503
504 ctx := context.Background()
505
506 t.Log("🔄 Testing OAuth token refresh flow...")
507
508 // Setup OAuth client and store
509 store := SetupOAuthTestStore(t, db)
510 client := SetupOAuthTestClient(t, store)
511 handler := oauth.NewOAuthHandler(client, store)
512
513 // Create a test DID and session
514 did, err := syntax.ParseDID("did:plc:refreshtest123")
515 require.NoError(t, err)
516
517 // Create initial session with refresh token
518 initialSession := oauthlib.ClientSessionData{
519 AccountDID: did,
520 SessionID: "refresh-session-1",
521 HostURL: "http://localhost:3001",
522 AuthServerURL: "http://localhost:3001",
523 AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
524 AuthServerRevocationEndpoint: "http://localhost:3001/oauth/revoke",
525 AccessToken: "initial-access-token",
526 RefreshToken: "initial-refresh-token",
527 DPoPPrivateKeyMultibase: "test-dpop-key",
528 DPoPAuthServerNonce: "test-nonce",
529 Scopes: []string{"atproto", "transition:generic"},
530 }
531
532 // Save the session
533 err = store.SaveSession(ctx, initialSession)
534 require.NoError(t, err, "Should save initial session")
535
536 t.Logf("✅ Initial session created")
537
538 // Create a sealed token for this session
539 sealedToken, err := client.SealSession(did.String(), initialSession.SessionID, time.Hour)
540 require.NoError(t, err, "Should seal session token")
541 require.NotEmpty(t, sealedToken, "Sealed token should not be empty")
542
543 t.Logf("✅ Session token sealed")
544
545 // Setup test server with refresh endpoint
546 r := chi.NewRouter()
547 r.Post("/oauth/refresh", handler.HandleRefresh)
548
549 server := httptest.NewServer(r)
550 defer server.Close()
551
552 t.Run("Valid refresh request", func(t *testing.T) {
553 // NOTE: This test verifies that the refresh endpoint can be called
554 // In a real scenario, the indigo client's RefreshTokens() would call the PDS
555 // Since we're in a component test, we're testing the Coves handler logic
556
557 // Create refresh request
558 refreshReq := map[string]interface{}{
559 "did": did.String(),
560 "session_id": initialSession.SessionID,
561 "sealed_token": sealedToken,
562 }
563
564 reqBody, err := json.Marshal(refreshReq)
565 require.NoError(t, err)
566
567 req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
568 require.NoError(t, err)
569 req.Header.Set("Content-Type", "application/json")
570
571 // NOTE: In component testing mode, the indigo client may not have
572 // real PDS credentials, so RefreshTokens() might fail
573 // We're testing that the handler correctly processes the request
574 resp, err := http.DefaultClient.Do(req)
575 require.NoError(t, err)
576 defer func() { _ = resp.Body.Close() }()
577
578 // In component test mode without real PDS, we may get 401
579 // In production with real PDS, this would return 200 with new tokens
580 t.Logf("Refresh response status: %d", resp.StatusCode)
581
582 // The important thing is that the handler doesn't crash
583 // and properly validates the request structure
584 assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized,
585 "Refresh should return either success or auth failure, got %d", resp.StatusCode)
586 })
587
588 t.Run("Invalid DID format (with valid token)", func(t *testing.T) {
589 // Create a sealed token with an invalid DID format
590 invalidDID := "invalid-did-format"
591 // Create the token with a valid DID first, then we'll try to use it with invalid DID in request
592 validToken, err := client.SealSession(did.String(), initialSession.SessionID, 30*24*time.Hour)
593 require.NoError(t, err)
594
595 refreshReq := map[string]interface{}{
596 "did": invalidDID, // Invalid DID format in request
597 "session_id": initialSession.SessionID,
598 "sealed_token": validToken, // Valid token for different DID
599 }
600
601 reqBody, err := json.Marshal(refreshReq)
602 require.NoError(t, err)
603
604 req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
605 require.NoError(t, err)
606 req.Header.Set("Content-Type", "application/json")
607
608 resp, err := http.DefaultClient.Do(req)
609 require.NoError(t, err)
610 defer func() { _ = resp.Body.Close() }()
611
612 // Should reject with 401 due to DID mismatch (not 400) since auth happens first
613 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
614 "DID mismatch should be rejected with 401 (auth check happens before format validation)")
615 })
616
617 t.Run("Missing sealed_token (security test)", func(t *testing.T) {
618 refreshReq := map[string]interface{}{
619 "did": did.String(),
620 "session_id": initialSession.SessionID,
621 // Missing sealed_token - should be rejected for security
622 }
623
624 reqBody, err := json.Marshal(refreshReq)
625 require.NoError(t, err)
626
627 req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
628 require.NoError(t, err)
629 req.Header.Set("Content-Type", "application/json")
630
631 resp, err := http.DefaultClient.Do(req)
632 require.NoError(t, err)
633 defer func() { _ = resp.Body.Close() }()
634
635 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
636 "Missing sealed_token should be rejected (proof of possession required)")
637 })
638
639 t.Run("Invalid sealed_token", func(t *testing.T) {
640 refreshReq := map[string]interface{}{
641 "did": did.String(),
642 "session_id": initialSession.SessionID,
643 "sealed_token": "invalid-token-data",
644 }
645
646 reqBody, err := json.Marshal(refreshReq)
647 require.NoError(t, err)
648
649 req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
650 require.NoError(t, err)
651 req.Header.Set("Content-Type", "application/json")
652
653 resp, err := http.DefaultClient.Do(req)
654 require.NoError(t, err)
655 defer func() { _ = resp.Body.Close() }()
656
657 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
658 "Invalid sealed_token should be rejected")
659 })
660
661 t.Run("DID mismatch (security test)", func(t *testing.T) {
662 // Create a sealed token for a different DID
663 wrongDID := "did:plc:wronguser123"
664 wrongToken, err := client.SealSession(wrongDID, initialSession.SessionID, 30*24*time.Hour)
665 require.NoError(t, err)
666
667 // Try to use it to refresh the original session
668 refreshReq := map[string]interface{}{
669 "did": did.String(), // Claiming original DID
670 "session_id": initialSession.SessionID,
671 "sealed_token": wrongToken, // But token is for different DID
672 }
673
674 reqBody, err := json.Marshal(refreshReq)
675 require.NoError(t, err)
676
677 req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
678 require.NoError(t, err)
679 req.Header.Set("Content-Type", "application/json")
680
681 resp, err := http.DefaultClient.Do(req)
682 require.NoError(t, err)
683 defer func() { _ = resp.Body.Close() }()
684
685 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
686 "DID mismatch should be rejected (prevents session hijacking)")
687 })
688
689 t.Run("Session ID mismatch (security test)", func(t *testing.T) {
690 // Create a sealed token with wrong session ID
691 wrongSessionID := "wrong-session-id"
692 wrongToken, err := client.SealSession(did.String(), wrongSessionID, 30*24*time.Hour)
693 require.NoError(t, err)
694
695 // Try to use it to refresh the original session
696 refreshReq := map[string]interface{}{
697 "did": did.String(),
698 "session_id": initialSession.SessionID, // Claiming original session
699 "sealed_token": wrongToken, // But token is for different session
700 }
701
702 reqBody, err := json.Marshal(refreshReq)
703 require.NoError(t, err)
704
705 req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
706 require.NoError(t, err)
707 req.Header.Set("Content-Type", "application/json")
708
709 resp, err := http.DefaultClient.Do(req)
710 require.NoError(t, err)
711 defer func() { _ = resp.Body.Close() }()
712
713 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
714 "Session ID mismatch should be rejected (prevents session hijacking)")
715 })
716
717 t.Run("Non-existent session", func(t *testing.T) {
718 // Create a valid sealed token for a non-existent session
719 nonExistentSessionID := "nonexistent-session-id"
720 validToken, err := client.SealSession(did.String(), nonExistentSessionID, 30*24*time.Hour)
721 require.NoError(t, err)
722
723 refreshReq := map[string]interface{}{
724 "did": did.String(),
725 "session_id": nonExistentSessionID,
726 "sealed_token": validToken, // Valid token but session doesn't exist
727 }
728
729 reqBody, err := json.Marshal(refreshReq)
730 require.NoError(t, err)
731
732 req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
733 require.NoError(t, err)
734 req.Header.Set("Content-Type", "application/json")
735
736 resp, err := http.DefaultClient.Do(req)
737 require.NoError(t, err)
738 defer func() { _ = resp.Body.Close() }()
739
740 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
741 "Non-existent session should be rejected with 401")
742 })
743
744 t.Logf("✅ Token refresh endpoint validation verified")
745}
746
747// TestOAuthE2E_SessionUpdate tests that refresh updates the session in database
748func TestOAuthE2E_SessionUpdate(t *testing.T) {
749 if testing.Short() {
750 t.Skip("Skipping OAuth session update test in short mode")
751 }
752
753 db := setupTestDB(t)
754 defer func() { _ = db.Close() }()
755
756 // Run migrations
757 require.NoError(t, goose.SetDialect("postgres"))
758 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
759
760 ctx := context.Background()
761
762 t.Log("💾 Testing OAuth session update on refresh...")
763
764 // Setup OAuth store
765 store := SetupOAuthTestStore(t, db)
766
767 // Create a test session
768 did, err := syntax.ParseDID("did:plc:sessionupdate123")
769 require.NoError(t, err)
770
771 originalSession := oauthlib.ClientSessionData{
772 AccountDID: did,
773 SessionID: "update-session-1",
774 HostURL: "http://localhost:3001",
775 AuthServerURL: "http://localhost:3001",
776 AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
777 AccessToken: "original-access-token",
778 RefreshToken: "original-refresh-token",
779 DPoPPrivateKeyMultibase: "original-dpop-key",
780 Scopes: []string{"atproto"},
781 }
782
783 // Save original session
784 err = store.SaveSession(ctx, originalSession)
785 require.NoError(t, err)
786
787 t.Logf("✅ Original session saved")
788
789 // Simulate a token refresh by updating the session with new tokens
790 updatedSession := originalSession
791 updatedSession.AccessToken = "new-access-token"
792 updatedSession.RefreshToken = "new-refresh-token"
793 updatedSession.DPoPAuthServerNonce = "new-nonce"
794
795 // Update the session (upsert)
796 err = store.SaveSession(ctx, updatedSession)
797 require.NoError(t, err)
798
799 t.Logf("✅ Session updated with new tokens")
800
801 // Retrieve the session and verify it was updated
802 retrieved, err := store.GetSession(ctx, did, originalSession.SessionID)
803 require.NoError(t, err, "Should retrieve updated session")
804
805 assert.Equal(t, "new-access-token", retrieved.AccessToken,
806 "Access token should be updated")
807 assert.Equal(t, "new-refresh-token", retrieved.RefreshToken,
808 "Refresh token should be updated")
809 assert.Equal(t, "new-nonce", retrieved.DPoPAuthServerNonce,
810 "DPoP nonce should be updated")
811
812 // Verify session ID and DID remain the same
813 assert.Equal(t, originalSession.SessionID, retrieved.SessionID,
814 "Session ID should remain the same")
815 assert.Equal(t, did, retrieved.AccountDID,
816 "DID should remain the same")
817
818 t.Logf("✅ Session update verified - tokens refreshed in database")
819
820 // Verify updated_at was changed
821 var updatedAt time.Time
822 err = db.QueryRowContext(ctx,
823 "SELECT updated_at FROM oauth_sessions WHERE did = $1 AND session_id = $2",
824 did.String(), originalSession.SessionID).Scan(&updatedAt)
825 require.NoError(t, err)
826
827 // Updated timestamp should be recent (within last minute)
828 assert.WithinDuration(t, time.Now(), updatedAt, time.Minute,
829 "Session updated_at should be recent")
830
831 t.Logf("✅ Session timestamp update verified")
832}
833
834// TestOAuthE2E_RefreshTokenRotation tests refresh token rotation behavior
835func TestOAuthE2E_RefreshTokenRotation(t *testing.T) {
836 if testing.Short() {
837 t.Skip("Skipping OAuth refresh token rotation test in short mode")
838 }
839
840 db := setupTestDB(t)
841 defer func() { _ = db.Close() }()
842
843 // Run migrations
844 require.NoError(t, goose.SetDialect("postgres"))
845 require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
846
847 ctx := context.Background()
848
849 t.Log("🔄 Testing OAuth refresh token rotation...")
850
851 // Setup OAuth store
852 store := SetupOAuthTestStore(t, db)
853
854 // Create a test session
855 did, err := syntax.ParseDID("did:plc:rotation123")
856 require.NoError(t, err)
857
858 // Simulate multiple refresh cycles
859 sessionID := "rotation-session-1"
860 tokens := []struct {
861 access string
862 refresh string
863 }{
864 {"access-token-v1", "refresh-token-v1"},
865 {"access-token-v2", "refresh-token-v2"},
866 {"access-token-v3", "refresh-token-v3"},
867 }
868
869 for i, tokenPair := range tokens {
870 session := oauthlib.ClientSessionData{
871 AccountDID: did,
872 SessionID: sessionID,
873 HostURL: "http://localhost:3001",
874 AuthServerURL: "http://localhost:3001",
875 AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
876 AccessToken: tokenPair.access,
877 RefreshToken: tokenPair.refresh,
878 Scopes: []string{"atproto"},
879 }
880
881 // Save/update session
882 err = store.SaveSession(ctx, session)
883 require.NoError(t, err, "Should save session iteration %d", i+1)
884
885 // Retrieve and verify
886 retrieved, err := store.GetSession(ctx, did, sessionID)
887 require.NoError(t, err, "Should retrieve session iteration %d", i+1)
888
889 assert.Equal(t, tokenPair.access, retrieved.AccessToken,
890 "Access token should match iteration %d", i+1)
891 assert.Equal(t, tokenPair.refresh, retrieved.RefreshToken,
892 "Refresh token should match iteration %d", i+1)
893
894 // Small delay to ensure timestamp differences
895 time.Sleep(10 * time.Millisecond)
896 }
897
898 t.Logf("✅ Refresh token rotation verified through %d cycles", len(tokens))
899
900 // Verify final state
901 finalSession, err := store.GetSession(ctx, did, sessionID)
902 require.NoError(t, err)
903
904 assert.Equal(t, "access-token-v3", finalSession.AccessToken,
905 "Final access token should be from last rotation")
906 assert.Equal(t, "refresh-token-v3", finalSession.RefreshToken,
907 "Final refresh token should be from last rotation")
908
909 t.Logf("✅ Token rotation state verified")
910}