fix(session): honor canonical gateway error contracts

This commit is contained in:
zarzet
2026-07-30 12:59:48 +07:00
parent 169a811162
commit 177a176218
8 changed files with 741 additions and 35 deletions
+9
View File
@@ -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`.
+11 -9
View File
@@ -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") {
+32 -3
View File
@@ -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
+1 -1
View File
@@ -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,
@@ -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)
+1 -1
View File
@@ -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,
)
}
+182 -14
View File
@@ -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 {
+504 -6
View File
@@ -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{