From 57f108b6351793fa50ce4505bf1fbfd4ec986415 Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Wed, 17 Jun 2026 15:15:51 +0200 Subject: [PATCH 1/4] :arrow_up: Updated dependencies --- analysis_options.yaml | 9 ++++++--- pubspec.yaml | 23 ++++++++++++++--------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index fd66045..ca008ae 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,12 +1,15 @@ include: package:very_good_analysis/analysis_options.yaml +formatter: + trailing_commas: automate + +linter: + rules: + require_trailing_commas: false analyzer: errors: - unawaited_futures: warning - avoid_void_async: warning missing_return: error missing_required_param: error - invalid_annotation_target: info language: strict-casts: true diff --git a/pubspec.yaml b/pubspec.yaml index 206c496..34e5334 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,18 +1,23 @@ name: oauth_chopper description: Add and manage OAuth2 authentication for your Chopper client. -version: 1.1.1 -homepage: https://github.com/DutchCodingCompany/oauth_chopper +version: 1.1.2 +repository: https://github.com/DutchCodingCompany/oauth_chopper +topics: + - http + - chopper + - oauth2 + - authentication environment: - sdk: ^3.6.1 + sdk: ^3.7.0 dependencies: - chopper: ^8.0.4 - http: ^1.3.0 + chopper: ^8.6.0 + http: ^1.6.0 http_parser: ^4.1.2 - oauth2: ^2.0.3 + oauth2: ^2.0.5 dev_dependencies: - mocktail: ^1.0.4 - test: ^1.25.14 - very_good_analysis: ^7.0.0 + mocktail: ^1.0.5 + test: ^1.31.1 + very_good_analysis: ^10.2.0 From 1e589f2f00eb385ebfa2a403a391041583ed698e Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Wed, 17 Jun 2026 16:02:16 +0200 Subject: [PATCH 2/4] :art: Minor improvements --- CHANGELOG.md | 11 ++ example/oauth_chopper_example.dart | 13 +- lib/oauth_chopper.dart | 6 +- lib/src/extensions/request.dart | 7 +- lib/src/oauth_chopper.dart | 44 ++++++- lib/src/oauth_grant.dart | 8 +- lib/src/oauth_interceptor.dart | 33 +++-- lib/src/oauth_token.dart | 39 ++++-- pubspec.yaml | 3 +- test/oauth_chopper_test.dart | 11 +- test/oauth_interceptor_test.dart | 170 +++++++++++++++++++++----- test/storage/memory_storage_test.dart | 34 +++--- 12 files changed, 282 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f367d45..c969c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.2.0 +- Added concurrent refresh protection: multiple simultaneous 401 responses now share a single refresh call instead of issuing redundant refresh requests. +- Added proactive token refresh: the interceptor now checks `OAuthToken.isExpired` before making the request and refreshes preemptively, avoiding unnecessary 401 round-trips. +- Added `==`, `hashCode`, and `toString()` to `OAuthToken` for value-based equality and easier debugging. +- Exported `OnErrorCallback` and `MemoryStorage` +- Fixed exports for `oauth2` package +- Updated dependencies: + - `sdk` to `^3.7.0` + - `meta` added + - `very_good_analysis` to `10.2.0` + ## 1.1.1 - Updated dependencies: - `sdk` to `^3.6.1` diff --git a/example/oauth_chopper_example.dart b/example/oauth_chopper_example.dart index 5b217d7..3b8873d 100644 --- a/example/oauth_chopper_example.dart +++ b/example/oauth_chopper_example.dart @@ -5,7 +5,7 @@ import 'package:chopper/chopper.dart'; import 'package:oauth_chopper/oauth_chopper.dart'; -void main() { +Future main() async { final authorizationEndpoint = Uri.parse('https://example.com/oauth'); final identifier = 'id'; final secret = 'secret'; @@ -20,16 +20,11 @@ void main() { /// Add the oauth_chopper interceptor to the chopper client. final chopperClient = ChopperClient( baseUrl: Uri.parse('https://example.com'), - interceptors: [ - oauthChopper.interceptor(), - ], + interceptors: [oauthChopper.interceptor()], ); /// Request grant - oauthChopper.requestGrant( - ResourceOwnerPasswordGrant( - username: 'username', - password: 'password', - ), + await oauthChopper.requestGrant( + ResourceOwnerPasswordGrant(username: 'username', password: 'password'), ); } diff --git a/lib/oauth_chopper.dart b/lib/oauth_chopper.dart index 2ba625e..68fb352 100644 --- a/lib/oauth_chopper.dart +++ b/lib/oauth_chopper.dart @@ -3,10 +3,12 @@ /// More dartdocs go here. library; -export 'package:oauth2/src/authorization_exception.dart'; -export 'package:oauth2/src/expiration_exception.dart'; +export 'package:oauth2/oauth2.dart' + show AuthorizationException, ExpirationException; export 'src/oauth_chopper.dart'; export 'src/oauth_grant.dart'; +export 'src/oauth_interceptor.dart' show OnErrorCallback; export 'src/oauth_token.dart'; +export 'src/storage/memory_storage.dart'; export 'src/storage/oauth_storage.dart'; diff --git a/lib/src/extensions/request.dart b/lib/src/extensions/request.dart index ddf82b7..f606d2b 100644 --- a/lib/src/extensions/request.dart +++ b/lib/src/extensions/request.dart @@ -3,9 +3,6 @@ import 'package:chopper/chopper.dart'; /// Helper extension to easily apply a authorization header to a request. extension ChopperRequest on Request { /// Adds a authorization header with a bearer [token] to the request. - Request addAuthorizationHeader(String token) => applyHeader( - this, - 'Authorization', - 'Bearer $token', - ); + Request addAuthorizationHeader(String token) => + applyHeader(this, 'Authorization', 'Bearer $token'); } diff --git a/lib/src/oauth_chopper.dart b/lib/src/oauth_chopper.dart index f3f24f6..4d3baf4 100644 --- a/lib/src/oauth_chopper.dart +++ b/lib/src/oauth_chopper.dart @@ -78,7 +78,12 @@ class OAuthChopper { /// The function used to parse parameters from a host's response. /// Will be passed to [oauth2]. final Map Function(MediaType? contentType, String body)? - getParameters; + getParameters; + + /// Tracks the in-flight refresh operation to prevent concurrent refresh + /// attempts. When multiple requests receive a 401 simultaneously, they + /// all await the same [Completer] rather than issuing separate refresh calls. + Completer? _refreshCompleter; /// Get stored [OAuthToken]. Future get token async { @@ -91,13 +96,15 @@ class OAuthChopper { /// Provides an [OAuthInterceptor] instance. /// If [onError] is provided exceptions will be passed to [onError] and not be /// thrown. - OAuthInterceptor interceptor({ - OnErrorCallback? onError, - }) => + OAuthInterceptor interceptor({OnErrorCallback? onError}) => OAuthInterceptor(this, onError); /// Tries to refresh the available credentials and returns a new [OAuthToken] /// instance. + /// + /// If a refresh is already in-flight, subsequent callers will await the same + /// result rather than issuing concurrent refresh requests. + /// /// Throws an exception when refreshing fails. If the exception is a /// [oauth2.AuthorizationException] it clears the storage. /// @@ -106,6 +113,33 @@ class OAuthChopper { Future refresh({ bool basicAuth = true, Iterable? newScopes, + }) async { + // If a refresh is already in-flight, await the existing operation. + if (_refreshCompleter != null) { + return _refreshCompleter!.future; + } + + _refreshCompleter = Completer(); + + try { + final result = await _performRefresh( + basicAuth: basicAuth, + newScopes: newScopes, + ); + _refreshCompleter!.complete(result); + return result; + } catch (e, s) { + _refreshCompleter!.completeError(e, s); + rethrow; + } finally { + _refreshCompleter = null; + } + } + + /// Internal method that performs the actual credential refresh. + Future _performRefresh({ + required bool basicAuth, + Iterable? newScopes, }) async { final credentialsJson = await _storage.fetchCredentials(); if (credentialsJson == null) return null; @@ -121,7 +155,7 @@ class OAuthChopper { await _storage.saveCredentials(newCredentials.toJson()); return OAuthToken.fromCredentials(newCredentials); } on oauth2.AuthorizationException { - _storage.clear(); + await _storage.clear(); rethrow; } } diff --git a/lib/src/oauth_grant.dart b/lib/src/oauth_grant.dart index 26e5ccc..5baade5 100644 --- a/lib/src/oauth_grant.dart +++ b/lib/src/oauth_grant.dart @@ -29,7 +29,7 @@ abstract interface class OAuthGrant { bool basicAuth = true, String? delimiter, Map Function(MediaType? contentType, String body)? - getParameters, + getParameters, }); } @@ -69,7 +69,7 @@ class ResourceOwnerPasswordGrant implements OAuthGrant { bool basicAuth = true, String? delimiter, Map Function(MediaType? contentType, String body)? - getParameters, + getParameters, }) async { final client = await oauth2.resourceOwnerPasswordGrant( authorizationEndpoint, @@ -105,7 +105,7 @@ class ClientCredentialsGrant implements OAuthGrant { bool basicAuth = true, String? delimiter, Map Function(MediaType? contentType, String body)? - getParameters, + getParameters, }) async { final client = await oauth2.clientCredentialsGrant( authorizationEndpoint, @@ -173,7 +173,7 @@ class AuthorizationCodeGrant implements OAuthGrant { bool basicAuth = true, String? delimiter, Map Function(MediaType? contentType, String body)? - getParameters, + getParameters, }) async { final grant = oauth2.AuthorizationCodeGrant( identifier, diff --git a/lib/src/oauth_interceptor.dart b/lib/src/oauth_interceptor.dart index bb80e52..4d795e4 100644 --- a/lib/src/oauth_interceptor.dart +++ b/lib/src/oauth_interceptor.dart @@ -14,7 +14,8 @@ typedef OnErrorCallback = void Function(Object, StackTrace); /// available no header is added. /// Its added as a Bearer token. /// -/// When the provided credentials are invalid it tries to refresh them. +/// When the token is expired it tries to proactively refresh before making the +/// request. When the server responds with 401 it also tries to refresh once. /// Can throw a exceptions if no [onError] is passed. When [onError] is passed /// exception will be passed to [onError] /// {@endtemplate} @@ -32,9 +33,26 @@ class OAuthInterceptor implements Interceptor { FutureOr> intercept( Chain chain, ) async { - // Add oauth token to the request. - final token = await oauthChopper.token; + // Get the current token. + var token = await oauthChopper.token; + + // If the token is expired, proactively refresh before making the request. + if (token != null && token.isExpired) { + try { + final refreshed = await oauthChopper.refresh(); + if (refreshed != null) { + token = refreshed; + } + } catch (e, s) { + if (onError != null) { + onError!(e, s); + } else { + rethrow; + } + } + } + // Add oauth token to the request. final Request request; if (token == null) { request = chain.request; @@ -50,13 +68,14 @@ class OAuthInterceptor implements Interceptor { try { final credentials = await oauthChopper.refresh(); if (credentials != null) { - final request = - chain.request.addAuthorizationHeader(credentials.accessToken); - return chain.proceed(request); + final retryRequest = chain.request.addAuthorizationHeader( + credentials.accessToken, + ); + return chain.proceed(retryRequest); } } catch (e, s) { if (onError != null) { - onError?.call(e, s); + onError!(e, s); } else { rethrow; } diff --git a/lib/src/oauth_token.dart b/lib/src/oauth_token.dart index e240739..803398e 100644 --- a/lib/src/oauth_token.dart +++ b/lib/src/oauth_token.dart @@ -1,8 +1,10 @@ +import 'package:meta/meta.dart'; import 'package:oauth2/oauth2.dart'; /// {@template oauth_token} /// A wrapper around [Credentials] to provide a more convenient API. /// {@endtemplate} +@immutable class OAuthToken { /// {@macro oauth_token} const OAuthToken._( @@ -22,11 +24,11 @@ class OAuthToken { /// Creates a new instance of [OAuthToken] from [Credentials]. /// {@macro oauth_token} factory OAuthToken.fromCredentials(Credentials credentials) => OAuthToken._( - credentials.accessToken, - credentials.refreshToken, - credentials.expiration, - credentials.idToken, - ); + credentials.accessToken, + credentials.refreshToken, + credentials.expiration, + credentials.idToken, + ); /// The token that is sent to the resource server to prove the authorization /// of a client. @@ -56,8 +58,27 @@ class OAuthToken { /// Whether the token is expired. bool get isExpired => - expiration != null && - DateTime.now().isAfter( - expiration!, - ); + expiration != null && DateTime.now().isAfter(expiration!); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OAuthToken && + runtimeType == other.runtimeType && + accessToken == other.accessToken && + refreshToken == other.refreshToken && + expiration == other.expiration && + idToken == other.idToken; + + @override + int get hashCode => + Object.hash(accessToken, refreshToken, expiration, idToken); + + @override + String toString() => + 'OAuthToken(' + 'accessToken: $accessToken, ' + 'refreshToken: $refreshToken, ' + 'expiration: $expiration, ' + 'idToken: $idToken)'; } diff --git a/pubspec.yaml b/pubspec.yaml index 34e5334..36a8988 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: oauth_chopper description: Add and manage OAuth2 authentication for your Chopper client. -version: 1.1.2 +version: 1.2.0 repository: https://github.com/DutchCodingCompany/oauth_chopper topics: - http @@ -15,6 +15,7 @@ dependencies: chopper: ^8.6.0 http: ^1.6.0 http_parser: ^4.1.2 + meta: ^1.18.3 oauth2: ^2.0.5 dev_dependencies: diff --git a/test/oauth_chopper_test.dart b/test/oauth_chopper_test.dart index 9bb180b..14afb8f 100644 --- a/test/oauth_chopper_test.dart +++ b/test/oauth_chopper_test.dart @@ -15,7 +15,7 @@ void main() { final grantMock = MockOAuthGrant(); const testJson = ''' { - "accessToken": "accesToken", + "accessToken": "accessToken", "refreshToken": "refreshToken", "idToken": "idToken", "tokenEndpoint": "https://test.test/oauth/token", @@ -53,7 +53,7 @@ void main() { final token = await oauthChopper.token; // assert - expect(token?.accessToken, 'accesToken'); + expect(token?.accessToken, 'accessToken'); expect(token?.refreshToken, 'refreshToken'); expect(token?.idToken, 'idToken'); }); @@ -101,10 +101,11 @@ void main() { final token = await oauthChopper.requestGrant(grantMock); // assert - verify(() => grantMock.handle(any(), 'identifier', secret: 'secret')) - .called(1); + verify( + () => grantMock.handle(any(), 'identifier', secret: 'secret'), + ).called(1); verify(() => storageMock.saveCredentials(testJson)).called(1); - expect(token.accessToken, 'accesToken'); + expect(token.accessToken, 'accessToken'); expect(token.idToken, 'idToken'); expect(token.refreshToken, 'refreshToken'); }); diff --git a/test/oauth_interceptor_test.dart b/test/oauth_interceptor_test.dart index c4a5154..895529e 100644 --- a/test/oauth_interceptor_test.dart +++ b/test/oauth_interceptor_test.dart @@ -16,13 +16,11 @@ class MockOAuthChopper extends Mock implements OAuthChopper {} class MockChain extends Mock implements Chain {} void main() { - final testRequest = Request( - 'GET', - Uri(host: 'test'), - Uri(host: 'test'), + final testRequest = Request('GET', Uri(host: 'test'), Uri(host: 'test')); + final authorizedResponse = Response( + http.Response('body', HttpStatus.accepted), + 'body', ); - final authorizedResponse = - Response(http.Response('body', HttpStatus.accepted), 'body'); registerFallbackValue(testRequest); registerFallbackValue(authorizedResponse); @@ -33,7 +31,7 @@ void main() { Credentials( 'token', refreshToken: 'refresh', - expiration: DateTime(2022, 9, 1), + expiration: DateTime.now().add(const Duration(hours: 1)), ), ); @@ -42,13 +40,14 @@ void main() { 'token', refreshToken: 'refresh', idToken: 'idToken', - expiration: DateTime(2022, 9, 1), + expiration: DateTime.now().add(const Duration(hours: 1)), ), ); when(() => mockChain.request).thenReturn(testRequest); - when(() => mockChain.proceed(any())) - .thenAnswer((_) async => authorizedResponse); + when( + () => mockChain.proceed(any()), + ).thenAnswer((_) async => authorizedResponse); test('HeaderInterceptor adds available token to headers', () async { // arrange @@ -60,37 +59,137 @@ void main() { await interceptor.intercept(mockChain); // assert - verify(() => mockChain.proceed(testRequest.copyWith(headers: expected))) - .called(1); + verify( + () => mockChain.proceed(testRequest.copyWith(headers: expected)), + ).called(1); }); - test('HeaderInterceptor does not add IDToken when available to headers', - () async { + test( + 'HeaderInterceptor does not add IDToken when available to headers', + () async { + // arrange + when(() => mockOAuthChopper.token).thenAnswer((_) async => testIDtoken); + final interceptor = OAuthInterceptor(mockOAuthChopper, null); + final expected = {'Authorization': 'Bearer token'}; + + // act + await interceptor.intercept(mockChain); + + // assert + verify( + () => mockChain.proceed(testRequest.copyWith(headers: expected)), + ).called(1); + }, + ); + + test('HeaderInterceptor adds no token to headers', () async { // arrange - when(() => mockOAuthChopper.token).thenAnswer((_) async => testIDtoken); + when(() => mockOAuthChopper.token).thenAnswer((_) async => null); final interceptor = OAuthInterceptor(mockOAuthChopper, null); - final expected = {'Authorization': 'Bearer token'}; + final expected = {}; // act await interceptor.intercept(mockChain); // assert - verify(() => mockChain.proceed(testRequest.copyWith(headers: expected))) - .called(1); + verify( + () => mockChain.proceed(testRequest.copyWith(headers: expected)), + ).called(1); }); + }); - test('HeaderInterceptor adds no token to headers', () async { + group('proactive refresh tests', () { + final mockChain = MockChain(); + final mockOAuthChopper = MockOAuthChopper(); + final expiredToken = OAuthToken.fromCredentials( + Credentials( + 'expired_token', + refreshToken: 'refresh', + expiration: DateTime(2022, 9, 1), + ), + ); + + final freshToken = OAuthToken.fromCredentials( + Credentials( + 'fresh_token', + refreshToken: 'refresh', + expiration: DateTime.now().add(const Duration(hours: 1)), + ), + ); + + setUp(() { + when(() => mockChain.request).thenReturn(testRequest); + when( + () => mockChain.proceed(any()), + ).thenAnswer((_) async => authorizedResponse); + }); + + test('Proactively refreshes expired token before request', () async { // arrange - when(() => mockOAuthChopper.token).thenAnswer((_) async => null); + when(() => mockOAuthChopper.token).thenAnswer((_) async => expiredToken); + when(mockOAuthChopper.refresh).thenAnswer((_) async => freshToken); final interceptor = OAuthInterceptor(mockOAuthChopper, null); - final expected = {}; + final expected = {'Authorization': 'Bearer fresh_token'}; + + // act + await interceptor.intercept(mockChain); + + // assert + verify(mockOAuthChopper.refresh).called(1); + verify( + () => mockChain.proceed(testRequest.copyWith(headers: expected)), + ).called(1); + }); + + test('Uses original token if proactive refresh returns null', () async { + // arrange + when(() => mockOAuthChopper.token).thenAnswer((_) async => expiredToken); + when(mockOAuthChopper.refresh).thenAnswer((_) async => null); + final interceptor = OAuthInterceptor(mockOAuthChopper, null); + final expected = {'Authorization': 'Bearer expired_token'}; + + // act + await interceptor.intercept(mockChain); + + // assert + verify(mockOAuthChopper.refresh).called(1); + verify( + () => mockChain.proceed(testRequest.copyWith(headers: expected)), + ).called(1); + }); + + test('Calls onError when proactive refresh throws', () async { + // arrange + FormatException? result; + when(() => mockOAuthChopper.token).thenAnswer((_) async => expiredToken); + when( + mockOAuthChopper.refresh, + ).thenThrow(const FormatException('refresh failed')); + final interceptor = OAuthInterceptor( + mockOAuthChopper, + (e, s) => result = e as FormatException, + ); // act await interceptor.intercept(mockChain); // assert - verify(() => mockChain.proceed(testRequest.copyWith(headers: expected))) - .called(1); + expect(result?.message, 'refresh failed'); + }); + + test('Throws when proactive refresh fails and onError is null', () async { + // arrange + when(() => mockOAuthChopper.token).thenAnswer((_) async => expiredToken); + when( + mockOAuthChopper.refresh, + ).thenThrow(const FormatException('refresh failed')); + final interceptor = OAuthInterceptor(mockOAuthChopper, null); + + // act & assert + expect( + () async => interceptor.intercept(mockChain), + throwsFormatException, + ); }); }); @@ -101,15 +200,18 @@ void main() { Credentials( 'token', refreshToken: 'refresh', - expiration: DateTime(2022, 9, 1), + expiration: DateTime.now().add(const Duration(hours: 1)), ), ); - final unauthorizedResponse = - Response(http.Response('body', HttpStatus.unauthorized), 'body'); + final unauthorizedResponse = Response( + http.Response('body', HttpStatus.unauthorized), + 'body', + ); setUp(() { when(() => mockChain.request).thenReturn(testRequest); - when(() => mockChain.proceed(any())) - .thenAnswer((_) async => unauthorizedResponse); + when( + () => mockChain.proceed(any()), + ).thenAnswer((_) async => unauthorizedResponse); }); test('only refresh on unauthorized and token', () async { @@ -124,14 +226,16 @@ void main() { // assert verify(mockOAuthChopper.refresh).called(1); - verify(() => mockChain.proceed(testRequest.copyWith(headers: expected))) - .called(2); + verify( + () => mockChain.proceed(testRequest.copyWith(headers: expected)), + ).called(2); }); test("Don't refresh on authorized", () async { // arrange - when(() => mockChain.proceed(any())) - .thenAnswer((_) async => authorizedResponse); + when( + () => mockChain.proceed(any()), + ).thenAnswer((_) async => authorizedResponse); when(mockOAuthChopper.refresh).thenAnswer((_) async => testToken); when(() => mockOAuthChopper.token).thenAnswer((_) async => testToken); final interceptor = OAuthInterceptor(mockOAuthChopper, null); @@ -180,7 +284,7 @@ void main() { // act // assert expect( - () async => await interceptor.intercept(mockChain), + () async => interceptor.intercept(mockChain), throwsFormatException, ); }); diff --git a/test/storage/memory_storage_test.dart b/test/storage/memory_storage_test.dart index 5faf71b..fce0a28 100644 --- a/test/storage/memory_storage_test.dart +++ b/test/storage/memory_storage_test.dart @@ -1,13 +1,13 @@ -import 'package:oauth_chopper/src/storage/memory_storage.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; import 'package:test/test.dart'; void main() { test('Store & read a value', () async { // arrange - final storage = MemoryStorage() - - // act - ..saveCredentials('test'); + final storage = + MemoryStorage() + // act + ..saveCredentials('test'); final result = await storage.fetchCredentials(); // assert @@ -16,10 +16,10 @@ void main() { test('Store, update & read a value', () async { // arrange - final storage = MemoryStorage() - - // act - ..saveCredentials('test'); + final storage = + MemoryStorage() + // act + ..saveCredentials('test'); final result1 = await storage.fetchCredentials(); storage.saveCredentials('test2'); final result2 = await storage.fetchCredentials(); @@ -31,10 +31,10 @@ void main() { test('Store, clear, store & read a value', () async { // arrange - final storage = MemoryStorage() - - // act - ..saveCredentials('test'); + final storage = + MemoryStorage() + // act + ..saveCredentials('test'); await storage.clear(); storage.saveCredentials('test2'); final result = await storage.fetchCredentials(); @@ -45,10 +45,10 @@ void main() { test('Store, clear & read a value', () async { // arrange - final storage = MemoryStorage() - - // act - ..saveCredentials('test'); + final storage = + MemoryStorage() + // act + ..saveCredentials('test'); await storage.clear(); final result = await storage.fetchCredentials(); From d0b10225b4fb0477bf1422741936711907927311 Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Wed, 17 Jun 2026 17:10:46 +0200 Subject: [PATCH 3/4] :art: Added extra tests. Improved coverage --- lib/src/oauth_chopper.dart | 29 +-- test/oauth_chopper_refresh_test.dart | 279 ++++++++++++++++++++++++ test/oauth_grant_test.dart | 315 +++++++++++++++++++++++++++ test/oauth_token_test.dart | 186 ++++++++++++++++ 4 files changed, 795 insertions(+), 14 deletions(-) create mode 100644 test/oauth_chopper_refresh_test.dart create mode 100644 test/oauth_grant_test.dart create mode 100644 test/oauth_token_test.dart diff --git a/lib/src/oauth_chopper.dart b/lib/src/oauth_chopper.dart index 4d3baf4..1b6b66c 100644 --- a/lib/src/oauth_chopper.dart +++ b/lib/src/oauth_chopper.dart @@ -113,7 +113,7 @@ class OAuthChopper { Future refresh({ bool basicAuth = true, Iterable? newScopes, - }) async { + }) { // If a refresh is already in-flight, await the existing operation. if (_refreshCompleter != null) { return _refreshCompleter!.future; @@ -121,19 +121,20 @@ class OAuthChopper { _refreshCompleter = Completer(); - try { - final result = await _performRefresh( - basicAuth: basicAuth, - newScopes: newScopes, - ); - _refreshCompleter!.complete(result); - return result; - } catch (e, s) { - _refreshCompleter!.completeError(e, s); - rethrow; - } finally { - _refreshCompleter = null; - } + unawaited( + _performRefresh(basicAuth: basicAuth, newScopes: newScopes).then( + (result) { + _refreshCompleter!.complete(result); + _refreshCompleter = null; + }, + onError: (Object e, StackTrace s) { + _refreshCompleter!.completeError(e, s); + _refreshCompleter = null; + }, + ), + ); + + return _refreshCompleter!.future; } /// Internal method that performs the actual credential refresh. diff --git a/test/oauth_chopper_refresh_test.dart b/test/oauth_chopper_refresh_test.dart new file mode 100644 index 0000000..a28a448 --- /dev/null +++ b/test/oauth_chopper_refresh_test.dart @@ -0,0 +1,279 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; +import 'package:test/test.dart'; + +class MockOAuthStorage extends Mock implements OAuthStorage {} + +void main() { + const storedCredentialsJson = ''' + { + "accessToken": "old_access_token", + "refreshToken": "old_refresh_token", + "idToken": "old_id_token", + "tokenEndpoint": "https://auth.example.com/oauth/token", + "scopes": ["read"], + "expiration": 1664359530234 + } + '''; + + const successfulTokenResponse = ''' + { + "access_token": "new_access_token", + "token_type": "bearer", + "refresh_token": "new_refresh_token", + "expires_in": 3600 + } + '''; + + const errorResponse = ''' + { + "error": "invalid_grant", + "error_description": "The refresh token is expired." + } + '''; + + group('OAuthChopper.refresh()', () { + late MockOAuthStorage storageMock; + + setUp(() { + storageMock = MockOAuthStorage(); + }); + + test('returns new token on successful refresh', () async { + when( + storageMock.fetchCredentials, + ).thenAnswer((_) async => storedCredentialsJson); + when(() => storageMock.saveCredentials(any())).thenAnswer((_) async {}); + + final mockHttpClient = MockClient((request) async { + expect(request.url.toString(), 'https://auth.example.com/oauth/token'); + expect(request.method, 'POST'); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse( + 'https://auth.example.com/oauth/authorize', + ), + identifier: 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + storage: storageMock, + ); + + final token = await oauthChopper.refresh(); + + expect(token, isNotNull); + expect(token!.accessToken, 'new_access_token'); + expect(token.refreshToken, 'new_refresh_token'); + }); + + test('returns null when no credentials in storage', () async { + when(storageMock.fetchCredentials).thenAnswer((_) async => null); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse( + 'https://auth.example.com/oauth/authorize', + ), + identifier: 'client_id', + storage: storageMock, + ); + + final token = await oauthChopper.refresh(); + + expect(token, isNull); + }); + + test('saves new credentials to storage', () async { + when( + storageMock.fetchCredentials, + ).thenAnswer((_) async => storedCredentialsJson); + when(() => storageMock.saveCredentials(any())).thenAnswer((_) async {}); + + final mockHttpClient = MockClient((request) async { + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse( + 'https://auth.example.com/oauth/authorize', + ), + identifier: 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + storage: storageMock, + ); + + await oauthChopper.refresh(); + + verify(() => storageMock.saveCredentials(any())).called(1); + }); + + test('clears storage on AuthorizationException', () async { + when( + storageMock.fetchCredentials, + ).thenAnswer((_) async => storedCredentialsJson); + when(storageMock.clear).thenAnswer((_) async {}); + + final mockHttpClient = MockClient((request) async { + return http.Response( + errorResponse, + 400, + headers: {'content-type': 'application/json'}, + ); + }); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse( + 'https://auth.example.com/oauth/authorize', + ), + identifier: 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + storage: storageMock, + ); + + Object? caughtError; + try { + await oauthChopper.refresh(); + } on AuthorizationException catch (e) { + caughtError = e; + } + + expect(caughtError, isA()); + verify(storageMock.clear).called(1); + }); + + test('deduplicates concurrent refresh calls', () async { + var requestCount = 0; + when( + storageMock.fetchCredentials, + ).thenAnswer((_) async => storedCredentialsJson); + when(() => storageMock.saveCredentials(any())).thenAnswer((_) async {}); + + final mockHttpClient = MockClient((request) async { + requestCount++; + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse( + 'https://auth.example.com/oauth/authorize', + ), + identifier: 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + storage: storageMock, + ); + + // Launch multiple concurrent refreshes + final results = await Future.wait([ + oauthChopper.refresh(), + oauthChopper.refresh(), + oauthChopper.refresh(), + ]); + + // Only one HTTP request should have been made + expect(requestCount, 1); + + // All results should be the same token + for (final token in results) { + expect(token, isNotNull); + expect(token!.accessToken, 'new_access_token'); + } + }); + + test( + 'resets completer after completion allowing subsequent refreshes', + () async { + var requestCount = 0; + when( + storageMock.fetchCredentials, + ).thenAnswer((_) async => storedCredentialsJson); + when(() => storageMock.saveCredentials(any())).thenAnswer((_) async {}); + + final mockHttpClient = MockClient((request) async { + requestCount++; + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse( + 'https://auth.example.com/oauth/authorize', + ), + identifier: 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + storage: storageMock, + ); + + // First refresh + await oauthChopper.refresh(); + expect(requestCount, 1); + + // Second refresh (should make a new request) + await oauthChopper.refresh(); + expect(requestCount, 2); + }, + ); + + test('propagates errors to all concurrent waiters', () async { + when( + storageMock.fetchCredentials, + ).thenAnswer((_) async => storedCredentialsJson); + when(storageMock.clear).thenAnswer((_) async {}); + + final mockHttpClient = MockClient((request) async { + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response( + errorResponse, + 400, + headers: {'content-type': 'application/json'}, + ); + }); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse( + 'https://auth.example.com/oauth/authorize', + ), + identifier: 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + storage: storageMock, + ); + + // Launch concurrent refreshes that should all fail + final futures = [ + oauthChopper.refresh(), + oauthChopper.refresh(), + oauthChopper.refresh(), + ]; + + for (final future in futures) { + await expectLater(future, throwsA(isA())); + } + }); + }); +} diff --git a/test/oauth_grant_test.dart b/test/oauth_grant_test.dart new file mode 100644 index 0000000..f52654c --- /dev/null +++ b/test/oauth_grant_test.dart @@ -0,0 +1,315 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; +import 'package:test/test.dart'; + +void main() { + const successfulTokenResponse = ''' + { + "access_token": "new_access_token", + "token_type": "bearer", + "refresh_token": "new_refresh_token", + "expires_in": 3600 + } + '''; + + const errorResponse = ''' + { + "error": "invalid_grant", + "error_description": "The refresh token is expired." + } + '''; + + group('ResourceOwnerPasswordGrant', () { + test('returns credentials JSON on successful grant', () async { + final mockHttpClient = MockClient((request) async { + expect(request.url.toString(), 'https://auth.example.com/oauth/token'); + expect(request.method, 'POST'); + expect(request.body, contains('grant_type=password')); + expect(request.body, contains('username=testuser')); + expect(request.body, contains('password=testpass')); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + const grant = ResourceOwnerPasswordGrant( + username: 'testuser', + password: 'testpass', + ); + + final credentialsJson = await grant.handle( + Uri.parse('https://auth.example.com/oauth/token'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + ); + + final decoded = jsonDecode(credentialsJson) as Map; + expect(decoded['accessToken'], 'new_access_token'); + expect(decoded['refreshToken'], 'new_refresh_token'); + }); + + test('throws AuthorizationException on server error', () async { + final mockHttpClient = MockClient((request) async { + return http.Response( + errorResponse, + 400, + headers: {'content-type': 'application/json'}, + ); + }); + + const grant = ResourceOwnerPasswordGrant( + username: 'testuser', + password: 'testpass', + ); + + expect( + () => grant.handle( + Uri.parse('https://auth.example.com/oauth/token'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + ), + throwsA(isA()), + ); + }); + + test('passes scopes to the request', () async { + final mockHttpClient = MockClient((request) async { + expect(request.body, contains('scope=read+write')); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + const grant = ResourceOwnerPasswordGrant( + username: 'testuser', + password: 'testpass', + ); + + await grant.handle( + Uri.parse('https://auth.example.com/oauth/token'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + scopes: ['read', 'write'], + ); + }); + }); + + group('ClientCredentialsGrant', () { + test('returns credentials JSON on successful grant', () async { + final mockHttpClient = MockClient((request) async { + expect(request.url.toString(), 'https://auth.example.com/oauth/token'); + expect(request.method, 'POST'); + expect(request.body, contains('grant_type=client_credentials')); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + const grant = ClientCredentialsGrant(); + + final credentialsJson = await grant.handle( + Uri.parse('https://auth.example.com/oauth/token'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + ); + + final decoded = jsonDecode(credentialsJson) as Map; + expect(decoded['accessToken'], 'new_access_token'); + }); + + test('throws AuthorizationException on server error', () async { + final mockHttpClient = MockClient((request) async { + return http.Response( + errorResponse, + 400, + headers: {'content-type': 'application/json'}, + ); + }); + + const grant = ClientCredentialsGrant(); + + expect( + () => grant.handle( + Uri.parse('https://auth.example.com/oauth/token'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + ), + throwsA(isA()), + ); + }); + + test('works without basicAuth', () async { + final mockHttpClient = MockClient((request) async { + // When basicAuth is false, credentials are in the body + expect(request.body, contains('client_id')); + expect(request.body, contains('client_secret')); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + const grant = ClientCredentialsGrant(); + + final credentialsJson = await grant.handle( + Uri.parse('https://auth.example.com/oauth/token'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + basicAuth: false, + ); + + final decoded = jsonDecode(credentialsJson) as Map; + expect(decoded['accessToken'], 'new_access_token'); + }); + }); + + group('AuthorizationCodeGrant', () { + test('returns credentials JSON on successful grant', () async { + final mockHttpClient = MockClient((request) async { + expect(request.url.toString(), 'https://auth.example.com/oauth/token'); + expect(request.method, 'POST'); + expect(request.body, contains('grant_type=authorization_code')); + expect(request.body, contains('code=auth_code_123')); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + Uri? capturedAuthUrl; + final grant = AuthorizationCodeGrant( + tokenEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + redirectUrl: Uri.parse('http://localhost:8080/callback'), + redirect: (authorizationUri) async { + capturedAuthUrl = authorizationUri; + }, + listen: (redirectUri) async { + return Uri.parse('http://localhost:8080/callback?code=auth_code_123'); + }, + ); + + final credentialsJson = await grant.handle( + Uri.parse('https://auth.example.com/oauth/authorize'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + ); + + final decoded = jsonDecode(credentialsJson) as Map; + expect(decoded['accessToken'], 'new_access_token'); + expect(decoded['refreshToken'], 'new_refresh_token'); + expect(capturedAuthUrl, isNotNull); + expect(capturedAuthUrl.toString(), contains('response_type=code')); + expect(capturedAuthUrl.toString(), contains('client_id')); + }); + + test('calls redirect with correct authorization URL', () async { + final mockHttpClient = MockClient((request) async { + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + Uri? capturedAuthUrl; + final grant = AuthorizationCodeGrant( + tokenEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + redirectUrl: Uri.parse('http://localhost:8080/callback'), + redirect: (authorizationUri) async { + capturedAuthUrl = authorizationUri; + }, + listen: (redirectUri) async { + return Uri.parse('http://localhost:8080/callback?code=code123'); + }, + ); + + await grant.handle( + Uri.parse('https://auth.example.com/oauth/authorize'), + 'my_client_id', + httpClient: mockHttpClient, + scopes: ['openid', 'profile'], + ); + + expect(capturedAuthUrl, isNotNull); + expect(capturedAuthUrl!.host, 'auth.example.com'); + expect(capturedAuthUrl!.path, '/oauth/authorize'); + expect(capturedAuthUrl!.queryParameters['client_id'], 'my_client_id'); + expect(capturedAuthUrl!.queryParameters['scope'], 'openid profile'); + }); + + test('passes PKCE code verifier', () async { + final mockHttpClient = MockClient((request) async { + expect(request.body, contains('code_verifier=my_custom_verifier')); + return http.Response( + successfulTokenResponse, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final grant = AuthorizationCodeGrant( + tokenEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + redirectUrl: Uri.parse('http://localhost:8080/callback'), + redirect: (authorizationUri) async {}, + listen: (redirectUri) async { + return Uri.parse('http://localhost:8080/callback?code=code123'); + }, + codeVerifier: 'my_custom_verifier', + ); + + await grant.handle( + Uri.parse('https://auth.example.com/oauth/authorize'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + ); + }); + + test('throws on server error during token exchange', () async { + final mockHttpClient = MockClient((request) async { + return http.Response( + errorResponse, + 400, + headers: {'content-type': 'application/json'}, + ); + }); + + final grant = AuthorizationCodeGrant( + tokenEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + redirectUrl: Uri.parse('http://localhost:8080/callback'), + redirect: (authorizationUri) async {}, + listen: (redirectUri) async { + return Uri.parse('http://localhost:8080/callback?code=code123'); + }, + ); + + expect( + () => grant.handle( + Uri.parse('https://auth.example.com/oauth/authorize'), + 'client_id', + secret: 'client_secret', + httpClient: mockHttpClient, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/oauth_token_test.dart b/test/oauth_token_test.dart new file mode 100644 index 0000000..5484f64 --- /dev/null +++ b/test/oauth_token_test.dart @@ -0,0 +1,186 @@ +import 'package:oauth2/oauth2.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; +import 'package:test/test.dart'; + +void main() { + group('OAuthToken equality', () { + final expiration = DateTime(2024); + + test('== returns true for tokens with identical fields', () { + final a = OAuthToken.fromCredentials( + Credentials( + 'access', + refreshToken: 'refresh', + expiration: expiration, + idToken: 'id', + ), + ); + final b = OAuthToken.fromCredentials( + Credentials( + 'access', + refreshToken: 'refresh', + expiration: expiration, + idToken: 'id', + ), + ); + + expect(a, equals(b)); + }); + + test('== returns false for tokens with different accessToken', () { + final a = OAuthToken.fromCredentials( + Credentials('access1', expiration: expiration), + ); + final b = OAuthToken.fromCredentials( + Credentials('access2', expiration: expiration), + ); + + expect(a, isNot(equals(b))); + }); + + test('== returns false for tokens with different refreshToken', () { + final a = OAuthToken.fromCredentials( + Credentials('access', refreshToken: 'refresh1', expiration: expiration), + ); + final b = OAuthToken.fromCredentials( + Credentials('access', refreshToken: 'refresh2', expiration: expiration), + ); + + expect(a, isNot(equals(b))); + }); + + test('== returns false for tokens with different expiration', () { + final a = OAuthToken.fromCredentials( + Credentials('access', expiration: DateTime(2024)), + ); + final b = OAuthToken.fromCredentials( + Credentials('access', expiration: DateTime(2025)), + ); + + expect(a, isNot(equals(b))); + }); + + test('== returns false for tokens with different idToken', () { + final a = OAuthToken.fromCredentials( + Credentials('access', idToken: 'id1', expiration: expiration), + ); + final b = OAuthToken.fromCredentials( + Credentials('access', idToken: 'id2', expiration: expiration), + ); + + expect(a, isNot(equals(b))); + }); + + test('== returns false when compared to non-OAuthToken', () { + final a = OAuthToken.fromCredentials(Credentials('access')); + + expect(a, isNot(equals('not a token'))); + }); + }); + + group('OAuthToken hashCode', () { + test('hashCode is consistent with ==', () { + final expiration = DateTime(2024); + final a = OAuthToken.fromCredentials( + Credentials( + 'access', + refreshToken: 'refresh', + expiration: expiration, + idToken: 'id', + ), + ); + final b = OAuthToken.fromCredentials( + Credentials( + 'access', + refreshToken: 'refresh', + expiration: expiration, + idToken: 'id', + ), + ); + + expect(a.hashCode, equals(b.hashCode)); + }); + + test('hashCode differs for different tokens', () { + final a = OAuthToken.fromCredentials(Credentials('access1')); + final b = OAuthToken.fromCredentials(Credentials('access2')); + + expect(a.hashCode, isNot(equals(b.hashCode))); + }); + }); + + group('OAuthToken toString', () { + test('toString() contains all fields', () { + final token = OAuthToken.fromCredentials( + Credentials( + 'myAccess', + refreshToken: 'myRefresh', + idToken: 'myId', + expiration: DateTime(2024), + ), + ); + final str = token.toString(); + + expect(str, contains('myAccess')); + expect(str, contains('myRefresh')); + expect(str, contains('myId')); + expect(str, contains('2024')); + }); + + test('toString() handles null fields', () { + final token = OAuthToken.fromCredentials(Credentials('access')); + final str = token.toString(); + + expect(str, contains('access')); + expect(str, contains('null')); + }); + }); + + group('OAuthToken isExpired', () { + test('isExpired returns true for past expiration', () { + final token = OAuthToken.fromCredentials( + Credentials('access', expiration: DateTime(2020)), + ); + + expect(token.isExpired, isTrue); + }); + + test('isExpired returns false for future expiration', () { + final token = OAuthToken.fromCredentials( + Credentials( + 'access', + expiration: DateTime.now().add(const Duration(hours: 1)), + ), + ); + + expect(token.isExpired, isFalse); + }); + + test('isExpired returns false when expiration is null', () { + final token = OAuthToken.fromCredentials(Credentials('access')); + + expect(token.isExpired, isFalse); + }); + }); + + group('OAuthToken fromJson', () { + test('fromJson creates token from valid JSON', () { + const json = ''' + { + "accessToken": "access123", + "refreshToken": "refresh123", + "idToken": "id123", + "tokenEndpoint": "https://example.com/token", + "scopes": [], + "expiration": 1700000000000 + } + '''; + + final token = OAuthToken.fromJson(json); + + expect(token.accessToken, 'access123'); + expect(token.refreshToken, 'refresh123'); + expect(token.idToken, 'id123'); + }); + }); +} From 79fd9ae2ce8cd4a33a0e27dce374c0603fd5d533 Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Thu, 18 Jun 2026 11:22:59 +0200 Subject: [PATCH 4/4] :sparkles: Added AI Skills --- CHANGELOG.md | 1 + skills/oauth-chopper-setup/SKILL.md | 184 ++++++++++++++++++++++++++ skills/oauth-chopper-storage/SKILL.md | 175 ++++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 skills/oauth-chopper-setup/SKILL.md create mode 100644 skills/oauth-chopper-storage/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c969c4d..0d9cda8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Added `==`, `hashCode`, and `toString()` to `OAuthToken` for value-based equality and easier debugging. - Exported `OnErrorCallback` and `MemoryStorage` - Fixed exports for `oauth2` package +- Added skills for: setup and storage - Updated dependencies: - `sdk` to `^3.7.0` - `meta` added diff --git a/skills/oauth-chopper-setup/SKILL.md b/skills/oauth-chopper-setup/SKILL.md new file mode 100644 index 0000000..bde0510 --- /dev/null +++ b/skills/oauth-chopper-setup/SKILL.md @@ -0,0 +1,184 @@ +--- +name: oauth-chopper-setup +description: Add and configure OAuth2 authentication for a Chopper HTTP client using `oauth_chopper`. Use when asked to "add OAuth to Chopper", "setup oauth_chopper", "integrate OAuth2 with Chopper", "add authentication to my API client", or when setting up any of the supported grant types (resource owner password, client credentials, authorization code). +--- +# Setting Up OAuth2 Authentication with Chopper + +## Contents +- [Package Overview](#package-overview) +- [Grant Type Selection](#grant-type-selection) +- [Interceptor Behavior](#interceptor-behavior) +- [Error Handling](#error-handling) +- [Workflow: Integrate oauth_chopper](#workflow-integrate-oauth_chopper) +- [Examples](#examples) + +## Package Overview +`oauth_chopper` bridges the Chopper HTTP client with the `oauth2` package to provide turnkey OAuth2 authentication. It manages token acquisition, storage, proactive refresh of expired tokens, and automatic retry on 401 responses. + +* Import the package with `import 'package:oauth_chopper/oauth_chopper.dart';`. +* The central class is `OAuthChopper`. Create an instance with the authorization server's endpoint, client identifier, and optional secret. +* Call `oauthChopper.interceptor()` to get an `OAuthInterceptor` and pass it to `ChopperClient(interceptors: [...])`. +* Call `oauthChopper.requestGrant(grant)` to authenticate and obtain an `OAuthToken`. +* Access the current token at any time with `await oauthChopper.token`. + +## Grant Type Selection +Choose the correct OAuth2 grant type based on the application context. + +* **Use `ResourceOwnerPasswordGrant`** for first-party apps where the user provides their username and password directly. Requires `username` and `password` parameters. Suitable for trusted mobile/desktop apps. +* **Use `ClientCredentialsGrant`** for server-to-server (machine-to-machine) communication where no user interaction is needed. Requires only the client `identifier` and `secret` on the `OAuthChopper` instance. +* **Use `AuthorizationCodeGrant`** for browser-based or redirect-based login flows. Requires a `tokenEndpoint`, `redirectUrl`, a `redirect` callback (to open the authorization URL), and a `listen` callback (to capture the redirect response). Supports PKCE via the optional `codeVerifier` parameter. + +## Interceptor Behavior +Understand how the `OAuthInterceptor` manages the token lifecycle automatically. + +* Before each request, the interceptor reads the current token from storage. +* If the token exists and is expired (`isExpired == true`), it proactively calls `refresh()` before sending the request. +* If no token is available, the request proceeds without an `Authorization` header. +* If the server responds with HTTP 401 and a token was present, the interceptor refreshes once and retries the request. +* Concurrent refresh calls are deduplicated internally. Multiple simultaneous 401 responses result in a single refresh request. + +## Error Handling +Handle authentication errors to prevent crashes and guide the user back to login. + +* `AuthorizationException` is thrown when the authorization server rejects credentials (e.g., invalid grant, revoked token). On refresh failure with this exception, storage is automatically cleared. +* `ExpirationException` is thrown on token expiration issues from the `oauth2` package. +* Pass an `onError` callback to `oauthChopper.interceptor(onError: ...)` to catch errors without throwing. Use this to redirect the user to a login screen on token failure. +* If no `onError` is provided, exceptions propagate to the caller. + +## Workflow: Integrate oauth_chopper + +Follow this sequential workflow to add OAuth2 authentication to an existing Chopper-based project. + +**Task Progress:** +- [ ] 1. Add the dependency: run `dart pub add oauth_chopper`. +- [ ] 2. Create an `OAuthChopper` instance with the authorization server's `authorizationEndpoint`, `identifier`, and optional `secret`. +- [ ] 3. Add `oauthChopper.interceptor()` to the `ChopperClient`'s `interceptors` list. Optionally pass an `onError` callback for graceful error handling. +- [ ] 4. Choose the appropriate grant type and call `oauthChopper.requestGrant(grant)` to authenticate. +- [ ] 5. If using `AuthorizationCodeGrant`, implement the `redirect` and `listen` callbacks for the browser-based flow. +- [ ] 6. Wrap the `requestGrant` call in a try-catch to handle `AuthorizationException` and other errors. +- [ ] 7. **Feedback Loop**: Run `dart analyze` -> review for missing imports or type errors -> fix -> re-run until clean. + +## Examples + +### Resource Owner Password Grant +The simplest grant type for first-party apps with direct username/password input. + +```dart +import 'package:chopper/chopper.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; + +Future main() async { + // 1. Create the OAuthChopper instance. + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + identifier: 'my_client_id', + secret: 'my_client_secret', + ); + + // 2. Wire the interceptor into the Chopper client. + final chopperClient = ChopperClient( + baseUrl: Uri.parse('https://api.example.com'), + interceptors: [ + oauthChopper.interceptor( + onError: (error, stackTrace) { + // Handle token errors (e.g., redirect to login screen). + print('Auth error: $error'); + }, + ), + ], + ); + + // 3. Authenticate with username and password. + try { + final token = await oauthChopper.requestGrant( + ResourceOwnerPasswordGrant( + username: 'user@example.com', + password: 'password123', + ), + ); + print('Authenticated. Access token: ${token.accessToken}'); + } on AuthorizationException catch (e) { + print('Login failed: ${e.message}'); + } +} +``` + +### Client Credentials Grant +For server-to-server authentication where no user interaction is required. + +```dart +import 'package:chopper/chopper.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; + +Future main() async { + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + identifier: 'service_client_id', + secret: 'service_client_secret', + scopes: ['read', 'write'], + ); + + final chopperClient = ChopperClient( + baseUrl: Uri.parse('https://api.example.com'), + interceptors: [oauthChopper.interceptor()], + ); + + try { + final token = await oauthChopper.requestGrant( + const ClientCredentialsGrant(), + ); + print('Service authenticated. Expires: ${token.expiration}'); + } on AuthorizationException catch (e) { + print('Authentication failed: ${e.message}'); + } +} +``` + +### Authorization Code Grant with PKCE +For browser-based login flows with redirect handling. Commonly used in mobile and web apps. + +```dart +import 'package:chopper/chopper.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; + +Future main() async { + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse('https://auth.example.com/authorize'), + identifier: 'my_client_id', + secret: 'my_client_secret', + scopes: ['openid', 'profile', 'email'], + ); + + final chopperClient = ChopperClient( + baseUrl: Uri.parse('https://api.example.com'), + interceptors: [oauthChopper.interceptor()], + ); + + try { + final token = await oauthChopper.requestGrant( + AuthorizationCodeGrant( + tokenEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + redirectUrl: Uri.parse('myapp://callback'), + redirect: (authorizationUrl) async { + // Open the authorization URL in a browser or webview. + // For example, using url_launcher: + // await launchUrl(authorizationUrl); + print('Open in browser: $authorizationUrl'); + }, + listen: (redirectUrl) async { + // Listen for the redirect and return the full response URI. + // Implementation depends on your platform (e.g., uni_links, + // app_links, or a local HTTP server for desktop). + return Uri.parse('myapp://callback?code=auth_code&state=xyz'); + }, + // Optional: provide a PKCE code verifier for enhanced security. + // If omitted, one is generated automatically by the oauth2 package. + // codeVerifier: 'your_pkce_code_verifier', + ), + ); + print('Logged in. ID token: ${token.idToken}'); + } on AuthorizationException catch (e) { + print('Login failed: ${e.message}'); + } +} +``` diff --git a/skills/oauth-chopper-storage/SKILL.md b/skills/oauth-chopper-storage/SKILL.md new file mode 100644 index 0000000..f8e06ab --- /dev/null +++ b/skills/oauth-chopper-storage/SKILL.md @@ -0,0 +1,175 @@ +--- +name: oauth-chopper-storage +description: Implement persistent token storage for `oauth_chopper` by creating a custom `OAuthStorage` class. Use when asked to "persist OAuth tokens", "save OAuth credentials", "implement OAuthStorage", "store tokens securely", or "oauth_chopper storage". +--- +# Implementing Persistent OAuth Token Storage + +## Contents +- [Why Custom Storage](#why-custom-storage) +- [The OAuthStorage Interface](#the-oauthstorage-interface) +- [Choosing a Storage Backend](#choosing-a-storage-backend) +- [Workflow: Implement Custom OAuthStorage](#workflow-implement-custom-oauthstorage) +- [Examples](#examples) + +## Why Custom Storage +Understand when and why the default in-memory storage must be replaced. + +* `oauth_chopper` uses `MemoryStorage` by default. Tokens are lost when the app restarts or the process ends. +* For any production application, implement a custom `OAuthStorage` to persist credentials across app launches. +* The storage handles raw credential JSON strings. The `oauth2` package serializes and deserializes the full credential payload (access token, refresh token, expiration, scopes, etc.). +* Storage is called automatically: `saveCredentials` on successful grant or refresh, `clear` when an `AuthorizationException` occurs during refresh. + +## The OAuthStorage Interface +The `OAuthStorage` abstract interface defines three methods that must be implemented. + +* **`FutureOr fetchCredentials()`** -- Return the stored credentials JSON string, or `null` if no credentials are stored. Called before every request by the interceptor. +* **`FutureOr saveCredentials(String? credentialsJson)`** -- Persist the credentials JSON string. Called after a successful `requestGrant` or `refresh`. The value may be `null`. +* **`FutureOr clear()`** -- Delete all stored credentials. Called when the authorization server rejects a refresh attempt. +* All methods support both synchronous and asynchronous return types via `FutureOr`. + +## Choosing a Storage Backend +Select the appropriate persistence mechanism based on the platform and security requirements. + +* **Mobile apps (iOS/Android):** Use `flutter_secure_storage` for encrypted storage backed by the platform keychain/keystore. This is the recommended approach for apps handling sensitive OAuth tokens. +* **Mobile/desktop apps (less sensitive):** Use `shared_preferences` for simple key-value storage. Suitable when tokens are short-lived or the threat model is low. +* **Web apps:** Use `shared_preferences_web` (backed by `localStorage`) or a secure cookie-based approach. Be aware that `localStorage` is accessible to JavaScript and vulnerable to XSS. +* **Server-side Dart:** Use file-based storage, a database, or an in-memory cache with persistence (e.g., Redis). Implement the storage interface around your preferred backend. +* Never log or print credential JSON in production. + +## Workflow: Implement Custom OAuthStorage + +Follow this sequential workflow to create and integrate a persistent storage implementation. + +**Task Progress:** +- [ ] 1. Add the storage backend dependency (e.g., `dart pub add flutter_secure_storage` or `dart pub add shared_preferences`). +- [ ] 2. Create a new class that implements `OAuthStorage`. +- [ ] 3. Implement `fetchCredentials()` to read the stored JSON string from the backend. +- [ ] 4. Implement `saveCredentials(String? credentialsJson)` to write the JSON string. Handle `null` values by deleting the entry. +- [ ] 5. Implement `clear()` to delete the stored credentials. +- [ ] 6. Pass the custom storage to `OAuthChopper(storage: MyCustomStorage(...))`. +- [ ] 7. **Feedback Loop**: Run `dart analyze` -> review for missing imports or async issues -> fix -> re-run until clean. + +## Examples + +### Secure Storage with flutter_secure_storage +Recommended for mobile apps. Credentials are encrypted using the platform keychain (iOS) or keystore (Android). + +```dart +import 'dart:async'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; + +/// Persists OAuth credentials using encrypted platform storage. +class SecureOAuthStorage implements OAuthStorage { + const SecureOAuthStorage(this._storage); + + static const _key = 'oauth_credentials'; + final FlutterSecureStorage _storage; + + @override + FutureOr fetchCredentials() async { + return _storage.read(key: _key); + } + + @override + FutureOr saveCredentials(String? credentialsJson) async { + if (credentialsJson == null) { + await _storage.delete(key: _key); + } else { + await _storage.write(key: _key, value: credentialsJson); + } + } + + @override + FutureOr clear() async { + await _storage.delete(key: _key); + } +} +``` + +Usage: + +```dart +import 'package:chopper/chopper.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; + +void main() { + final storage = SecureOAuthStorage(const FlutterSecureStorage()); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + identifier: 'my_client_id', + secret: 'my_client_secret', + storage: storage, // Credentials persist across app restarts. + ); + + final chopperClient = ChopperClient( + baseUrl: Uri.parse('https://api.example.com'), + interceptors: [oauthChopper.interceptor()], + ); +} +``` + +### Simple Storage with shared_preferences +Suitable for less sensitive scenarios or when encrypted storage is not needed. + +```dart +import 'dart:async'; + +import 'package:oauth_chopper/oauth_chopper.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Persists OAuth credentials using SharedPreferences. +class PrefsOAuthStorage implements OAuthStorage { + const PrefsOAuthStorage(this._prefs); + + static const _key = 'oauth_credentials'; + final SharedPreferences _prefs; + + @override + FutureOr fetchCredentials() { + // SharedPreferences is synchronous for reads. + return _prefs.getString(_key); + } + + @override + FutureOr saveCredentials(String? credentialsJson) async { + if (credentialsJson == null) { + await _prefs.remove(_key); + } else { + await _prefs.setString(_key, credentialsJson); + } + } + + @override + FutureOr clear() async { + await _prefs.remove(_key); + } +} +``` + +Usage: + +```dart +import 'package:chopper/chopper.dart'; +import 'package:oauth_chopper/oauth_chopper.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future main() async { + final prefs = await SharedPreferences.getInstance(); + final storage = PrefsOAuthStorage(prefs); + + final oauthChopper = OAuthChopper( + authorizationEndpoint: Uri.parse('https://auth.example.com/oauth/token'), + identifier: 'my_client_id', + storage: storage, // Credentials persist across app restarts. + ); + + final chopperClient = ChopperClient( + baseUrl: Uri.parse('https://api.example.com'), + interceptors: [oauthChopper.interceptor()], + ); +} +```