Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## 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
- Added skills for: setup and storage
- 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`
Expand Down
9 changes: 6 additions & 3 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 4 additions & 9 deletions example/oauth_chopper_example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import 'package:chopper/chopper.dart';
import 'package:oauth_chopper/oauth_chopper.dart';

void main() {
Future<void> main() async {
final authorizationEndpoint = Uri.parse('https://example.com/oauth');
final identifier = 'id';
final secret = 'secret';
Expand All @@ -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'),
);
}
6 changes: 4 additions & 2 deletions lib/oauth_chopper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
7 changes: 2 additions & 5 deletions lib/src/extensions/request.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
45 changes: 40 additions & 5 deletions lib/src/oauth_chopper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ class OAuthChopper {
/// The function used to parse parameters from a host's response.
/// Will be passed to [oauth2].
final Map<String, dynamic> 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<OAuthToken?>? _refreshCompleter;

/// Get stored [OAuthToken].
Future<OAuthToken?> get token async {
Expand All @@ -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.
///
Expand All @@ -106,6 +113,34 @@ class OAuthChopper {
Future<OAuthToken?> refresh({
bool basicAuth = true,
Iterable<String>? newScopes,
}) {
// If a refresh is already in-flight, await the existing operation.
if (_refreshCompleter != null) {
return _refreshCompleter!.future;
}

_refreshCompleter = Completer<OAuthToken?>();

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.
Future<OAuthToken?> _performRefresh({
required bool basicAuth,
Iterable<String>? newScopes,
}) async {
final credentialsJson = await _storage.fetchCredentials();
if (credentialsJson == null) return null;
Expand All @@ -121,7 +156,7 @@ class OAuthChopper {
await _storage.saveCredentials(newCredentials.toJson());
return OAuthToken.fromCredentials(newCredentials);
} on oauth2.AuthorizationException {
_storage.clear();
await _storage.clear();
rethrow;
}
}
Expand Down
8 changes: 4 additions & 4 deletions lib/src/oauth_grant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ abstract interface class OAuthGrant {
bool basicAuth = true,
String? delimiter,
Map<String, dynamic> Function(MediaType? contentType, String body)?
getParameters,
getParameters,
});
}

Expand Down Expand Up @@ -69,7 +69,7 @@ class ResourceOwnerPasswordGrant implements OAuthGrant {
bool basicAuth = true,
String? delimiter,
Map<String, dynamic> Function(MediaType? contentType, String body)?
getParameters,
getParameters,
}) async {
final client = await oauth2.resourceOwnerPasswordGrant(
authorizationEndpoint,
Expand Down Expand Up @@ -105,7 +105,7 @@ class ClientCredentialsGrant implements OAuthGrant {
bool basicAuth = true,
String? delimiter,
Map<String, dynamic> Function(MediaType? contentType, String body)?
getParameters,
getParameters,
}) async {
final client = await oauth2.clientCredentialsGrant(
authorizationEndpoint,
Expand Down Expand Up @@ -173,7 +173,7 @@ class AuthorizationCodeGrant implements OAuthGrant {
bool basicAuth = true,
String? delimiter,
Map<String, dynamic> Function(MediaType? contentType, String body)?
getParameters,
getParameters,
}) async {
final grant = oauth2.AuthorizationCodeGrant(
identifier,
Expand Down
33 changes: 26 additions & 7 deletions lib/src/oauth_interceptor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -32,9 +33,26 @@ class OAuthInterceptor implements Interceptor {
FutureOr<Response<BodyType>> intercept<BodyType>(
Chain<BodyType> 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;
Expand All @@ -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;
}
Expand Down
39 changes: 30 additions & 9 deletions lib/src/oauth_token.dart
Original file line number Diff line number Diff line change
@@ -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._(
Expand All @@ -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.
Expand Down Expand Up @@ -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)';
}
24 changes: 15 additions & 9 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
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.2.0
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
meta: ^1.18.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
Loading
Loading