mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(session): block challenged session generations
This commit is contained in:
@@ -115,7 +115,12 @@ gateway session state for the exact `SESSION_INVALID/bootstrap_session` or
|
||||
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.
|
||||
reauthentication remains a separate provider action. An active generation that
|
||||
receives canonical `428 VERIFY_REQUIRED` is blocked in the shared coordinator,
|
||||
so later requests join the same verification flow instead of hitting the
|
||||
gateway repeatedly. Every retry is a newly signed request with a fresh
|
||||
timestamp and nonce; `retryable: true` therefore also asserts that any ticket
|
||||
attached to the operation is safe/idempotent for that retry.
|
||||
|
||||
Do not use legacy spellings such as `display_name`, `types`,
|
||||
`permissions.network.domains`, or an object for `permissions.network`.
|
||||
|
||||
@@ -28,7 +28,7 @@ const signedSessionRefreshSkew = time.Hour
|
||||
const (
|
||||
signedSessionExchangeMaxAttempts = 3
|
||||
signedSessionMaxRetryAfter = 5 * time.Minute
|
||||
signedSessionMaxSessionRetries = 2
|
||||
signedSessionMaxSessionRetries = 1
|
||||
signedSessionMaxProviderRetries = 2
|
||||
signedSessionProviderRetryDelay = time.Second
|
||||
)
|
||||
@@ -39,6 +39,7 @@ var (
|
||||
signedSessionCoordinators sync.Map
|
||||
signedSessionRetryWait = time.Sleep
|
||||
signedSessionProviderWait = sleepRetry
|
||||
signedSessionRequestNow = time.Now
|
||||
)
|
||||
|
||||
// signedSessionCoordinator serializes authentication state for every runtime
|
||||
@@ -53,6 +54,7 @@ type signedSessionCoordinator struct {
|
||||
challengeCreatedAt time.Time
|
||||
pendingExtensionIDs map[string]struct{}
|
||||
completedGrantHash string
|
||||
blockedGeneration string
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionCoordinator(config SignedSessionConfig) (*signedSessionCoordinator, error) {
|
||||
@@ -90,6 +92,27 @@ func (c *signedSessionCoordinator) activeChallenge() bool {
|
||||
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"`
|
||||
@@ -385,7 +408,8 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if signedSessionRecordIsUsable(record) {
|
||||
if signedSessionRecordIsUsable(record) &&
|
||||
!coordinator.generationIsBlocked(record) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -423,14 +447,16 @@ func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Valu
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
|
||||
}
|
||||
authenticated := signedSessionRecordIsUsable(record)
|
||||
blocked := coordinator.generationIsBlocked(record)
|
||||
authenticated := signedSessionRecordIsUsable(record) && !blocked
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"authenticated": authenticated,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"install_id": record.InstallID,
|
||||
"session_id": record.SessionID,
|
||||
"app_version": config.AppVersion,
|
||||
"platform": config.Platform,
|
||||
"authenticated": authenticated,
|
||||
"verification_required": blocked,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"install_id": record.InstallID,
|
||||
"session_id": record.SessionID,
|
||||
"app_version": config.AppVersion,
|
||||
"platform": config.Platform,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -452,6 +478,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.clearBlockedGeneration()
|
||||
coordinator.clearChallenge()
|
||||
ClearPendingAuthRequest(r.extensionID)
|
||||
return r.vm.ToValue(map[string]any{"success": true})
|
||||
@@ -510,6 +537,7 @@ func (r *extensionRuntime) exchangeSignedSessionGrantLocked(
|
||||
// instead of consuming the grant again.
|
||||
if coordinator.completedGrantHash == grantHash &&
|
||||
signedSessionRecordIsUsable(record) {
|
||||
coordinator.clearBlockedGeneration()
|
||||
coordinator.clearChallenge()
|
||||
return nil
|
||||
}
|
||||
@@ -584,6 +612,7 @@ func (r *extensionRuntime) exchangeSignedSessionGrantLocked(
|
||||
return err
|
||||
}
|
||||
coordinator.completedGrantHash = grantHash
|
||||
coordinator.clearBlockedGeneration()
|
||||
coordinator.clearChallenge()
|
||||
return nil
|
||||
}
|
||||
@@ -631,6 +660,33 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
}
|
||||
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
|
||||
@@ -734,6 +790,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
// 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 = ""
|
||||
@@ -741,6 +798,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
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,
|
||||
@@ -999,6 +1060,7 @@ func (r *extensionRuntime) startSignedSessionVerificationLocked(
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return "", fmt.Errorf("save bootstrapped signed session: %w", err)
|
||||
}
|
||||
coordinator.clearBlockedGeneration()
|
||||
coordinator.clearChallenge()
|
||||
return "", nil
|
||||
}
|
||||
@@ -1082,7 +1144,7 @@ func (r *extensionRuntime) doSignedSessionRequest(
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
ts := signedSessionRequestNow().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
nonce := randomHex(12)
|
||||
bodyHashBytes := sha256.Sum256(body)
|
||||
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||
|
||||
@@ -1103,7 +1103,8 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if strings.Contains(req.URL.Path, "/bootstrap") {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/bootstrap"):
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
AuthURL: "https://auth.example.com/verify",
|
||||
})
|
||||
@@ -1113,6 +1114,25 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) {
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Request: req,
|
||||
}, nil
|
||||
case strings.Contains(req.URL.Path, "/session/exchange"):
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
SessionID: "sess-after-verify",
|
||||
SessionSecret: "secret-after-verify",
|
||||
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-after-verify":
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusPreconditionRequired,
|
||||
@@ -1146,6 +1166,36 @@ func TestSignedSessionFetchCanonicalVerifyDoesNotClearSession(t *testing.T) {
|
||||
if reloaded.SessionID != "sess-verified" || reloaded.SessionSecret != "secret-sess-verified" {
|
||||
t.Fatalf("VERIFY_REQUIRED cleared the valid gateway session: %+v", reloaded)
|
||||
}
|
||||
|
||||
blockedResult := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if blockedResult["needsVerification"] != true {
|
||||
t.Fatalf("blocked generation did not join verification: %+v", blockedResult)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("blocked generation reached the gateway again: calls=%d", calls)
|
||||
}
|
||||
verificationRequired, err := runtime.preflightSignedSession()
|
||||
if err != nil || !verificationRequired {
|
||||
t.Fatalf("preflight did not preserve the blocked verification state: required=%v err=%v", verificationRequired, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("blocked preflight reached the gateway again: calls=%d", calls)
|
||||
}
|
||||
status := runtime.signedSessionStatus(goja.FunctionCall{}).Export().(map[string]any)
|
||||
if status["authenticated"] != false || status["verification_required"] != true {
|
||||
t.Fatalf("blocked generation status is incorrect: %+v", status)
|
||||
}
|
||||
|
||||
if err := runtime.exchangeSignedSessionGrant("grant-after-verify"); err != nil {
|
||||
t.Fatalf("exchange grant: %v", err)
|
||||
}
|
||||
afterVerification := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if afterVerification["ok"] != true {
|
||||
t.Fatalf("new verified generation remained blocked: %+v", afterVerification)
|
||||
}
|
||||
if calls != 4 {
|
||||
t.Fatalf("network calls = %d, want 428, bootstrap, exchange, success", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchRequestAuthInvalidPreservesSession(t *testing.T) {
|
||||
@@ -1319,16 +1369,32 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
|
||||
t.Run("temporary provider failure retries with Retry-After", func(t *testing.T) {
|
||||
previousWait := signedSessionProviderWait
|
||||
previousNow := signedSessionRequestNow
|
||||
var waits []time.Duration
|
||||
signedSessionProviderWait = func(_ context.Context, delay time.Duration) error {
|
||||
waits = append(waits, delay)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { signedSessionProviderWait = previousWait })
|
||||
nextRequestTime := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC)
|
||||
signedSessionRequestNow = func() time.Time {
|
||||
current := nextRequestTime
|
||||
nextRequestTime = nextRequestTime.Add(time.Second)
|
||||
return current
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
signedSessionProviderWait = previousWait
|
||||
signedSessionRequestNow = previousNow
|
||||
})
|
||||
|
||||
calls := 0
|
||||
nonces := make(map[string]struct{})
|
||||
timestamps := make(map[string]struct{})
|
||||
signatures := make(map[string]struct{})
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
nonces[req.Header.Get("X-Sig-Nonce")] = struct{}{}
|
||||
timestamps[req.Header.Get("X-Sig-Timestamp")] = struct{}{}
|
||||
signatures[req.Header.Get("X-Sig-Signature")] = struct{}{}
|
||||
if calls <= signedSessionMaxProviderRetries {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
@@ -1367,6 +1433,18 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
t.Fatalf("Retry-After header was not authoritative: waits=%v", waits)
|
||||
}
|
||||
}
|
||||
wantSignedAttempts := signedSessionMaxProviderRetries + 1
|
||||
if len(nonces) != wantSignedAttempts ||
|
||||
len(timestamps) != wantSignedAttempts ||
|
||||
len(signatures) != wantSignedAttempts {
|
||||
t.Fatalf(
|
||||
"provider retries reused signed request headers: nonces=%d timestamps=%d signatures=%d want=%d",
|
||||
len(nonces),
|
||||
len(timestamps),
|
||||
len(signatures),
|
||||
wantSignedAttempts,
|
||||
)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Reference in New Issue
Block a user