diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index eb0e350e..d1fd2c5a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 4c5bb490..15e87ac5 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -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() diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index 29095c6b..0612bd41 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -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) } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e7c1a534..7d6fafc8 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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 diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 6881e784..f6b110bc 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -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 showExtensionVerificationHelpDialog( await _completeSessionGrantFromClipboard( dialogContext, extensionId, + authUri, ); if (dialogContext.mounted) { setDialogState(() => clipboardGrantInFlight = false); @@ -389,15 +391,13 @@ Future showExtensionVerificationHelpDialog( Future _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 _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 _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 diff --git a/lib/utils/extension_session_callback.dart b/lib/utils/extension_session_callback.dart new file mode 100644 index 00000000..234c23bc --- /dev/null +++ b/lib/utils/extension_session_callback.dart @@ -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; +} diff --git a/test/extension_session_callback_test.dart b/test/extension_session_callback_test.dart new file mode 100644 index 00000000..faed682d --- /dev/null +++ b/test/extension_session_callback_test.dart @@ -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, + ); + }); +}