Skip to content

Commit

Permalink
fix: token refresh doesn't block on ClientException (#660)
Browse files Browse the repository at this point in the history
  • Loading branch information
Vinzent03 committed Oct 7, 2023
1 parent fa341bf commit a5ef8b7
Show file tree
Hide file tree
Showing 2 changed files with 42 additions and 19 deletions.
35 changes: 23 additions & 12 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ part 'gotrue_mfa_api.dart';
/// [asyncStorage] local storage to store pkce code verifiers. Required when using the pkce flow.
///
/// Set [flowType] to [AuthFlowType.implicit] to perform old implicit auth flow.
/// /// {@endtemplate}
/// {@endtemplate}
class GoTrueClient {
/// Namespace for the GoTrue API methods.
/// These can be used for example to get a user from a JWT in a server environment or reset a user's password.
Expand Down Expand Up @@ -58,8 +58,6 @@ class GoTrueClient {
/// Completer to combine multiple simultaneous token refresh requests.
Completer<AuthResponse>? _refreshTokenCompleter;

bool _isRefreshingToken = false;

final _onAuthStateChangeController = BehaviorSubject<AuthState>();
final _onAuthStateChangeControllerSync =
BehaviorSubject<AuthState>(sync: true);
Expand Down Expand Up @@ -908,17 +906,25 @@ class GoTrueClient {

/// Generates a new JWT.
///
/// To prevent multiple simultaneous requests it catches an already ongoing requests by using the global [_refreshTokenCompleter]. If that's not null and not completed it returns the future that the ongoing request.
/// To prevent multiple simultaneous requests it catches an already ongoing request by using the global [_refreshTokenCompleter].
/// If that's not null and not completed it returns the future of the ongoing request.
///
/// To call [_callRefreshToken] during a running request [ignorePendingRequest] is used to bypass that check.
///
/// To be able to call [_callRefreshToken] again after a [SocketException] and not get trapped by the ongoing request, [ignorePendingRequest] is used to bypass that check.
/// When a [ClientException] occurs [_setTokenRefreshTimer] is used to schedule a retry in the background, which emits the result via [onAuthStateChange].
Future<AuthResponse> _callRefreshToken({
String? refreshToken,
String? accessToken,
bool ignorePendingRequest = false,
}) async {
if (_refreshTokenCompleter?.isCompleted ?? true) {
_refreshTokenCompleter = Completer<AuthResponse>();
} else if (!ignorePendingRequest && _isRefreshingToken) {
// Catch any error in case nobody awaits the future
_refreshTokenCompleter!.future.then(
(value) => null,
onError: (error, stack) => null,
);
} else if (!ignorePendingRequest) {
return _refreshTokenCompleter!.future;
}
final token = refreshToken ?? currentSession?.refreshToken;
Expand All @@ -929,7 +935,6 @@ class GoTrueClient {
final jwt = accessToken ?? currentSession?.accessToken;

try {
_isRefreshingToken = true;
final body = {'refresh_token': token};
if (jwt != null) {
_headers['Authorization'] = 'Bearer $jwt';
Expand All @@ -947,27 +952,33 @@ class GoTrueClient {
}

_saveSession(authResponse.session!);
if (!_refreshTokenCompleter!.isCompleted) {
_refreshTokenCompleter!.complete(authResponse);
}

notifyAllSubscribers(AuthChangeEvent.tokenRefreshed);
_refreshTokenCompleter!.complete(authResponse);
return authResponse;
} on SocketException {
} on SocketException catch (e, stack) {
_setTokenRefreshTimer(
Constants.retryInterval * pow(2, _refreshTokenRetryCount),
refreshToken: token,
accessToken: accessToken,
);
return _refreshTokenCompleter!.future;
if (!_refreshTokenCompleter!.isCompleted) {
_refreshTokenCompleter!.completeError(e, stack);
}
rethrow;
} catch (error, stack) {
if (error is AuthException) {
if (error.message == 'Invalid Refresh Token: Refresh Token Not Found') {
await signOut();
}
}
if (!_refreshTokenCompleter!.isCompleted) {
_refreshTokenCompleter!.completeError(error, stack);
}
_onAuthStateChangeController.addError(error, stack);
rethrow;
} finally {
_isRefreshingToken = false;
}
}

Expand Down
26 changes: 19 additions & 7 deletions packages/gotrue/test/client_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';

import 'package:dotenv/dotenv.dart';
import 'package:gotrue/gotrue.dart';
Expand Down Expand Up @@ -360,22 +361,25 @@ void main() {
'{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODAzNDE3MDUsInN1YiI6IjRkMjU4M2RhLThkZTQtNDlkMy05Y2QxLTM3YTlhNzRmNTViZCIsImVtYWlsIjoiZmFrZTE2ODAzMzgxMDVAZW1haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJIZWxsbyI6IldvcmxkIn0sInJvbGUiOiIiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY4MDMzODEwNX1dLCJzZXNzaW9uX2lkIjoiYzhiOTg2Y2UtZWJkZC00ZGUxLWI4MjAtZjIyOWYyNjg1OGIwIn0.0x1rFlPKbIU1rZPY1SH_FNSZaXerfkFA1Y-EOlhuzUs","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"},"expiresAt":1680341705}';

///These 3 are bundled and in sum 4 refresh token requests are made, because the first 3 fail in [RetryTestHttpClient]
await Future.wait([
final future1 = Future.wait([
client.recoverSession(session),
client.recoverSession(session),
client.recoverSession(session),
]);

expect(httpClient.retryCount, 4);
await expectLater(future1, throwsA(isA<SocketException>()));
expect(httpClient.retryCount, 1);

/// Again these 3 are bundled and only one refresh token request is made
await Future.wait([
final future2 = Future.wait([
client.recoverSession(session),
client.recoverSession(session),
client.recoverSession(session),
]);

expect(httpClient.retryCount, 5);
await expectLater(future2, throwsA(isA<SocketException>()));
expect(client.onAuthStateChange, emits(isA<AuthState>()));
expect(httpClient.retryCount, 2);
});

test('Sign out on wrong refresh token', () async {
Expand Down Expand Up @@ -437,9 +441,17 @@ void main() {
});

test('Session recovery succeeds after retries', () async {
await client.recoverSession(
'{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODAzNDE3MDUsInN1YiI6IjRkMjU4M2RhLThkZTQtNDlkMy05Y2QxLTM3YTlhNzRmNTViZCIsImVtYWlsIjoiZmFrZTE2ODAzMzgxMDVAZW1haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJIZWxsbyI6IldvcmxkIn0sInJvbGUiOiIiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY4MDMzODEwNX1dLCJzZXNzaW9uX2lkIjoiYzhiOTg2Y2UtZWJkZC00ZGUxLWI4MjAtZjIyOWYyNjg1OGIwIn0.0x1rFlPKbIU1rZPY1SH_FNSZaXerfkFA1Y-EOlhuzUs","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"},"expiresAt":1680341705}');
expect(httpClient.retryCount, 4);
try {
await client.recoverSession(
'{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODAzNDE3MDUsInN1YiI6IjRkMjU4M2RhLThkZTQtNDlkMy05Y2QxLTM3YTlhNzRmNTViZCIsImVtYWlsIjoiZmFrZTE2ODAzMzgxMDVAZW1haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJIZWxsbyI6IldvcmxkIn0sInJvbGUiOiIiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY4MDMzODEwNX1dLCJzZXNzaW9uX2lkIjoiYzhiOTg2Y2UtZWJkZC00ZGUxLWI4MjAtZjIyOWYyNjg1OGIwIn0.0x1rFlPKbIU1rZPY1SH_FNSZaXerfkFA1Y-EOlhuzUs","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"},"expiresAt":1680341705}');
} on SocketException {
// the method should throw
}
await for (final AuthState event in client.onAuthStateChange) {
expect(httpClient.retryCount, 4);
expect(event.event, AuthChangeEvent.tokenRefreshed);
break;
}
});
});

Expand Down

0 comments on commit a5ef8b7

Please sign in to comment.