fix(extensions): preserve verification callback retries

This commit is contained in:
zarzet
2026-09-04 12:39:03 +07:00
parent 4aba2926f8
commit 7be959e7e9
7 changed files with 186 additions and 60 deletions
@@ -750,7 +750,11 @@ class MainActivity: FlutterFragmentActivity() {
var callbackExtensionId = ""
scope.launch(Dispatchers.IO) {
try {
val extId = Gobackend.consumeExtensionCallbackState(callbackState)
val extId = if (isSessionGrant) {
Gobackend.resolveExtensionCallbackState(callbackState)
} else {
Gobackend.consumeExtensionCallbackState(callbackState)
}
callbackExtensionId = extId
val json = if (isSessionGrant) {
Gobackend.setExtensionSessionGrantByID(extId, code)
+29 -6
View File
@@ -150,6 +150,32 @@ func removePendingAuthStateLocked(state string) {
}
}
func resolveExtensionCallbackStateLocked(state string) (string, error) {
extensionID := pendingAuthStates[state]
request := pendingAuthRequests[extensionID]
if extensionID == "" || request == nil || request.State != state ||
time.Since(request.CreatedAt) >= pendingAuthRequestTTL {
removePendingAuthStateLocked(state)
return "", fmt.Errorf("callback state is invalid, expired, or already used")
}
return extensionID, nil
}
// ResolveExtensionCallbackState validates a callback nonce without consuming
// it. Callback handlers use this before an exchange so a transient exchange
// failure can still be retried with the same short-lived challenge.
func ResolveExtensionCallbackState(state string) (string, error) {
state = strings.TrimSpace(state)
if state == "" {
return "", fmt.Errorf("callback state is required")
}
pendingAuthRequestsMu.Lock()
extensionID, err := resolveExtensionCallbackStateLocked(state)
pendingAuthRequestsMu.Unlock()
return extensionID, err
}
func ConsumeExtensionCallbackState(state string) (string, error) {
state = strings.TrimSpace(state)
if state == "" {
@@ -157,13 +183,10 @@ func ConsumeExtensionCallbackState(state string) (string, error) {
}
pendingAuthRequestsMu.Lock()
extensionID := pendingAuthStates[state]
request := pendingAuthRequests[extensionID]
if extensionID == "" || request == nil || request.State != state ||
time.Since(request.CreatedAt) >= pendingAuthRequestTTL {
removePendingAuthStateLocked(state)
extensionID, err := resolveExtensionCallbackStateLocked(state)
if err != nil {
pendingAuthRequestsMu.Unlock()
return "", fmt.Errorf("callback state is invalid, expired, or already used")
return "", err
}
removePendingAuthStateLocked(state)
pendingAuthRequestsMu.Unlock()
@@ -83,6 +83,12 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
if pending == nil || pending.AuthURL == "" || pending.State == "" || !strings.Contains(pending.AuthURL, "state=") {
t.Fatalf("pending auth = %#v", pending)
}
if extensionID, err := ResolveExtensionCallbackState(pending.State); err != nil || extensionID != "auth-ext" {
t.Fatalf("resolve callback state = %q/%v", extensionID, err)
}
if extensionID, err := ResolveExtensionCallbackState(pending.State); err != nil || extensionID != "auth-ext" {
t.Fatalf("resolve callback state retry = %q/%v", extensionID, err)
}
if extensionID, err := ConsumeExtensionCallbackState(pending.State); err != nil || extensionID != "auth-ext" {
t.Fatalf("consume callback state = %q/%v", extensionID, err)
}
+12 -4
View File
@@ -118,10 +118,18 @@ import Gobackend
streamQueue.async {
var err: NSError?
var response: String?
let extensionId = GobackendConsumeExtensionCallbackState(
route.state,
&err
)
let extensionId: String
if route.isSessionGrant {
extensionId = GobackendResolveExtensionCallbackState(
route.state,
&err
)
} else {
extensionId = GobackendConsumeExtensionCallbackState(
route.state,
&err
)
}
guard err == nil, !extensionId.isEmpty else {
NSLog("SpotiFLAC Mobile: Rejected invalid or expired extension callback")
return
+17 -49
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/extension_session_callback.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -359,6 +360,7 @@ Future<bool> showExtensionVerificationHelpDialog(
await _completeSessionGrantFromClipboard(
dialogContext,
extensionId,
authUri,
);
if (dialogContext.mounted) {
setDialogState(() => clipboardGrantInFlight = false);
@@ -389,15 +391,13 @@ Future<bool> showExtensionVerificationHelpDialog(
Future<void> _completeSessionGrantFromClipboard(
BuildContext context,
String extensionId,
Uri verificationUri,
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text?.trim() ?? '';
final parsed = _parseSessionGrantCallback(
text,
fallbackExtensionId: extensionId,
);
final parsed = parseExtensionSessionGrantCallback(text);
if (parsed == null) {
messenger?.showSnackBar(
const SnackBar(content: Text('No verification callback found')),
@@ -405,8 +405,20 @@ Future<void> _completeSessionGrantFromClipboard(
return;
}
final expectedState = extensionCallbackStateFromVerificationUri(
verificationUri,
);
if (expectedState == null || parsed.state != expectedState) {
messenger?.showSnackBar(
const SnackBar(
content: Text('This callback does not match the active verification'),
),
);
return;
}
final success = await PlatformBridge.completeExtensionSessionGrant(
parsed.extensionId,
extensionId.trim(),
parsed.grant,
);
if (!context.mounted) return;
@@ -429,50 +441,6 @@ Future<void> _completeSessionGrantFromClipboard(
}
}
({String extensionId, String grant})? _parseSessionGrantCallback(
String text, {
required String fallbackExtensionId,
}) {
final trimmed = text.trim();
if (trimmed.isEmpty) return null;
String? grant;
String? state;
final uri = Uri.tryParse(trimmed);
if (uri != null) {
grant = uri.queryParameters['grant'] ?? uri.queryParameters['code'];
state = uri.queryParameters['state'];
final nestedCallback = uri.queryParameters['cb'];
if ((grant == null || grant.trim().isEmpty) &&
nestedCallback != null &&
nestedCallback.trim().isNotEmpty) {
final nested = _parseSessionGrantCallback(
nestedCallback,
fallbackExtensionId: fallbackExtensionId,
);
if (nested != null) return nested;
}
}
grant ??= _firstRegexGroup(trimmed, RegExp(r'(?:^|[?&#\s])grant=([^&#\s]+)'));
grant ??= _firstRegexGroup(trimmed, RegExp(r'(?:^|[?&#\s])code=([^&#\s]+)'));
state ??= _firstRegexGroup(trimmed, RegExp(r'(?:^|[?&#\s])state=([^&#\s]+)'));
grant = grant == null ? null : Uri.decodeComponent(grant.trim());
state = state == null ? null : Uri.decodeComponent(state.trim());
final extension = (state != null && state.isNotEmpty)
? state
: fallbackExtensionId.trim();
if (extension.isEmpty || grant == null || grant.isEmpty) return null;
return (extensionId: extension, grant: grant);
}
String? _firstRegexGroup(String input, RegExp regex) {
final match = regex.firstMatch(input);
return match?.group(1);
}
/// Opens an extension auth/verification page. On iOS this prefers an
/// ASWebAuthenticationSession, which captures the spotiflac:// callback
/// in-process — required for environments where the app's URL scheme is not
+75
View File
@@ -0,0 +1,75 @@
typedef ExtensionSessionGrantCallback = ({String grant, String state});
ExtensionSessionGrantCallback? parseExtensionSessionGrantCallback(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) return null;
String? grant;
String? state;
final uri = Uri.tryParse(trimmed);
if (uri != null) {
grant = _nonEmpty(
uri.queryParameters['grant'] ?? uri.queryParameters['code'],
);
state = _nonEmpty(uri.queryParameters['state']);
final nestedCallback = _nonEmpty(uri.queryParameters['cb']);
if (grant == null && nestedCallback != null) {
final nested = parseExtensionSessionGrantCallback(nestedCallback);
if (nested != null) return nested;
}
}
grant ??= _decodedRegexGroup(
trimmed,
RegExp(r'(?:^|[?&#\s])grant=([^&#\s]+)'),
);
grant ??= _decodedRegexGroup(
trimmed,
RegExp(r'(?:^|[?&#\s])code=([^&#\s]+)'),
);
state ??= _decodedRegexGroup(
trimmed,
RegExp(r'(?:^|[?&#\s])state=([^&#\s]+)'),
);
grant = _nonEmpty(grant);
state = _nonEmpty(state);
if (grant == null || state == null) return null;
return (grant: grant, state: state);
}
String? extensionCallbackStateFromVerificationUri(Uri uri, [int depth = 0]) {
if (depth > 3) return null;
final directState = _nonEmpty(uri.queryParameters['state']);
if (directState != null) return directState;
for (final key in const ['cb', 'callback', 'callback_url', 'redirect_uri']) {
final nestedText = _nonEmpty(uri.queryParameters[key]);
if (nestedText == null) continue;
final nestedUri = Uri.tryParse(nestedText);
if (nestedUri == null) continue;
final nestedState = extensionCallbackStateFromVerificationUri(
nestedUri,
depth + 1,
);
if (nestedState != null) return nestedState;
}
return null;
}
String? _decodedRegexGroup(String input, RegExp regex) {
final value = _nonEmpty(regex.firstMatch(input)?.group(1));
if (value == null) return null;
try {
return Uri.decodeComponent(value);
} on FormatException {
return null;
}
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim() ?? '';
return trimmed.isEmpty ? null : trimmed;
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/extension_session_callback.dart';
void main() {
test('parses a signed-session callback without treating state as an id', () {
final callback = parseExtensionSessionGrantCallback(
'spotiflac://session-grant?cb_version=v2grant&state=nonce_123&grant=gr_456',
);
expect(callback, isNotNull);
expect(callback!.state, 'nonce_123');
expect(callback.grant, 'gr_456');
});
test('extracts the expected state from a nested challenge callback', () {
final callback = Uri(
scheme: 'spotiflac',
host: 'session-grant',
queryParameters: const {'cb_version': 'v2grant', 'state': 'nonce_123'},
);
final challenge = Uri.https('api.zarz.moe', '/v2/challenge', {
'cb': callback.toString(),
});
expect(extensionCallbackStateFromVerificationUri(challenge), 'nonce_123');
});
test('rejects callbacks without both a grant and state', () {
expect(
parseExtensionSessionGrantCallback(
'spotiflac://session-grant?grant=gr_456',
),
isNull,
);
expect(
parseExtensionSessionGrantCallback(
'spotiflac://session-grant?state=nonce_123',
),
isNull,
);
});
}