fix(session): coordinate verification across parallel downloads

This commit is contained in:
zarzet
2026-07-30 11:39:37 +07:00
parent 76f7b9c025
commit 169a811162
3 changed files with 560 additions and 54 deletions
+3 -13
View File
@@ -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
}
+279 -40
View File
@@ -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
}
+278 -1
View File
@@ -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) {