From f02194720e5996582261ce1df8a377e9c8466292 Mon Sep 17 00:00:00 2001 From: Christian Denker Date: Mon, 14 Sep 2026 09:41:56 +0200 Subject: [PATCH] fix: keep the credential when a token refresh fails for want of a connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _performCheckAuthentication caught every refresh failure alike and fell through to authenticate(). A rejected credential (OpenIdException) is the one case where that is right. A refresh that fails because there is no connection — SocketException, timeout, a 5xx from the token endpoint — says nothing about the session, yet it sent the user to a login page they could not load, over an app that could have answered from its cache. Now only OpenIdException clears the credential and leads to the login. Any other failure keeps the credential for the next attempt and propagates out of checkAuthentication, where callers treat it like any other connectivity failure. performSetup completes in both cases, so isAuthenticated never hangs on a failed silent restore. Co-Authored-By: Claude Opus 5 --- packages/apptive_grid_core/CHANGELOG.md | 4 + .../apptive_grid_authenticator.dart | 22 ++++- packages/apptive_grid_core/pubspec.yaml | 2 +- .../test/authenticator_test.dart | 81 +++++++++++++++++-- 4 files changed, 99 insertions(+), 10 deletions(-) diff --git a/packages/apptive_grid_core/CHANGELOG.md b/packages/apptive_grid_core/CHANGELOG.md index a7c7caae..490efb9f 100644 --- a/packages/apptive_grid_core/CHANGELOG.md +++ b/packages/apptive_grid_core/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.3.1 + + - **FIX**: A saved token whose refresh fails for want of a connection no longer falls through to `authenticate()`. Only the auth server rejecting the credential (`OpenIdException`) leads to the login; any other failure keeps the credential and propagates from `checkAuthentication`, so an app that is merely offline can answer from its cache instead of sending the user to a login page they cannot load. `performSetup` completes either way, so `isAuthenticated` never hangs on a failed restore. + ## 2.3.0 - **FEAT**: Add `Address` DataType, `Address` model and `AddressDataEntity`. diff --git a/packages/apptive_grid_core/lib/src/network/authentication/apptive_grid_authenticator.dart b/packages/apptive_grid_core/lib/src/network/authentication/apptive_grid_authenticator.dart index a92f8390..a04cf71d 100644 --- a/packages/apptive_grid_core/lib/src/network/authentication/apptive_grid_authenticator.dart +++ b/packages/apptive_grid_core/lib/src/network/authentication/apptive_grid_authenticator.dart @@ -55,8 +55,16 @@ class ApptiveGridAuthenticator { if (client.options.authenticationOptions.persistCredentials) { _authenticationStorage ??= const FlutterSecureStorageCredentialStorage(); - await checkAuthentication(requestNewToken: false) - .then((_) => _setupCompleter.complete()); + try { + await checkAuthentication(requestNewToken: false); + } catch (error) { + // Restoring the session failed for want of a connection — a + // rejected credential is handled inside and never thrown. Start + // without a token; the next authenticated request tries again. + debugPrint('Could not restore the session: $error'); + } finally { + _setupCompleter.complete(); + } } else { _setupCompleter.complete(); } @@ -342,11 +350,17 @@ class ApptiveGridAuthenticator { setToken(token); return; } on OpenIdException catch (openIdError) { + // The auth server turned the credential down: the session is + // over, and a login is the only way on. setCredential(null); debugPrint('Could not refresh saved token: $openIdError'); - } catch (error) { - debugPrint('Error refreshing token: $error'); } + // Anything else — no connection, a timeout, a server having a + // bad day — says nothing about the session. The credential stays + // for the next attempt and the failure propagates, instead of + // falling through to a login: sending someone who is merely + // offline to a login page they cannot load helps nobody, and the + // caller may well have a cached answer to fall back on. } else { setCredential(null); } diff --git a/packages/apptive_grid_core/pubspec.yaml b/packages/apptive_grid_core/pubspec.yaml index 06b9b1a1..e4ba0900 100644 --- a/packages/apptive_grid_core/pubspec.yaml +++ b/packages/apptive_grid_core/pubspec.yaml @@ -1,6 +1,6 @@ name: apptive_grid_core description: Core Library for ApptiveGrid used to provide general ApptiveGrid functionality to other Packages or Apps -version: 2.3.0 +version: 2.3.1 homepage: https://www.apptivegrid.de repository: https://github.com/ApptiveGrid/apptive_grid_flutter/tree/main/packages/apptive_grid_core diff --git a/packages/apptive_grid_core/test/authenticator_test.dart b/packages/apptive_grid_core/test/authenticator_test.dart index 33c181ee..e6539baf 100644 --- a/packages/apptive_grid_core/test/authenticator_test.dart +++ b/packages/apptive_grid_core/test/authenticator_test.dart @@ -1060,8 +1060,12 @@ void main() { test( 'Storage Provided ' - 'Error ' - 'Calls authenticate', () async { + 'Connection error while refreshing ' + 'Keeps the credential, throws, does not log in', () async { + // No connection is not "no session". Sending the user to a login page + // they cannot load — over an app that could answer from its cache — + // was the old behaviour. Now the failure surfaces and the credential + // stays for the next attempt. final tokenTime = DateTime.now(); final httpClient = MockHttpClient(); final authClient = MockAuthClient(); @@ -1134,10 +1138,77 @@ void main() { when(() => client.issuer).thenReturn(zweidenkerIssuer); when(() => client.clientId).thenReturn('clientId'); - await authenticator.checkAuthentication(); + await expectLater(authenticator.checkAuthentication(), throwsException); - verify(testAuthenticator.authorize).called(1); - expect(await authenticator.isAuthenticated, equals(true)); + verifyNever(testAuthenticator.authorize); + verifyNever(() => storage.saveCredential(null)); + expect(await authenticator.isAuthenticatedWithToken, isFalse); + }); + + test( + 'Storage Provided ' + 'Connection error while restoring at setup ' + 'Completes setup without a token', () async { + // The constructor restores the session silently. When that fails for + // want of a connection the authenticator must still finish setting + // up, or isAuthenticated would wait forever. + final tokenTime = DateTime.now(); + final httpClient = MockHttpClient(); + final authClient = MockAuthClient(); + when(() => authClient.issuer).thenReturn(zweidenkerIssuer); + when(() => authClient.clientSecret).thenReturn(''); + when(() => authClient.clientId).thenReturn('test'); + when(() => authClient.httpClient).thenReturn(httpClient); + + final tokenResponse = TokenResponse.fromJson({ + 'token_type': 'Bearer', + 'access_token': '12345', + 'expires_at': tokenTime.millisecondsSinceEpoch, + 'expires_in': tokenTime.microsecondsSinceEpoch, + }); + final credential = Credential.fromJson({ + 'issuer': authClient.issuer.metadata.toJson(), + 'client_id': authClient.clientId, + 'client_secret': authClient.clientSecret, + 'token': tokenResponse.toJson(), + 'nonce': null, + }); + when( + () => httpClient.post( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + encoding: any(named: 'encoding'), + ), + ).thenAnswer( + (invocation) => Future.error(Exception('Network is unreachable')), + ); + + final storage = MockAuthenticationStorage(); + when(() => storage.credential) + .thenAnswer((invocation) => jsonEncode(credential.toJson())); + final agClient = MockApptiveGridClient(); + when(() => agClient.options).thenReturn( + const ApptiveGridOptions( + authenticationOptions: ApptiveGridAuthenticationOptions( + autoAuthenticate: true, + persistCredentials: true, + ), + ), + ); + final testAuthenticator = MockAuthenticator(); + + authenticator = ApptiveGridAuthenticator( + client: agClient, + authenticationStorage: storage, + httpClient: httpClient, + ); + authenticator.testAuthenticator = testAuthenticator; + authenticator.setAuthClient(authClient); + + expect(await authenticator.isAuthenticatedWithToken, isFalse); + verifyNever(testAuthenticator.authorize); + verifyNever(() => storage.saveCredential(null)); }); test(