From 0d93e0cadee3a7bfee76727af875a76fcf1f0860 Mon Sep 17 00:00:00 2001 From: Moritz Jacob Date: Fri, 17 Jul 2026 11:22:05 +0200 Subject: [PATCH 1/7] store pending authorization - for auth with and without code exchange - only when receiving activity result with no pending operation, i.e. when no Result is available to send auth result to flutter - preserves null intent error because then no data is available to store --- .../flutterappauth/FlutterAppauthPlugin.java | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java b/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java index a24b4894..304d1ece 100644 --- a/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java +++ b/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java @@ -88,6 +88,7 @@ public class FlutterAppauthPlugin private Context applicationContext; private Activity mainActivity; private PendingOperation pendingOperation; + private PendingAuthorization pendingAuthorization; private String clientSecret; private boolean allowInsecureConnections; private AuthorizationService defaultAuthorizationService; @@ -669,20 +670,32 @@ private String getCauseFromException(@Nullable Exception ex) { @Override public boolean onActivityResult(int requestCode, int resultCode, Intent intent) { - if (pendingOperation == null) { - return false; - } if (requestCode == RC_AUTH_EXCHANGE_CODE || requestCode == RC_AUTH) { if (intent == null) { + if (pendingOperation == null) { + return false; + } finishWithError(NULL_INTENT_ERROR_CODE, NULL_INTENT_ERROR_FORMAT, null); - } else { - final AuthorizationResponse authResponse = AuthorizationResponse.fromIntent(intent); - AuthorizationException ex = AuthorizationException.fromIntent(intent); - processAuthorizationData(authResponse, ex, requestCode == RC_AUTH_EXCHANGE_CODE); + return true; + } + final AuthorizationResponse authResponse = AuthorizationResponse.fromIntent(intent); + final AuthorizationException authException = AuthorizationException.fromIntent(intent); + final boolean exchangeCode = requestCode == RC_AUTH_EXCHANGE_CODE; + if (pendingOperation == null) { + // The Flutter call that started this authorization flow no longer has a + // pending Result to complete. This happens when the host activity is killed and recreated + // while in the background of the browser. Stash the response so it can be retrieved + // later via resumePendingAuthorization() to process the authorization on the flutter side. + pendingAuthorization = new PendingAuthorization(authResponse, authException, exchangeCode); + return true; } + processAuthorizationData(authResponse, authException, exchangeCode); return true; } if (requestCode == RC_END_SESSION) { + if (pendingOperation == null) { + return false; + } if (intent == null) { finishWithError(NULL_INTENT_ERROR_CODE, NULL_INTENT_ERROR_FORMAT, null); } else { @@ -785,6 +798,22 @@ private class PendingOperation { } } + private class PendingAuthorization { + final AuthorizationResponse response; + final AuthorizationException exception; + final boolean exchangeCode; + + PendingAuthorization( + AuthorizationResponse response, + AuthorizationException exception, + boolean exchangeCode + ) { + this.response = response; + this.exception = exception; + this.exchangeCode = exchangeCode; + } + } + private class TokenRequestParameters { final String clientId; final String issuer; From e5cc921884162935b60483afef91d5cb745bc7f1 Mon Sep 17 00:00:00 2001 From: Moritz Jacob Date: Fri, 17 Jul 2026 11:23:49 +0200 Subject: [PATCH 2/7] handle resuming pending authorization takes pending authorization data and returns it to new Result --- .../flutterappauth/FlutterAppauthPlugin.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java b/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java index 304d1ece..3729283e 100644 --- a/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java +++ b/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java @@ -52,6 +52,7 @@ public class FlutterAppauthPlugin private static final String AUTHORIZE_METHOD = "authorize"; private static final String TOKEN_METHOD = "token"; private static final String END_SESSION_METHOD = "endSession"; + private static final String RESUME_PENDING_AUTHORIZATION_METHOD = "resumePendingAuthorization"; private static final String DISCOVERY_ERROR_CODE = "discovery_failed"; private static final String AUTHORIZE_AND_EXCHANGE_CODE_ERROR_CODE = @@ -199,6 +200,17 @@ public void onMethodCall(MethodCall call, @NonNull Result result) { finishWithError(END_SESSION_ERROR_CODE, ex.getLocalizedMessage(), ex); } break; + case RESUME_PENDING_AUTHORIZATION_METHOD: + try { + handleResumePendingAuthorizationMethodCall(result); + } catch (Exception ex) { + String errorCode = AUTHORIZE_ERROR_CODE; + if (pendingAuthorization != null && pendingAuthorization.exchangeCode ) { + errorCode = AUTHORIZE_AND_EXCHANGE_CODE_ERROR_CODE; + } + finishWithError(errorCode, ex.getLocalizedMessage(), ex); + } + break; default: result.notImplemented(); } @@ -714,6 +726,19 @@ public boolean onActivityResult(int requestCode, int resultCode, Intent intent) return false; } + private void handleResumePendingAuthorizationMethodCall(Result result) { + if (pendingAuthorization == null) { + result.success(null); + return; + } + + final PendingAuthorization pendingAuth = pendingAuthorization; + pendingAuthorization = null; + + checkAndSetPendingOperation(RESUME_PENDING_AUTHORIZATION_METHOD, result); + processAuthorizationData(pendingAuth.response, pendingAuth.exception, pendingAuth.exchangeCode); + } + private void processAuthorizationData( final AuthorizationResponse authResponse, AuthorizationException authException, From 064c21e738acaea216931f3d4fbca5ebcfc3df71 Mon Sep 17 00:00:00 2001 From: Moritz Jacob Date: Fri, 17 Jul 2026 12:58:48 +0200 Subject: [PATCH 3/7] add no-ops to ios and macos plugins afaik there is no scenario this can happen on iOS or macOS --- .../flutter_appauth/Sources/flutter_appauth/FlutterAppAuth.h | 2 ++ .../Sources/flutter_appauth/FlutterAppauthPlugin.m | 4 ++++ .../Sources/flutter_appauth/FlutterAppauthPlugin.m | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppAuth.h b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppAuth.h index 5683ab88..3a0bec37 100644 --- a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppAuth.h +++ b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppAuth.h @@ -33,6 +33,8 @@ static NSString *const AUTHORIZE_AND_EXCHANGE_CODE_METHOD = @"authorizeAndExchangeCode"; static NSString *const TOKEN_METHOD = @"token"; static NSString *const END_SESSION_METHOD = @"endSession"; +static NSString *const RESUME_PENDING_AUTHORIZATION_METHOD = + @"resumePendingAuthorization"; static NSString *const AUTHORIZE_ERROR_CODE = @"authorize_failed"; static NSString *const AUTHORIZE_AND_EXCHANGE_CODE_ERROR_CODE = @"authorize_and_exchange_code_failed"; diff --git a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m index d948c5ec..2fbc078b 100644 --- a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m +++ b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m @@ -164,6 +164,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call [self handleTokenMethodCall:[call arguments] result:result]; } else if ([END_SESSION_METHOD isEqualToString:call.method]) { [self handleEndSessionMethodCall:[call arguments] result:result]; + } else if ([RESUME_PENDING_AUTHORIZATION_METHOD isEqualToString:call.method]) { + // Only Android can lose a pending authorization result to Activity + // recreation; there is never anything to resume here. + result(nil); } else { result(FlutterMethodNotImplemented); } diff --git a/flutter_appauth/macos/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m b/flutter_appauth/macos/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m index 1ccf9549..502c314a 100644 --- a/flutter_appauth/macos/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m +++ b/flutter_appauth/macos/flutter_appauth/Sources/flutter_appauth/FlutterAppauthPlugin.m @@ -163,6 +163,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call [self handleTokenMethodCall:[call arguments] result:result]; } else if ([END_SESSION_METHOD isEqualToString:call.method]) { [self handleEndSessionMethodCall:[call arguments] result:result]; + } else if ([RESUME_PENDING_AUTHORIZATION_METHOD isEqualToString:call.method]) { + // Only Android can lose a pending authorization result to Activity + // recreation; there is never anything to resume here. + result(nil); } else { result(FlutterMethodNotImplemented); } From 0b713d362bb75648b2a1df9ab87624212a795770 Mon Sep 17 00:00:00 2001 From: Moritz Jacob Date: Fri, 17 Jul 2026 13:33:17 +0200 Subject: [PATCH 4/7] expose resume through method channel --- flutter_appauth/lib/flutter_appauth.dart | 3 + flutter_appauth/lib/src/flutter_appauth.dart | 12 ++ .../flutter_appauth_platform_interface.dart | 1 + .../src/authorization_resume_response.dart | 32 +++++ .../lib/src/flutter_appauth_platform.dart | 14 ++ .../src/method_channel_flutter_appauth.dart | 123 ++++++++++-------- .../lib/src/method_channel_mappers.dart | 33 +++++ .../method_channel_flutter_appauth_test.dart | 88 +++++++++++++ 8 files changed, 253 insertions(+), 53 deletions(-) create mode 100644 flutter_appauth_platform_interface/lib/src/authorization_resume_response.dart diff --git a/flutter_appauth/lib/flutter_appauth.dart b/flutter_appauth/lib/flutter_appauth.dart index 0ea2ac94..4e4d69fe 100644 --- a/flutter_appauth/lib/flutter_appauth.dart +++ b/flutter_appauth/lib/flutter_appauth.dart @@ -1,6 +1,9 @@ export 'package:flutter_appauth_platform_interface/flutter_appauth_platform_interface.dart' show AuthorizationRequest, + AuthorizationResumeResponse, + AuthorizationResumeResponseAuthorize, + AuthorizationResumeResponseToken, AuthorizationResponse, AuthorizationServiceConfiguration, AuthorizationTokenRequest, diff --git a/flutter_appauth/lib/src/flutter_appauth.dart b/flutter_appauth/lib/src/flutter_appauth.dart index 1b326325..2f6979b0 100644 --- a/flutter_appauth/lib/src/flutter_appauth.dart +++ b/flutter_appauth/lib/src/flutter_appauth.dart @@ -32,4 +32,16 @@ class FlutterAppAuth { Future endSession(EndSessionRequest request) { return FlutterAppAuthPlatform.instance.endSession(request); } + + /// On Android, attempts to resume an authorization result that the native + /// platform received while no Dart call is awaiting it. This can happen + /// when the host Activity is recreated (e.g. by the OS reclaiming memory) + /// while the authorization browser is in the foreground. + /// Call this after the app has reinitialized to + /// retrieve that result. Returns null if there is nothing pending. + /// + /// Always returns null on other platforms. + Future resumePendingAuthorization() { + return FlutterAppAuthPlatform.instance.resumePendingAuthorization(); + } } diff --git a/flutter_appauth_platform_interface/lib/flutter_appauth_platform_interface.dart b/flutter_appauth_platform_interface/lib/flutter_appauth_platform_interface.dart index 7039340c..8e46ed6a 100644 --- a/flutter_appauth_platform_interface/lib/flutter_appauth_platform_interface.dart +++ b/flutter_appauth_platform_interface/lib/flutter_appauth_platform_interface.dart @@ -1,4 +1,5 @@ export 'src/authorization_request.dart'; +export 'src/authorization_resume_response.dart'; export 'src/authorization_response.dart'; export 'src/authorization_service_configuration.dart'; export 'src/authorization_token_request.dart'; diff --git a/flutter_appauth_platform_interface/lib/src/authorization_resume_response.dart b/flutter_appauth_platform_interface/lib/src/authorization_resume_response.dart new file mode 100644 index 00000000..f6b98f84 --- /dev/null +++ b/flutter_appauth_platform_interface/lib/src/authorization_resume_response.dart @@ -0,0 +1,32 @@ +import 'authorization_response.dart'; +import 'authorization_token_response.dart'; + +/// The result of resuming a pending authorization result that the native +/// platform received while no Dart call was awaiting it. +/// +/// Depending on whether the original authorization flow was started with +/// `authorize()` or `authorizeAndExchangeCode()`, this is either +/// [AuthorizationResumeResponseAuthorize] or +/// [AuthorizationResumeResponseToken]. +sealed class AuthorizationResumeResponse { + const factory AuthorizationResumeResponse.authorize( + AuthorizationResponse response, + ) = AuthorizationResumeResponseAuthorize; + + const factory AuthorizationResumeResponse.token( + AuthorizationTokenResponse response, + ) = AuthorizationResumeResponseToken; +} + +class AuthorizationResumeResponseAuthorize + implements AuthorizationResumeResponse { + final AuthorizationResponse response; + + const AuthorizationResumeResponseAuthorize(this.response); +} + +class AuthorizationResumeResponseToken implements AuthorizationResumeResponse { + final AuthorizationTokenResponse response; + + const AuthorizationResumeResponseToken(this.response); +} diff --git a/flutter_appauth_platform_interface/lib/src/flutter_appauth_platform.dart b/flutter_appauth_platform_interface/lib/src/flutter_appauth_platform.dart index 0985727a..8f36b8d0 100644 --- a/flutter_appauth_platform_interface/lib/src/flutter_appauth_platform.dart +++ b/flutter_appauth_platform_interface/lib/src/flutter_appauth_platform.dart @@ -1,6 +1,7 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'authorization_request.dart'; +import 'authorization_resume_response.dart'; import 'authorization_response.dart'; import 'authorization_token_request.dart'; import 'authorization_token_response.dart'; @@ -62,4 +63,17 @@ abstract class FlutterAppAuthPlatform extends PlatformInterface { Future endSession(EndSessionRequest request) { throw UnimplementedError('endSession() has not been implemented'); } + + /// On Android, attempts to resume an authorization result that the native + /// platform received while no Dart call is awaiting it. This can happen + /// when the host Activity is recreated (e.g. by the OS reclaiming memory) + /// while the authorization browser is in the foreground. + /// Call this after the app has reinitialized to + /// retrieve that result. Returns null if there is nothing pending. + /// + /// Always returns null on other platforms. + Future resumePendingAuthorization() { + throw UnimplementedError( + 'resumePendingAuthorization() has not been implemented'); + } } diff --git a/flutter_appauth_platform_interface/lib/src/method_channel_flutter_appauth.dart b/flutter_appauth_platform_interface/lib/src/method_channel_flutter_appauth.dart index 114a20d3..412728a1 100644 --- a/flutter_appauth_platform_interface/lib/src/method_channel_flutter_appauth.dart +++ b/flutter_appauth_platform_interface/lib/src/method_channel_flutter_appauth.dart @@ -3,6 +3,7 @@ import 'package:flutter_appauth_platform_interface/src/end_session_request.dart' import 'package:flutter_appauth_platform_interface/src/end_session_response.dart'; import 'authorization_request.dart'; +import 'authorization_resume_response.dart'; import 'authorization_response.dart'; import 'authorization_token_request.dart'; import 'authorization_token_response.dart'; @@ -23,13 +24,7 @@ class MethodChannelFlutterAppAuth extends FlutterAppAuthPlatform { request.toMap(), ); - return AuthorizationResponse( - authorizationCode: result['authorizationCode'], - codeVerifier: result['codeVerifier'], - nonce: result['nonce'], - authorizationAdditionalParameters: - result['authorizationAdditionalParameters']?.cast(), - ); + return authorizationResponseFromResultMap(result); } @override @@ -40,18 +35,7 @@ class MethodChannelFlutterAppAuth extends FlutterAppAuthPlatform { request.toMap(), ); - return AuthorizationTokenResponse( - result['accessToken'], - result['refreshToken'], - result['accessTokenExpirationTime'] == null - ? null - : DateTime.fromMillisecondsSinceEpoch( - result['accessTokenExpirationTime'].toInt()), - result['idToken'], - result['tokenType'], - result['scopes']?.cast(), - result['authorizationAdditionalParameters']?.cast(), - result['tokenAdditionalParameters']?.cast()); + return authorizationTokenResponseFromResultMap(result); } @override @@ -84,47 +68,80 @@ class MethodChannelFlutterAppAuth extends FlutterAppAuthPlatform { return EndSessionResponse(result['state']); } + @override + Future resumePendingAuthorization() async { + Map? result; + try { + result = await _channel.invokeMethod>( + 'resumePendingAuthorization', + ); + } on PlatformException catch (e) { + _mapAndThrowPlatformException(e); + } + + if (result == null) { + return null; + } + + if (result.containsKey('accessToken')) { + return AuthorizationResumeResponse.token( + authorizationTokenResponseFromResultMap(result), + ); + } + + return AuthorizationResumeResponse.authorize( + authorizationResponseFromResultMap(result), + ); + } + Future> invokeMethod( String method, dynamic arguments) async { try { return (await _channel.invokeMethod>( method, arguments))!; } on PlatformException catch (e) { - if (e.details == null) { - rethrow; - } - final Map? errorDetails = _extractErrorDetails( - e.details, + _mapAndThrowPlatformException(e); + } + } + + /// Maps a raw [PlatformException] from the method channel into one of the + /// package's typed exceptions and throws it, or rethrows [e] unchanged if + /// it doesn't carry the expected error details. + Never _mapAndThrowPlatformException(PlatformException e) { + if (e.details == null) { + throw e; + } + final Map? errorDetails = _extractErrorDetails( + e.details, + ); + if (errorDetails == null) { + throw e; + } + + // Ensures that the PlatformException remains the same as before the + // introduction of custom exception handling so as to not break existing + // usages. + final dynamic legacyErrorDetails = + errorDetails['legacy_error_details'] ?? errorDetails; + final FlutterAppAuthPlatformErrorDetails parsedDetails = + FlutterAppAuthPlatformErrorDetails.fromMap(errorDetails); + + if (errorDetails['user_did_cancel']?.toLowerCase().trim() == 'true') { + throw FlutterAppAuthUserCancelledException( + code: e.code, + message: e.message, + stacktrace: e.stacktrace, + legacyDetails: legacyErrorDetails, + platformErrorDetails: parsedDetails, + ); + } else { + throw FlutterAppAuthPlatformException( + code: e.code, + message: e.message, + stacktrace: e.stacktrace, + legacyDetails: legacyErrorDetails, + platformErrorDetails: parsedDetails, ); - if (errorDetails == null) { - rethrow; - } - - // Ensures that the PlatformException remains the same as before the - // introduction of custom exception handling so as to not break existing - // usages. - final dynamic legacyErrorDetails = - errorDetails['legacy_error_details'] ?? errorDetails; - final FlutterAppAuthPlatformErrorDetails parsedDetails = - FlutterAppAuthPlatformErrorDetails.fromMap(errorDetails); - - if (errorDetails['user_did_cancel']?.toLowerCase().trim() == 'true') { - throw FlutterAppAuthUserCancelledException( - code: e.code, - message: e.message, - stacktrace: e.stacktrace, - legacyDetails: legacyErrorDetails, - platformErrorDetails: parsedDetails, - ); - } else { - throw FlutterAppAuthPlatformException( - code: e.code, - message: e.message, - stacktrace: e.stacktrace, - legacyDetails: legacyErrorDetails, - platformErrorDetails: parsedDetails, - ); - } } } diff --git a/flutter_appauth_platform_interface/lib/src/method_channel_mappers.dart b/flutter_appauth_platform_interface/lib/src/method_channel_mappers.dart index aab82ea2..66231882 100644 --- a/flutter_appauth_platform_interface/lib/src/method_channel_mappers.dart +++ b/flutter_appauth_platform_interface/lib/src/method_channel_mappers.dart @@ -1,7 +1,9 @@ import 'authorization_parameters.dart'; import 'authorization_request.dart'; +import 'authorization_response.dart'; import 'authorization_service_configuration.dart'; import 'authorization_token_request.dart'; +import 'authorization_token_response.dart'; import 'common_request_details.dart'; import 'end_session_request.dart'; import 'grant_type.dart'; @@ -103,3 +105,34 @@ Map _convertAuthorizationParametersToMap( 'responseMode': authorizationParameters.responseMode, }; } + +/// Builds an [AuthorizationResponse] from the raw method channel result of an +/// `authorize` call. +AuthorizationResponse authorizationResponseFromResultMap( + Map result) { + return AuthorizationResponse( + authorizationCode: result['authorizationCode'], + codeVerifier: result['codeVerifier'], + nonce: result['nonce'], + authorizationAdditionalParameters: + result['authorizationAdditionalParameters']?.cast(), + ); +} + +/// Builds an [AuthorizationTokenResponse] from the raw method channel result +/// of an `authorizeAndExchangeCode` call. +AuthorizationTokenResponse authorizationTokenResponseFromResultMap( + Map result) { + return AuthorizationTokenResponse( + result['accessToken'], + result['refreshToken'], + result['accessTokenExpirationTime'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + result['accessTokenExpirationTime'].toInt()), + result['idToken'], + result['tokenType'], + result['scopes']?.cast(), + result['authorizationAdditionalParameters']?.cast(), + result['tokenAdditionalParameters']?.cast()); +} diff --git a/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart b/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart index dd59971e..3945fb35 100644 --- a/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart +++ b/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart @@ -190,4 +190,92 @@ void main() { }) ]); }); + + group('resumePendingAuthorization', () { + tearDown(() { + // Restore the default handler used by the rest of the tests. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return {}; + }); + }); + + test('returns null when nothing is pending', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + + final AuthorizationResumeResponse? result = + await flutterAppAuth.resumePendingAuthorization(); + + expect(result, isNull); + expect(log, [ + isMethodCall('resumePendingAuthorization', arguments: null) + ]); + }); + + test('returns an AuthorizationResponse for a resumed authorize() flow', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return { + 'authorizationCode': 'someAuthorizationCode', + 'codeVerifier': 'someCodeVerifier', + 'nonce': 'someNonce', + 'authorizationAdditionalParameters': {}, + }; + }); + + final AuthorizationResumeResponse? result = + await flutterAppAuth.resumePendingAuthorization(); + + expect(result, isA()); + final response = (result as AuthorizationResumeResponseAuthorize) + .response; + expect(response.authorizationCode, + 'someAuthorizationCode'); + expect(response.codeVerifier, 'someCodeVerifier'); + expect(response.nonce, 'someNonce'); + expect(response.authorizationAdditionalParameters, isNotNull); + }); + + test( + 'returns an AuthorizationTokenResponse for a resumed ' + 'authorizeAndExchangeCode() flow', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return { + 'accessToken': 'someAccessToken', + 'refreshToken': 'someRefreshToken', + 'accessTokenExpirationTime': 1784239200000, + 'idToken': 'someIdToken', + 'tokenType': 'bearer', + 'scopes': ['someScope'], + 'authorizationAdditionalParameters': {}, + 'tokenAdditionalParameters': {}, + }; + }); + + final AuthorizationResumeResponse? result = + await flutterAppAuth.resumePendingAuthorization(); + + expect(result, isA()); + final response = (result as AuthorizationResumeResponseToken).response; + expect( + response.accessToken, 'someAccessToken'); + expect( + response.refreshToken, 'someRefreshToken'); + expect(response.accessTokenExpirationDateTime, DateTime(2026, 7, 17)); + expect(response.idToken, 'someIdToken'); + expect(response.tokenType, 'bearer'); + expect(response.scopes, ["someScope"]); + expect(response.authorizationAdditionalParameters, isNotNull); + expect(response.tokenAdditionalParameters, isNotNull); + }); + }); } From ca24633aa7073e3d4cb9734b106b3fb6b038237d Mon Sep 17 00:00:00 2001 From: Moritz Jacob Date: Mon, 14 Sep 2026 09:43:43 +0200 Subject: [PATCH 5/7] add auth resume to example app --- flutter_appauth/example/lib/main.dart | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/flutter_appauth/example/lib/main.dart b/flutter_appauth/example/lib/main.dart index a47b0893..f6bed395 100644 --- a/flutter_appauth/example/lib/main.dart +++ b/flutter_appauth/example/lib/main.dart @@ -62,6 +62,24 @@ class _MyAppState extends State { endSessionEndpoint: 'https://demo.duendesoftware.com/connect/endsession', ); + @override + void initState() { + super.initState(); + /* + On Android, the OS can recreate the host Activity (and with it this + Flutter engine/widget tree) while the authorization browser is in the + foreground, e.g. to reclaim memory. When that happens the auth result + arrives natively with nothing on the Dart side awaiting it, so it's + stored and can be retrieved once the app has reinitialized by calling + resumePendingAuthorization(). + To test this on Android, enable "Don't keep activities" in the + developer options, start a sign in flow and then complete it. The + Activity will have been destroyed and recreated in the background. + Always returns null on other platforms, so can be called unconditionally. + */ + _resumePendingAuthorization(); + } + @override Widget build(BuildContext context) { return MaterialApp( @@ -98,6 +116,19 @@ class _MyAppState extends State { child: const Text('Sign in with auto code exchange'), onPressed: () => _signInWithAutoCodeExchange(), ), + // resumePendingAuthorization() only ever returns something on + // Android, so only show this button there. + if (Platform.isAndroid) + Padding( + padding: const EdgeInsets.all(8.0), + child: ElevatedButton( + child: const Text( + 'Resume pending authorization', + textAlign: TextAlign.center, + ), + onPressed: () => _resumePendingAuthorization(), + ), + ), if (Platform.isIOS || Platform.isMacOS) Padding( padding: const EdgeInsets.all(8.0), @@ -331,6 +362,34 @@ class _MyAppState extends State { } } + Future _resumePendingAuthorization() async { + try { + final AuthorizationResumeResponse? result = + await _appAuth.resumePendingAuthorization(); + // Returns null when there was nothing pending, e.g. on a normal app + // start rather than a resumption after the Activity was recreated. + if (result == null) { + return; + } + _setBusyState(); + // The response depends on whether the pending flow was started with + // authorize() or authorizeAndExchangeCode(). The response can be handled + // just like any standard sign in response. + + switch (result) { + case AuthorizationResumeResponseAuthorize(:final response): + _processAuthResponse(response); + case AuthorizationResumeResponseToken(:final response): + _processAuthTokenResponse(response); + await _testApi(response); + } + } catch (e) { + _handleError(e); + } finally { + _clearBusyState(); + } + } + Future _signInWithAutoCodeExchange( {ExternalUserAgent externalUserAgent = ExternalUserAgent.asWebAuthenticationSession}) async { From f81234914abf9604e11b733974322db54c7c8dea Mon Sep 17 00:00:00 2001 From: Moritz Jacob Date: Mon, 14 Sep 2026 10:07:31 +0200 Subject: [PATCH 6/7] make resume test time zone agnostic --- .../test/method_channel_flutter_appauth_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart b/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart index 3945fb35..05c4a72c 100644 --- a/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart +++ b/flutter_appauth_platform_interface/test/method_channel_flutter_appauth_test.dart @@ -270,7 +270,8 @@ void main() { response.accessToken, 'someAccessToken'); expect( response.refreshToken, 'someRefreshToken'); - expect(response.accessTokenExpirationDateTime, DateTime(2026, 7, 17)); + expect(response.accessTokenExpirationDateTime, + DateTime.fromMillisecondsSinceEpoch(1784239200000)); expect(response.idToken, 'someIdToken'); expect(response.tokenType, 'bearer'); expect(response.scopes, ["someScope"]); From 4f3d05a22437494e145a4c95367137b44bd7dd8e Mon Sep 17 00:00:00 2001 From: Moritz Jacob Date: Mon, 14 Sep 2026 10:23:05 +0200 Subject: [PATCH 7/7] move error handling inward to capture correct error code --- .../flutterappauth/FlutterAppauthPlugin.java | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java b/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java index 3729283e..69b45277 100644 --- a/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java +++ b/flutter_appauth/android/src/main/java/io/crossingthestreams/flutterappauth/FlutterAppauthPlugin.java @@ -201,15 +201,7 @@ public void onMethodCall(MethodCall call, @NonNull Result result) { } break; case RESUME_PENDING_AUTHORIZATION_METHOD: - try { handleResumePendingAuthorizationMethodCall(result); - } catch (Exception ex) { - String errorCode = AUTHORIZE_ERROR_CODE; - if (pendingAuthorization != null && pendingAuthorization.exchangeCode ) { - errorCode = AUTHORIZE_AND_EXCHANGE_CODE_ERROR_CODE; - } - finishWithError(errorCode, ex.getLocalizedMessage(), ex); - } break; default: result.notImplemented(); @@ -735,8 +727,15 @@ private void handleResumePendingAuthorizationMethodCall(Result result) { final PendingAuthorization pendingAuth = pendingAuthorization; pendingAuthorization = null; - checkAndSetPendingOperation(RESUME_PENDING_AUTHORIZATION_METHOD, result); - processAuthorizationData(pendingAuth.response, pendingAuth.exception, pendingAuth.exchangeCode); + try { + checkAndSetPendingOperation(RESUME_PENDING_AUTHORIZATION_METHOD, result); + processAuthorizationData(pendingAuth.response, pendingAuth.exception, pendingAuth.exchangeCode); + } catch(Exception ex) { + final String errorCode = pendingAuth.exchangeCode + ? AUTHORIZE_AND_EXCHANGE_CODE_ERROR_CODE + : AUTHORIZE_ERROR_CODE; + finishWithError(errorCode, ex.getLocalizedMessage(), ex); + } } private void processAuthorizationData(