MaikuB / MaikuB/flutter_appauth

[iOS] `authorizeAndExchangeCode` never completes when `sfSafariViewController` is dismissed by swipe

Open
#649 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Objective-C
Stars
308
Forks
301
Avg merge
2d 11h
Merged PRs (30d)
5

Description

Hi, we are seeing an iOS issue with `flutter_appauth` when using:

- `authorizeAndExchangeCode`
- `ExternalUserAgent.sfSafariViewController`

If the system auth dialog is dismissed by swipe instead of finishing the OAuth flow, the returned `Future` never completes.

## Environment

- `flutter_appauth: ^12.0.0`
- iOS
- `ExternalUserAgent.sfSafariViewController`
- sample provider: `https://demo.duendesoftware.com`
- sample client: `interactive.public`

Important: you can replace the provider/client with your own values. The issue does not appear to depend on a specific identity provider.

## What we expected

When the user dismisses the iOS auth dialog by swipe, `authorizeAndExchangeCode` should settle somehow:

- either complete with a cancellation error
- or complete with any other explicit failure

The important part is that the returned `Future` should not stay pending forever.

## What actually happens

After dismissing the dialog by swipe:

- the app returns to the foreground
- `authorizeAndExchangeCode` does not complete
- it does not throw
- it stays pending indefinitely

## Steps to reproduce

1. Run the attached sample `main.dart` on iOS.
2. Tap `Start authorizeAndExchangeCode`.
3. Wait until the iOS `SFSafariViewController` auth UI is shown.
4. Dismiss it with a swipe gesture.
5. Return to the app and observe the state.

## Reproducible result

The sample remains in `Pending...` state forever and never logs completion or error.

## Video

https://github.com/user-attachments/assets/b08aebae-3afc-4f93-b0bd-8c006d58c4d7

## Notes

- Reproduced with `ExternalUserAgent.sfSafariViewController`.
- The issue is that `authorizeAndExchangeCode` does not settle after swipe-dismiss.

## Minimal repro

main.dart

```dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_appauth/flutter_appauth.dart';

const _issuer = 'https://demo.duendesoftware.com';
const _clientId = 'interactive.public';
const _redirectUrl = 'com.duendesoftware.demo:/oauthredirect';
const _scopes = ['openid', 'profile', 'email', 'offline_access', 'api'];

void main() {
runApp(const ReproApp());
}

class ReproApp extends StatelessWidget {
const ReproApp({super.key});

@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: ReproScreen(),
);
}
}

class ReproScreen extends StatefulWidget {
const ReproScreen({super.key});

@override
State createState() => _ReproScreenState();
}

class _ReproScreenState extends State {
final FlutterAppAuth _appAuth = const FlutterAppAuth();

bool _isPending = false;
DateTime? _startedAt;
Timer? _elapsedTimer;
Duration _elapsed = Duration.zero;
String _status = 'Idle';

@override
void dispose() {
_elapsedTimer?.cancel();
super.dispose();
}

Future _startAuthorization() async {
if (_isPending) {
setState(() {
_status = 'Already pending';
});
return;
}

final request = AuthorizationTokenRequest(
_clientId,
_redirectUrl,
issuer: _issuer,
scopes: _scopes,
externalUserAgent: ExternalUserAgent.sfSafariViewController,
);

setState(() {
_isPending = true;
_startedAt = DateTime.now();
_elapsed = Duration.zero;
_status = 'Pending for 0s';
});

_elapsedTimer?.cancel();
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _startedAt == null) {
return;
}
setState(() {
_elapsed = DateTime.now().difference(_startedAt!);
_status = 'Pending for ${_elapsed.inSeconds}s';
});
});

try {
final response = await _appAuth.authorizeAndExchangeCode(request);
final hasAccessToken = response.accessToken != null;
_status =
'Completed successfully. accessToken ${hasAccessToken ? 'present' : 'null'}.';
} catch (error, stackTrace) {
_status = 'Completed with error: $error';
debugPrintStack(stackTrace: stackTrace);
} finally {
_elapsedTimer?.cancel();
if (!mounted) {
return;
}
setState(() {
_isPending = false;
});
}
}

void _resetState() {
_elapsedTimer?.cancel();
setState(() {
_isPending = false;
_startedAt = null;
_elapsed = Duration.zero;
_status = 'Idle';
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('flutter_appauth iOS repro')),
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_status,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _startAuthorization,
child: const Text('Start authorizeAndExchangeCode'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _resetState,
child: const Text('Reset local state'),
),
],
),
),
),
),
);
}
}
```

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the attached main.dart on iOS with ExternalUserAgent.sfSafariViewController and reproduce the swipe dismissal. Trace the authorizeAndExchangeCode entry point through the iOS flow, focusing on what happens when SFSafariViewController is dismissed; done means the returned Future settles with an explicit success or failure instead of remaining pending.

Written by the indexing model from the issue text.

Assessment

Tech stack
dart, flutter, ios
Domain
mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.