From 169a811162382842c874ac755fc93fb4097fd609 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 30 Jul 2026 11:39:37 +0700 Subject: [PATCH] fix(session): coordinate verification across parallel downloads --- go_backend/exports_extensions.go | 16 +- go_backend/extension_signed_session.go | 319 +++++++++++++++++--- go_backend/extension_signed_session_test.go | 279 ++++++++++++++++- 3 files changed, 560 insertions(+), 54 deletions(-) diff --git a/go_backend/exports_extensions.go b/go_backend/exports_extensions.go index 528c8c6e..d15bbb85 100644 --- a/go_backend/exports_extensions.go +++ b/go_backend/exports_extensions.go @@ -622,22 +622,12 @@ func ensureExtensionPendingAuthRequest(extensionID string) (*PendingAuthRequest, return nil, fmt.Errorf("extension '%s' runtime is unavailable", extensionID) } - config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession) - if config.Namespace == "" || config.BaseURL == "" { - return nil, nil - } - record, err := ext.runtime.loadSignedSession(config) + verificationRequired, err := ext.runtime.preflightSignedSession() if err != nil { return nil, err } - record.SessionID = "" - record.SessionSecret = "" - record.ExpiresAt = "" - if err := ext.runtime.saveSignedSession(config, record); err != nil { - return nil, err - } - if _, err := ext.runtime.startSignedSessionVerification(config, "pending-auth-request"); err != nil { - return nil, err + if !verificationRequired { + return nil, nil } return GetPendingAuthRequest(extensionID), nil } diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index c2b8fe7f..4db4d122 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -25,11 +25,67 @@ import ( const signedSessionRefreshSkew = time.Hour +const ( + signedSessionExchangeMaxAttempts = 3 + signedSessionMaxRetryAfter = 5 * time.Minute +) + var ( pendingSignedSessionGrants = make(map[string]string) pendingSignedSessionGrantsMu sync.Mutex + signedSessionCoordinators sync.Map + signedSessionRetryWait = time.Sleep ) +// signedSessionCoordinator serializes authentication state for every runtime +// that shares one persisted signed-session file. Parallel downloads use +// isolated extension runtimes, so a runtime-local mutex cannot prevent two +// bootstraps or an old 401 response from overwriting a newly exchanged session. +type signedSessionCoordinator struct { + mu sync.Mutex + + authURL string + callbackURL string + challengeCreatedAt time.Time + pendingExtensionIDs map[string]struct{} + completedGrantHash string +} + +func (r *extensionRuntime) signedSessionCoordinator(config SignedSessionConfig) (*signedSessionCoordinator, error) { + path, err := r.signedSessionFilePath(config) + if err != nil { + return nil, err + } + value, _ := signedSessionCoordinators.LoadOrStore(path, &signedSessionCoordinator{}) + return value.(*signedSessionCoordinator), nil +} + +func (c *signedSessionCoordinator) clearChallenge() { + for extensionID := range c.pendingExtensionIDs { + ClearPendingAuthRequest(extensionID) + } + c.authURL = "" + c.callbackURL = "" + c.challengeCreatedAt = time.Time{} + c.pendingExtensionIDs = nil +} + +func (c *signedSessionCoordinator) rememberChallenge(extensionID, authURL, callbackURL string) { + if c.pendingExtensionIDs == nil { + c.pendingExtensionIDs = make(map[string]struct{}) + } + c.authURL = authURL + c.callbackURL = callbackURL + c.challengeCreatedAt = time.Now() + c.pendingExtensionIDs[extensionID] = struct{}{} +} + +func (c *signedSessionCoordinator) activeChallenge() bool { + return strings.TrimSpace(c.authURL) != "" && + !c.challengeCreatedAt.IsZero() && + time.Since(c.challengeCreatedAt) < pendingAuthRequestTTL +} + type signedSessionRecord struct { InstallID string `json:"install_id"` SessionID string `json:"session_id,omitempty"` @@ -222,6 +278,13 @@ func signedSessionRecordIsUsable(record *signedSessionRecord) bool { return true } +func sameSignedSession(a, b *signedSessionRecord) bool { + return a != nil && b != nil && + a.SessionID != "" && + a.SessionID == b.SessionID && + a.SessionSecret == b.SessionSecret +} + // preflightSignedSession prepares a signed session before download metadata // enrichment starts. A fresh pending challenge is reused, while bootstrap // responses that can issue a session silently are accepted without prompting @@ -236,6 +299,12 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) { if config.Namespace == "" || config.BaseURL == "" { return false, fmt.Errorf("signedSession is not configured") } + coordinator, err := r.signedSessionCoordinator(config) + if err != nil { + return false, err + } + coordinator.mu.Lock() + defer coordinator.mu.Unlock() record, err := r.loadSignedSession(config) if err != nil { @@ -245,15 +314,7 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) { return false, nil } - if pending := GetPendingAuthRequest(r.extensionID); pending != nil { - if time.Since(pending.CreatedAt) < pendingAuthRequestTTL && - strings.TrimSpace(pending.AuthURL) != "" { - return true, nil - } - ClearPendingAuthRequest(r.extensionID) - } - - if authURL, err := r.startSignedSessionVerification(config, "download-preflight"); err != nil { + if authURL, err := r.startSignedSessionVerificationLocked(config, coordinator, "download-preflight"); err != nil { return false, err } else if authURL != "" { return true, nil @@ -277,6 +338,12 @@ func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Valu if config.Namespace == "" || config.BaseURL == "" { return r.vm.ToValue(map[string]any{"authenticated": false, "error": "signedSession is not configured"}) } + coordinator, err := r.signedSessionCoordinator(config) + if err != nil { + return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()}) + } + coordinator.mu.Lock() + defer coordinator.mu.Unlock() record, err := r.loadSignedSession(config) if err != nil { return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()}) @@ -294,6 +361,12 @@ func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Valu func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value { config := signedSessionConfigWithDefaults(r.manifest.SignedSession) + coordinator, err := r.signedSessionCoordinator(config) + if err != nil { + return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) + } + coordinator.mu.Lock() + defer coordinator.mu.Unlock() record, err := r.loadSignedSession(config) if err != nil { return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) @@ -304,6 +377,7 @@ func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value if err := r.saveSignedSession(config, record); err != nil { return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) } + coordinator.clearChallenge() ClearPendingAuthRequest(r.extensionID) return r.vm.ToValue(map[string]any{"success": true}) } @@ -313,10 +387,12 @@ func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) go if len(call.Arguments) > 0 { grant = strings.TrimSpace(call.Arguments[0].String()) } + if grant != "" { + setPendingSignedSessionGrant(r.extensionID, grant) + } if grant == "" { pendingSignedSessionGrantsMu.Lock() grant = pendingSignedSessionGrants[r.extensionID] - delete(pendingSignedSessionGrants, r.extensionID) pendingSignedSessionGrantsMu.Unlock() } if grant == "" { @@ -325,16 +401,43 @@ func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) go if err := r.exchangeSignedSessionGrant(grant); err != nil { return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) } + pendingSignedSessionGrantsMu.Lock() + delete(pendingSignedSessionGrants, r.extensionID) + pendingSignedSessionGrantsMu.Unlock() ClearPendingAuthRequest(r.extensionID) return r.vm.ToValue(map[string]any{"success": true}) } func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error { config := signedSessionConfigWithDefaults(r.manifest.SignedSession) + coordinator, err := r.signedSessionCoordinator(config) + if err != nil { + return err + } + coordinator.mu.Lock() + defer coordinator.mu.Unlock() + return r.exchangeSignedSessionGrantLocked(config, coordinator, grant) +} + +func (r *extensionRuntime) exchangeSignedSessionGrantLocked( + config SignedSessionConfig, + coordinator *signedSessionCoordinator, + grant string, +) error { record, err := r.loadSignedSession(config) if err != nil { return err } + grantHashBytes := sha256.Sum256([]byte(grant)) + grantHash := hex.EncodeToString(grantHashBytes[:]) + // A duplicated callback may arrive after another runtime already exchanged + // the same one-time grant. Treat that exact shared result as completed + // instead of consuming the grant again. + if coordinator.completedGrantHash == grantHash && + signedSessionRecordIsUsable(record) { + coordinator.clearChallenge() + return nil + } endpoint, err := signedSessionURL(config, config.Endpoints.Exchange) if err != nil { return err @@ -346,24 +449,51 @@ func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error { "platform": config.Platform, } body, _ := json.Marshal(payload) - req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion) - resp, err := r.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - respBody, err := readExtensionHTTPResponseBody(resp) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode) + var respBody []byte + for attempt := 1; attempt <= signedSessionExchangeMaxAttempts; attempt++ { + req, requestErr := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if requestErr != nil { + return requestErr + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion) + resp, requestErr := r.httpClient.Do(req) + if requestErr != nil { + return requestErr + } + respBody, requestErr = readExtensionHTTPResponseBody(resp) + resp.Body.Close() + if requestErr != nil { + return requestErr + } + if resp.StatusCode == http.StatusTooManyRequests && attempt < signedSessionExchangeMaxAttempts { + retryAfter := time.Duration(signedSessionRetryAfterSeconds(resp)) * time.Second + if retryAfter <= 0 { + retryAfter = time.Second + } + if retryAfter > signedSessionMaxRetryAfter { + retryAfter = signedSessionMaxRetryAfter + } + LogWarn( + "SignedSession", + "Grant exchange rate limited for extension %s; retrying in %s (attempt %d/%d)", + r.extensionID, + retryAfter, + attempt+1, + signedSessionExchangeMaxAttempts, + ) + signedSessionRetryWait(retryAfter) + continue + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + message := fmt.Sprintf("session exchange failed: HTTP %d", resp.StatusCode) + if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 { + message += fmt.Sprintf("; retry-after seconds: %d", retryAfter) + } + return errors.New(message) + } + break } var exchanged signedSessionExchangeResponse if err := json.Unmarshal(respBody, &exchanged); err != nil { @@ -375,7 +505,12 @@ func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error { record.SessionID = exchanged.SessionID record.SessionSecret = exchanged.SessionSecret record.ExpiresAt = exchanged.ExpiresAt - return r.saveSignedSession(config, record) + if err := r.saveSignedSession(config, record); err != nil { + return err + } + coordinator.completedGrantHash = grantHash + coordinator.clearChallenge() + return nil } func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value { @@ -405,31 +540,91 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value } extraHeaders := parseGojaHeaders(call.Argument(3).Export()) + coordinator, err := r.signedSessionCoordinator(config) + if err != nil { + return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) + } + coordinator.mu.Lock() record, err := r.ensureSignedSession(config) if err != nil { - if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch"); authURL != "" { + authURL, verificationErr := r.startSignedSessionVerificationLocked(config, coordinator, "signed-fetch") + coordinator.mu.Unlock() + if authURL != "" { return r.signedSessionVerificationRequiredValue(authURL) } else if verificationErr != nil { return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()}) } return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) } + coordinator.mu.Unlock() - resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders) - if err != nil { - return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired { - record.SessionID = "" - record.SessionSecret = "" - record.ExpiresAt = "" - _ = r.saveSignedSession(config, record) - if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch-reauth"); authURL != "" { + // A request that loses a race with a successful grant exchange may return + // 401 using the old secret. Reload and retry with the newer shared session; + // never let the stale response erase that replacement session. + for sessionAttempt := 0; sessionAttempt < 3; sessionAttempt++ { + resp, respBody, respHeaders, requestErr := r.doSignedSessionRequest( + config, + record, + method, + requestPath, + body, + extraHeaders, + ) + if requestErr != nil { + return r.vm.ToValue(map[string]any{"ok": false, "error": requestErr.Error()}) + } + if resp.StatusCode != http.StatusUnauthorized && + resp.StatusCode != http.StatusPreconditionRequired { + return r.signedSessionResponseValue(resp, respBody, respHeaders) + } + + coordinator.mu.Lock() + latest, loadErr := r.loadSignedSession(config) + if loadErr != nil { + coordinator.mu.Unlock() + return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()}) + } + if signedSessionRecordIsUsable(latest) && + !sameSignedSession(latest, record) { + record = latest + coordinator.mu.Unlock() + LogDebug( + "SignedSession", + "Discarding stale 401 for extension %s and retrying with the exchanged session", + r.extensionID, + ) + continue + } + if sameSignedSession(latest, record) { + latest.SessionID = "" + latest.SessionSecret = "" + latest.ExpiresAt = "" + if saveErr := r.saveSignedSession(config, latest); saveErr != nil { + coordinator.mu.Unlock() + return r.vm.ToValue(map[string]any{"ok": false, "error": saveErr.Error()}) + } + } + authURL, verificationErr := r.startSignedSessionVerificationLocked( + config, + coordinator, + "signed-fetch-reauth", + ) + coordinator.mu.Unlock() + if authURL != "" { return r.signedSessionVerificationRequiredValue(authURL) } else if verificationErr != nil { return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()}) } + return r.signedSessionResponseValue(resp, respBody, respHeaders) } + return r.vm.ToValue(map[string]any{"ok": false, "error": "signed-session retry limit reached"}) +} + +func (r *extensionRuntime) signedSessionResponseValue( + resp *http.Response, + respBody []byte, + respHeaders map[string]any, +) goja.Value { return r.vm.ToValue(map[string]any{ "statusCode": resp.StatusCode, "status": resp.StatusCode, @@ -508,6 +703,48 @@ func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, reco } func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) (string, error) { + coordinator, err := r.signedSessionCoordinator(config) + if err != nil { + return "", err + } + coordinator.mu.Lock() + defer coordinator.mu.Unlock() + return r.startSignedSessionVerificationLocked(config, coordinator, reason) +} + +func (r *extensionRuntime) startSignedSessionVerificationLocked( + config SignedSessionConfig, + coordinator *signedSessionCoordinator, + reason string, +) (string, error) { + if coordinator.activeChallenge() { + pendingAuthRequestsMu.Lock() + pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ + ExtensionID: r.extensionID, + AuthURL: coordinator.authURL, + CallbackURL: coordinator.callbackURL, + CreatedAt: coordinator.challengeCreatedAt, + } + pendingAuthRequestsMu.Unlock() + coordinator.pendingExtensionIDs[r.extensionID] = struct{}{} + return coordinator.authURL, nil + } + if coordinator.authURL != "" { + coordinator.clearChallenge() + } + if pending := GetPendingAuthRequest(r.extensionID); pending != nil { + if time.Since(pending.CreatedAt) < pendingAuthRequestTTL && + strings.TrimSpace(pending.AuthURL) != "" { + coordinator.rememberChallenge( + r.extensionID, + pending.AuthURL, + pending.CallbackURL, + ) + return pending.AuthURL, nil + } + ClearPendingAuthRequest(r.extensionID) + } + record, err := r.loadSignedSession(config) if err != nil { return "", fmt.Errorf("load signed-session bootstrap state: %w", err) @@ -594,6 +831,7 @@ func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionCo if err := r.saveSignedSession(config, record); err != nil { return "", fmt.Errorf("save bootstrapped signed session: %w", err) } + coordinator.clearChallenge() return "", nil } authURL := boot.AuthURL @@ -614,6 +852,7 @@ func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionCo CreatedAt: time.Now(), } pendingAuthRequestsMu.Unlock() + coordinator.rememberChallenge(r.extensionID, authURL, config.CallbackURL) return authURL, nil } diff --git a/go_backend/extension_signed_session_test.go b/go_backend/extension_signed_session_test.go index 9577661a..51d92202 100644 --- a/go_backend/extension_signed_session_test.go +++ b/go_backend/extension_signed_session_test.go @@ -10,8 +10,10 @@ import ( "io" "net/http" "os" + "path/filepath" goruntime "runtime" "strings" + "sync/atomic" "testing" "time" @@ -353,7 +355,9 @@ func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) manager.mu.Unlock() }) - requestJSON := `{"service":"preflight-download","item_id":"preflight-item","isrc":"USRC17607839"}` + // Metadata may originate from another extension, but the explicitly chosen + // download provider owns the signed-session namespace and verification. + requestJSON := `{"source":"spotify-metadata","service":"preflight-download","item_id":"preflight-item","isrc":"USRC17607839"}` responseJSON, err := DownloadWithExtensionsJSON(requestJSON) if err != nil { t.Fatalf("DownloadWithExtensionsJSON: %v", err) @@ -370,6 +374,68 @@ func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) } } +func TestParallelSignedSessionPreflightSharesOneBootstrap(t *testing.T) { + var calls atomic.Int32 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls.Add(1) + // Keep the first bootstrap in flight long enough for the second runtime + // to contend on the shared session coordinator. + time.Sleep(20 * time.Millisecond) + payload, _ := json.Marshal(signedSessionExchangeResponse{ + ChallengeURL: "https://auth.example.com/challenge", + }) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(payload))), + Request: req, + }, nil + }) + + root := t.TempDir() + config := &SignedSessionConfig{ + Namespace: "shared-download-session", + BaseURL: "https://auth.example.com", + } + newRuntime := func(extensionID string) *extensionRuntime { + return &extensionRuntime{ + extensionID: extensionID, + manifest: &ExtensionManifest{ + Name: extensionID, + SignedSession: config, + }, + dataDir: filepath.Join(root, extensionID), + vm: goja.New(), + httpClient: &http.Client{Transport: transport}, + } + } + runtimeA := newRuntime("provider-a") + runtimeB := newRuntime("provider-b") + t.Cleanup(func() { + ClearPendingAuthRequest("provider-a") + ClearPendingAuthRequest("provider-b") + }) + + results := make(chan error, 2) + for _, runtime := range []*extensionRuntime{runtimeA, runtimeB} { + go func(runtime *extensionRuntime) { + verificationRequired, err := runtime.preflightSignedSession() + if err == nil && !verificationRequired { + err = fmt.Errorf("verification was not requested") + } + results <- err + }(runtime) + } + for range 2 { + if err := <-results; err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("parallel preflight bootstrap calls = %d, want 1", got) + } +} + func TestDownloadWithExtensionsStopsAfterFailedSignedSessionPreflight(t *testing.T) { extensionID := "preflight-network-failure" itemID := "preflight-network-item" @@ -857,9 +923,115 @@ func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) { } } +func TestStaleSignedSession401RetriesWithoutClearingExchangedSession(t *testing.T) { + oldRequestStarted := make(chan struct{}) + releaseOldRequest := make(chan struct{}) + var searchCalls atomic.Int32 + + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/session/exchange"): + payload, _ := json.Marshal(signedSessionExchangeResponse{ + SessionID: "sess-new", + SessionSecret: "secret-new", + ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(payload))), + Request: req, + }, nil + case strings.HasSuffix(req.URL.Path, "/tracks/search"): + searchCalls.Add(1) + if req.Header.Get("X-Sig-Session") == "sess-old" { + close(oldRequestStarted) + <-releaseOldRequest + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{}`)), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + Request: req, + }, nil + default: + t.Fatalf("unexpected request: %s", req.URL.String()) + return nil, nil + } + }) + + root := t.TempDir() + config := &SignedSessionConfig{ + Namespace: "shared-stale-session", + BaseURL: "https://auth.example.com", + } + newRuntime := func(extensionID string) *extensionRuntime { + return &extensionRuntime{ + extensionID: extensionID, + manifest: &ExtensionManifest{ + Name: extensionID, + SignedSession: config, + }, + dataDir: filepath.Join(root, extensionID), + vm: goja.New(), + httpClient: &http.Client{Transport: transport}, + } + } + runtimeA := newRuntime("download-a") + runtimeB := newRuntime("download-b") + resolved := signedSessionConfigWithDefaults(config) + record, err := runtimeA.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + record.SessionID = "sess-old" + record.SessionSecret = "secret-old" + record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + if err := runtimeA.saveSignedSession(resolved, record); err != nil { + t.Fatal(err) + } + + resultCh := make(chan map[string]any, 1) + go func() { + call := goja.FunctionCall{Arguments: []goja.Value{ + runtimeA.vm.ToValue("GET"), + runtimeA.vm.ToValue("/tracks/search"), + }} + resultCh <- runtimeA.signedSessionFetch(call).Export().(map[string]any) + }() + <-oldRequestStarted + if err := runtimeB.exchangeSignedSessionGrant("grant-new"); err != nil { + t.Fatalf("exchange grant: %v", err) + } + close(releaseOldRequest) + + result := <-resultCh + if result["ok"] != true { + t.Fatalf("stale request was not retried with the new session: %+v", result) + } + if got := searchCalls.Load(); got != 2 { + t.Fatalf("signed search calls = %d, want stale request plus one retry", got) + } + reloaded, err := runtimeA.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-new" || reloaded.SessionSecret != "secret-new" { + t.Fatalf("stale 401 overwrote exchanged session: %+v", reloaded) + } +} + func TestExchangeSignedSessionGrant(t *testing.T) { t.Run("success stores the exchanged session", func(t *testing.T) { + calls := 0 transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ if !strings.HasSuffix(req.URL.Path, "/session/exchange") { t.Fatalf("unexpected path: %s", req.URL.Path) } @@ -873,6 +1045,12 @@ func TestExchangeSignedSessionGrant(t *testing.T) { if err := runtime.exchangeSignedSessionGrant("grant-token"); err != nil { t.Fatalf("unexpected error: %v", err) } + if err := runtime.exchangeSignedSessionGrant("grant-token"); err != nil { + t.Fatalf("duplicate callback was not idempotent: %v", err) + } + if calls != 1 { + t.Fatalf("duplicate grant exchange calls = %d, want 1", calls) + } config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession) record, err := runtime.loadSignedSession(config) @@ -895,6 +1073,105 @@ func TestExchangeSignedSessionGrant(t *testing.T) { t.Fatal("expected an error for a non-2xx exchange response") } }) + + t.Run("429 preserves the grant and retries after the server delay", func(t *testing.T) { + pendingSignedSessionGrantsMu.Lock() + pendingSignedSessionGrants = make(map[string]string) + pendingSignedSessionGrantsMu.Unlock() + previousWait := signedSessionRetryWait + var waits []time.Duration + signedSessionRetryWait = func(delay time.Duration) { + waits = append(waits, delay) + } + t.Cleanup(func() { signedSessionRetryWait = previousWait }) + + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"7"}}, + Body: io.NopCloser(strings.NewReader(`{}`)), + Request: req, + }, nil + } + payload, _ := json.Marshal(signedSessionExchangeResponse{ + SessionID: "sess-after-429", + SessionSecret: "secret-after-429", + ExpiresAt: "2030-01-01T00:00:00Z", + }) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(payload))), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "tidal-rate-limit", transport) + runtime.manifest.SignedSession = &SignedSessionConfig{ + Namespace: "tidal-rate-limit", + BaseURL: "https://auth.example.com", + } + setPendingSignedSessionGrant(runtime.extensionID, "grant-preserved") + + result := runtime.signedSessionCompleteGrant(goja.FunctionCall{}).Export().(map[string]any) + if result["success"] != true { + t.Fatalf("grant exchange did not recover from 429: %+v", result) + } + if calls != 2 { + t.Fatalf("exchange calls = %d, want 2", calls) + } + if len(waits) != 1 || waits[0] != 7*time.Second { + t.Fatalf("exchange retry waits = %v, want [7s]", waits) + } + pendingSignedSessionGrantsMu.Lock() + _, stillPending := pendingSignedSessionGrants[runtime.extensionID] + pendingSignedSessionGrantsMu.Unlock() + if stillPending { + t.Fatal("grant was not cleared after successful retry") + } + }) + + t.Run("exhausted 429 retries keep the grant for a later retry", func(t *testing.T) { + pendingSignedSessionGrantsMu.Lock() + pendingSignedSessionGrants = make(map[string]string) + pendingSignedSessionGrantsMu.Unlock() + previousWait := signedSessionRetryWait + signedSessionRetryWait = func(time.Duration) {} + t.Cleanup(func() { signedSessionRetryWait = previousWait }) + + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"3"}}, + Body: io.NopCloser(strings.NewReader(`{}`)), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "tidal-rate-limit-exhausted", transport) + runtime.manifest.SignedSession = &SignedSessionConfig{ + Namespace: "tidal-rate-limit-exhausted", + BaseURL: "https://auth.example.com", + } + setPendingSignedSessionGrant(runtime.extensionID, "grant-retry-later") + + result := runtime.signedSessionCompleteGrant(goja.FunctionCall{}).Export().(map[string]any) + if result["success"] != false { + t.Fatalf("exhausted rate limit unexpectedly succeeded: %+v", result) + } + if calls != signedSessionExchangeMaxAttempts { + t.Fatalf("exchange calls = %d, want %d", calls, signedSessionExchangeMaxAttempts) + } + pendingSignedSessionGrantsMu.Lock() + pendingGrant := pendingSignedSessionGrants[runtime.extensionID] + pendingSignedSessionGrantsMu.Unlock() + if pendingGrant != "grant-retry-later" { + t.Fatalf("pending grant = %q, want it preserved for retry", pendingGrant) + } + }) } func TestSetPendingSignedSessionGrant(t *testing.T) {