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
Run the attached sample main.dart on iOS.
Tap Start authorizeAndExchangeCode.
Wait until the iOS SFSafariViewController auth UI is shown.
Dismiss it with a swipe gesture.
Return to the app and observe the state.
Reproducible result
The sample remains in Pending... state forever and never logs completion or error.
Video
Screen.Recording.2026-04-16.at.15.21.25.mov
Notes
Reproduced with ExternalUserAgent.sfSafariViewController.
The issue is that authorizeAndExchangeCode does not settle after swipe-dismiss.
Minimal repro
main.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 = < String > ['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 <ReproScreen > createState () => _ReproScreenState ();
}
class _ReproScreenState extends State <ReproScreen > {
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 <void > _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' ),
),
],
),
),
),
),
);
}
}
Hi, we are seeing an iOS issue with
flutter_appauthwhen using:authorizeAndExchangeCodeExternalUserAgent.sfSafariViewControllerIf the system auth dialog is dismissed by swipe instead of finishing the OAuth flow, the returned
Futurenever completes.Environment
flutter_appauth: ^12.0.0ExternalUserAgent.sfSafariViewControllerhttps://demo.duendesoftware.cominteractive.publicImportant: 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,
authorizeAndExchangeCodeshould settle somehow:The important part is that the returned
Futureshould not stay pending forever.What actually happens
After dismissing the dialog by swipe:
authorizeAndExchangeCodedoes not completeSteps to reproduce
main.darton iOS.Start authorizeAndExchangeCode.SFSafariViewControllerauth UI is shown.Reproducible result
The sample remains in
Pending...state forever and never logs completion or error.Video
Screen.Recording.2026-04-16.at.15.21.25.mov
Notes
ExternalUserAgent.sfSafariViewController.authorizeAndExchangeCodedoes not settle after swipe-dismiss.Minimal repro
main.dart