From 66c87e67e6a6e48776b62caefecd4e0e6cee4942 Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Wed, 21 Jan 2026 12:20:12 +0000 Subject: [PATCH 1/8] Exceptions in refresh are properly handled and 401s prompt a retry --- lib/src/oauth.dart | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/src/oauth.dart b/lib/src/oauth.dart index 77e154c..17c8670 100644 --- a/lib/src/oauth.dart +++ b/lib/src/oauth.dart @@ -42,7 +42,13 @@ class OAuth extends Interceptor { ); if (expiresAt.isBefore(clock.now())) { - await refresh(); + try { + await refresh(); + } on DioException { + // If refresh fails then log out, and allow the request to 401 + await logout(); + return super.onRequest(options, handler); + } } } @@ -54,6 +60,21 @@ class OAuth extends Interceptor { return super.onRequest(options, handler); } + @override + Future onError( + DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode == 401) { + try { + await refresh(); + await _retry(err, handler); + } on DioException { + super.onError(err, handler); + } + } else { + super.onError(err, handler); + } + } + Future login(OAuthGrantType grant) async { await logout(); @@ -113,4 +134,27 @@ class OAuth extends Interceptor { await storage.delete(key: '$name-refresh-token'); await storage.delete(key: '$name-expires-at'); } + + Future _retry( + DioException err, + ErrorInterceptorHandler handler, + ) async { + final token = await storage.read(key: '$name-token'); + + if (token != null) { + final options = err.requestOptions + ..headers['Authorization'] = 'Bearer $token'; + if (options.data is FormData) { + options.data = (options.data as FormData).clone(); + } + final dio = this.dio ?? Dio(); + try { + await dio.fetch(options).then((value) => handler.resolve(value)); + } on DioException catch (ex) { + handler.next(ex); + } + } else { + handler.next(err); + } + } } From f22235ef0215e83c6cf56f0bbc8107023f203f37 Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Wed, 21 Jan 2026 17:29:19 +0000 Subject: [PATCH 2/8] Test coverage to 100% --- lib/src/oauth.dart | 4 +- test/src/oauth_test.dart | 476 +++++++++++++++++++++++++++++++++------ 2 files changed, 407 insertions(+), 73 deletions(-) diff --git a/lib/src/oauth.dart b/lib/src/oauth.dart index 17c8670..3bfc9af 100644 --- a/lib/src/oauth.dart +++ b/lib/src/oauth.dart @@ -149,7 +149,9 @@ class OAuth extends Interceptor { } final dio = this.dio ?? Dio(); try { - await dio.fetch(options).then((value) => handler.resolve(value)); + await dio.fetch(options).then((value) { + handler.resolve(value); + }); } on DioException catch (ex) { handler.next(ex); } diff --git a/test/src/oauth_test.dart b/test/src/oauth_test.dart index 55ccaa1..479c3f4 100644 --- a/test/src/oauth_test.dart +++ b/test/src/oauth_test.dart @@ -12,6 +12,8 @@ import '../utils/fake_secure_storage.dart'; class MockRequestHandler extends Mock implements RequestInterceptorHandler {} +class MockErrorHandler extends Mock implements ErrorInterceptorHandler {} + void main() { group('OAuth', () { const tokenStorageKey = 'oauth-token'; @@ -44,6 +46,11 @@ void main() { ); }); + setUpAll(() { + registerFallbackValue(Response(requestOptions: RequestOptions())); + registerFallbackValue(DioException(requestOptions: RequestOptions())); + }); + Future saveToken(DateTime expiresAt) async { await tokenStorage.write(key: tokenStorageKey, value: initialToken); await tokenStorage.write( @@ -57,6 +64,403 @@ void main() { } group('Interceptor functions', () { + group('Refresh - Auth token past expiry date', () { + test('adds refreshed token request is successful', () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2020, 2); + await saveToken(expiresAt); + adapter.onPost( + 'oauth/token', + (server) { + server.reply( + 200, + { + 'access_token': nextToken, + 'expires_in': expirySeconds, + 'refresh_token': nextRefreshToken, + }, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockRequestHandler(); + + await oauth.onRequest(options, handler); + + expect( + options.headers, + containsPair('Authorization', 'Bearer $nextToken'), + ); + verify(() => handler.next(options)).called(1); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isTrue); + final token = await oauth.token; + expect(token, nextToken); + }); + + test('request proceeds without auth if token refresh returns error', + () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2020, 2); + await saveToken(expiresAt); + + adapter.onPost( + 'oauth/token', + (server) { + server.reply( + 400, + {'error_message': 'error'}, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockRequestHandler(); + await oauth.onRequest(options, handler); + + expect(options.headers.isEmpty, true); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isFalse); + final token = await oauth.token; + expect(token, isNull); + }); + }); + + group('Refresh - 401 error', () { + test('does nothing with non-401 errors', () async { + final options = + RequestOptions(path: '1', headers: {}); + final handler = MockErrorHandler(); + final ex = DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 400), + ); + await oauth.onError(ex, handler); + + verifyNever(() => handler.resolve(any())); + verify(() => handler.next(ex)).called(1); + }); + + test('adds new token and retries if refresh succeeds', () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2021, 2); + await saveToken(expiresAt); + adapter + ..onGet( + '1', + headers: {'Authorization': 'Bearer $nextToken'}, + (server) { + server.reply(200, {}); + }, + ) + ..onPost( + 'oauth/token', + (server) { + server.reply( + 200, + { + 'access_token': nextToken, + 'expires_in': expirySeconds, + 'refresh_token': nextRefreshToken, + }, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockErrorHandler(); + + await oauth.onError( + DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 401), + ), + handler, + ); + + expect( + options.headers, + containsPair('Authorization', 'Bearer $nextToken'), + ); + verify(() => handler.resolve(any())).called(1); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isTrue); + final token = await oauth.token; + expect(token, nextToken); + }); + + test('retry clones FormData from the initial request', () async { + final originalData = FormData.fromMap({'1': 'a'})..finalize(); + final options = RequestOptions( + path: '1', + headers: {}, + data: originalData, + method: 'POST', + ); + final expiresAt = DateTime(2021, 2); + await saveToken(expiresAt); + adapter + ..onPost( + '1', + headers: {'Authorization': 'Bearer $nextToken'}, + data: originalData, + (server) { + server.reply(200, {}); + }, + ) + ..onPost( + 'oauth/token', + (server) { + server.reply( + 200, + { + 'access_token': nextToken, + 'expires_in': expirySeconds, + 'refresh_token': nextRefreshToken, + }, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockErrorHandler(); + + await oauth.onError( + DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 401), + ), + handler, + ); + + expect( + options.headers, + containsPair('Authorization', 'Bearer $nextToken'), + ); + verify( + () => handler.resolve( + any( + that: isA().having( + (res) => res.requestOptions.data, + 'request data', + isA() + .having( + (data) => data.hashCode, + 'isNotEqual', + isNot(originalData.hashCode), + ) + .having( + (data) => data.fields, + 'fields', + equals(originalData.fields), + ) + .having( + (data) => data.files, + 'files', + equals(originalData.files), + ), + ), + ), + ), + ).called(1); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isTrue); + final token = await oauth.token; + expect(token, nextToken); + }); + + test('returns old error if token is null on retry', () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2021, 2); + await saveToken(expiresAt); + adapter + ..onGet( + '1', + headers: {'Authorization': 'Bearer $nextToken'}, + (server) { + server.reply(200, {}); + }, + ) + ..onPost( + 'oauth/token', + (server) { + server.reply( + 200, + { + 'access_token': null, + 'expires_in': expirySeconds, + 'refresh_token': nextRefreshToken, + }, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockErrorHandler(); + + await oauth.onError( + DioException( + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: 401, + statusMessage: 'Error!', + ), + ), + handler, + ); + verifyNever(() => handler.resolve(any())); + verify( + () => handler.next( + any( + that: isA().having( + (ex) => ex.response?.statusMessage, + 'message', + 'Error!', + ), + ), + ), + ).called(1); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isFalse); + final token = await oauth.token; + expect(token, isNull); + }); + + test('returns new error if retry fails', () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2021, 2); + await saveToken(expiresAt); + adapter + ..onGet( + '1', + headers: {'Authorization': 'Bearer $nextToken'}, + (server) { + server.reply(400, {}, statusMessage: 'Error!'); + }, + ) + ..onPost( + 'oauth/token', + (server) { + server.reply( + 200, + { + 'access_token': nextToken, + 'expires_in': expirySeconds, + 'refresh_token': nextRefreshToken, + }, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockErrorHandler(); + + await oauth.onError( + DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 401), + ), + handler, + ); + + expect( + options.headers, + containsPair('Authorization', 'Bearer $nextToken'), + ); + verify( + () => handler.next( + any( + that: isA().having( + (ex) => ex.response?.statusMessage, + 'message', + 'Error!', + ), + ), + ), + ).called(1); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isTrue); + final token = await oauth.token; + expect(token, nextToken); + }); + + test('returns original error if refresh fails', () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2021, 2); + await saveToken(expiresAt); + + adapter.onPost( + 'oauth/token', + (server) { + server.reply( + 400, + {'error_message': 'error'}, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockErrorHandler(); + final ex = DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 401), + ); + await oauth.onError(ex, handler); + + verifyNever(() => handler.resolve(any())); + verify(() => handler.next(ex)).called(1); + + expect(options.headers.isEmpty, true); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isFalse); + final token = await oauth.token; + expect(token, isNull); + }); + }); + test('adds user token if one is present and has not expired', () async { final options = RequestOptions(path: '1', headers: {}); @@ -98,78 +502,6 @@ void main() { final token = await oauth.token; expect(token, isNull); }); - - test('adds refreshed token if refresh is successful', () async { - final options = RequestOptions(path: '1', headers: {}); - final expiresAt = DateTime(2020, 2); - await saveToken(expiresAt); - adapter.onPost( - 'oauth/token', - (server) { - server.reply( - 200, - { - 'access_token': nextToken, - 'expires_in': expirySeconds, - 'refresh_token': nextRefreshToken, - }, - ); - }, - data: { - 'grant_type': 'refresh_token', - 'refresh_token': initialRefreshToken, - 'client_id': 'id', - 'client_secret': 'secret', - }, - ); - - final handler = MockRequestHandler(); - - await oauth.onRequest(options, handler); - - expect( - options.headers, - containsPair('Authorization', 'Bearer $nextToken'), - ); - verify(() => handler.next(options)).called(1); - final signedIn = await oauth.isSignedIn; - expect(signedIn, isTrue); - final token = await oauth.token; - expect(token, nextToken); - }); - - test('return error if token refresh returns error', () async { - final options = RequestOptions(path: '1', headers: {}); - final expiresAt = DateTime(2020, 2); - await saveToken(expiresAt); - - adapter.onPost( - 'oauth/token', - (server) { - server.reply( - 400, - {'error_message': 'error'}, - ); - }, - data: { - 'grant_type': 'refresh_token', - 'refresh_token': initialRefreshToken, - 'client_id': 'id', - 'client_secret': 'secret', - }, - ); - - final handler = MockRequestHandler(); - - await expectLater( - oauth.onRequest(options, handler), - throwsA(isA()), - ); - final signedIn = await oauth.isSignedIn; - expect(signedIn, isFalse); - final token = await oauth.token; - expect(token, isNull); - }); }); group('Login function', () { From c65b1c0628d8b3450da37c77bca3779d2609fa3c Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Wed, 21 Jan 2026 17:35:03 +0000 Subject: [PATCH 3/8] Added changelog entry and coverage workflow for PRs --- .github/workflows/coverage.yaml | 42 +++++++++++++++++++++++++++++++++ CHANGELOG.md | 5 ++++ 2 files changed, 47 insertions(+) create mode 100644 .github/workflows/coverage.yaml diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 0000000..9e475b8 --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,42 @@ +name: Coverage +on: + pull_request: + +jobs: + test: + runs-on: [self-hosted, internal-linux] + + steps: + - uses: actions/checkout@v4 + name: Checkout + + - name: Setup LCOV + uses: hrishikesh-kadam/setup-lcov@v1 + + - name: Install correct version of flutter + run: | + dart pub global activate fvm + fvm install + + - name: Clean + run: | + fvm flutter clean + + - name: Install project dependencies + run: | + fvm flutter pub get + + - name: Run code generation + run: | + fvm flutter pub run build_runner build --delete-conflicting-outputs + + - name: Run tests + run: fvm flutter test --coverage --test-randomize-ordering-seed random + + - name: Report code coverage + uses: zgosalvez/github-actions-report-lcov@v3 + with: + coverage-files: coverage/lcov.info + minimum-coverage: 100 + github-token: ${{ secrets.GITHUB_TOKEN }} + update-comment: true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fa87bb9..e5a0ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.3.0 + +- Token refresh failures no longer throw an exception +- Refresh is automatically called when a 401 is recieved + ## 2.2.1 - Updated flutter_secure_storage to 9.2.4 From f4d4423118d610b9fb7e75cfcef0c61fc96f3484 Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Thu, 22 Jan 2026 12:10:25 +0000 Subject: [PATCH 4/8] Added request flowchart --- README.md | 4 + documentation/request_flow.drawio.svg | 658 ++++++++++++++++++++++++++ 2 files changed, 662 insertions(+) create mode 100644 documentation/request_flow.drawio.svg diff --git a/README.md b/README.md index a967749..c6d5dda 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,7 @@ class CustomGrantType implements OAuthGrantType { } } ``` + +### Interceptor flowchart + +![Flow chart showing interceptor logic](documentation/request_flow.drawio.svg) diff --git a/documentation/request_flow.drawio.svg b/documentation/request_flow.drawio.svg new file mode 100644 index 0000000..a16c127 --- /dev/null +++ b/documentation/request_flow.drawio.svg @@ -0,0 +1,658 @@ + + + + + + + + + + + +
+
+
+ + No + +
+
+
+
+ + No + +
+
+
+ + + + + + + +
+
+
+ Is saved auth data (token, refresh token, expiry date) present? +
+
+
+
+ + Is saved auth data (... + +
+
+
+ + + + + + + + +
+
+
+ Request +
+
+
+
+ + Request + +
+
+
+ + + + + + + + +
+
+
+ + Yes + +
+
+
+
+ + Yes + +
+
+
+ + + + + + + + +
+
+
+ + Yes + +
+
+
+
+ + Yes + +
+
+
+ + + + + + + + + + + +
+
+
+ Is the current date/time before the expiry date/time? +
+
+
+
+ + Is the current date/... + +
+
+
+ + + + + + + + + +
+
+
+ Token is valid: +
+ Add it to request and proceed authed +
+
+
+
+ + Token is valid:... + +
+
+
+ + + + + + + +
+
+
+ Allow request to proceed unauthed, where it should get a 401 +
+
+
+
+ + Allow request to pro... + +
+
+
+ + + + + + + + + +
+
+
+ Refresh the +
+ auth token +
+
+
+
+ + Refresh the... + +
+
+
+ + + + + + + + + + + +
+
+
+ Error when +
+ refreshing token! +
+
+
+
+ + Error when... + +
+
+
+ + + + + + + + + + + +
+
+
+ Clear all stored +
+ auth token data +
+
+
+
+ + Clear all stored... + +
+
+
+ + + + + + + + + + + +
+
+
+ Token refresh is successful! +
+
+
+
+ + Token refresh... + +
+
+
+ + + + + + + +
+
+
+ Request completes successfully! +
+
+
+
+ + Request completes suc... + +
+
+
+ + + + + + + + + + + +
+
+
+ Request fails! +
+
+
+
+ + Request fails! + +
+
+
+ + + + + + + + +
+
+
+ + No + +
+
+
+
+ + No + +
+
+
+ + + + + + + + +
+
+
+ + Yes + +
+
+
+
+ + Yes + +
+
+
+ + + + + + + +
+
+
+ Is the response +
+ code 401? +
+
+
+
+ + Is the response... + +
+
+
+ + + + + + + +
+
+
+ Allow request +
+ to fail +
+
+
+
+ + Allow request... + +
+
+
+ + + + + + + + + +
+
+
+ Refresh the +
+ auth token +
+
+
+
+ + Refresh the... + +
+
+
+ + + + + + + + + + + +
+
+
+ Error when +
+ refreshing token! +
+
+
+
+ + Error when... + +
+
+
+ + + + + + + + + + + +
+
+
+ Token refresh is successful! +
+
+
+
+ + Token refresh... + +
+
+
+ + + + + + + +
+
+
+ Allow request to fail with original 401 error +
+
+
+
+ + Allow request to fai... + +
+
+
+ + + + + + + + + + + + + +
+
+
+ Retry original request +
+
+
+
+ + Retry original requ... + +
+
+
+ + + + + + + +
+
+
+ Request completes successfully! +
+
+
+
+ + Request completes suc... + +
+
+
+ + + + + + + +
+
+
+ Allow request to fail with new error +
+
+
+
+ + Allow request to fa... + +
+
+
+ + + + + + + + + + + +
+
+
+ Clear all stored +
+ auth token data +
+
+
+
+ + Clear all stored... + +
+
+
+ + + + + + + +
+
+
+ Request fails! +
+
+
+
+ + Request fails! + +
+
+
+
+ + + + + Text is not SVG - cannot display + + + +
\ No newline at end of file From 69917a1875d792e5445ce5e3abeb6e69dcb43db6 Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Thu, 22 Jan 2026 17:41:04 +0000 Subject: [PATCH 5/8] Add callback for invalid token state and update diagram --- documentation/request_flow.drawio.svg | 315 +++++++++++++++++--------- lib/src/oauth.dart | 48 +++- 2 files changed, 249 insertions(+), 114 deletions(-) diff --git a/documentation/request_flow.drawio.svg b/documentation/request_flow.drawio.svg index a16c127..34cee16 100644 --- a/documentation/request_flow.drawio.svg +++ b/documentation/request_flow.drawio.svg @@ -1,15 +1,15 @@ - + - - + + -
+
@@ -19,42 +19,42 @@
- + No - + -
+
- Is saved auth data (token, refresh token, expiry date) present? + Is saved auth token present?
- - Is saved auth data (... + + Is saved auth token... - - + + -
+
Request @@ -62,21 +62,21 @@
- + Request - - + + -
+
@@ -86,21 +86,21 @@
- + Yes - - + + -
+
@@ -110,24 +110,44 @@
- + Yes - - + + - + + + +
+
+
+ + No + +
+
+
+
+ + No + +
+
+
+ + -
+
Is the current date/time before the expiry date/time? @@ -135,7 +155,7 @@
- + Is the current date/... @@ -144,13 +164,13 @@ - + -
+
Token is valid: @@ -160,29 +180,31 @@
- + Token is valid:... - + -
+
- Allow request to proceed unauthed, where it should get a 401 + Call onInvalidToken if present, or +
+ proceed with unauthed request
- - Allow request to pro... + + Call onInvalidToken if pr... @@ -190,13 +212,13 @@ - + -
+
Refresh the @@ -206,24 +228,24 @@
- + Refresh the... - - + + - + -
+
Error when @@ -233,24 +255,24 @@
- + Error when... - - + + - + -
+
Clear all stored @@ -260,24 +282,20 @@
- + Clear all stored... - - - - - + -
+
Token refresh is successful! @@ -285,20 +303,20 @@
- + Token refresh... - + -
+
Request completes successfully! @@ -306,24 +324,24 @@
- + Request completes suc... - - + + - + -
+
Request fails! @@ -331,21 +349,21 @@
- + Request fails! - - + + -
+
@@ -355,21 +373,21 @@
- + No - - + + -
+
@@ -379,20 +397,20 @@
- + Yes - + -
+
Is the response @@ -402,20 +420,20 @@
- + Is the response... - + -
+
Allow request @@ -425,7 +443,7 @@
- + Allow request... @@ -434,13 +452,13 @@ - + -
+
Refresh the @@ -450,24 +468,24 @@
- + Refresh the... - - + + - + -
+
Error when @@ -477,24 +495,24 @@
- + Error when... - - + + - + -
+
Token refresh is successful! @@ -502,20 +520,20 @@
- + Token refresh... - + -
+
Allow request to fail with original 401 error @@ -523,7 +541,7 @@
- + Allow request to fai... @@ -531,18 +549,18 @@ - - + + - + -
+
Retry original request @@ -550,20 +568,20 @@
- + Retry original requ... - + -
+
Request completes successfully! @@ -571,20 +589,20 @@
- + Request completes suc... - + -
+
Allow request to fail with new error @@ -592,24 +610,24 @@
- + Allow request to fa... - - + + - + -
+
Clear all stored @@ -619,20 +637,20 @@
- + Clear all stored... - + -
+
Request fails! @@ -640,12 +658,85 @@
- + Request fails! + + + + + + + + +
+
+
+ + No + +
+
+
+
+ + No + +
+
+
+ + + + + + + + +
+
+
+ + Yes + +
+
+
+
+ + Yes + +
+
+
+ + + + + + + +
+
+
+ Is saved token expiry date present? +
+
+
+
+ + Is saved token expir... + +
+
+
+ + + + diff --git a/lib/src/oauth.dart b/lib/src/oauth.dart index 3bfc9af..3280a9f 100644 --- a/lib/src/oauth.dart +++ b/lib/src/oauth.dart @@ -4,6 +4,11 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:oauth_interceptor/src/oauth_grant_type.dart'; import 'package:time/time.dart'; +typedef InvalidTokenCallback = Function( + RequestOptions options, + RequestInterceptorHandler handler, +); + class OAuth extends Interceptor { OAuth({ required this.tokenUrl, @@ -13,16 +18,42 @@ class OAuth extends Interceptor { this.name = 'oauth', this.clock = const Clock(), this.storage = const FlutterSecureStorage(), + this.onInvalidToken, }); + /// The oauth url of your API final String tokenUrl; + + /// The client ID required to request a login final String clientId; + + /// The client secret required to request a login final String clientSecret; + + /// The name of this OAuth setup, used to save and fetch data. + /// This allows you to have multiple OAuth setups without clashing, + /// as long as they have different names. final String name; + + /// The Dio instance that will be used to request login + /// or to retry a request after a token refresh. final Dio? dio; + + /// Pass in a Clock instance to set the "current time" - + /// mostly useful for testing. final Clock clock; + + /// The FlutterSecureStorage instance that will store authorization data + /// between sessions. final FlutterSecureStorage storage; + /// Set this to override what happens if there is no token found in storage, + /// or if an attempt to refresh a token fails. + /// The user will probably need to log in again from the start. + /// The default behaviour is to proceed with the request without setting + /// the auth header - usually leading to a 401 response. + final InvalidTokenCallback? onInvalidToken; + Future get isSignedIn async { final token = await storage.read(key: '$name-token'); return token != null; @@ -45,9 +76,17 @@ class OAuth extends Interceptor { try { await refresh(); } on DioException { - // If refresh fails then log out, and allow the request to 401 + // If refresh fails then log out await logout(); - return super.onRequest(options, handler); + if (onInvalidToken != null) { + // If invalid token callback is set then call that + onInvalidToken?.call(options, handler); + return; + } else { + // Otherwise default to proceeding with the request + // and likely receiving 401 + return super.onRequest(options, handler); + } } } } @@ -56,6 +95,11 @@ class OAuth extends Interceptor { if (token != null) { options.headers['Authorization'] = 'Bearer $token'; + } else if (onInvalidToken != null) { + // If we have no token and we have a custom callback for invalid tokens, + // then call that. + onInvalidToken?.call(options, handler); + return; } return super.onRequest(options, handler); } From 84304c80922feb5427bdf1539441ce5ca8d51c90 Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Thu, 22 Jan 2026 17:43:05 +0000 Subject: [PATCH 6/8] Fix arrow --- documentation/request_flow.drawio.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/request_flow.drawio.svg b/documentation/request_flow.drawio.svg index 34cee16..3a19dd0 100644 --- a/documentation/request_flow.drawio.svg +++ b/documentation/request_flow.drawio.svg @@ -1,4 +1,4 @@ - + @@ -734,8 +734,8 @@ - - + + From 9cc7826da0c2b8624d704b5086cc92118fc8f782 Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Fri, 23 Jan 2026 13:48:14 +0000 Subject: [PATCH 7/8] Use onInvalidToken for a 401 failure --- documentation/request_flow.drawio.svg | 37 ++++++--------------------- lib/src/oauth.dart | 19 ++++++++------ 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/documentation/request_flow.drawio.svg b/documentation/request_flow.drawio.svg index 3a19dd0..3b8c573 100644 --- a/documentation/request_flow.drawio.svg +++ b/documentation/request_flow.drawio.svg @@ -1,4 +1,4 @@ - + @@ -475,8 +475,8 @@ - - + + @@ -526,27 +526,6 @@ - - - - - - - -
-
-
- Allow request to fail with original 401 error -
-
-
-
- - Allow request to fai... - -
-
-
@@ -617,17 +596,17 @@ - - + + - + -
+
Clear all stored @@ -637,7 +616,7 @@
- + Clear all stored... diff --git a/lib/src/oauth.dart b/lib/src/oauth.dart index 3280a9f..b330998 100644 --- a/lib/src/oauth.dart +++ b/lib/src/oauth.dart @@ -4,10 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:oauth_interceptor/src/oauth_grant_type.dart'; import 'package:time/time.dart'; -typedef InvalidTokenCallback = Function( - RequestOptions options, - RequestInterceptorHandler handler, -); +typedef InvalidTokenCallback = Function(RequestOptions options); class OAuth extends Interceptor { OAuth({ @@ -80,7 +77,7 @@ class OAuth extends Interceptor { await logout(); if (onInvalidToken != null) { // If invalid token callback is set then call that - onInvalidToken?.call(options, handler); + onInvalidToken?.call(options); return; } else { // Otherwise default to proceeding with the request @@ -98,7 +95,7 @@ class OAuth extends Interceptor { } else if (onInvalidToken != null) { // If we have no token and we have a custom callback for invalid tokens, // then call that. - onInvalidToken?.call(options, handler); + onInvalidToken?.call(options); return; } return super.onRequest(options, handler); @@ -106,13 +103,19 @@ class OAuth extends Interceptor { @override Future onError( - DioException err, ErrorInterceptorHandler handler) async { + DioException err, + ErrorInterceptorHandler handler, + ) async { if (err.response?.statusCode == 401) { try { await refresh(); await _retry(err, handler); } on DioException { - super.onError(err, handler); + if (onInvalidToken != null) { + onInvalidToken?.call(err.requestOptions); + } else { + super.onError(err, handler); + } } } else { super.onError(err, handler); From 25c41cb4f68f979c0c1cfc83caab601749cd252c Mon Sep 17 00:00:00 2001 From: Helena Sewell Date: Fri, 23 Jan 2026 15:03:10 +0000 Subject: [PATCH 8/8] Updated tests --- test/src/oauth_test.dart | 154 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 11 deletions(-) diff --git a/test/src/oauth_test.dart b/test/src/oauth_test.dart index 479c3f4..b7c7652 100644 --- a/test/src/oauth_test.dart +++ b/test/src/oauth_test.dart @@ -1,4 +1,6 @@ // 📦 Package imports: +import 'dart:async'; + import 'package:clock/clock.dart'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -105,7 +107,53 @@ void main() { expect(token, nextToken); }); - test('request proceeds without auth if token refresh returns error', + test('onInvalidToken called if present and token refresh returns error', + () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2020, 2); + await saveToken(expiresAt); + final callback = Completer(); + final callbackOauth = OAuth( + tokenUrl: 'oauth/token', + clientId: 'id', + clientSecret: 'secret', + dio: dio, + clock: clock, + storage: tokenStorage, + onInvalidToken: callback.complete, + ); + + adapter.onPost( + 'oauth/token', + (server) { + server.reply( + 400, + {'error_message': 'error'}, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockRequestHandler(); + await callbackOauth.onRequest(options, handler); + + expect(callback.isCompleted, true); + + expect(options.headers.isEmpty, true); + final signedIn = await callbackOauth.isSignedIn; + expect(signedIn, isFalse); + final token = await callbackOauth.token; + expect(token, isNull); + }); + + test( + '''request proceeds without auth if token refresh returns error and no onInvalidToken''', () async { final options = RequestOptions(path: '1', headers: {}); @@ -421,7 +469,59 @@ void main() { expect(token, nextToken); }); - test('returns original error if refresh fails', () async { + test('calls onInvalidToken if present and if refresh fails', () async { + final options = + RequestOptions(path: '1', headers: {}); + final expiresAt = DateTime(2021, 2); + await saveToken(expiresAt); + final callback = Completer(); + final callbackOauth = OAuth( + tokenUrl: 'oauth/token', + clientId: 'id', + clientSecret: 'secret', + dio: dio, + clock: clock, + storage: tokenStorage, + onInvalidToken: callback.complete, + ); + + adapter.onPost( + 'oauth/token', + (server) { + server.reply( + 400, + {'error_message': 'error'}, + ); + }, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': initialRefreshToken, + 'client_id': 'id', + 'client_secret': 'secret', + }, + ); + + final handler = MockErrorHandler(); + final ex = DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 401), + ); + await callbackOauth.onError(ex, handler); + + verifyNever(() => handler.resolve(any())); + verifyNever(() => handler.next(ex)); + + expect(callback.isCompleted, isTrue); + + expect(options.headers.isEmpty, true); + final signedIn = await callbackOauth.isSignedIn; + expect(signedIn, isFalse); + final token = await callbackOauth.token; + expect(token, isNull); + }); + + test('returns original error if refresh fails and no onInvalidToken', + () async { final options = RequestOptions(path: '1', headers: {}); final expiresAt = DateTime(2021, 2); @@ -483,23 +583,55 @@ void main() { expect(token, initialToken); }); - test('adds nothing if no token is present', () async { + test( + 'adds nothing if no token is present and onInvalidToken not set', + () async { + final options = + RequestOptions(path: '1', headers: {}); + + await tokenStorage.deleteAll(); + + final handler = MockRequestHandler(); + + await oauth.onRequest(options, handler); + + expect( + options.headers, + isNot(contains('Authorization')), + ); + verify(() => handler.next(options)).called(1); + final signedIn = await oauth.isSignedIn; + expect(signedIn, isFalse); + final token = await oauth.token; + expect(token, isNull); + }, + ); + + test('calls onInvalidToken if no token is present', () async { final options = RequestOptions(path: '1', headers: {}); + final callback = Completer(); + final callbackOauth = OAuth( + tokenUrl: 'oauth/token', + clientId: 'id', + clientSecret: 'secret', + dio: dio, + clock: clock, + storage: tokenStorage, + onInvalidToken: callback.complete, + ); await tokenStorage.deleteAll(); final handler = MockRequestHandler(); - await oauth.onRequest(options, handler); + await callbackOauth.onRequest(options, handler); - expect( - options.headers, - isNot(contains('Authorization')), - ); - verify(() => handler.next(options)).called(1); - final signedIn = await oauth.isSignedIn; + expect(callback.isCompleted, true); + + verifyNever(() => handler.next(options)); + final signedIn = await callbackOauth.isSignedIn; expect(signedIn, isFalse); - final token = await oauth.token; + final token = await callbackOauth.token; expect(token, isNull); }); });