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 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 + + diff --git a/documentation/request_flow.drawio.svg b/documentation/request_flow.drawio.svg new file mode 100644 index 0000000..3b8c573 --- /dev/null +++ b/documentation/request_flow.drawio.svg @@ -0,0 +1,728 @@ + + + + + + + + + + + + + + + + No + + + + + + + No + + + + + + + + + + + + + + + Is saved auth token present? + + + + + + Is saved auth token... + + + + + + + + + + + + + + + + Request + + + + + + Request + + + + + + + + + + + + + + + + + Yes + + + + + + + Yes + + + + + + + + + + + + + + + + + Yes + + + + + + + Yes + + + + + + + + + + + + + + + + + No + + + + + + + No + + + + + + + + + + + + + + + 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:... + + + + + + + + + + + + + + + Call onInvalidToken if present, or + + proceed with unauthed request + + + + + + Call onInvalidToken if pr... + + + + + + + + + + + + + + + + + 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... + + + + + + + + + + + + + + + + + + + + + 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! + + + + + + + + + + + + + + + + + No + + + + + + + No + + + + + + + + + + + + + + + + + Yes + + + + + + + Yes + + + + + + + + + + + + + + + Is saved token expiry date present? + + + + + + Is saved token expir... + + + + + + + + + + + + + + Text is not SVG - cannot display + + + + \ No newline at end of file diff --git a/lib/src/oauth.dart b/lib/src/oauth.dart index 77e154c..b330998 100644 --- a/lib/src/oauth.dart +++ b/lib/src/oauth.dart @@ -4,6 +4,8 @@ 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); + class OAuth extends Interceptor { OAuth({ required this.tokenUrl, @@ -13,16 +15,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; @@ -42,7 +70,21 @@ class OAuth extends Interceptor { ); if (expiresAt.isBefore(clock.now())) { - await refresh(); + try { + await refresh(); + } on DioException { + // If refresh fails then log out + await logout(); + if (onInvalidToken != null) { + // If invalid token callback is set then call that + onInvalidToken?.call(options); + return; + } else { + // Otherwise default to proceeding with the request + // and likely receiving 401 + return super.onRequest(options, handler); + } + } } } @@ -50,10 +92,36 @@ 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); + return; } 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 { + if (onInvalidToken != null) { + onInvalidToken?.call(err.requestOptions); + } else { + super.onError(err, handler); + } + } + } else { + super.onError(err, handler); + } + } + Future login(OAuthGrantType grant) async { await logout(); @@ -113,4 +181,29 @@ 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); + } + } } diff --git a/test/src/oauth_test.dart b/test/src/oauth_test.dart index 55ccaa1..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'; @@ -12,6 +14,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 +48,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 +66,501 @@ 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('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: {}); + 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('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); + 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: {}); @@ -79,95 +583,55 @@ void main() { expect(token, initialToken); }); - test('adds nothing if no token is present', () async { - final options = RequestOptions(path: '1', headers: {}); + test( + 'adds nothing if no token is present and onInvalidToken not set', + () async { + final options = + RequestOptions(path: '1', headers: {}); - await tokenStorage.deleteAll(); + await tokenStorage.deleteAll(); - final handler = MockRequestHandler(); + final handler = MockRequestHandler(); - await oauth.onRequest(options, handler); + 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); - }); + 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('adds refreshed token if refresh is successful', () async { + test('calls onInvalidToken if no token is present', () 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 callback = Completer(); + final callbackOauth = OAuth( + tokenUrl: 'oauth/token', + clientId: 'id', + clientSecret: 'secret', + dio: dio, + clock: clock, + storage: tokenStorage, + onInvalidToken: callback.complete, ); - 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); - }); + await tokenStorage.deleteAll(); - test('return error if token refresh returns error', () async { - final options = RequestOptions(path: '1', headers: {}); - final expiresAt = DateTime(2020, 2); - await saveToken(expiresAt); + final handler = MockRequestHandler(); - 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', - }, - ); + await callbackOauth.onRequest(options, handler); - final handler = MockRequestHandler(); + expect(callback.isCompleted, true); - await expectLater( - oauth.onRequest(options, handler), - throwsA(isA()), - ); - final signedIn = await oauth.isSignedIn; + 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); }); });