Skip to content

Commit c5217c9

Browse files
authored
Merge pull request #33 from DutchCodingCompany/chore/maintenance
Maintenance
2 parents e8ef31a + 79fd9ae commit c5217c9

18 files changed

Lines changed: 1442 additions & 108 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
## 1.2.0
2+
- Added concurrent refresh protection: multiple simultaneous 401 responses now share a single refresh call instead of issuing redundant refresh requests.
3+
- Added proactive token refresh: the interceptor now checks `OAuthToken.isExpired` before making the request and refreshes preemptively, avoiding unnecessary 401 round-trips.
4+
- Added `==`, `hashCode`, and `toString()` to `OAuthToken` for value-based equality and easier debugging.
5+
- Exported `OnErrorCallback` and `MemoryStorage`
6+
- Fixed exports for `oauth2` package
7+
- Added skills for: setup and storage
8+
- Updated dependencies:
9+
- `sdk` to `^3.7.0`
10+
- `meta` added
11+
- `very_good_analysis` to `10.2.0`
12+
113
## 1.1.1
214
- Updated dependencies:
315
- `sdk` to `^3.6.1`

analysis_options.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
include: package:very_good_analysis/analysis_options.yaml
22

3+
formatter:
4+
trailing_commas: automate
5+
6+
linter:
7+
rules:
8+
require_trailing_commas: false
39
analyzer:
410
errors:
5-
unawaited_futures: warning
6-
avoid_void_async: warning
711
missing_return: error
812
missing_required_param: error
9-
invalid_annotation_target: info
1013

1114
language:
1215
strict-casts: true

example/oauth_chopper_example.dart

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import 'package:chopper/chopper.dart';
66
import 'package:oauth_chopper/oauth_chopper.dart';
77

8-
void main() {
8+
Future<void> main() async {
99
final authorizationEndpoint = Uri.parse('https://example.com/oauth');
1010
final identifier = 'id';
1111
final secret = 'secret';
@@ -20,16 +20,11 @@ void main() {
2020
/// Add the oauth_chopper interceptor to the chopper client.
2121
final chopperClient = ChopperClient(
2222
baseUrl: Uri.parse('https://example.com'),
23-
interceptors: [
24-
oauthChopper.interceptor(),
25-
],
23+
interceptors: [oauthChopper.interceptor()],
2624
);
2725

2826
/// Request grant
29-
oauthChopper.requestGrant(
30-
ResourceOwnerPasswordGrant(
31-
username: 'username',
32-
password: 'password',
33-
),
27+
await oauthChopper.requestGrant(
28+
ResourceOwnerPasswordGrant(username: 'username', password: 'password'),
3429
);
3530
}

lib/oauth_chopper.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
/// More dartdocs go here.
44
library;
55

6-
export 'package:oauth2/src/authorization_exception.dart';
7-
export 'package:oauth2/src/expiration_exception.dart';
6+
export 'package:oauth2/oauth2.dart'
7+
show AuthorizationException, ExpirationException;
88

99
export 'src/oauth_chopper.dart';
1010
export 'src/oauth_grant.dart';
11+
export 'src/oauth_interceptor.dart' show OnErrorCallback;
1112
export 'src/oauth_token.dart';
13+
export 'src/storage/memory_storage.dart';
1214
export 'src/storage/oauth_storage.dart';

lib/src/extensions/request.dart

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import 'package:chopper/chopper.dart';
33
/// Helper extension to easily apply a authorization header to a request.
44
extension ChopperRequest on Request {
55
/// Adds a authorization header with a bearer [token] to the request.
6-
Request addAuthorizationHeader(String token) => applyHeader(
7-
this,
8-
'Authorization',
9-
'Bearer $token',
10-
);
6+
Request addAuthorizationHeader(String token) =>
7+
applyHeader(this, 'Authorization', 'Bearer $token');
118
}

lib/src/oauth_chopper.dart

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ class OAuthChopper {
7878
/// The function used to parse parameters from a host's response.
7979
/// Will be passed to [oauth2].
8080
final Map<String, dynamic> Function(MediaType? contentType, String body)?
81-
getParameters;
81+
getParameters;
82+
83+
/// Tracks the in-flight refresh operation to prevent concurrent refresh
84+
/// attempts. When multiple requests receive a 401 simultaneously, they
85+
/// all await the same [Completer] rather than issuing separate refresh calls.
86+
Completer<OAuthToken?>? _refreshCompleter;
8287

8388
/// Get stored [OAuthToken].
8489
Future<OAuthToken?> get token async {
@@ -91,13 +96,15 @@ class OAuthChopper {
9196
/// Provides an [OAuthInterceptor] instance.
9297
/// If [onError] is provided exceptions will be passed to [onError] and not be
9398
/// thrown.
94-
OAuthInterceptor interceptor({
95-
OnErrorCallback? onError,
96-
}) =>
99+
OAuthInterceptor interceptor({OnErrorCallback? onError}) =>
97100
OAuthInterceptor(this, onError);
98101

99102
/// Tries to refresh the available credentials and returns a new [OAuthToken]
100103
/// instance.
104+
///
105+
/// If a refresh is already in-flight, subsequent callers will await the same
106+
/// result rather than issuing concurrent refresh requests.
107+
///
101108
/// Throws an exception when refreshing fails. If the exception is a
102109
/// [oauth2.AuthorizationException] it clears the storage.
103110
///
@@ -106,6 +113,34 @@ class OAuthChopper {
106113
Future<OAuthToken?> refresh({
107114
bool basicAuth = true,
108115
Iterable<String>? newScopes,
116+
}) {
117+
// If a refresh is already in-flight, await the existing operation.
118+
if (_refreshCompleter != null) {
119+
return _refreshCompleter!.future;
120+
}
121+
122+
_refreshCompleter = Completer<OAuthToken?>();
123+
124+
unawaited(
125+
_performRefresh(basicAuth: basicAuth, newScopes: newScopes).then(
126+
(result) {
127+
_refreshCompleter!.complete(result);
128+
_refreshCompleter = null;
129+
},
130+
onError: (Object e, StackTrace s) {
131+
_refreshCompleter!.completeError(e, s);
132+
_refreshCompleter = null;
133+
},
134+
),
135+
);
136+
137+
return _refreshCompleter!.future;
138+
}
139+
140+
/// Internal method that performs the actual credential refresh.
141+
Future<OAuthToken?> _performRefresh({
142+
required bool basicAuth,
143+
Iterable<String>? newScopes,
109144
}) async {
110145
final credentialsJson = await _storage.fetchCredentials();
111146
if (credentialsJson == null) return null;
@@ -121,7 +156,7 @@ class OAuthChopper {
121156
await _storage.saveCredentials(newCredentials.toJson());
122157
return OAuthToken.fromCredentials(newCredentials);
123158
} on oauth2.AuthorizationException {
124-
_storage.clear();
159+
await _storage.clear();
125160
rethrow;
126161
}
127162
}

lib/src/oauth_grant.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ abstract interface class OAuthGrant {
2929
bool basicAuth = true,
3030
String? delimiter,
3131
Map<String, dynamic> Function(MediaType? contentType, String body)?
32-
getParameters,
32+
getParameters,
3333
});
3434
}
3535

@@ -69,7 +69,7 @@ class ResourceOwnerPasswordGrant implements OAuthGrant {
6969
bool basicAuth = true,
7070
String? delimiter,
7171
Map<String, dynamic> Function(MediaType? contentType, String body)?
72-
getParameters,
72+
getParameters,
7373
}) async {
7474
final client = await oauth2.resourceOwnerPasswordGrant(
7575
authorizationEndpoint,
@@ -105,7 +105,7 @@ class ClientCredentialsGrant implements OAuthGrant {
105105
bool basicAuth = true,
106106
String? delimiter,
107107
Map<String, dynamic> Function(MediaType? contentType, String body)?
108-
getParameters,
108+
getParameters,
109109
}) async {
110110
final client = await oauth2.clientCredentialsGrant(
111111
authorizationEndpoint,
@@ -173,7 +173,7 @@ class AuthorizationCodeGrant implements OAuthGrant {
173173
bool basicAuth = true,
174174
String? delimiter,
175175
Map<String, dynamic> Function(MediaType? contentType, String body)?
176-
getParameters,
176+
getParameters,
177177
}) async {
178178
final grant = oauth2.AuthorizationCodeGrant(
179179
identifier,

lib/src/oauth_interceptor.dart

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ typedef OnErrorCallback = void Function(Object, StackTrace);
1414
/// available no header is added.
1515
/// Its added as a Bearer token.
1616
///
17-
/// When the provided credentials are invalid it tries to refresh them.
17+
/// When the token is expired it tries to proactively refresh before making the
18+
/// request. When the server responds with 401 it also tries to refresh once.
1819
/// Can throw a exceptions if no [onError] is passed. When [onError] is passed
1920
/// exception will be passed to [onError]
2021
/// {@endtemplate}
@@ -32,9 +33,26 @@ class OAuthInterceptor implements Interceptor {
3233
FutureOr<Response<BodyType>> intercept<BodyType>(
3334
Chain<BodyType> chain,
3435
) async {
35-
// Add oauth token to the request.
36-
final token = await oauthChopper.token;
36+
// Get the current token.
37+
var token = await oauthChopper.token;
38+
39+
// If the token is expired, proactively refresh before making the request.
40+
if (token != null && token.isExpired) {
41+
try {
42+
final refreshed = await oauthChopper.refresh();
43+
if (refreshed != null) {
44+
token = refreshed;
45+
}
46+
} catch (e, s) {
47+
if (onError != null) {
48+
onError!(e, s);
49+
} else {
50+
rethrow;
51+
}
52+
}
53+
}
3754

55+
// Add oauth token to the request.
3856
final Request request;
3957
if (token == null) {
4058
request = chain.request;
@@ -50,13 +68,14 @@ class OAuthInterceptor implements Interceptor {
5068
try {
5169
final credentials = await oauthChopper.refresh();
5270
if (credentials != null) {
53-
final request =
54-
chain.request.addAuthorizationHeader(credentials.accessToken);
55-
return chain.proceed(request);
71+
final retryRequest = chain.request.addAuthorizationHeader(
72+
credentials.accessToken,
73+
);
74+
return chain.proceed(retryRequest);
5675
}
5776
} catch (e, s) {
5877
if (onError != null) {
59-
onError?.call(e, s);
78+
onError!(e, s);
6079
} else {
6180
rethrow;
6281
}

lib/src/oauth_token.dart

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import 'package:meta/meta.dart';
12
import 'package:oauth2/oauth2.dart';
23

34
/// {@template oauth_token}
45
/// A wrapper around [Credentials] to provide a more convenient API.
56
/// {@endtemplate}
7+
@immutable
68
class OAuthToken {
79
/// {@macro oauth_token}
810
const OAuthToken._(
@@ -22,11 +24,11 @@ class OAuthToken {
2224
/// Creates a new instance of [OAuthToken] from [Credentials].
2325
/// {@macro oauth_token}
2426
factory OAuthToken.fromCredentials(Credentials credentials) => OAuthToken._(
25-
credentials.accessToken,
26-
credentials.refreshToken,
27-
credentials.expiration,
28-
credentials.idToken,
29-
);
27+
credentials.accessToken,
28+
credentials.refreshToken,
29+
credentials.expiration,
30+
credentials.idToken,
31+
);
3032

3133
/// The token that is sent to the resource server to prove the authorization
3234
/// of a client.
@@ -56,8 +58,27 @@ class OAuthToken {
5658

5759
/// Whether the token is expired.
5860
bool get isExpired =>
59-
expiration != null &&
60-
DateTime.now().isAfter(
61-
expiration!,
62-
);
61+
expiration != null && DateTime.now().isAfter(expiration!);
62+
63+
@override
64+
bool operator ==(Object other) =>
65+
identical(this, other) ||
66+
other is OAuthToken &&
67+
runtimeType == other.runtimeType &&
68+
accessToken == other.accessToken &&
69+
refreshToken == other.refreshToken &&
70+
expiration == other.expiration &&
71+
idToken == other.idToken;
72+
73+
@override
74+
int get hashCode =>
75+
Object.hash(accessToken, refreshToken, expiration, idToken);
76+
77+
@override
78+
String toString() =>
79+
'OAuthToken('
80+
'accessToken: $accessToken, '
81+
'refreshToken: $refreshToken, '
82+
'expiration: $expiration, '
83+
'idToken: $idToken)';
6384
}

pubspec.yaml

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
name: oauth_chopper
22
description: Add and manage OAuth2 authentication for your Chopper client.
3-
version: 1.1.1
4-
homepage: https://github.com/DutchCodingCompany/oauth_chopper
3+
version: 1.2.0
4+
repository: https://github.com/DutchCodingCompany/oauth_chopper
5+
topics:
6+
- http
7+
- chopper
8+
- oauth2
9+
- authentication
510

611
environment:
7-
sdk: ^3.6.1
12+
sdk: ^3.7.0
813

914
dependencies:
10-
chopper: ^8.0.4
11-
http: ^1.3.0
15+
chopper: ^8.6.0
16+
http: ^1.6.0
1217
http_parser: ^4.1.2
13-
oauth2: ^2.0.3
18+
meta: ^1.18.3
19+
oauth2: ^2.0.5
1420

1521
dev_dependencies:
16-
mocktail: ^1.0.4
17-
test: ^1.25.14
18-
very_good_analysis: ^7.0.0
22+
mocktail: ^1.0.5
23+
test: ^1.31.1
24+
very_good_analysis: ^10.2.0

0 commit comments

Comments
 (0)