diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index bdd1fac2..f85bd993 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -108,6 +108,15 @@ The behavior flags currently supported are `skipMetadataEnrichment`, extension-specific behavior must be added as a generic manifest capability; the host must not branch on a particular provider ID. +Extensions that depend on canonical gateway/provider error ownership should +declare `requiredRuntimeFeatures: ["signedSession@2"]`. Version 2 only mutates +gateway session state for the exact `SESSION_INVALID/bootstrap_session` or +`VERIFY_REQUIRED/verify` contracts, exposes canonical error fields to JS, and +applies bounded `Retry-After` handling to retryable `PROVIDER_UNAVAILABLE` +responses. `403 REQUEST_AUTH_INVALID` preserves the session and only retries +once when a newer session generation is already available. BYOA +reauthentication remains a separate provider action. + Do not use legacy spellings such as `display_name`, `types`, `permissions.network.domains`, or an object for `permissions.network`. diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index 3732936b..1b7d7477 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -435,18 +435,20 @@ func classifyDownloadErrorType(msg string) string { return "isp_blocked" } else if strings.Contains(lowerMsg, "cancel") { return "cancelled" - } else if strings.Contains(lowerMsg, "verify_required") || - strings.Contains(lowerMsg, "verification_required") || - strings.Contains(lowerMsg, "verification required") || - strings.Contains(lowerMsg, "needs verification") || + } else if strings.Contains(lowerMsg, "verification_required") || strings.Contains(lowerMsg, "session is not authenticated") || strings.Contains(lowerMsg, "signed session is not authenticated") || - strings.Contains(lowerMsg, "signed session expired") || - strings.Contains(lowerMsg, "unauthorized") || - strings.Contains(lowerMsg, "precondition required") || - messageHasHTTPStatusCode(lowerMsg, "401") || - messageHasHTTPStatusCode(lowerMsg, "428") { + strings.Contains(lowerMsg, "signed session expired") { return "verification_required" + } else if strings.Contains(lowerMsg, "byoa_provider_reauth_required") || + strings.Contains(lowerMsg, "reauth_provider") { + return "provider_reauth_required" + } else if strings.Contains(lowerMsg, "request_auth_invalid") { + return "request_auth_invalid" + } else if strings.Contains(lowerMsg, "provider_auth_failed") { + return "provider_auth_failed" + } else if strings.Contains(lowerMsg, "provider_unavailable") { + return "provider_unavailable" } else if strings.Contains(lowerMsg, "rate limit") || messageHasHTTPStatusCode(lowerMsg, "429") || strings.Contains(lowerMsg, "too many requests") { diff --git a/go_backend/exports_supplement_test.go b/go_backend/exports_supplement_test.go index 6e9db8b7..f39f1099 100644 --- a/go_backend/exports_supplement_test.go +++ b/go_backend/exports_supplement_test.go @@ -33,9 +33,8 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) { func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) { cases := []string{ - "HTTP 401 for /tickets", - "HTTP status 428: precondition required", - "Verification required", + "verification_required: canonical gateway challenge", + "signed session expired", } for _, tc := range cases { if got := classifyDownloadErrorType(tc); got != "verification_required" { @@ -44,6 +43,36 @@ func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) { } } +func TestDownloadErrorClassificationDoesNotInferVerificationFromHTTPStatus(t *testing.T) { + cases := []string{ + "HTTP 401 for /tickets", + "HTTP 403 forbidden", + "HTTP status 428: precondition required", + "Provider returned unauthorized", + "VERIFY_REQUIRED without canonical origin and action", + "Verification required without a typed contract", + } + for _, tc := range cases { + if got := classifyDownloadErrorType(tc); got == "verification_required" { + t.Fatalf("classifyDownloadErrorType(%q) inferred verification from an ambiguous status", tc) + } + } +} + +func TestDownloadErrorClassificationPreservesProviderContracts(t *testing.T) { + tests := map[string]string{ + "PROVIDER_AUTH_FAILED": "provider_auth_failed", + "PROVIDER_UNAVAILABLE": "provider_unavailable", + "REQUEST_AUTH_INVALID": "request_auth_invalid", + "BYOA_PROVIDER_REAUTH_REQUIRED action": "provider_reauth_required", + } + for message, want := range tests { + if got := classifyDownloadErrorType(message); got != want { + t.Fatalf("classifyDownloadErrorType(%q) = %q, want %q", message, got, want) + } + } +} + func TestOutputStorageWriteFailureDetection(t *testing.T) { cases := []struct { name string diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 3faf3c11..c8fe00c1 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -251,7 +251,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded } var supportedRuntimeFeatures = map[string]int{ - "signedSession": 1, + "signedSession": 2, "sessionRefresh": 1, "sessionGrant": 1, "globalAction": 1, diff --git a/go_backend/extension_manager_supplement_test.go b/go_backend/extension_manager_supplement_test.go index e89d394c..239c4a32 100644 --- a/go_backend/extension_manager_supplement_test.go +++ b/go_backend/extension_manager_supplement_test.go @@ -177,7 +177,7 @@ func TestValidateManifestGates(t *testing.T) { SetAppVersion("4.5.0") pass := &ExtensionManifest{ - RequiredRuntimeFeatures: []string{"signedSession@1", "sessionGrant"}, + RequiredRuntimeFeatures: []string{"signedSession@2", "sessionGrant"}, } if err := validateManifestGates(pass); err != nil { t.Fatalf("supported features should pass: %v", err) diff --git a/go_backend/extension_provider_wrapper.go b/go_backend/extension_provider_wrapper.go index eff9f561..f23f7f0d 100644 --- a/go_backend/extension_provider_wrapper.go +++ b/go_backend/extension_provider_wrapper.go @@ -548,7 +548,7 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a if !availability.Available && p.extension.runtime != nil { if p.extension.runtime.consumeVerificationRequired() != "" { return nil, fmt.Errorf( - "VERIFY_REQUIRED: extension '%s' needs signed-session verification", + "verification_required: extension '%s' needs signed-session verification", p.extension.ID, ) } diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 4db4d122..26d6ab23 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -28,6 +28,9 @@ const signedSessionRefreshSkew = time.Hour const ( signedSessionExchangeMaxAttempts = 3 signedSessionMaxRetryAfter = 5 * time.Minute + signedSessionMaxSessionRetries = 2 + signedSessionMaxProviderRetries = 2 + signedSessionProviderRetryDelay = time.Second ) var ( @@ -35,6 +38,7 @@ var ( pendingSignedSessionGrantsMu sync.Mutex signedSessionCoordinators sync.Map signedSessionRetryWait = time.Sleep + signedSessionProviderWait = sleepRetry ) // signedSessionCoordinator serializes authentication state for every runtime @@ -106,6 +110,19 @@ type signedSessionExchangeResponse struct { AuthURL string `json:"auth_url,omitempty"` } +// signedSessionErrorContract is the gateway-owned error envelope. Decisions +// that mutate authentication state must use these fields together with the +// HTTP status; a provider response must never be able to masquerade as a +// gateway session failure merely by returning 401 or 403 upstream. +type signedSessionErrorContract struct { + Error string `json:"error,omitempty"` + Code string `json:"code,omitempty"` + Origin string `json:"origin,omitempty"` + Action string `json:"action,omitempty"` + Retryable bool `json:"retryable,omitempty"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` +} + func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig { if config == nil { return SignedSessionConfig{} @@ -285,6 +302,64 @@ func sameSignedSession(a, b *signedSessionRecord) bool { a.SessionSecret == b.SessionSecret } +func parseSignedSessionErrorContract(body []byte) (signedSessionErrorContract, bool) { + var contract signedSessionErrorContract + if len(body) == 0 || json.Unmarshal(body, &contract) != nil { + return signedSessionErrorContract{}, false + } + contract.Error = strings.TrimSpace(contract.Error) + contract.Code = strings.ToUpper(strings.TrimSpace(contract.Code)) + contract.Origin = strings.ToLower(strings.TrimSpace(contract.Origin)) + contract.Action = strings.ToLower(strings.TrimSpace(contract.Action)) + if contract.RetryAfterSeconds < 0 { + contract.RetryAfterSeconds = 0 + } + return contract, contract.Code != "" || contract.Origin != "" || contract.Action != "" +} + +func signedSessionGatewayAction(statusCode int, contract signedSessionErrorContract) string { + if contract.Origin != "gateway" { + return "" + } + switch { + case statusCode == http.StatusUnauthorized && + contract.Code == "SESSION_INVALID" && + contract.Action == "bootstrap_session": + return "bootstrap_session" + case statusCode == http.StatusPreconditionRequired && + contract.Code == "VERIFY_REQUIRED" && + contract.Action == "verify": + return "verify" + default: + return "" + } +} + +func signedSessionProviderUnavailable(statusCode int, contract signedSessionErrorContract) bool { + return statusCode == http.StatusServiceUnavailable && + contract.Origin == "provider" && + contract.Code == "PROVIDER_UNAVAILABLE" && + contract.Retryable +} + +func signedSessionRequestAuthInvalid(statusCode int, contract signedSessionErrorContract) bool { + return statusCode == http.StatusForbidden && + contract.Origin == "gateway" && + contract.Code == "REQUEST_AUTH_INVALID" && + contract.Action == "" +} + +func signedSessionProviderRetryDuration(resp *http.Response, contract signedSessionErrorContract) time.Duration { + if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 { + return retryAfter + } + if contract.RetryAfterSeconds > 0 { + maxSeconds := int(maxRetryAfterDelay / time.Second) + return time.Duration(min(contract.RetryAfterSeconds, maxSeconds)) * time.Second + } + return signedSessionProviderRetryDelay +} + // 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 @@ -558,10 +633,14 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value } coordinator.mu.Unlock() - // 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++ { + // A request that loses a race with a successful grant exchange may return a + // canonical SESSION_INVALID response for the old secret. Reload and retry + // with the newer shared session; never let that stale response erase its + // replacement. Provider retries have an independent, bounded budget. + sessionRetries := 0 + providerRetries := 0 + requestAuthRetryUsed := false + for { resp, respBody, respHeaders, requestErr := r.doSignedSessionRequest( config, record, @@ -573,8 +652,59 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value if requestErr != nil { return r.vm.ToValue(map[string]any{"ok": false, "error": requestErr.Error()}) } - if resp.StatusCode != http.StatusUnauthorized && - resp.StatusCode != http.StatusPreconditionRequired { + contract, _ := parseSignedSessionErrorContract(respBody) + + if signedSessionProviderUnavailable(resp.StatusCode, contract) { + if providerRetries >= signedSessionMaxProviderRetries { + return r.signedSessionResponseValue(resp, respBody, respHeaders) + } + providerRetries++ + delay := signedSessionProviderRetryDuration(resp, contract) + LogWarn( + "SignedSession", + "Provider temporarily unavailable for extension %s; retrying in %s (attempt %d/%d)", + r.extensionID, + delay, + providerRetries+1, + signedSessionMaxProviderRetries+1, + ) + if waitErr := signedSessionProviderWait(resp.Request.Context(), delay); waitErr != nil { + return r.vm.ToValue(map[string]any{"ok": false, "error": waitErr.Error()}) + } + continue + } + + if signedSessionRequestAuthInvalid(resp.StatusCode, contract) { + 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 !requestAuthRetryUsed && + signedSessionRecordIsUsable(latest) && + !sameSignedSession(latest, record) { + requestAuthRetryUsed = true + record = latest + coordinator.mu.Unlock() + LogDebug( + "SignedSession", + "Retrying stale REQUEST_AUTH_INVALID for extension %s with the current session generation", + r.extensionID, + ) + continue + } + coordinator.mu.Unlock() + LogWarn( + "SignedSession", + "REQUEST_AUTH_INVALID for extension %s on the current session generation; preserving session state", + r.extensionID, + ) + return r.signedSessionResponseValue(resp, respBody, respHeaders) + } + + gatewayAction := signedSessionGatewayAction(resp.StatusCode, contract) + if gatewayAction == "" { return r.signedSessionResponseValue(resp, respBody, respHeaders) } @@ -586,16 +716,24 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value } if signedSessionRecordIsUsable(latest) && !sameSignedSession(latest, record) { + if sessionRetries >= signedSessionMaxSessionRetries { + coordinator.mu.Unlock() + return r.vm.ToValue(map[string]any{"ok": false, "error": "signed-session retry limit reached"}) + } + sessionRetries++ record = latest coordinator.mu.Unlock() LogDebug( "SignedSession", - "Discarding stale 401 for extension %s and retrying with the exchanged session", + "Discarding stale %s response for extension %s and retrying with the exchanged session", + contract.Code, r.extensionID, ) continue } - if sameSignedSession(latest, record) { + // VERIFY_REQUIRED is an explicit challenge request, not a revocation. + // Only SESSION_INVALID is allowed to clear the current gateway session. + if gatewayAction == "bootstrap_session" && sameSignedSession(latest, record) { latest.SessionID = "" latest.SessionSecret = "" latest.ExpiresAt = "" @@ -607,17 +745,34 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value authURL, verificationErr := r.startSignedSessionVerificationLocked( config, coordinator, - "signed-fetch-reauth", + "signed-fetch-"+gatewayAction, ) - coordinator.mu.Unlock() if authURL != "" { + coordinator.mu.Unlock() return r.signedSessionVerificationRequiredValue(authURL) } else if verificationErr != nil { + coordinator.mu.Unlock() return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()}) } + + // Bootstrap may silently issue a replacement session instead of a + // challenge. Retry the original operation with that generation. + bootstrapped, loadErr := r.loadSignedSession(config) + if loadErr != nil { + coordinator.mu.Unlock() + return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()}) + } + if signedSessionRecordIsUsable(bootstrapped) && + !sameSignedSession(bootstrapped, record) && + sessionRetries < signedSessionMaxSessionRetries { + sessionRetries++ + record = bootstrapped + coordinator.mu.Unlock() + continue + } + coordinator.mu.Unlock() 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( @@ -625,15 +780,28 @@ func (r *extensionRuntime) signedSessionResponseValue( respBody []byte, respHeaders map[string]any, ) goja.Value { - return r.vm.ToValue(map[string]any{ + contract, hasContract := parseSignedSessionErrorContract(respBody) + retryAfterSeconds := signedSessionRetryAfterSeconds(resp) + if retryAfterSeconds <= 0 && contract.RetryAfterSeconds > 0 { + retryAfterSeconds = contract.RetryAfterSeconds + } + result := map[string]any{ "statusCode": resp.StatusCode, "status": resp.StatusCode, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "url": resp.Request.URL.String(), "body": string(respBody), "headers": respHeaders, - "retryAfterSeconds": signedSessionRetryAfterSeconds(resp), - }) + "retryAfterSeconds": retryAfterSeconds, + } + if hasContract { + result["error"] = contract.Error + result["code"] = contract.Code + result["origin"] = contract.Origin + result["action"] = contract.Action + result["retryable"] = contract.Retryable + } + return r.vm.ToValue(result) } func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value { diff --git a/go_backend/extension_signed_session_test.go b/go_backend/extension_signed_session_test.go index 51d92202..61d96c3c 100644 --- a/go_backend/extension_signed_session_test.go +++ b/go_backend/extension_signed_session_test.go @@ -1,6 +1,7 @@ package gobackend import ( + "context" "crypto/hmac" "crypto/sha256" "encoding/base64" @@ -582,6 +583,24 @@ func TestSignedSessionRetryAfterSeconds(t *testing.T) { }) } +func TestSignedSessionProviderRetryDuration(t *testing.T) { + t.Run("header is authoritative", func(t *testing.T) { + resp := &http.Response{Header: http.Header{"Retry-After": []string{"10"}}} + contract := signedSessionErrorContract{RetryAfterSeconds: 30} + if got := signedSessionProviderRetryDuration(resp, contract); got != 10*time.Second { + t.Fatalf("retry duration = %s, want 10s", got) + } + }) + + t.Run("body fallback is bounded", func(t *testing.T) { + resp := &http.Response{Header: make(http.Header)} + contract := signedSessionErrorContract{RetryAfterSeconds: int(^uint(0) >> 1)} + if got := signedSessionProviderRetryDuration(resp, contract); got != maxRetryAfterDelay { + t.Fatalf("retry duration = %s, want cap %s", got, maxRetryAfterDelay) + } + }) +} + func TestNormalizeSignedSessionRecordScope(t *testing.T) { config := SignedSessionConfig{Namespace: "Tidal", BaseURL: "https://a.example.com", AppVersion: "1.0", Platform: "mobile"} @@ -634,6 +653,27 @@ func newSignedSessionTestRuntime(t *testing.T, extensionID string, transport rou } } +func saveUsableSignedSession( + t *testing.T, + runtime *extensionRuntime, + config SignedSessionConfig, + sessionID string, +) SignedSessionConfig { + t.Helper() + resolved := signedSessionConfigWithDefaults(&config) + record, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + record.SessionID = sessionID + record.SessionSecret = "secret-" + sessionID + record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + if err := runtime.saveSignedSession(resolved, record); err != nil { + t.Fatal(err) + } + return resolved +} + func TestSignedSessionFilePathDeterminism(t *testing.T) { runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil) @@ -878,7 +918,7 @@ func TestSignedSessionFetchUnauthenticatedTriggersVerification(t *testing.T) { } } -func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) { +func TestSignedSessionFetchRevokesSessionOnCanonicalSessionInvalid(t *testing.T) { calls := 0 transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { calls++ @@ -888,7 +928,14 @@ func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) { body, _ := json.Marshal(payload) return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil default: - return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway","action":"bootstrap_session"}`, + )), + Request: req, + }, nil } }) @@ -911,7 +958,7 @@ func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) { call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("GET"), runtime.vm.ToValue("/tracks/search")}} result := runtime.signedSessionFetch(call).Export().(map[string]any) if result["ok"] != false || result["needsVerification"] != true { - t.Fatalf("expected a verification-required response after 401, got %+v", result) + t.Fatalf("expected verification after canonical SESSION_INVALID, got %+v", result) } reloaded, err := runtime.loadSignedSession(resolved) @@ -919,10 +966,459 @@ func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) { t.Fatalf("unexpected error: %v", err) } if reloaded.SessionID != "" || reloaded.SessionSecret != "" { - t.Fatalf("expected session to be wiped after 401, got %+v", reloaded) + t.Fatalf("expected session to be wiped after SESSION_INVALID, got %+v", reloaded) } } +func TestSignedSessionFetchRetriesAfterSilentSessionBootstrap(t *testing.T) { + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + switch { + case strings.Contains(req.URL.Path, "/bootstrap"): + payload, _ := json.Marshal(signedSessionExchangeResponse{ + SessionID: "sess-replacement", + SessionSecret: "secret-replacement", + 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 req.Header.Get("X-Sig-Session") == "sess-replacement": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"ticket_id":"fresh"}`)), + Request: req, + }, nil + default: + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway","action":"bootstrap_session"}`, + )), + Request: req, + }, nil + } + }) + runtime := newSignedSessionTestRuntime(t, "silent-bootstrap", transport) + config := SignedSessionConfig{Namespace: "silent-bootstrap", BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + resolved := saveUsableSignedSession(t, runtime, config, "sess-old") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("POST"), + runtime.vm.ToValue("/tickets"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if result["ok"] != true || calls != 3 { + t.Fatalf("signed request was not retried after silent bootstrap: calls=%d result=%+v", calls, result) + } + reloaded, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-replacement" || reloaded.SessionSecret != "secret-replacement" { + t.Fatalf("replacement session was not persisted: %+v", reloaded) + } +} + +func TestSignedSessionFetchDoesNotMutateSessionForNonCanonicalAuthStatus(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + }{ + { + name: "bare 401", + statusCode: http.StatusUnauthorized, + body: `{}`, + }, + { + name: "provider-origin 401", + statusCode: http.StatusUnauthorized, + body: `{"error":"Unauthorized","code":"PROVIDER_AUTH_FAILED","origin":"provider"}`, + }, + { + name: "gateway 401 missing action", + statusCode: http.StatusUnauthorized, + body: `{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway"}`, + }, + { + name: "bare 428", + statusCode: http.StatusPreconditionRequired, + body: `{"error":"VERIFY_REQUIRED"}`, + }, + { + name: "request auth 403 with forbidden action", + statusCode: http.StatusForbidden, + body: `{"error":"Forbidden","code":"REQUEST_AUTH_INVALID","origin":"gateway","action":"bootstrap_session"}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{ + StatusCode: tc.statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(tc.body)), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "strict-contract-"+strings.ReplaceAll(tc.name, " ", "-"), transport) + config := SignedSessionConfig{Namespace: runtime.extensionID, BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + resolved := saveUsableSignedSession(t, runtime, config, "sess-safe") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("GET"), + runtime.vm.ToValue("/tracks/search"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if result["needsVerification"] == true { + t.Fatalf("non-canonical response triggered verification: %+v", result) + } + if calls != 1 { + t.Fatalf("network calls = %d, want no bootstrap or retry", calls) + } + reloaded, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-safe" || reloaded.SessionSecret != "secret-sess-safe" { + t.Fatalf("non-canonical response mutated the session: %+v", reloaded) + } + }) + } +} + +func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) { + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if strings.Contains(req.URL.Path, "/bootstrap") { + payload, _ := json.Marshal(signedSessionExchangeResponse{ + AuthURL: "https://auth.example.com/verify", + }) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(payload))), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusPreconditionRequired, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":"VERIFY_REQUIRED","code":"VERIFY_REQUIRED","origin":"gateway","action":"verify"}`, + )), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "canonical-verify", transport) + config := SignedSessionConfig{Namespace: "canonical-verify", BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + resolved := saveUsableSignedSession(t, runtime, config, "sess-verified") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("GET"), + runtime.vm.ToValue("/tracks/search"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if result["needsVerification"] != true || result["auth_url"] != "https://auth.example.com/verify" { + t.Fatalf("canonical VERIFY_REQUIRED did not open verification: %+v", result) + } + if calls != 2 { + t.Fatalf("network calls = %d, want signed request plus bootstrap", calls) + } + reloaded, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-verified" || reloaded.SessionSecret != "secret-sess-verified" { + t.Fatalf("VERIFY_REQUIRED cleared the valid gateway session: %+v", reloaded) + } +} + +func TestSignedSessionFetchRequestAuthInvalidPreservesSession(t *testing.T) { + t.Run("current generation returns the error without bootstrap", func(t *testing.T) { + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":"Forbidden","code":"REQUEST_AUTH_INVALID","origin":"gateway","retryable":false}`, + )), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "request-auth-current", transport) + config := SignedSessionConfig{Namespace: "request-auth-current", BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + resolved := saveUsableSignedSession(t, runtime, config, "sess-request-auth") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("POST"), + runtime.vm.ToValue("/tickets"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if result["code"] != "REQUEST_AUTH_INVALID" || result["needsVerification"] == true { + t.Fatalf("REQUEST_AUTH_INVALID was not returned as a non-verification error: %+v", result) + } + if calls != 1 { + t.Fatalf("current generation request was retried or bootstrapped: calls=%d", calls) + } + reloaded, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-request-auth" || reloaded.SessionSecret != "secret-sess-request-auth" { + t.Fatalf("REQUEST_AUTH_INVALID mutated the current session: %+v", reloaded) + } + }) + + t.Run("stale generation retries once with the current session", func(t *testing.T) { + oldRequestStarted := make(chan struct{}) + releaseOldRequest := make(chan struct{}) + var requestCalls 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-request-new", + SessionSecret: "secret-request-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, "/tickets"): + requestCalls.Add(1) + if req.Header.Get("X-Sig-Session") == "sess-request-old" { + close(oldRequestStarted) + <-releaseOldRequest + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":"Forbidden","code":"REQUEST_AUTH_INVALID","origin":"gateway","retryable":false}`, + )), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"ticket_id":"fresh"}`)), + Request: req, + }, nil + default: + t.Fatalf("unexpected request: %s", req.URL.String()) + return nil, nil + } + }) + + root := t.TempDir() + config := &SignedSessionConfig{Namespace: "request-auth-shared", 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("request-auth-a") + runtimeB := newRuntime("request-auth-b") + resolved := saveUsableSignedSession(t, runtimeA, *config, "sess-request-old") + + resultCh := make(chan map[string]any, 1) + go func() { + call := goja.FunctionCall{Arguments: []goja.Value{ + runtimeA.vm.ToValue("POST"), + runtimeA.vm.ToValue("/tickets"), + }} + resultCh <- runtimeA.signedSessionFetch(call).Export().(map[string]any) + }() + <-oldRequestStarted + if err := runtimeB.exchangeSignedSessionGrant("grant-request-auth"); err != nil { + t.Fatalf("exchange grant: %v", err) + } + close(releaseOldRequest) + + result := <-resultCh + if result["ok"] != true || requestCalls.Load() != 2 { + t.Fatalf("stale request was not rebuilt once: calls=%d result=%+v", requestCalls.Load(), result) + } + reloaded, err := runtimeA.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-request-new" || reloaded.SessionSecret != "secret-request-new" { + t.Fatalf("stale REQUEST_AUTH_INVALID changed the new session: %+v", reloaded) + } + }) +} + +func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) { + t.Run("provider authentication failure passes through without retry", func(t *testing.T) { + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":"Provider authentication failed","code":"PROVIDER_AUTH_FAILED","origin":"provider","retryable":false}`, + )), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "provider-auth-failed", transport) + config := SignedSessionConfig{Namespace: "provider-auth-failed", BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + resolved := saveUsableSignedSession(t, runtime, config, "sess-provider-auth") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("POST"), + runtime.vm.ToValue("/tickets"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if result["code"] != "PROVIDER_AUTH_FAILED" || result["origin"] != "provider" { + t.Fatalf("provider contract was not exposed to the extension: %+v", result) + } + if result["needsVerification"] == true || calls != 1 { + t.Fatalf("provider auth failure triggered verification or retry: calls=%d result=%+v", calls, result) + } + reloaded, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-provider-auth" { + t.Fatalf("provider auth failure cleared the gateway session: %+v", reloaded) + } + }) + + t.Run("temporary provider failure retries with Retry-After", func(t *testing.T) { + previousWait := signedSessionProviderWait + var waits []time.Duration + signedSessionProviderWait = func(_ context.Context, delay time.Duration) error { + waits = append(waits, delay) + return nil + } + t.Cleanup(func() { signedSessionProviderWait = previousWait }) + + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if calls <= signedSessionMaxProviderRetries { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: http.Header{"Retry-After": []string{"10"}}, + Body: io.NopCloser(strings.NewReader( + `{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_after_seconds":30}`, + )), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "provider-unavailable", transport) + config := SignedSessionConfig{Namespace: "provider-unavailable", BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + resolved := saveUsableSignedSession(t, runtime, config, "sess-provider-retry") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("POST"), + runtime.vm.ToValue("/tickets"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if result["ok"] != true || calls != signedSessionMaxProviderRetries+1 { + t.Fatalf("provider retry did not recover: calls=%d result=%+v", calls, result) + } + if len(waits) != signedSessionMaxProviderRetries { + t.Fatalf("provider retry waits = %v", waits) + } + for _, delay := range waits { + if delay != 10*time.Second { + t.Fatalf("Retry-After header was not authoritative: waits=%v", waits) + } + } + reloaded, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-provider-retry" { + t.Fatalf("provider retry changed the gateway session: %+v", reloaded) + } + }) + + t.Run("temporary provider retry budget is bounded", func(t *testing.T) { + previousWait := signedSessionProviderWait + signedSessionProviderWait = func(context.Context, time.Duration) error { return nil } + t.Cleanup(func() { signedSessionProviderWait = previousWait }) + + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: http.Header{"Retry-After": []string{"1"}}, + Body: io.NopCloser(strings.NewReader( + `{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_after_seconds":1}`, + )), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "provider-unavailable-bounded", transport) + config := SignedSessionConfig{Namespace: "provider-unavailable-bounded", BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + resolved := saveUsableSignedSession(t, runtime, config, "sess-provider-bounded") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("POST"), + runtime.vm.ToValue("/tickets"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if calls != signedSessionMaxProviderRetries+1 { + t.Fatalf("provider attempts = %d, want %d", calls, signedSessionMaxProviderRetries+1) + } + if result["code"] != "PROVIDER_UNAVAILABLE" || result["retryable"] != true { + t.Fatalf("final provider failure was not returned intact: %+v", result) + } + reloaded, err := runtime.loadSignedSession(resolved) + if err != nil { + t.Fatal(err) + } + if reloaded.SessionID != "sess-provider-bounded" { + t.Fatalf("exhausted provider retries changed the gateway session: %+v", reloaded) + } + }) +} + func TestStaleSignedSession401RetriesWithoutClearingExchangedSession(t *testing.T) { oldRequestStarted := make(chan struct{}) releaseOldRequest := make(chan struct{}) @@ -950,8 +1446,10 @@ func TestStaleSignedSession401RetriesWithoutClearingExchangedSession(t *testing. return &http.Response{ StatusCode: http.StatusUnauthorized, Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{}`)), - Request: req, + Body: io.NopCloser(strings.NewReader( + `{"error":"Unauthorized","code":"SESSION_INVALID","origin":"gateway","action":"bootstrap_session"}`, + )), + Request: req, }, nil } return &http.Response{