fix(verification): recover signed-session bootstrap failures

This commit is contained in:
zarzet
2026-07-26 11:09:18 +07:00
parent 08d648cfa8
commit c4abbf8743
6 changed files with 340 additions and 63 deletions
+38 -16
View File
@@ -512,7 +512,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
preflightStartedAt := time.Now()
verificationRequired, preflightErr := preflightExtensionDownloadSession(sessionProvider)
if preflightErr != nil {
GoLog("[DownloadWithExtensions] Signed-session preflight for %s was inconclusive after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr)
message := fmt.Sprintf("Could not start verification for %s: %v", sessionProvider, preflightErr)
GoLog("[DownloadWithExtensions] Signed-session preflight for %s failed after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr)
if req.ItemID != "" {
RemoveItemProgress(req.ItemID)
}
return marshalJSONString(&DownloadResponse{
Success: false,
Error: message,
ErrorType: classifyDownloadErrorType(message),
Service: sessionProvider,
})
} else if verificationRequired {
GoLog("[DownloadWithExtensions] Signed-session verification required for %s after %s; skipping metadata preparation\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond))
cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req)
@@ -566,7 +576,10 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
}
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := ensureExtensionPendingAuthRequest(extensionID)
req, err := ensureExtensionPendingAuthRequest(extensionID)
if err != nil {
return "", err
}
if req == nil {
return "", nil
}
@@ -580,15 +593,15 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return marshalJSONString(result)
}
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
func ensureExtensionPendingAuthRequest(extensionID string) (*PendingAuthRequest, error) {
extensionID = strings.TrimSpace(extensionID)
if extensionID == "" {
return nil
return nil, nil
}
if req := GetPendingAuthRequest(extensionID); req != nil {
if time.Since(req.CreatedAt) < pendingAuthRequestTTL {
return req
return req, nil
}
// The cached challenge is stale (e.g. verification was requested
// while the app was backgrounded and never completed); serving it
@@ -599,25 +612,34 @@ func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
return nil
return nil, nil
}
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
return nil
if err := ext.ensureRuntimeReady(); err != nil {
return nil, err
}
if ext.runtime == nil {
return nil, fmt.Errorf("extension '%s' runtime is unavailable", extensionID)
}
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return nil
return nil, nil
}
if record, err := ext.runtime.loadSignedSession(config); err == nil {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = ext.runtime.saveSignedSession(config, record)
record, err := ext.runtime.loadSignedSession(config)
if err != nil {
return nil, err
}
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
return GetPendingAuthRequest(extensionID)
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
}
return GetPendingAuthRequest(extensionID), nil
}
func SetExtensionAuthCodeByID(extensionID, authCode string) {
+89 -32
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -214,8 +215,8 @@ func signedSessionRecordIsUsable(record *signedSessionRecord) bool {
// 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 remain non-fatal to the caller so the normal
// provider path can still surface its more specific error.
// 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
@@ -242,7 +243,9 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) {
ClearPendingAuthRequest(r.extensionID)
}
if authURL := r.startSignedSessionVerification(config, "download-preflight"); authURL != "" {
if authURL, err := r.startSignedSessionVerification(config, "download-preflight"); err != nil {
return false, err
} else if authURL != "" {
return true, nil
}
@@ -394,8 +397,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
record, err := r.ensureSignedSession(config)
if err != nil {
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch"); 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()})
}
@@ -409,8 +414,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch-reauth"); 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{
@@ -490,45 +497,94 @@ func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, reco
return nil
}
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, _ string) string {
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) (string, error) {
record, err := r.loadSignedSession(config)
if err != nil {
return ""
return "", fmt.Errorf("load signed-session bootstrap state: %w", err)
}
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
if err != nil {
return ""
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)
}
parsed, _ := url.Parse(bootstrapURL)
query := parsed.Query()
query.Set("app_version", config.AppVersion)
query.Set("install_id", record.InstallID)
parsed.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
if err != nil {
return ""
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()
}
}
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 ""
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()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
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 ""
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
_ = r.saveSignedSession(config, record)
return ""
if err := r.saveSignedSession(config, record); err != nil {
return "", fmt.Errorf("save bootstrapped signed session: %w", err)
}
return "", nil
}
authURL := boot.AuthURL
if authURL == "" && boot.ChallengeURL != "" {
@@ -537,17 +593,18 @@ func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionCo
if authURL == "" && boot.ChallengeID != "" {
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
}
if authURL != "" {
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
if authURL == "" {
return "", fmt.Errorf("signed-session bootstrap did not return a session or verification challenge")
}
return authURL
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
CreatedAt: time.Now(),
}
pendingAuthRequestsMu.Unlock()
return authURL, nil
}
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
+119
View File
@@ -245,6 +245,60 @@ func TestPreflightSignedSession(t *testing.T) {
t.Fatalf("bootstrapped session = %#v error:%v", record, err)
}
})
t.Run("retries a transport failure once and surfaces the cause", func(t *testing.T) {
calls := 0
runtime := newSignedSessionTestRuntime(t, "preflight-network", roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("dial tcp: Wi-Fi route unavailable")
}))
runtime.manifest.SignedSession = &SignedSessionConfig{
Namespace: "preflight-network",
BaseURL: "https://auth.example.com",
}
verificationRequired, err := runtime.preflightSignedSession()
if err == nil || verificationRequired {
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
}
if calls != 2 {
t.Fatalf("bootstrap calls = %d, want one initial attempt and one retry", calls)
}
if !strings.Contains(err.Error(), "network request") || !strings.Contains(err.Error(), "Wi-Fi route unavailable") {
t.Fatalf("bootstrap error did not preserve the transport cause: %v", err)
}
if strings.Contains(err.Error(), "install_id=") {
t.Fatalf("bootstrap error leaked the install identifier: %v", err)
}
})
t.Run("surfaces HTTP status and retry-after without inventing a challenge", func(t *testing.T) {
calls := 0
runtime := newSignedSessionTestRuntime(t, "preflight-rate-limit", roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Retry-After": []string{"17"}},
Body: io.NopCloser(strings.NewReader(`{"error":"limited"}`)),
Request: req,
}, nil
}))
runtime.manifest.SignedSession = &SignedSessionConfig{
Namespace: "preflight-rate-limit",
BaseURL: "https://auth.example.com",
}
verificationRequired, err := runtime.preflightSignedSession()
if err == nil || verificationRequired {
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
}
if calls != 1 {
t.Fatalf("rate-limited bootstrap calls = %d, want 1", calls)
}
if !strings.Contains(err.Error(), "HTTP 429") || !strings.Contains(err.Error(), "retry-after seconds: 17") {
t.Fatalf("rate-limit details missing from bootstrap error: %v", err)
}
})
}
func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) {
@@ -316,6 +370,71 @@ func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T)
}
}
func TestDownloadWithExtensionsStopsAfterFailedSignedSessionPreflight(t *testing.T) {
extensionID := "preflight-network-failure"
itemID := "preflight-network-item"
RemoveItemProgress(itemID)
t.Cleanup(func() { RemoveItemProgress(itemID) })
calls := 0
runtime := newSignedSessionTestRuntime(t, extensionID, roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("dial tcp: Wi-Fi route unavailable")
}))
manifest := &ExtensionManifest{
Name: extensionID,
Types: []ExtensionType{ExtensionTypeDownloadProvider},
SignedSession: &SignedSessionConfig{
Namespace: extensionID,
BaseURL: "https://auth.example.com",
},
}
runtime.manifest = manifest
ext := &loadedExtension{
ID: extensionID,
Manifest: manifest,
VM: runtime.vm,
runtime: runtime,
initialized: true,
Enabled: true,
DataDir: runtime.dataDir,
}
manager := getExtensionManager()
manager.mu.Lock()
previous, hadPrevious := manager.extensions[extensionID]
manager.extensions[extensionID] = ext
manager.mu.Unlock()
t.Cleanup(func() {
manager.mu.Lock()
if hadPrevious {
manager.extensions[extensionID] = previous
} else {
delete(manager.extensions, extensionID)
}
manager.mu.Unlock()
})
requestJSON := `{"service":"preflight-network-failure","item_id":"preflight-network-item","isrc":"USRC17607839"}`
responseJSON, err := DownloadWithExtensionsJSON(requestJSON)
if err != nil {
t.Fatalf("DownloadWithExtensionsJSON: %v", err)
}
var response DownloadResponse
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.ErrorType != "network" || response.Service != extensionID || !strings.Contains(response.Error, "Could not start verification") {
t.Fatalf("response = %#v", response)
}
if calls != 2 {
t.Fatalf("bootstrap calls = %d, want only the initial attempt and its transport retry", calls)
}
if got := GetItemProgress(itemID); got != "{}" {
t.Fatalf("failed preflight left stale progress: %s", got)
}
}
func TestSignedSessionURL(t *testing.T) {
base := SignedSessionConfig{BaseURL: "https://auth.example.com/api"}
+14 -15
View File
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
import 'package:spotiflac_android/providers/download_queue_state.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -173,7 +174,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _lastNotifQueueCount = -1;
final Set<String> _locallyCancelledItemIds = {};
final Set<String> _pausePendingItemIds = {};
final Set<String> _verificationRetriedItemIds = {};
final DownloadVerificationRetryGuard _verificationRetryGuard =
DownloadVerificationRetryGuard();
final Map<String, Future<bool>> _verificationFlowsByExtension = {};
final Set<String> _rateLimitRetriedItemIds = {};
String? _activeNativeWorkerRunId;
@@ -185,9 +187,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// then computes and writes album gain/peak to every track in the album.
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
String _verificationRetryKey(String itemId, String service) =>
'$itemId::${service.trim().toLowerCase()}';
double _normalizeProgressForUi(double value) {
final clamped = value.clamp(0.0, 1.0).toDouble();
if (clamped <= 0) return 0;
@@ -424,8 +423,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final targetService = (verificationService ?? '').trim().isNotEmpty
? verificationService!.trim()
: item.service;
final verificationRetryKey = _verificationRetryKey(item.id, targetService);
if (_verificationRetriedItemIds.contains(verificationRetryKey)) {
if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) {
_log.e(
'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge',
);
@@ -438,8 +436,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_failedInSession++;
return true;
}
_verificationRetriedItemIds.add(verificationRetryKey);
_log.i(
'Download for ${item.track.name} requires verification; waiting for $targetService grant',
);
@@ -457,6 +453,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return true;
}
// Only a completed grant consumes the automatic retry. If bootstrap or
// browser launch failed before a challenge opened, a later attempt must
// still be allowed after the network changes.
_verificationRetryGuard.recordVerificationResult(
item.id,
targetService,
granted: verified,
);
if (verified) {
_log.i(
'Verification complete for $targetService; retrying ${item.track.name}',
@@ -1586,9 +1590,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}),
);
_locallyCancelledItemIds.remove(id);
_verificationRetriedItemIds.removeWhere(
(retryKey) => retryKey == id || retryKey.startsWith('$id::'),
);
_verificationRetryGuard.clearItem(id);
_rateLimitRetriedItemIds.remove(id);
// Purge stale ReplayGain entry for this track so a re-scan doesn't
@@ -2295,10 +2297,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final remainingIds = state.items.map((item) => item.id).toSet();
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
_verificationRetriedItemIds.removeWhere((retryKey) {
final itemId = retryKey.split('::').first;
return !remainingIds.contains(itemId);
});
_verificationRetryGuard.retainItems(remainingIds);
_rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id));
}
@@ -0,0 +1,32 @@
class DownloadVerificationRetryGuard {
final Set<String> _grantedRetryKeys = {};
bool hasRetriedAfterGrant(String itemId, String service) =>
_grantedRetryKeys.contains(_key(itemId, service));
void recordVerificationResult(
String itemId,
String service, {
required bool granted,
}) {
if (granted) {
_grantedRetryKeys.add(_key(itemId, service));
}
}
void clearItem(String itemId) {
_grantedRetryKeys.removeWhere(
(retryKey) => retryKey == itemId || retryKey.startsWith('$itemId::'),
);
}
void retainItems(Set<String> itemIds) {
_grantedRetryKeys.removeWhere((retryKey) {
final itemId = retryKey.split('::').first;
return !itemIds.contains(itemId);
});
}
String _key(String itemId, String service) =>
'$itemId::${service.trim().toLowerCase()}';
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
void main() {
group('DownloadVerificationRetryGuard', () {
test('failed challenge attempts do not consume the granted retry', () {
final guard = DownloadVerificationRetryGuard();
guard.recordVerificationResult('item-1', 'tidal-web', granted: false);
expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isFalse);
});
test('completed grants allow only one automatic retry per service', () {
final guard = DownloadVerificationRetryGuard();
guard.recordVerificationResult('item-1', ' TIDAL-WEB ', granted: true);
expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isTrue);
expect(guard.hasRetriedAfterGrant('item-1', 'qobuz-web'), isFalse);
expect(guard.hasRetriedAfterGrant('item-2', 'tidal-web'), isFalse);
});
test('manual retry clears every service marker for an item', () {
final guard = DownloadVerificationRetryGuard()
..recordVerificationResult('item-1', 'tidal-web', granted: true)
..recordVerificationResult('item-1', 'qobuz-web', granted: true)
..recordVerificationResult('item-2', 'tidal-web', granted: true);
guard.clearItem('item-1');
expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isFalse);
expect(guard.hasRetriedAfterGrant('item-1', 'qobuz-web'), isFalse);
expect(guard.hasRetriedAfterGrant('item-2', 'tidal-web'), isTrue);
});
test('queue cleanup retains markers only for remaining items', () {
final guard = DownloadVerificationRetryGuard()
..recordVerificationResult('removed', 'tidal-web', granted: true)
..recordVerificationResult('remaining', 'tidal-web', granted: true);
guard.retainItems({'remaining'});
expect(guard.hasRetriedAfterGrant('removed', 'tidal-web'), isFalse);
expect(guard.hasRetriedAfterGrant('remaining', 'tidal-web'), isTrue);
});
});
}