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}