Skip to content

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

Description

@AlexeyKatsuro

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

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'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions