mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-14 13:59:11 +02:00
fix(extensions): preserve verification callback retries
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user