package gobackend import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/dop251/goja" ) const signedSessionRefreshSkew = time.Hour const ( signedSessionExchangeMaxAttempts = 3 signedSessionMaxRetryAfter = 5 * time.Minute signedSessionMaxSessionRetries = 1 signedSessionMaxProviderRetries = 2 signedSessionProviderRetryDelay = time.Second ) var ( pendingSignedSessionGrants = make(map[string]string) pendingSignedSessionGrantsMu sync.Mutex signedSessionCoordinators sync.Map signedSessionRetryWait = time.Sleep signedSessionProviderWait = sleepRetry signedSessionRequestNow = time.Now ) // 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 blockedGeneration 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 } func signedSessionGeneration(record *signedSessionRecord) string { if record == nil || record.SessionID == "" || record.SessionSecret == "" { return "" } sum := sha256.Sum256([]byte(record.SessionID + "\n" + record.SessionSecret)) return hex.EncodeToString(sum[:]) } func (c *signedSessionCoordinator) blockGeneration(record *signedSessionRecord) { c.blockedGeneration = signedSessionGeneration(record) } func (c *signedSessionCoordinator) generationIsBlocked(record *signedSessionRecord) bool { generation := signedSessionGeneration(record) return generation != "" && generation == c.blockedGeneration } func (c *signedSessionCoordinator) clearBlockedGeneration() { c.blockedGeneration = "" } type signedSessionRecord struct { InstallID string `json:"install_id"` SessionID string `json:"session_id,omitempty"` SessionSecret string `json:"session_secret,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` Namespace string `json:"namespace,omitempty"` BaseURL string `json:"base_url,omitempty"` AppVersion string `json:"app_version,omitempty"` Platform string `json:"platform,omitempty"` } type signedSessionExchangeResponse struct { SessionID string `json:"session_id,omitempty"` SessionSecret string `json:"session_secret,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` ChallengeID string `json:"challenge_id,omitempty"` ChallengeURL string `json:"challenge_url,omitempty"` 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"` RetryMode string `json:"retry_mode,omitempty"` RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` } func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig { if config == nil { return SignedSessionConfig{} } resolved := *config if resolved.AppVersion == "" { resolved.AppVersion = "ext-1.0" } if resolved.Platform == "" { resolved.Platform = "extension" } if resolved.CallbackURL == "" { resolved.CallbackURL = "spotiflac://session-grant" } if resolved.SchemeLabel == "" { resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1" } if resolved.HeaderPrefix == "" { resolved.HeaderPrefix = "X-Sig-" } if resolved.TimeWindowSeconds <= 0 { resolved.TimeWindowSeconds = 300 } if resolved.Endpoints.Bootstrap == "" { resolved.Endpoints.Bootstrap = "/bootstrap" } if resolved.Endpoints.Challenge == "" { resolved.Endpoints.Challenge = "/challenge" } if resolved.Endpoints.Exchange == "" { resolved.Endpoints.Exchange = "/session/exchange" } return resolved } func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) { namespace := sanitizeSignedSessionNamespace(config.Namespace) if namespace == "" { return "", fmt.Errorf("signed session namespace is empty") } baseDir := filepath.Dir(r.dataDir) if baseDir == "." || baseDir == "" { baseDir = r.dataDir } dir := filepath.Join(baseDir, "signed_sessions") scope := strings.Join([]string{ namespace, strings.TrimSpace(strings.ToLower(config.BaseURL)), strings.TrimSpace(strings.ToLower(config.AppVersion)), strings.TrimSpace(strings.ToLower(config.Platform)), }, "\n") sum := sha256.Sum256([]byte(scope)) return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil } func sanitizeSignedSessionNamespace(namespace string) string { namespace = strings.TrimSpace(strings.ToLower(namespace)) var b strings.Builder for _, ch := range namespace { if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' { b.WriteRune(ch) } } return strings.Trim(b.String(), ".-_") } func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) { path, err := r.signedSessionFilePath(config) if err != nil { return nil, err } record := &signedSessionRecord{} if data, err := os.ReadFile(path); err == nil { _ = json.Unmarshal(data, record) } changed := false if strings.TrimSpace(record.InstallID) == "" { record.InstallID = randomHex(16) changed = true } if normalizeSignedSessionRecordScope(config, record) { changed = true } // Only rewrite the file when the record actually changed; loads happen on // every signed request and preflight. if changed { if err := r.saveSignedSession(config, record); err != nil { return nil, err } } return record, nil } // normalizeSignedSessionRecordScope stamps the config scope onto the record, // resetting the session when the scope changed. Returns whether the record // was modified. func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) bool { namespace := sanitizeSignedSessionNamespace(config.Namespace) baseURL := strings.TrimSpace(config.BaseURL) appVersion := strings.TrimSpace(config.AppVersion) platform := strings.TrimSpace(config.Platform) if record.Namespace == namespace && record.BaseURL == baseURL && record.AppVersion == appVersion && record.Platform == platform { return false } blankScope := record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" if !blankScope { record.SessionID = "" record.SessionSecret = "" record.ExpiresAt = "" } record.Namespace = namespace record.BaseURL = baseURL record.AppVersion = appVersion record.Platform = platform return true } func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error { path, err := r.signedSessionFilePath(config) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } data, err := json.MarshalIndent(record, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0600) } func randomHex(bytesLen int) string { buf := make([]byte, bytesLen) if _, err := rand.Read(buf); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(buf) } func parseSignedSessionTime(value string) (time.Time, bool) { value = strings.TrimSpace(value) if value == "" { return time.Time{}, false } layouts := []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.000Z", } for _, layout := range layouts { if parsed, err := time.Parse(layout, value); err == nil { return parsed, true } } return time.Time{}, false } func signedSessionRecordIsUsable(record *signedSessionRecord) bool { if record == nil || strings.TrimSpace(record.SessionID) == "" || strings.TrimSpace(record.SessionSecret) == "" { return false } if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok { return time.Now().Before(expiresAt) } return true } func sameSignedSession(a, b *signedSessionRecord) bool { return a != nil && b != nil && a.SessionID != "" && a.SessionID == b.SessionID && 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)) contract.RetryMode = strings.ToLower(strings.TrimSpace(contract.RetryMode)) 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 signedSessionSameOperationRetry(statusCode int, contract signedSessionErrorContract) bool { return statusCode == http.StatusServiceUnavailable && contract.Origin == "provider" && contract.Code == "PROVIDER_UNAVAILABLE" && contract.Retryable && contract.RetryMode == "same_operation" } 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 // the user. Bootstrap failures are returned so callers do not continue into // the provider and accidentally issue the same failing bootstrap repeatedly. func (r *extensionRuntime) preflightSignedSession() (bool, error) { if r == nil || r.manifest == nil || r.manifest.SignedSession == nil { return false, nil } config := signedSessionConfigWithDefaults(r.manifest.SignedSession) 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 { return false, err } if signedSessionRecordIsUsable(record) && !coordinator.generationIsBlocked(record) { return false, nil } if authURL, err := r.startSignedSessionVerificationLocked(config, coordinator, "download-preflight"); err != nil { return false, err } else if authURL != "" { return true, nil } // Bootstrap may provision a session directly instead of returning a // challenge. Reload the record before treating the empty URL as a failure. record, err = r.loadSignedSession(config) if err != nil { return false, err } if signedSessionRecordIsUsable(record) { return false, nil } return false, fmt.Errorf("signed-session bootstrap did not return a session or verification challenge") } func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value { config := signedSessionConfigWithDefaults(r.manifest.SignedSession) 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()}) } blocked := coordinator.generationIsBlocked(record) authenticated := signedSessionRecordIsUsable(record) && !blocked return r.vm.ToValue(map[string]any{ "authenticated": authenticated, "verification_required": blocked, "expires_at": record.ExpiresAt, "install_id": record.InstallID, "session_id": record.SessionID, "app_version": config.AppVersion, "platform": config.Platform, }) } 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()}) } record.SessionID = "" record.SessionSecret = "" record.ExpiresAt = "" if err := r.saveSignedSession(config, record); err != nil { return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()}) } coordinator.clearBlockedGeneration() coordinator.clearChallenge() ClearPendingAuthRequest(r.extensionID) return r.vm.ToValue(map[string]any{"success": true}) } func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value { grant := "" 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] pendingSignedSessionGrantsMu.Unlock() } if grant == "" { return r.vm.ToValue(map[string]any{"success": false, "error": "no pending grant"}) } 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.clearBlockedGeneration() coordinator.clearChallenge() return nil } endpoint, err := signedSessionURL(config, config.Endpoints.Exchange) if err != nil { return err } payload := map[string]any{ "grant": grant, "install_id": record.InstallID, "app_version": config.AppVersion, "platform": config.Platform, } body, _ := json.Marshal(payload) 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 { return fmt.Errorf("invalid session exchange response: %w", err) } if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" { return fmt.Errorf("session exchange response missing session fields") } record.SessionID = exchanged.SessionID record.SessionSecret = exchanged.SessionSecret record.ExpiresAt = exchanged.ExpiresAt if err := r.saveSignedSession(config, record); err != nil { return err } coordinator.completedGrantHash = grantHash coordinator.clearBlockedGeneration() coordinator.clearChallenge() return nil } func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]any{"ok": false, "error": "method and path are required"}) } config := signedSessionConfigWithDefaults(r.manifest.SignedSession) if config.Namespace == "" || config.BaseURL == "" { return r.vm.ToValue(map[string]any{"ok": false, "error": "signedSession is not configured"}) } method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String())) requestPath := call.Arguments[1].String() body := []byte{} if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { switch v := call.Arguments[2].Export().(type) { case string: body = []byte(v) case map[string]any, []any: encoded, err := json.Marshal(v) if err != nil { return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) } body = encoded default: body = []byte(call.Arguments[2].String()) } } 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 { 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()}) } if coordinator.generationIsBlocked(record) { authURL, verificationErr := r.startSignedSessionVerificationLocked( config, coordinator, "signed-fetch-blocked-generation", ) if authURL != "" { coordinator.mu.Unlock() return r.signedSessionVerificationRequiredValue(authURL) } if verificationErr != nil { coordinator.mu.Unlock() return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()}) } record, err = r.loadSignedSession(config) if err != nil { coordinator.mu.Unlock() return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) } if !signedSessionRecordIsUsable(record) || coordinator.generationIsBlocked(record) { coordinator.mu.Unlock() return r.vm.ToValue(map[string]any{ "ok": false, "error": "verification_required: signed-session generation is blocked", }) } } coordinator.mu.Unlock() // 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, method, requestPath, body, extraHeaders, ) if requestErr != nil { return r.vm.ToValue(map[string]any{"ok": false, "error": requestErr.Error()}) } contract, _ := parseSignedSessionErrorContract(respBody) if signedSessionSameOperationRetry(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) } 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) { 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 %s response for extension %s and retrying with the exchanged session", contract.Code, r.extensionID, ) continue } // 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) { coordinator.clearBlockedGeneration() 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()}) } } else if gatewayAction == "verify" && sameSignedSession(latest, record) { // Stop subsequent requests from repeatedly hitting the gateway with // a generation that is known to require human verification. coordinator.blockGeneration(record) } authURL, verificationErr := r.startSignedSessionVerificationLocked( config, coordinator, "signed-fetch-"+gatewayAction, ) 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) } } func (r *extensionRuntime) signedSessionResponseValue( resp *http.Response, respBody []byte, respHeaders map[string]any, ) goja.Value { 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": retryAfterSeconds, } if hasContract { result["error"] = contract.Error result["code"] = contract.Code result["origin"] = contract.Origin result["action"] = contract.Action result["retryable"] = contract.Retryable result["retryMode"] = contract.RetryMode } return r.vm.ToValue(result) } func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value { r.noteVerificationRequired(authURL) return r.vm.ToValue(map[string]any{ "ok": false, "needsVerification": true, "error": "VERIFY_REQUIRED", "open_auth_url": authURL, "auth_url": authURL, }) } func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) { record, err := r.loadSignedSession(config) if err != nil { return nil, err } if record.SessionID == "" || record.SessionSecret == "" { return nil, fmt.Errorf("signed session is not authenticated") } if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok { if time.Now().After(expiresAt) { record.SessionID = "" record.SessionSecret = "" record.ExpiresAt = "" _ = r.saveSignedSession(config, record) return nil, fmt.Errorf("signed session expired") } if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew { _ = r.refreshSignedSession(config, record) } } return record, nil } func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error { body, _ := json.Marshal(map[string]string{"install_id": record.InstallID}) resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil) if err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode) } var refreshed signedSessionExchangeResponse if err := json.Unmarshal(respBody, &refreshed); err != nil { return err } changed := false if refreshed.SessionID != "" { record.SessionID = refreshed.SessionID changed = true } if refreshed.SessionSecret != "" { record.SessionSecret = refreshed.SessionSecret changed = true } if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt { record.ExpiresAt = refreshed.ExpiresAt changed = true } if changed { return r.saveSignedSession(config, record) } return nil } 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) } bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap) if err != nil { return "", fmt.Errorf("build signed-session bootstrap URL: %w", err) } parsed, err := url.Parse(bootstrapURL) if err != nil { return "", fmt.Errorf("parse signed-session bootstrap URL: %w", err) } query := parsed.Query() query.Set("app_version", config.AppVersion) query.Set("install_id", record.InstallID) parsed.RawQuery = query.Encode() if r.httpClient == nil { return "", fmt.Errorf("signed-session bootstrap HTTP client is unavailable") } var resp *http.Response for attempt := 0; attempt < 2; attempt++ { req, requestErr := http.NewRequest(http.MethodGet, parsed.String(), nil) if requestErr != nil { return "", fmt.Errorf("build signed-session bootstrap request: %w", requestErr) } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion) resp, err = r.httpClient.Do(req) if err == nil { break } if resp != nil && resp.Body != nil { resp.Body.Close() resp = nil } if attempt == 0 { // Android can retain pooled connections across a Wi-Fi/cellular // transition. Rebuild the GET once after dropping those sockets. r.httpClient.CloseIdleConnections() } } if err != nil { var urlErr *url.Error if errors.As(err, &urlErr) && urlErr.Err != nil { err = urlErr.Err } bootstrapErr := fmt.Errorf( "signed-session bootstrap network request to %s failed: %v", parsed.Host, err, ) LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr) return "", bootstrapErr } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { // Drain a bounded error response so the transport can reuse the // connection without exposing response bodies that may contain secrets. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) message := fmt.Sprintf("signed-session bootstrap returned HTTP %d", resp.StatusCode) if resp.StatusCode >= 500 { message = fmt.Sprintf("signed-session bootstrap network request returned HTTP %d", resp.StatusCode) } if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 { message += fmt.Sprintf("; retry-after seconds: %d", retryAfter) } bootstrapErr := errors.New(message) LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr) return "", bootstrapErr } body, err := readExtensionHTTPResponseBody(resp) if err != nil { return "", fmt.Errorf("read signed-session bootstrap response: %w", err) } var boot signedSessionExchangeResponse if err := json.Unmarshal(body, &boot); err != nil { return "", fmt.Errorf("decode signed-session bootstrap response: %w", err) } if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" { record.SessionID = boot.SessionID record.SessionSecret = boot.SessionSecret record.ExpiresAt = boot.ExpiresAt if err := r.saveSignedSession(config, record); err != nil { return "", fmt.Errorf("save bootstrapped signed session: %w", err) } coordinator.clearBlockedGeneration() coordinator.clearChallenge() return "", nil } authURL := boot.AuthURL if authURL == "" && boot.ChallengeURL != "" { authURL = boot.ChallengeURL } if authURL == "" && boot.ChallengeID != "" { authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID) } if authURL == "" { return "", fmt.Errorf("signed-session bootstrap did not return a session or verification challenge") } pendingAuthRequestsMu.Lock() pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ ExtensionID: r.extensionID, AuthURL: authURL, CallbackURL: config.CallbackURL, CreatedAt: time.Now(), } pendingAuthRequestsMu.Unlock() coordinator.rememberChallenge(r.extensionID, authURL, config.CallbackURL) return authURL, nil } func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string { challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge) if err != nil { return "" } parsed, err := url.Parse(challengeURL) if err != nil { return "" } callback, err := url.Parse(config.CallbackURL) if err != nil { return "" } q := callback.Query() q.Set("cb_version", "v2grant") q.Set("state", r.extensionID) callback.RawQuery = q.Encode() query := parsed.Query() query.Set("id", challengeID) query.Set("cb", callback.String()) parsed.RawQuery = query.Encode() return parsed.String() } func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) { base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/") if err != nil || base.Scheme != "https" || base.Host == "" { return "", fmt.Errorf("invalid signed session baseUrl") } endpoint = strings.TrimSpace(endpoint) if endpoint == "" { return "", fmt.Errorf("signed session endpoint is empty") } if strings.HasPrefix(endpoint, "https://") { return endpoint, nil } endpoint = strings.TrimLeft(endpoint, "/") ref, _ := url.Parse(endpoint) return base.ResolveReference(ref).String(), nil } func (r *extensionRuntime) doSignedSessionRequest( config SignedSessionConfig, record *signedSessionRecord, method string, requestPath string, body []byte, extraHeaders map[string]string, ) (*http.Response, []byte, map[string]any, error) { fullURL, err := signedSessionURL(config, requestPath) if err != nil { return nil, nil, nil, err } parsed, err := url.Parse(fullURL) if err != nil { return nil, nil, nil, err } ts := signedSessionRequestNow().UTC().Format("2006-01-02T15:04:05.000Z") nonce := randomHex(12) bodyHashBytes := sha256.Sum256(body) bodyHash := hex.EncodeToString(bodyHashBytes[:]) parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts) window := parsedTs.Unix() / int64(config.TimeWindowSeconds) rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID) rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput))) signingInput := strings.Join([]string{ config.SchemeLabel, method, parsed.EscapedPath(), "", bodyHash, ts, nonce, record.SessionID, config.AppVersion, config.Platform, }, "\n") sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput))) req, err := http.NewRequest(method, fullURL, bytes.NewReader(body)) if err != nil { return nil, nil, nil, err } req = r.bindDownloadCancelContext(req) req.Header.Set("Accept", "application/json") if len(body) > 0 { req.Header.Set("Content-Type", "application/json") } req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion) prefix := config.HeaderPrefix req.Header.Set(prefix+"Session", record.SessionID) req.Header.Set(prefix+"Timestamp", ts) req.Header.Set(prefix+"Nonce", nonce) req.Header.Set(prefix+"Body-SHA256", bodyHash) req.Header.Set(prefix+"Signature", sig) req.Header.Set(prefix+"App-Version", config.AppVersion) req.Header.Set(prefix+"Platform", config.Platform) for k, v := range extraHeaders { req.Header.Set(k, v) } resp, err := r.httpClient.Do(req) if err != nil { return nil, nil, nil, err } defer resp.Body.Close() respBody, err := readExtensionHTTPResponseBody(resp) if err != nil { return nil, nil, nil, err } headers := make(map[string]any) for k, v := range resp.Header { if len(v) == 1 { headers[k] = v[0] } else { headers[k] = v } } return resp, respBody, headers, nil } func signedSessionRetryAfterSeconds(resp *http.Response) int { if resp == nil { return 0 } value := strings.TrimSpace(resp.Header.Get("Retry-After")) if value == "" { return 0 } if seconds, err := strconv.Atoi(value); err == nil { if seconds < 0 { return 0 } return seconds } if retryAt, err := http.ParseTime(value); err == nil { seconds := int(time.Until(retryAt).Seconds()) if seconds < 0 { return 0 } return seconds } return 0 } func hmacSHA256Bytes(key, message []byte) []byte { mac := hmac.New(sha256.New, key) mac.Write(message) return mac.Sum(nil) } func setPendingSignedSessionGrant(extensionID, grant string) { extensionID = strings.TrimSpace(extensionID) grant = strings.TrimSpace(grant) if extensionID == "" || grant == "" { return } pendingSignedSessionGrantsMu.Lock() pendingSignedSessionGrants[extensionID] = grant pendingSignedSessionGrantsMu.Unlock() }