From 06cc911be526276a71ea43e259a98dc87df7b279 Mon Sep 17 00:00:00 2001 From: Erick Date: Fri, 14 Jun 2024 13:56:03 -0300 Subject: [PATCH] feat: better handling of errors when refreshing the token fails (#2244) --- packages/shorebird_cli/lib/src/auth/auth.dart | 32 +++++- packages/shorebird_cli/pubspec.lock | 8 +- .../test/src/auth/auth_test.dart | 106 ++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index 6cc77bf0..cc9d19b8 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -9,6 +9,7 @@ import 'package:googleapis_auth/auth_io.dart' as oauth2; import 'package:googleapis_auth/googleapis_auth.dart'; import 'package:http/http.dart' as http; import 'package:jwt/jwt.dart'; +import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/auth/ci_token.dart'; @@ -18,6 +19,7 @@ import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; export 'ci_token.dart'; @@ -115,7 +117,7 @@ class AuthenticatedClient extends http.BaseClient { if (credentials == null) { final token = _token!; - credentials = _credentials = await _refreshCredentials( + credentials = _credentials = await _tryRefreshCredentials( token.authProvider.clientId, oauth2.AccessCredentials( // This isn't relevant for a refresh operation. @@ -133,7 +135,7 @@ class AuthenticatedClient extends http.BaseClient { final jwt = Jwt.parse(credentials.idToken!); final authProvider = jwt.authProvider; - credentials = _credentials = await _refreshCredentials( + credentials = _credentials = await _tryRefreshCredentials( authProvider.clientId, credentials, _baseClient, @@ -146,6 +148,32 @@ class AuthenticatedClient extends http.BaseClient { request.headers['Authorization'] = 'Bearer $token'; return _baseClient.send(request); } + + Future _tryRefreshCredentials( + oauth2.ClientId clientId, + oauth2.AccessCredentials credentials, + http.Client client, { + required oauth2.AuthEndpoints authEndpoints, + }) async { + try { + return await _refreshCredentials( + clientId, + credentials, + client, + authEndpoints: authEndpoints, + ); + } catch (e, s) { + logger + ..err('Failed to refresh credentials.') + ..info( + '''Try logging out with ${lightBlue.wrap('shorebird logout')} and logging in again.''', + ) + ..detail(e.toString()) + ..detail(s.toString()); + + throw ProcessExit(ExitCode.software.code); + } + } } /// An OAuth 2.0 authentication provider. diff --git a/packages/shorebird_cli/pubspec.lock b/packages/shorebird_cli/pubspec.lock index 6a2698b7..f46f659c 100644 --- a/packages/shorebird_cli/pubspec.lock +++ b/packages/shorebird_cli/pubspec.lock @@ -202,10 +202,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" convert: dependency: transitive description: @@ -791,10 +791,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "360c4271613beb44db559547d02f8b0dc044741d0eeb9aa6ccdb47e8ec54c63a" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.3" + version: "14.2.4" watcher: dependency: transitive description: diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 2f99c1ad..3bb1492d 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -6,6 +6,7 @@ import 'package:googleapis_auth/auth_io.dart'; import 'package:googleapis_auth/googleapis_auth.dart' as oauth2; import 'package:http/http.dart' as http; import 'package:jwt/jwt.dart' show Jwt, JwtPayload; +import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; @@ -19,6 +20,7 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; import '../fakes.dart'; +import '../matchers.dart'; import '../mocks.dart'; const googleJwtIssuer = 'https://accounts.google.com'; @@ -266,6 +268,52 @@ void main() { expect(request.headers['Authorization'], equals('Bearer $idToken')); }); + group('when refreshing the token fails', () { + late AuthenticatedClient client; + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.badRequest, + ), + ); + + final onRefreshCredentialsCalls = []; + + client = AuthenticatedClient.token( + token: ciToken, + httpClient: httpClient, + onRefreshCredentials: onRefreshCredentialsCalls.add, + refreshCredentials: ( + clientId, + credentials, + client, { + AuthEndpoints authEndpoints = const GoogleAuthEndpoints(), + }) async => + throw Exception('error.'), + ); + }); + + test('exits and logs correctly', () async { + await expectLater( + () => runWithOverrides( + () => client.get(Uri.parse('https://example.com')), + ), + exitsWithCode(ExitCode.software), + ); + verify(() => logger.err('Failed to refresh credentials.')) + .called(1); + verify( + () => logger.info( + '''Try logging out with ${lightBlue.wrap('shorebird logout')} and logging in again.''', + ), + ).called(1); + verify( + () => logger.detail('Exception: error.'), + ).called(1); + }); + }); + test('uses valid token when credentials valid.', () async { when(() => httpClient.send(any())).thenAnswer( (_) async => http.StreamedResponse( @@ -358,6 +406,64 @@ void main() { expect(request.headers['Authorization'], equals('Bearer $idToken')); }); + group('when refreshing the token fails', () { + late AuthenticatedClient client; + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.badRequest, + ), + ); + + const expiredIdToken = + '''eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMjM0NSIsImhkIjoic2hvcmViaXJkLmRldiIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWF0IjoxMjM0LCJleHAiOjY3ODl9.MYbITALvKsGYTYjw1o7AQ0ObkqRWVBSr9cFYJrvA46g'''; + final onRefreshCredentialsCalls = []; + final expiredCredentials = oauth2.AccessCredentials( + oauth2.AccessToken( + 'Bearer', + 'accessToken', + DateTime.now().subtract(const Duration(minutes: 1)).toUtc(), + ), + '', + [], + idToken: expiredIdToken, + ); + + client = AuthenticatedClient.credentials( + credentials: expiredCredentials, + httpClient: httpClient, + onRefreshCredentials: onRefreshCredentialsCalls.add, + refreshCredentials: ( + clientId, + credentials, + client, { + AuthEndpoints authEndpoints = const GoogleAuthEndpoints(), + }) async => + throw Exception('error.'), + ); + }); + + test('exits and logs correctly', () async { + await expectLater( + () => runWithOverrides( + () => client.get(Uri.parse('https://example.com')), + ), + exitsWithCode(ExitCode.software), + ); + verify(() => logger.err('Failed to refresh credentials.')) + .called(1); + verify( + () => logger.info( + '''Try logging out with ${lightBlue.wrap('shorebird logout')} and logging in again.''', + ), + ).called(1); + verify( + () => logger.detail('Exception: error.'), + ).called(1); + }); + }); + test('uses valid token when credentials valid.', () async { when(() => httpClient.send(any())).thenAnswer( (_) async => http.StreamedResponse(