···
4
+
"Coves/internal/atproto/oauth"
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"
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
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).
33
+
func TestOAuth_Components(t *testing.T) {
34
+
if testing.Short() {
35
+
t.Skip("Skipping OAuth component test in short mode")
38
+
// Setup test database
39
+
db := setupTestDB(t)
41
+
if err := db.Close(); err != nil {
42
+
t.Logf("Failed to close database: %v", err)
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"))
50
+
t.Log("🔧 Testing OAuth Components")
52
+
ctx := context.Background()
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")
59
+
// Use a test DID (doesn't need to exist on PDS for component tests)
60
+
testDID := "did:plc:componenttest123"
62
+
// Run component tests
63
+
testOAuthComponentsWithMockedSession(t, ctx, nil, store, client, testDID, "")
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")
75
+
t.Log("NOTE: Full OAuth redirect flow requires HTTPS PDS + HTTPS Coves")
76
+
t.Log(strings.Repeat("=", 60))
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.
81
+
func testOAuthComponentsWithMockedSession(t *testing.T, ctx context.Context, _ interface{}, store oauthlib.ClientAuthStore, client *oauth.OAuthClient, userDID, _ string) {
84
+
t.Log("🔧 Testing OAuth components with mocked session...")
87
+
parsedDID, err := syntax.ParseDID(userDID)
88
+
require.NoError(t, err, "Should parse DID")
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"},
100
+
err = store.SaveSession(ctx, testSession)
101
+
require.NoError(t, err, "Should save session")
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")
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]
118
+
t.Logf(" ✅ Token sealed: %s...", tokenPreview)
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")
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")
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")
137
+
t.Log("✅ All OAuth components verified!")
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: ✓")
145
+
t.Log("⚠️ To test full OAuth redirect flow, use a production PDS with HTTPS")
148
+
// TestOAuthE2E_TokenExpiration tests that expired sealed tokens are rejected
149
+
func TestOAuthE2E_TokenExpiration(t *testing.T) {
150
+
if testing.Short() {
151
+
t.Skip("Skipping OAuth token expiration test in short mode")
154
+
db := setupTestDB(t)
155
+
defer func() { _ = db.Close() }()
158
+
require.NoError(t, goose.SetDialect("postgres"))
159
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
161
+
ctx := context.Background()
163
+
t.Log("⏰ Testing OAuth token expiration...")
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
170
+
// Create test session with past expiration
171
+
did, err := syntax.ParseDID("did:plc:expiredtest123")
172
+
require.NoError(t, err)
174
+
testSession := oauthlib.ClientSessionData{
176
+
SessionID: "expired-session",
177
+
HostURL: "http://localhost:3001",
178
+
AccessToken: "expired-token",
179
+
Scopes: []string{"atproto"},
183
+
err = store.SaveSession(ctx, testSession)
184
+
require.NoError(t, err)
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)
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")
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")
202
+
t.Logf("✅ Expired session handling verified (cleaned %d sessions)", cleaned)
205
+
// TestOAuthE2E_InvalidToken tests that invalid/tampered tokens are rejected
206
+
func TestOAuthE2E_InvalidToken(t *testing.T) {
207
+
if testing.Short() {
208
+
t.Skip("Skipping OAuth invalid token test in short mode")
211
+
db := setupTestDB(t)
212
+
defer func() { _ = db.Close() }()
215
+
require.NoError(t, goose.SetDialect("postgres"))
216
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
218
+
t.Log("🔒 Testing OAuth invalid token rejection...")
220
+
// Setup OAuth client and store
221
+
store := SetupOAuthTestStore(t, db)
222
+
client := SetupOAuthTestClient(t, store)
223
+
handler := oauth.NewOAuthHandler(client, store)
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)
230
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
233
+
w.Header().Set("Content-Type", "application/json")
234
+
_ = json.NewEncoder(w).Encode(map[string]string{"did": sessData.AccountDID.String()})
237
+
server := httptest.NewServer(r)
238
+
defer server.Close()
240
+
// Test with invalid token formats
241
+
testCases := []struct {
245
+
{"Empty token", ""},
246
+
{"Invalid base64", "not-valid-base64!!!"},
247
+
{"Tampered token", "dGFtcGVyZWQtdG9rZW4tZGF0YQ=="}, // Valid base64 but invalid content
248
+
{"Short token", "abc"},
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)
258
+
resp, err := http.DefaultClient.Do(req)
259
+
require.NoError(t, err)
260
+
defer func() { _ = resp.Body.Close() }()
262
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
263
+
"Invalid token should be rejected with 401")
267
+
t.Logf("✅ Invalid token rejection verified")
270
+
// TestOAuthE2E_SessionNotFound tests behavior when session doesn't exist in DB
271
+
func TestOAuthE2E_SessionNotFound(t *testing.T) {
272
+
if testing.Short() {
273
+
t.Skip("Skipping OAuth session not found test in short mode")
276
+
db := setupTestDB(t)
277
+
defer func() { _ = db.Close() }()
280
+
require.NoError(t, goose.SetDialect("postgres"))
281
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
283
+
ctx := context.Background()
285
+
t.Log("🔍 Testing OAuth session not found behavior...")
287
+
// Setup OAuth store
288
+
store := SetupOAuthTestStore(t, db)
290
+
// Try to retrieve non-existent session
291
+
nonExistentDID, err := syntax.ParseDID("did:plc:nonexistent123")
292
+
require.NoError(t, err)
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")
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")
303
+
t.Logf("✅ Session not found handling verified")
306
+
// TestOAuthE2E_MultipleSessionsPerUser tests that a user can have multiple active sessions
307
+
func TestOAuthE2E_MultipleSessionsPerUser(t *testing.T) {
308
+
if testing.Short() {
309
+
t.Skip("Skipping OAuth multiple sessions test in short mode")
312
+
db := setupTestDB(t)
313
+
defer func() { _ = db.Close() }()
316
+
require.NoError(t, goose.SetDialect("postgres"))
317
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
319
+
ctx := context.Background()
321
+
t.Log("👥 Testing multiple OAuth sessions per user...")
323
+
// Setup OAuth store
324
+
store := SetupOAuthTestStore(t, db)
326
+
// Create a test DID
327
+
did, err := syntax.ParseDID("did:plc:multisession123")
328
+
require.NoError(t, err)
330
+
// Create multiple sessions for the same user
331
+
sessions := []oauthlib.ClientSessionData{
334
+
SessionID: "session-1-web",
335
+
HostURL: "http://localhost:3001",
336
+
AccessToken: "token-1",
337
+
Scopes: []string{"atproto"},
341
+
SessionID: "session-2-mobile",
342
+
HostURL: "http://localhost:3001",
343
+
AccessToken: "token-2",
344
+
Scopes: []string{"atproto"},
348
+
SessionID: "session-3-tablet",
349
+
HostURL: "http://localhost:3001",
350
+
AccessToken: "token-3",
351
+
Scopes: []string{"atproto"},
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)
361
+
t.Logf("✅ Created %d sessions for user", len(sessions))
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")
371
+
t.Logf("✅ All sessions retrieved independently")
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")
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")
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)
387
+
t.Logf("✅ Multiple sessions per user verified")
390
+
for i := 1; i < len(sessions); i++ {
391
+
_ = store.DeleteSession(ctx, did, sessions[i].SessionID)
395
+
// TestOAuthE2E_AuthRequestStorage tests OAuth auth request storage and retrieval
396
+
func TestOAuthE2E_AuthRequestStorage(t *testing.T) {
397
+
if testing.Short() {
398
+
t.Skip("Skipping OAuth auth request storage test in short mode")
401
+
db := setupTestDB(t)
402
+
defer func() { _ = db.Close() }()
405
+
require.NoError(t, goose.SetDialect("postgres"))
406
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
408
+
ctx := context.Background()
410
+
t.Log("📝 Testing OAuth auth request storage...")
412
+
// Setup OAuth store
413
+
store := SetupOAuthTestStore(t, db)
415
+
// Create test auth request data
416
+
did, err := syntax.ParseDID("did:plc:authrequest123")
417
+
require.NoError(t, err)
419
+
authRequest := oauthlib.AuthRequestData{
420
+
State: "test-state-12345",
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"},
432
+
// Save auth request
433
+
err = store.SaveAuthRequestInfo(ctx, authRequest)
434
+
require.NoError(t, err, "Should be able to save auth request")
436
+
t.Logf("✅ Auth request saved")
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")
446
+
t.Logf("✅ Auth request retrieved and verified")
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")
453
+
t.Logf("✅ Duplicate state prevention verified")
455
+
// Delete auth request
456
+
err = store.DeleteAuthRequestInfo(ctx, authRequest.State)
457
+
require.NoError(t, err, "Should be able to delete auth request")
460
+
_, err = store.GetAuthRequestInfo(ctx, authRequest.State)
461
+
assert.Equal(t, oauth.ErrAuthRequestNotFound, err, "Auth request should be deleted")
463
+
t.Logf("✅ Auth request deletion verified")
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"},
474
+
err = store.SaveAuthRequestInfo(ctx, oldAuthRequest)
475
+
require.NoError(t, err)
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)
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")
488
+
t.Logf("✅ Expired auth request cleanup verified (cleaned %d requests)", cleaned)
491
+
// TestOAuthE2E_TokenRefresh tests the refresh token flow
492
+
func TestOAuthE2E_TokenRefresh(t *testing.T) {
493
+
if testing.Short() {
494
+
t.Skip("Skipping OAuth token refresh test in short mode")
497
+
db := setupTestDB(t)
498
+
defer func() { _ = db.Close() }()
501
+
require.NoError(t, goose.SetDialect("postgres"))
502
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
504
+
ctx := context.Background()
506
+
t.Log("🔄 Testing OAuth token refresh flow...")
508
+
// Setup OAuth client and store
509
+
store := SetupOAuthTestStore(t, db)
510
+
client := SetupOAuthTestClient(t, store)
511
+
handler := oauth.NewOAuthHandler(client, store)
513
+
// Create a test DID and session
514
+
did, err := syntax.ParseDID("did:plc:refreshtest123")
515
+
require.NoError(t, err)
517
+
// Create initial session with refresh token
518
+
initialSession := oauthlib.ClientSessionData{
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"},
532
+
// Save the session
533
+
err = store.SaveSession(ctx, initialSession)
534
+
require.NoError(t, err, "Should save initial session")
536
+
t.Logf("✅ Initial session created")
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")
543
+
t.Logf("✅ Session token sealed")
545
+
// Setup test server with refresh endpoint
546
+
r := chi.NewRouter()
547
+
r.Post("/oauth/refresh", handler.HandleRefresh)
549
+
server := httptest.NewServer(r)
550
+
defer server.Close()
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
557
+
// Create refresh request
558
+
refreshReq := map[string]interface{}{
559
+
"did": did.String(),
560
+
"session_id": initialSession.SessionID,
561
+
"sealed_token": sealedToken,
564
+
reqBody, err := json.Marshal(refreshReq)
565
+
require.NoError(t, err)
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")
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() }()
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)
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)
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)
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
601
+
reqBody, err := json.Marshal(refreshReq)
602
+
require.NoError(t, err)
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")
608
+
resp, err := http.DefaultClient.Do(req)
609
+
require.NoError(t, err)
610
+
defer func() { _ = resp.Body.Close() }()
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)")
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
624
+
reqBody, err := json.Marshal(refreshReq)
625
+
require.NoError(t, err)
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")
631
+
resp, err := http.DefaultClient.Do(req)
632
+
require.NoError(t, err)
633
+
defer func() { _ = resp.Body.Close() }()
635
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
636
+
"Missing sealed_token should be rejected (proof of possession required)")
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",
646
+
reqBody, err := json.Marshal(refreshReq)
647
+
require.NoError(t, err)
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")
653
+
resp, err := http.DefaultClient.Do(req)
654
+
require.NoError(t, err)
655
+
defer func() { _ = resp.Body.Close() }()
657
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
658
+
"Invalid sealed_token should be rejected")
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)
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
674
+
reqBody, err := json.Marshal(refreshReq)
675
+
require.NoError(t, err)
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")
681
+
resp, err := http.DefaultClient.Do(req)
682
+
require.NoError(t, err)
683
+
defer func() { _ = resp.Body.Close() }()
685
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
686
+
"DID mismatch should be rejected (prevents session hijacking)")
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)
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
702
+
reqBody, err := json.Marshal(refreshReq)
703
+
require.NoError(t, err)
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")
709
+
resp, err := http.DefaultClient.Do(req)
710
+
require.NoError(t, err)
711
+
defer func() { _ = resp.Body.Close() }()
713
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
714
+
"Session ID mismatch should be rejected (prevents session hijacking)")
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)
723
+
refreshReq := map[string]interface{}{
724
+
"did": did.String(),
725
+
"session_id": nonExistentSessionID,
726
+
"sealed_token": validToken, // Valid token but session doesn't exist
729
+
reqBody, err := json.Marshal(refreshReq)
730
+
require.NoError(t, err)
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")
736
+
resp, err := http.DefaultClient.Do(req)
737
+
require.NoError(t, err)
738
+
defer func() { _ = resp.Body.Close() }()
740
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
741
+
"Non-existent session should be rejected with 401")
744
+
t.Logf("✅ Token refresh endpoint validation verified")
747
+
// TestOAuthE2E_SessionUpdate tests that refresh updates the session in database
748
+
func TestOAuthE2E_SessionUpdate(t *testing.T) {
749
+
if testing.Short() {
750
+
t.Skip("Skipping OAuth session update test in short mode")
753
+
db := setupTestDB(t)
754
+
defer func() { _ = db.Close() }()
757
+
require.NoError(t, goose.SetDialect("postgres"))
758
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
760
+
ctx := context.Background()
762
+
t.Log("💾 Testing OAuth session update on refresh...")
764
+
// Setup OAuth store
765
+
store := SetupOAuthTestStore(t, db)
767
+
// Create a test session
768
+
did, err := syntax.ParseDID("did:plc:sessionupdate123")
769
+
require.NoError(t, err)
771
+
originalSession := oauthlib.ClientSessionData{
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"},
783
+
// Save original session
784
+
err = store.SaveSession(ctx, originalSession)
785
+
require.NoError(t, err)
787
+
t.Logf("✅ Original session saved")
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"
795
+
// Update the session (upsert)
796
+
err = store.SaveSession(ctx, updatedSession)
797
+
require.NoError(t, err)
799
+
t.Logf("✅ Session updated with new tokens")
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")
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")
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")
818
+
t.Logf("✅ Session update verified - tokens refreshed in database")
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)
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")
831
+
t.Logf("✅ Session timestamp update verified")
834
+
// TestOAuthE2E_RefreshTokenRotation tests refresh token rotation behavior
835
+
func TestOAuthE2E_RefreshTokenRotation(t *testing.T) {
836
+
if testing.Short() {
837
+
t.Skip("Skipping OAuth refresh token rotation test in short mode")
840
+
db := setupTestDB(t)
841
+
defer func() { _ = db.Close() }()
844
+
require.NoError(t, goose.SetDialect("postgres"))
845
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
847
+
ctx := context.Background()
849
+
t.Log("🔄 Testing OAuth refresh token rotation...")
851
+
// Setup OAuth store
852
+
store := SetupOAuthTestStore(t, db)
854
+
// Create a test session
855
+
did, err := syntax.ParseDID("did:plc:rotation123")
856
+
require.NoError(t, err)
858
+
// Simulate multiple refresh cycles
859
+
sessionID := "rotation-session-1"
860
+
tokens := []struct {
864
+
{"access-token-v1", "refresh-token-v1"},
865
+
{"access-token-v2", "refresh-token-v2"},
866
+
{"access-token-v3", "refresh-token-v3"},
869
+
for i, tokenPair := range tokens {
870
+
session := oauthlib.ClientSessionData{
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"},
881
+
// Save/update session
882
+
err = store.SaveSession(ctx, session)
883
+
require.NoError(t, err, "Should save session iteration %d", i+1)
885
+
// Retrieve and verify
886
+
retrieved, err := store.GetSession(ctx, did, sessionID)
887
+
require.NoError(t, err, "Should retrieve session iteration %d", i+1)
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)
894
+
// Small delay to ensure timestamp differences
895
+
time.Sleep(10 * time.Millisecond)
898
+
t.Logf("✅ Refresh token rotation verified through %d cycles", len(tokens))
900
+
// Verify final state
901
+
finalSession, err := store.GetSession(ctx, did, sessionID)
902
+
require.NoError(t, err)
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")
909
+
t.Logf("✅ Token rotation state verified")