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