From 978381152dced363286457f4174036ee4a1fc061 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 22 Feb 2024 10:00:52 -0500 Subject: [PATCH] feat: add AuthProvider (#1743) --- packages/shorebird_cli/lib/src/auth/auth.dart | 110 ++++++++++---- .../providers/microsoft_auth_provider.dart | 12 ++ .../lib/src/auth/providers/providers.dart | 2 + .../lib/src/commands/login_ci_command.dart | 2 +- .../lib/src/commands/login_command.dart | 3 +- packages/shorebird_cli/pubspec.lock | 22 +-- .../test/src/auth/auth_test.dart | 139 +++++++++++++++--- .../microsoft_auth_provider_test.dart | 12 ++ .../src/commands/login_ci_command_test.dart | 38 ++++- .../test/src/commands/login_command_test.dart | 43 +++++- packages/shorebird_cli/test/src/mocks.dart | 5 + third_party/googleapis_auth/lib/auth_io.dart | 91 ++---------- .../googleapis_auth/lib/googleapis_auth.dart | 1 + .../googleapis_auth/lib/src/adc_utils.dart | 7 +- .../lib/src/auth_functions.dart | 23 +-- .../lib/src/auth_http_utils.dart | 15 +- .../lib/src/auth_provider.dart | 14 ++ .../lib/src/oauth2_flows/auth_code.dart | 10 +- ...uthorization_code_grant_abstract_flow.dart | 6 + .../authorization_code_grant_manual_flow.dart | 2 + .../authorization_code_grant_server_flow.dart | 2 + .../lib/src/oauth2_flows/jwt.dart | 14 +- .../googleapis_auth/lib/src/utils.dart | 11 +- .../googleapis_auth/test/adc_test.dart | 4 + .../test/oauth2_flows/auth_code_test.dart | 91 ++++++++++++ .../googleapis_auth/test/oauth2_test.dart | 17 ++- .../src/service_account_credentials_test.dart | 37 +++++ 27 files changed, 546 insertions(+), 187 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/auth/providers/microsoft_auth_provider.dart create mode 100644 packages/shorebird_cli/lib/src/auth/providers/providers.dart create mode 100644 packages/shorebird_cli/test/src/auth/providers/microsoft_auth_provider_test.dart create mode 100644 third_party/googleapis_auth/lib/src/auth_provider.dart create mode 100644 third_party/googleapis_auth/test/src/service_account_credentials_test.dart diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index 9a0569c3..764ab484 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart' as http; import 'package:jwt/jwt.dart'; import 'package:path/path.dart' as p; import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/auth/providers/providers.dart'; import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/command_runner.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; @@ -20,25 +21,8 @@ final authRef = create(Auth.new); // The [Auth] instance available in the current zone. Auth get auth => read(authRef); -final _clientId = oauth2.ClientId( - /// Shorebird CLI's OAuth 2.0 identifier. - '523302233293-eia5antm0tgvek240t46orctktiabrek.apps.googleusercontent.com', - - /// Shorebird CLI's OAuth 2.0 secret. - /// - /// This isn't actually meant to be kept secret. - /// There is no way to properly secure a secret for installed/console applications. - /// Fortunately the OAuth2 flow used in this case assumes that the app cannot - /// keep secrets so this particular secret DOES NOT need to be kept secret. - /// You should however make sure not to re-use the same secret - /// anywhere secrecy is required. - /// - /// For more info see: https://developers.google.com/identity/protocols/oauth2/native-app - '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', -); -final _scopes = ['openid', 'https://www.googleapis.com/auth/userinfo.email']; - typedef ObtainAccessCredentials = Future Function( + AuthProvider authProvider, oauth2.ClientId clientId, List scopes, http.Client client, @@ -46,6 +30,7 @@ typedef ObtainAccessCredentials = Future Function( ); typedef RefreshCredentials = Future Function( + AuthProvider authProvider, oauth2.ClientId clientId, oauth2.AccessCredentials credentials, http.Client client, @@ -104,22 +89,29 @@ class AuthenticatedClient extends http.BaseClient { if (credentials == null) { final token = _token!; + final jwt = Jwt.parse(token); + final authProvider = jwt.authProvider; credentials = _credentials = await _refreshCredentials( - _clientId, + authProvider, + authProvider.clientId, oauth2.AccessCredentials( // This isn't relevant for a refresh operation. AccessToken('Bearer', '', DateTime.timestamp()), token, - _scopes, + authProvider.scopes, ), _baseClient, ); _onRefreshCredentials?.call(credentials); } - if (credentials.accessToken.hasExpired) { + if (credentials.accessToken.hasExpired && credentials.idToken != null) { + final jwt = Jwt.parse(credentials.idToken!); + final authProvider = jwt.authProvider; + credentials = _credentials = await _refreshCredentials( - _clientId, + authProvider, + authProvider.clientId, credentials, _baseClient, ); @@ -175,12 +167,16 @@ class Auth { ); } - Future loginCI(void Function(String) prompt) async { + Future loginCI( + AuthProvider authProvider, { + required void Function(String) prompt, + }) async { final client = http.Client(); try { final credentials = await _obtainAccessCredentials( - _clientId, - _scopes, + authProvider, + authProvider.clientId, + authProvider.scopes, client, prompt, ); @@ -201,7 +197,10 @@ class Auth { } } - Future login(void Function(String) prompt) async { + Future login( + AuthProvider authProvider, { + required void Function(String) prompt, + }) async { if (_credentials != null) { throw UserAlreadyLoggedInException(email: _credentials!.email!); } @@ -209,8 +208,9 @@ class Auth { final client = http.Client(); try { _credentials = await _obtainAccessCredentials( - _clientId, - _scopes, + authProvider, + authProvider.clientId, + authProvider.scopes, client, prompt, ); @@ -317,3 +317,57 @@ class UserNotFoundException implements Exception { /// credentials. final String email; } + +extension OauthAuthProvider on Jwt { + oauth2.AuthProvider get authProvider { + if (payload.iss.startsWith('https://login.microsoftonline.com')) { + return MicrosoftAuthProvider(); + } else if (payload.iss == 'https://accounts.google.com') { + return oauth2.GoogleAuthProvider(); + } + + throw Exception('Unknown jwt issuer: ${payload.iss}'); + } +} + +extension OauthValues on AuthProvider { + oauth2.ClientId get clientId { + switch (runtimeType) { + case oauth2.GoogleAuthProvider: + return oauth2.ClientId( + /// Shorebird CLI's OAuth 2.0 identifier for GCP, + '''523302233293-eia5antm0tgvek240t46orctktiabrek.apps.googleusercontent.com''', + + /// Shorebird CLI's OAuth 2.0 secret for GCP. + /// + /// This isn't actually meant to be kept secret. + /// There is no way to properly secure a secret for installed/console applications. + /// Fortunately the OAuth2 flow used in this case assumes that the app + /// cannot keep secrets so this particular secret DOES NOT need to be + /// kept secret. You should however make sure not to re-use the same + /// secret anywhere secrecy is required. + /// + /// For more info see: https://developers.google.com/identity/protocols/oauth2/native-app + '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', + ); + case MicrosoftAuthProvider: + return oauth2.ClientId( + /// Shorebird CLI's OAuth 2.0 identifier for Azure/Entra. + 'c4af9566-8a36-4348-b413-dab665b8717d', + ); + } + + throw UnsupportedError('Unknown auth provider: $this'); + } + + List get scopes { + switch (runtimeType) { + case oauth2.GoogleAuthProvider: + return ['openid', 'https://www.googleapis.com/auth/userinfo.email']; + case MicrosoftAuthProvider: + return ['openid']; + } + + throw UnsupportedError('Unknown auth provider: $this'); + } +} diff --git a/packages/shorebird_cli/lib/src/auth/providers/microsoft_auth_provider.dart b/packages/shorebird_cli/lib/src/auth/providers/microsoft_auth_provider.dart new file mode 100644 index 00000000..01eef74b --- /dev/null +++ b/packages/shorebird_cli/lib/src/auth/providers/microsoft_auth_provider.dart @@ -0,0 +1,12 @@ +import 'package:googleapis_auth/googleapis_auth.dart'; + +/// Endpoints for OAuth authentication with Azure/Entra/Microsoft. +class MicrosoftAuthProvider extends AuthProvider { + @override + Uri get authorizationEndpoint => + Uri.https('login.microsoftonline.com', 'common/oauth2/v2.0/authorize'); + + @override + Uri get tokenEndpoint => + Uri.https('login.microsoftonline.com', 'common/oauth2/v2.0/token'); +} diff --git a/packages/shorebird_cli/lib/src/auth/providers/providers.dart b/packages/shorebird_cli/lib/src/auth/providers/providers.dart new file mode 100644 index 00000000..e7734b0b --- /dev/null +++ b/packages/shorebird_cli/lib/src/auth/providers/providers.dart @@ -0,0 +1,2 @@ +export 'package:googleapis_auth/auth_io.dart' show GoogleAuthProvider; +export 'microsoft_auth_provider.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/login_ci_command.dart b/packages/shorebird_cli/lib/src/commands/login_ci_command.dart index f8c1aaed..0b58935b 100644 --- a/packages/shorebird_cli/lib/src/commands/login_ci_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_ci_command.dart @@ -20,7 +20,7 @@ class LoginCiCommand extends ShorebirdCommand { final AccessCredentials credentials; try { - credentials = await auth.loginCI(prompt); + credentials = await auth.loginCI(GoogleAuthProvider(), prompt: prompt); } on UserNotFoundException catch (error) { logger ..err( diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 9e0a384f..410fc19e 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -1,3 +1,4 @@ +import 'package:googleapis_auth/auth_io.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/command.dart'; @@ -17,7 +18,7 @@ class LoginCommand extends ShorebirdCommand { @override Future run() async { try { - await auth.login(prompt); + await auth.login(GoogleAuthProvider(), prompt: prompt); } on UserAlreadyLoggedInException catch (error) { logger ..info('You are already logged in as <${error.email}>.') diff --git a/packages/shorebird_cli/pubspec.lock b/packages/shorebird_cli/pubspec.lock index a76d9adf..c1b6ffe4 100644 --- a/packages/shorebird_cli/pubspec.lock +++ b/packages/shorebird_cli/pubspec.lock @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: built_value - sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 + sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e url: "https://pub.dev" source: hosted - version: "8.9.0" + version: "8.9.1" characters: dependency: transitive description: @@ -245,10 +245,10 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" file: dependency: transitive description: @@ -300,10 +300,10 @@ packages: dependency: "direct main" description: name: http - sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba + sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" http_multi_server: dependency: transitive description: @@ -768,18 +768,18 @@ packages: dependency: transitive description: name: web - sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05" + sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" url: "https://pub.dev" source: hosted - version: "0.4.2" + version: "0.5.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23" + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.4" webkit_inspection_protocol: dependency: transitive description: @@ -821,4 +821,4 @@ packages: source: hosted version: "2.1.1" sdks: - dart: ">=3.2.0 <4.0.0" + dart: ">=3.3.0 <4.0.0" diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 798f5310..2f949a29 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -4,12 +4,14 @@ import 'dart:io' hide Platform; import 'package:cli_util/cli_util.dart'; import 'package:googleapis_auth/googleapis_auth.dart'; 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'; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/auth/providers/providers.dart'; import 'package:shorebird_cli/src/command_runner.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/logger.dart'; @@ -20,6 +22,14 @@ import 'package:test/test.dart'; import '../fakes.dart'; import '../mocks.dart'; +class FakeProvider extends AuthProvider { + @override + Uri get authorizationEndpoint => Uri.https('example.com'); + + @override + Uri get tokenEndpoint => Uri.https('example.com'); +} + void main() { group('scoped', () { test('creates instance with default constructor', () { @@ -37,6 +47,22 @@ void main() { }); }); + group('OauthValues', () { + final fakeAuthProvider = FakeProvider(); + + group('clientId', () { + test('throws UnsupportedError when provider is not a known type', () { + expect(() => fakeAuthProvider.clientId, throwsUnsupportedError); + }); + }); + + group('scopes', () { + test('throws UnsupportedError when provider is not a known type', () { + expect(() => fakeAuthProvider.scopes, throwsUnsupportedError); + }); + }); + }); + group('JwtClaims', () { group('email', () { test('returns null when idToken is not a valid jwt', () { @@ -56,6 +82,62 @@ void main() { }); }); + group('OauthAuthProvider', () { + late Jwt jwt; + late JwtPayload payload; + + setUp(() { + payload = MockJwtPayload(); + jwt = Jwt( + header: MockJwtHeader(), + payload: payload, + signature: 'signature', + ); + }); + + group('authProvider', () { + group('when issuer is login.microsoft.online', () { + setUp(() { + when(() => payload.iss) + .thenReturn('https://login.microsoftonline.com'); + }); + + test('returns AuthProvider.microsoft', () { + expect(jwt.authProvider, isA()); + }); + }); + + group('when issuer is accounts.google.com', () { + setUp(() { + when(() => payload.iss).thenReturn('https://accounts.google.com'); + }); + + test('returns AuthProvider.google', () { + expect(jwt.authProvider, isA()); + }); + }); + + group('when issuer is unknown', () { + setUp(() { + when(() => payload.iss).thenReturn('https://example.com'); + }); + + test('throws exception', () { + expect( + () => jwt.authProvider, + throwsA( + isA().having( + (e) => e.toString(), + 'message', + 'Exception: Unknown jwt issuer: https://example.com', + ), + ), + ); + }); + }); + }); + }); + group(Auth, () { const idToken = '''eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMjM0NSIsImhkIjoic2hvcmViaXJkLmRldiIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWF0IjoxMjM0LCJleHAiOjY3ODl9.MYbITALvKsGYTYjw1o7AQ0ObkqRWVBSr9cFYJrvA46g'''; @@ -63,6 +145,8 @@ void main() { const user = User(id: 42, email: email); const refreshToken = ''; const scopes = []; + final googleAuthProvider = GoogleAuthProvider(); + final microsoftAuthProvider = MicrosoftAuthProvider(); final accessToken = AccessToken( 'Bearer', 'accessToken', @@ -107,7 +191,7 @@ void main() { return codePushClient; }, obtainAccessCredentials: - (clientId, scopes, client, userPrompt) async { + (authProvider, clientId, scopes, client, userPrompt) async { return accessCredentials; }, ), @@ -135,15 +219,17 @@ void main() { group('AuthenticatedClient', () { group('token', () { - const token = 'shorebird-token'; + const token = + '''eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMjM0NSIsImhkIjoic2hvcmViaXJkLmRldiIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWF0IjoxMjM0LCJleHAiOjY3ODl9.MYbITALvKsGYTYjw1o7AQ0ObkqRWVBSr9cFYJrvA46g'''; test('does not require an onRefreshCredentials callback', () { expect( () => AuthenticatedClient.token( token: token, httpClient: httpClient, - refreshCredentials: (clientId, credentials, client) async => - accessCredentials, + refreshCredentials: + (authProvider, clientId, credentials, client) async => + accessCredentials, ), returnsNormally, ); @@ -164,8 +250,9 @@ void main() { token: token, httpClient: httpClient, onRefreshCredentials: onRefreshCredentialsCalls.add, - refreshCredentials: (clientId, credentials, client) async => - accessCredentials, + refreshCredentials: + (authProvider, clientId, credentials, client) async => + accessCredentials, ); await runWithOverrides( @@ -197,8 +284,9 @@ void main() { token: token, httpClient: httpClient, onRefreshCredentials: onRefreshCredentialsCalls.add, - refreshCredentials: (clientId, credentials, client) async => - accessCredentials, + refreshCredentials: + (authProvider, clientId, credentials, client) async => + accessCredentials, ); await runWithOverrides( @@ -228,6 +316,8 @@ void main() { ), ); + const expiredIdToken = + '''eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMjM0NSIsImhkIjoic2hvcmViaXJkLmRldiIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWF0IjoxMjM0LCJleHAiOjY3ODl9.MYbITALvKsGYTYjw1o7AQ0ObkqRWVBSr9cFYJrvA46g'''; final onRefreshCredentialsCalls = []; final expiredCredentials = AccessCredentials( AccessToken( @@ -237,15 +327,16 @@ void main() { ), '', [], - idToken: 'expiredIdToken', + idToken: expiredIdToken, ); final client = AuthenticatedClient.credentials( credentials: expiredCredentials, httpClient: httpClient, onRefreshCredentials: onRefreshCredentialsCalls.add, - refreshCredentials: (clientId, credentials, client) async => - accessCredentials, + refreshCredentials: + (authProvider, clientId, credentials, client) async => + accessCredentials, ); await runWithOverrides( @@ -302,7 +393,7 @@ void main() { HttpStatus.ok, ), ); - await auth.login((_) {}); + await auth.login(googleAuthProvider, prompt: (_) {}); final client = auth.client; expect(client, isA()); expect(client, isA()); @@ -347,20 +438,32 @@ void main() { group('login', () { test('should set the email when claims are valid and current user exists', () async { - await auth.login((_) {}); + await auth.login(googleAuthProvider, prompt: (_) {}); expect(auth.email, email); expect(auth.isAuthenticated, isTrue); expect(buildAuth().email, email); expect(buildAuth().isAuthenticated, isTrue); }); + group('with a custom auth provider', () { + test( + '''should set the email when claims are valid and current user exists''', + () async { + await auth.login(microsoftAuthProvider, prompt: (_) {}); + expect(auth.email, email); + expect(auth.isAuthenticated, isTrue); + expect(buildAuth().email, email); + expect(buildAuth().isAuthenticated, isTrue); + }); + }); + test('throws UserAlreadyLoggedInException if user is authenticated', () async { writeCredentials(); auth = buildAuth(); await expectLater( - auth.login((_) {}), + auth.login(googleAuthProvider, prompt: (_) {}), throwsA(isA()), ); @@ -373,7 +476,7 @@ void main() { .thenAnswer((_) async => null); await expectLater( - auth.login((_) {}), + auth.login(googleAuthProvider, prompt: (_) {}), throwsA(isA()), ); @@ -395,7 +498,7 @@ void main() { 'returns credentials and does not set the email or cache credentials', () async { await expectLater( - auth.loginCI((_) {}), + auth.loginCI(googleAuthProvider, prompt: (_) {}), completion(equals(accessCredentials)), ); expect(auth.email, isNull); @@ -412,7 +515,7 @@ void main() { ).thenAnswer((_) async => null); await expectLater( - auth.loginCI((_) {}), + auth.loginCI(googleAuthProvider, prompt: (_) {}), throwsA(isA()), ); @@ -422,7 +525,7 @@ void main() { group('logout', () { test('clears session and wipes state', () async { - await auth.login((_) {}); + await auth.login(googleAuthProvider, prompt: (_) {}); expect(auth.email, email); expect(auth.isAuthenticated, isTrue); diff --git a/packages/shorebird_cli/test/src/auth/providers/microsoft_auth_provider_test.dart b/packages/shorebird_cli/test/src/auth/providers/microsoft_auth_provider_test.dart new file mode 100644 index 00000000..d9c5de48 --- /dev/null +++ b/packages/shorebird_cli/test/src/auth/providers/microsoft_auth_provider_test.dart @@ -0,0 +1,12 @@ +import 'package:shorebird_cli/src/auth/providers/providers.dart'; +import 'package:test/test.dart'; + +void main() { + group(MicrosoftAuthProvider, () { + test('has valid endpoints', () { + final provider = MicrosoftAuthProvider(); + expect(provider.authorizationEndpoint, isNotNull); + expect(provider.tokenEndpoint, isNotNull); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart b/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart index 1240d423..b25697a3 100644 --- a/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart @@ -1,3 +1,4 @@ +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -28,6 +29,10 @@ void main() { ); } + setUpAll(() { + registerFallbackValue(GoogleAuthProvider()); + }); + setUp(() { auth = MockAuth(); httpClient = MockHttpClient(); @@ -39,7 +44,10 @@ void main() { test('exits with code 70 if no user is found', () async { when( - () => auth.loginCI(any()), + () => auth.loginCI( + any(), + prompt: any(named: 'prompt'), + ), ).thenThrow(UserNotFoundException(email: email)); final result = await runWithOverrides(command.run); @@ -55,12 +63,22 @@ void main() { test('exits with code 70 when error occurs', () async { final error = Exception('oops something went wrong!'); - when(() => auth.loginCI(any())).thenThrow(error); + when( + () => auth.loginCI( + any(), + prompt: any(named: 'prompt'), + ), + ).thenThrow(error); final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.software.code)); - verify(() => auth.loginCI(any())).called(1); + verify( + () => auth.loginCI( + any(), + prompt: any(named: 'prompt'), + ), + ).called(1); verify(() => logger.err(error.toString())).called(1); }); @@ -68,13 +86,23 @@ void main() { const token = 'shorebird-token'; final credentials = MockAccessCredentials(); when(() => credentials.refreshToken).thenReturn(token); - when(() => auth.loginCI(any())).thenAnswer((_) async => credentials); + when( + () => auth.loginCI( + any(), + prompt: any(named: 'prompt'), + ), + ).thenAnswer((_) async => credentials); when(() => auth.email).thenReturn(email); final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.success.code)); - verify(() => auth.loginCI(any())).called(1); + verify( + () => auth.loginCI( + any(), + prompt: any(named: 'prompt'), + ), + ).called(1); verify( () => logger.info(any(that: contains('${lightCyan.wrap(token)}'))), ).called(1); diff --git a/packages/shorebird_cli/test/src/commands/login_command_test.dart b/packages/shorebird_cli/test/src/commands/login_command_test.dart index 8408590f..263d56ea 100644 --- a/packages/shorebird_cli/test/src/commands/login_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_command_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -32,6 +33,10 @@ void main() { ); } + setUpAll(() { + registerFallbackValue(GoogleAuthProvider()); + }); + setUp(() { applicationConfigHome = Directory.systemTemp.createTempSync(); auth = MockAuth(); @@ -48,7 +53,10 @@ void main() { test('exits with code 0 when already logged in', () async { when( - () => auth.login(any()), + () => auth.login( + any(), + prompt: any(named: 'prompt'), + ), ).thenThrow(UserAlreadyLoggedInException(email: email)); final result = await runWithOverrides(command.run); @@ -66,7 +74,10 @@ void main() { test('exits with code 70 if no user is found', () async { when( - () => auth.login(any()), + () => auth.login( + any(), + prompt: any(named: 'prompt'), + ), ).thenThrow(UserNotFoundException(email: email)); final result = await runWithOverrides(command.run); @@ -82,23 +93,43 @@ void main() { test('exits with code 70 when error occurs', () async { final error = Exception('oops something went wrong!'); - when(() => auth.login(any())).thenThrow(error); + when( + () => auth.login( + any(), + prompt: any(named: 'prompt'), + ), + ).thenThrow(error); final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.software.code)); - verify(() => auth.login(any())).called(1); + verify( + () => auth.login( + any(), + prompt: any(named: 'prompt'), + ), + ).called(1); verify(() => logger.err(error.toString())).called(1); }); test('exits with code 0 when logged in successfully', () async { - when(() => auth.login(any())).thenAnswer((_) async {}); + when( + () => auth.login( + any(), + prompt: any(named: 'prompt'), + ), + ).thenAnswer((_) async {}); when(() => auth.email).thenReturn(email); final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.success.code)); - verify(() => auth.login(any())).called(1); + verify( + () => auth.login( + any(), + prompt: any(named: 'prompt'), + ), + ).called(1); verify( () => logger.info( any(that: contains('You are now logged in as <$email>.')), diff --git a/packages/shorebird_cli/test/src/mocks.dart b/packages/shorebird_cli/test/src/mocks.dart index fb32d419..607efe89 100644 --- a/packages/shorebird_cli/test/src/mocks.dart +++ b/packages/shorebird_cli/test/src/mocks.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart' as http; +import 'package:jwt/jwt.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:platform/platform.dart'; @@ -90,6 +91,10 @@ class MockIosArchiveDiffer extends Mock implements IosArchiveDiffer {} class MockJava extends Mock implements Java {} +class MockJwtHeader extends Mock implements JwtHeader {} + +class MockJwtPayload extends Mock implements JwtPayload {} + class MockLogger extends Mock implements Logger {} class MockOperatingSystemInterface extends Mock diff --git a/third_party/googleapis_auth/lib/auth_io.dart b/third_party/googleapis_auth/lib/auth_io.dart index f99f70e8..70596b73 100644 --- a/third_party/googleapis_auth/lib/auth_io.dart +++ b/third_party/googleapis_auth/lib/auth_io.dart @@ -2,18 +2,12 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'dart:io'; - +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart'; -import 'src/adc_utils.dart'; import 'src/auth_http_utils.dart'; -import 'src/http_client_base.dart'; -import 'src/metadata_server_client.dart' show clientViaMetadataServer; import 'src/oauth2_flows/authorization_code_grant_manual_flow.dart'; import 'src/oauth2_flows/authorization_code_grant_server_flow.dart'; -import 'src/service_account_credentials.dart'; -import 'src/typedefs.dart'; export 'googleapis_auth.dart'; export 'src/metadata_server_client.dart'; @@ -22,79 +16,6 @@ export 'src/oauth2_flows/auth_code.dart' export 'src/service_account_client.dart'; export 'src/typedefs.dart'; -/// Create a client using -/// [Application Default Credentials](https://cloud.google.com/docs/authentication/production). -/// -/// Looks for credentials in the following order of preference: -/// 1. A JSON file whose path is specified by `GOOGLE_APPLICATION_CREDENTIALS`, -/// this file typically contains [exported service account keys][svc-keys]. -/// 2. A JSON file created by -/// [`gcloud auth application-default login`][gcloud-login] -/// in a well-known location (`%APPDATA%/gcloud/application_default_credentials.json` -/// on Windows and `$HOME/.config/gcloud/application_default_credentials.json` on Linux/Mac). -/// 3. On Google Compute Engine and App Engine Flex we fetch credentials from -/// [GCE metadata service][meta-data]. -/// -/// [meta-data]: https://cloud.google.com/compute/docs/storing-retrieving-metadata -/// [svc-keys]: https://cloud.google.com/docs/authentication/getting-started -/// [gcloud-login]: https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login -/// -/// {@macro googleapis_auth_baseClient_param} -/// -/// {@macro googleapis_auth_returned_auto_refresh_client} -Future clientViaApplicationDefaultCredentials({ - required List scopes, - Client? baseClient, -}) async { - if (baseClient == null) { - baseClient = Client(); - } else { - baseClient = nonClosingClient(baseClient); - } - - // If env var specifies a file to load credentials from we'll do that. - final credsEnv = Platform.environment['GOOGLE_APPLICATION_CREDENTIALS']; - if (credsEnv != null && credsEnv.isNotEmpty) { - // If env var is specific and not empty, we always try to load, even if - // the file doesn't exist. - return await fromApplicationsCredentialsFile( - File(credsEnv), - 'GOOGLE_APPLICATION_CREDENTIALS', - scopes, - baseClient, - ); - } - - // Attempt to use file created by `gcloud auth application-default login` - File credFile; - if (Platform.isWindows) { - credFile = File.fromUri( - Uri.directory(Platform.environment['APPDATA']!) - .resolve('gcloud/application_default_credentials.json'), - ); - } else { - final homeVar = Platform.environment['HOME']; - if (homeVar == null) { - throw StateError('The expected environment variable HOME must be set.'); - } - credFile = File.fromUri( - Uri.directory(homeVar) - .resolve('.config/gcloud/application_default_credentials.json'), - ); - } - // Only try to load from credFile if it exists. - if (await credFile.exists()) { - return await fromApplicationsCredentialsFile( - credFile, - '`gcloud auth application-default login`', - scopes, - baseClient, - ); - } - - return await clientViaMetadataServer(baseClient: baseClient); -} - /// Obtains oauth2 credentials and returns an authenticated HTTP client. /// /// See [obtainAccessCredentialsViaUserConsent] for specifics about the @@ -116,6 +37,7 @@ Future clientViaApplicationDefaultCredentials({ /// {@macro googleapis_auth_not_close_the_baseClient} /// {@macro googleapis_auth_listen_port} Future clientViaUserConsent( + AuthProvider authProvider, ClientId clientId, List scopes, PromptUserForConsent userPrompt, { @@ -130,6 +52,7 @@ Future clientViaUserConsent( } final flow = AuthorizationCodeGrantServerFlow( + authProvider, clientId, scopes, baseClient, @@ -150,6 +73,7 @@ Future clientViaUserConsent( } return AutoRefreshingClient( baseClient, + authProvider, clientId, credentials, closeUnderlyingClient: closeUnderlyingClient, @@ -172,6 +96,7 @@ Future clientViaUserConsent( /// {@macro googleapis_auth_close_the_client} /// {@macro googleapis_auth_not_close_the_baseClient} Future clientViaUserConsentManual( + AuthProvider authProvider, ClientId clientId, List scopes, PromptUserForConsentManual userPrompt, { @@ -185,6 +110,7 @@ Future clientViaUserConsentManual( } final flow = AuthorizationCodeGrantManualFlow( + authProvider, clientId, scopes, baseClient, @@ -205,6 +131,7 @@ Future clientViaUserConsentManual( return AutoRefreshingClient( baseClient, + authProvider, clientId, credentials, closeUnderlyingClient: closeUnderlyingClient, @@ -232,6 +159,7 @@ Future clientViaUserConsentManual( /// on the Google Cloud console. /// {@endtemplate} Future obtainAccessCredentialsViaUserConsent( + AuthProvider authProvider, ClientId clientId, List scopes, Client client, @@ -240,6 +168,7 @@ Future obtainAccessCredentialsViaUserConsent( int listenPort = 0, }) => AuthorizationCodeGrantServerFlow( + authProvider, clientId, scopes, client, @@ -261,6 +190,7 @@ Future obtainAccessCredentialsViaUserConsent( /// /// {@macro googleapis_auth_user_consent_return} Future obtainAccessCredentialsViaUserConsentManual( + AuthProvider authProvider, ClientId clientId, List scopes, Client client, @@ -268,6 +198,7 @@ Future obtainAccessCredentialsViaUserConsentManual( String? hostedDomain, }) => AuthorizationCodeGrantManualFlow( + authProvider, clientId, scopes, client, diff --git a/third_party/googleapis_auth/lib/googleapis_auth.dart b/third_party/googleapis_auth/lib/googleapis_auth.dart index ce2033af..522d010d 100644 --- a/third_party/googleapis_auth/lib/googleapis_auth.dart +++ b/third_party/googleapis_auth/lib/googleapis_auth.dart @@ -27,6 +27,7 @@ library googleapis_auth; export 'src/auth_client.dart'; export 'src/auth_functions.dart'; +export 'src/auth_provider.dart'; export 'src/client_id.dart'; export 'src/exceptions.dart'; export 'src/response_type.dart'; diff --git a/third_party/googleapis_auth/lib/src/adc_utils.dart b/third_party/googleapis_auth/lib/src/adc_utils.dart index e44b4f5a..e693f1db 100644 --- a/third_party/googleapis_auth/lib/src/adc_utils.dart +++ b/third_party/googleapis_auth/lib/src/adc_utils.dart @@ -6,15 +6,14 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart'; -import 'auth_functions.dart'; import 'auth_http_utils.dart'; -import 'service_account_client.dart'; -import 'service_account_credentials.dart'; Future fromApplicationsCredentialsFile( File file, + AuthProvider authProvider, String fileSource, List scopes, Client baseClient, @@ -39,8 +38,10 @@ Future fromApplicationsCredentialsFile( ); return AutoRefreshingClient( baseClient, + authProvider, clientId, await refreshCredentials( + authProvider, clientId, AccessCredentials( // Hack: Create empty credentials that have expired. diff --git a/third_party/googleapis_auth/lib/src/auth_functions.dart b/third_party/googleapis_auth/lib/src/auth_functions.dart index 203fc738..3d5069bb 100644 --- a/third_party/googleapis_auth/lib/src/auth_functions.dart +++ b/third_party/googleapis_auth/lib/src/auth_functions.dart @@ -4,12 +4,10 @@ import 'dart:async'; +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart'; -import 'access_credentials.dart'; -import 'auth_client.dart'; import 'auth_http_utils.dart'; -import 'client_id.dart'; import 'http_client_base.dart'; import 'utils.dart'; @@ -82,6 +80,7 @@ AuthClient authenticatedClient( /// {@macro googleapis_auth_close_the_client} /// {@macro googleapis_auth_not_close_the_baseClient} AutoRefreshingAuthClient autoRefreshingClient( + AuthProvider authProvider, ClientId clientId, AccessCredentials credentials, Client baseClient, @@ -92,7 +91,7 @@ AutoRefreshingAuthClient autoRefreshingClient( if (credentials.refreshToken == null) { throw ArgumentError('Refresh token in AccessCredentials was `null`.'); } - return AutoRefreshingClient(baseClient, clientId, credentials); + return AutoRefreshingClient(baseClient, authProvider, clientId, credentials); } /// Obtains refreshed [AccessCredentials] for [clientId] and [credentials]. @@ -101,6 +100,7 @@ AutoRefreshingAuthClient autoRefreshingClient( /// /// {@macro googleapis_auth_client_for_creds} Future refreshCredentials( + AuthProvider authProvider, ClientId clientId, AccessCredentials credentials, Client client, @@ -116,12 +116,15 @@ Future refreshCredentials( } // https://developers.google.com/identity/protocols/oauth2/native-app#offline - final jsonMap = await client.oauthTokenRequest({ - 'client_id': clientId.identifier, - 'client_secret': secret, - 'refresh_token': refreshToken, - 'grant_type': 'refresh_token', - }); + final jsonMap = await client.oauthTokenRequest( + { + 'client_id': clientId.identifier, + 'client_secret': secret, + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + }, + authProvider: authProvider, + ); final accessToken = parseAccessToken(jsonMap); diff --git a/third_party/googleapis_auth/lib/src/auth_http_utils.dart b/third_party/googleapis_auth/lib/src/auth_http_utils.dart index f1798f99..dd044503 100644 --- a/third_party/googleapis_auth/lib/src/auth_http_utils.dart +++ b/third_party/googleapis_auth/lib/src/auth_http_utils.dart @@ -4,13 +4,9 @@ import 'dart:async'; +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart'; -import 'access_credentials.dart'; -import 'auth_client.dart'; -import 'auth_functions.dart'; -import 'client_id.dart'; -import 'exceptions.dart'; import 'http_client_base.dart'; /// Will close the underlying `http.Client` depending on a constructor argument. @@ -91,9 +87,11 @@ class AutoRefreshingClient extends AutoRefreshDelegatingClient { @override AccessCredentials credentials; late Client authClient; + final AuthProvider authProvider; AutoRefreshingClient( super.client, + this.authProvider, this.clientId, this.credentials, { super.closeUnderlyingClient, @@ -114,7 +112,12 @@ class AutoRefreshingClient extends AutoRefreshDelegatingClient { // If so, we should handle it. return authClient.send(request); } else { - final cred = await refreshCredentials(clientId, credentials, baseClient); + final cred = await refreshCredentials( + authProvider, + clientId, + credentials, + baseClient, + ); notifyAboutNewCredentials(cred); credentials = cred; authClient = AuthenticatedClient( diff --git a/third_party/googleapis_auth/lib/src/auth_provider.dart b/third_party/googleapis_auth/lib/src/auth_provider.dart new file mode 100644 index 00000000..e03bf9da --- /dev/null +++ b/third_party/googleapis_auth/lib/src/auth_provider.dart @@ -0,0 +1,14 @@ +import 'known_uris.dart'; + +abstract class AuthProvider { + Uri get authorizationEndpoint; + Uri get tokenEndpoint; +} + +class GoogleAuthProvider extends AuthProvider { + @override + Uri get authorizationEndpoint => googleOauth2AuthorizationEndpoint; + + @override + Uri get tokenEndpoint => googleOauth2TokenEndpoint; +} diff --git a/third_party/googleapis_auth/lib/src/oauth2_flows/auth_code.dart b/third_party/googleapis_auth/lib/src/oauth2_flows/auth_code.dart index 5d214272..3d63db61 100644 --- a/third_party/googleapis_auth/lib/src/oauth2_flows/auth_code.dart +++ b/third_party/googleapis_auth/lib/src/oauth2_flows/auth_code.dart @@ -8,15 +8,13 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart' as http; -import '../access_credentials.dart'; -import '../client_id.dart'; -import '../exceptions.dart'; -import '../known_uris.dart'; import '../utils.dart'; Uri createAuthenticationUri({ + required AuthProvider authProvider, required String redirectUri, required String clientId, required Iterable scopes, @@ -36,7 +34,7 @@ Uri createAuthenticationUri({ if (hostedDomain != null) 'hd': hostedDomain, if (state != null) 'state': state, }; - return googleOauth2AuthorizationEndpoint.replace( + return authProvider.authorizationEndpoint.replace( queryParameters: queryValues, ); } @@ -106,6 +104,7 @@ String _stripBase64Equals(String value) { /// to the server. You should use "anti-request forgery state tokens" to guard /// against "cross site request forgery" attacks. Future obtainAccessCredentialsViaCodeExchange( + AuthProvider authProvider, http.Client client, ClientId clientId, String code, { @@ -121,6 +120,7 @@ Future obtainAccessCredentialsViaCodeExchange( 'grant_type': 'authorization_code', 'redirect_uri': redirectUrl, }, + authProvider: authProvider, ); final accessToken = parseAccessToken(jsonMap); diff --git a/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_abstract_flow.dart b/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_abstract_flow.dart index 350b5199..d68b80de 100644 --- a/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_abstract_flow.dart +++ b/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_abstract_flow.dart @@ -2,6 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:googleapis_auth/src/auth_provider.dart'; import 'package:http/http.dart' as http; import '../access_credentials.dart'; @@ -10,12 +11,14 @@ import 'auth_code.dart'; import 'base_flow.dart'; abstract class AuthorizationCodeGrantAbstractFlow implements BaseFlow { + final AuthProvider authProvider; final ClientId clientId; final String? hostedDomain; final List scopes; final http.Client _client; AuthorizationCodeGrantAbstractFlow( + this.authProvider, this.clientId, this.scopes, this._client, { @@ -25,9 +28,11 @@ abstract class AuthorizationCodeGrantAbstractFlow implements BaseFlow { Future obtainAccessCredentialsUsingCodeImpl( String code, String redirectUri, { + required AuthProvider authProvider, required String codeVerifier, }) => obtainAccessCredentialsViaCodeExchange( + authProvider, _client, clientId, code, @@ -41,6 +46,7 @@ abstract class AuthorizationCodeGrantAbstractFlow implements BaseFlow { required String codeVerifier, }) => createAuthenticationUri( + authProvider: authProvider, redirectUri: redirectUri, clientId: clientId.identifier, scopes: scopes, diff --git a/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_manual_flow.dart b/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_manual_flow.dart index 2613795a..13862559 100644 --- a/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_manual_flow.dart +++ b/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_manual_flow.dart @@ -24,6 +24,7 @@ class AuthorizationCodeGrantManualFlow final PromptUserForConsentManual userPrompt; AuthorizationCodeGrantManualFlow( + super.authProvider, super.clientId, super.scopes, super.client, @@ -47,6 +48,7 @@ class AuthorizationCodeGrantManualFlow return obtainAccessCredentialsUsingCodeImpl( code, _redirectionUri, + authProvider: authProvider, codeVerifier: codeVerifier, ); } diff --git a/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_server_flow.dart b/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_server_flow.dart index 52817ac5..fed65fce 100644 --- a/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_server_flow.dart +++ b/third_party/googleapis_auth/lib/src/oauth2_flows/authorization_code_grant_server_flow.dart @@ -27,6 +27,7 @@ class AuthorizationCodeGrantServerFlow final int listenPort; AuthorizationCodeGrantServerFlow( + super.authProvider, super.clientId, super.scopes, super.client, @@ -89,6 +90,7 @@ class AuthorizationCodeGrantServerFlow final credentials = await obtainAccessCredentialsUsingCodeImpl( code, redirectionUri, + authProvider: authProvider, codeVerifier: codeVerifier, ); diff --git a/third_party/googleapis_auth/lib/src/oauth2_flows/jwt.dart b/third_party/googleapis_auth/lib/src/oauth2_flows/jwt.dart index 39b90f2f..376f0deb 100644 --- a/third_party/googleapis_auth/lib/src/oauth2_flows/jwt.dart +++ b/third_party/googleapis_auth/lib/src/oauth2_flows/jwt.dart @@ -7,13 +7,14 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../access_credentials.dart'; +import '../../auth_io.dart'; import '../crypto/rsa.dart'; import '../crypto/rsa_sign.dart'; import '../known_uris.dart'; import '../utils.dart'; import 'base_flow.dart'; +/// Currently only supports the Google auth provider. class JwtFlow extends BaseFlow { // All details are described at: // https://developers.google.com/accounts/docs/OAuth2ServiceAccount @@ -58,10 +59,13 @@ class JwtFlow extends BaseFlow { final jwt = '$jwtSignatureInput.${_base64url(signature)}'; // https://developers.google.com/identity/protocols/oauth2/service-account#authorizingrequests - final response = await _client.oauthTokenRequest({ - 'grant_type': _uri, - 'assertion': jwt, - }); + final response = await _client.oauthTokenRequest( + { + 'grant_type': _uri, + 'assertion': jwt, + }, + authProvider: GoogleAuthProvider(), + ); final accessToken = parseAccessToken(response); return AccessCredentials(accessToken, null, _scopes); } diff --git a/third_party/googleapis_auth/lib/src/utils.dart b/third_party/googleapis_auth/lib/src/utils.dart index 785473bb..6672288a 100644 --- a/third_party/googleapis_auth/lib/src/utils.dart +++ b/third_party/googleapis_auth/lib/src/utils.dart @@ -4,13 +4,11 @@ import 'dart:convert'; +import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart' show BaseRequest, Client, StreamedResponse; import 'package:http_parser/http_parser.dart'; -import 'access_token.dart'; -import 'exceptions.dart'; import 'http_client_base.dart'; -import 'known_uris.dart'; /// Due to differences of clock speed, network latency, etc. we /// will shorten expiry dates by 20 seconds. @@ -109,8 +107,9 @@ extension ClientExtensions on Client { } Future> oauthTokenRequest( - Map postValues, - ) async { + Map postValues, { + required AuthProvider authProvider, + }) async { final body = Stream>.value( ascii.encode( postValues.entries @@ -118,7 +117,7 @@ extension ClientExtensions on Client { .join('&'), ), ); - final request = RequestImpl('POST', googleOauth2TokenEndpoint, body) + final request = RequestImpl('POST', authProvider.tokenEndpoint, body) ..headers['content-type'] = _contentTypeUrlEncoded; return requestJson(request, 'Failed to obtain access credentials.'); diff --git a/third_party/googleapis_auth/test/adc_test.dart b/third_party/googleapis_auth/test/adc_test.dart index 3d9fd8b9..374e55fa 100644 --- a/third_party/googleapis_auth/test/adc_test.dart +++ b/third_party/googleapis_auth/test/adc_test.dart @@ -4,6 +4,7 @@ library googleapis_auth.adc_test; import 'dart:convert'; import 'dart:io'; +import 'package:googleapis_auth/auth_io.dart'; import 'package:googleapis_auth/src/adc_utils.dart' show fromApplicationsCredentialsFile; import 'package:googleapis_auth/src/known_uris.dart'; @@ -14,6 +15,7 @@ import 'test_utils.dart'; void main() { test('fromApplicationsCredentialsFile', () async { + final authProvider = GoogleAuthProvider(); final tmp = await Directory.systemTemp.createTemp('googleapis_auth-test'); try { final credsFile = File.fromUri(tmp.uri.resolve('creds.json')); @@ -25,6 +27,7 @@ void main() { })); final c = await fromApplicationsCredentialsFile( credsFile, + authProvider, 'test-credentials-file', [], mockClient((Request request) async { @@ -80,6 +83,7 @@ void main() { })); final c = await fromApplicationsCredentialsFile( credsFile, + GoogleAuthProvider(), 'test-credentials-file', [], mockClient((Request request) async { diff --git a/third_party/googleapis_auth/test/oauth2_flows/auth_code_test.dart b/third_party/googleapis_auth/test/oauth2_flows/auth_code_test.dart index 92f35481..dd9c056f 100644 --- a/third_party/googleapis_auth/test/oauth2_flows/auth_code_test.dart +++ b/third_party/googleapis_auth/test/oauth2_flows/auth_code_test.dart @@ -31,6 +31,7 @@ final _browserFlowRedirectMatcher = predicate((object) { void main() { final clientId = ClientId('id', 'secret'); final scopes = ['s1', 's2']; + final authProvider = GoogleAuthProvider(); // Validation + Responses from the authorization server. @@ -136,6 +137,7 @@ void main() { test('successful', () async { final flow = AuthorizationCodeGrantManualFlow( + authProvider, clientId, scopes, mockClient(successFullResponse(manual: true), expectClose: false), @@ -150,6 +152,7 @@ void main() { Future.error(TransportException()); final flow = AuthorizationCodeGrantManualFlow( + authProvider, clientId, scopes, mockClient(successFullResponse(manual: true), expectClose: false), @@ -160,6 +163,7 @@ void main() { test('transport-exception', () async { final flow = AuthorizationCodeGrantManualFlow( + authProvider, clientId, scopes, transportFailure, @@ -170,6 +174,7 @@ void main() { test('invalid-server-response', () async { final flow = AuthorizationCodeGrantManualFlow( + authProvider, clientId, scopes, mockClient(invalidResponse, expectClose: false), @@ -194,6 +199,20 @@ void main() { } } + Future postToRedirectionEndpoint(Uri authCodeCall) async { + final ioClient = HttpClient(); + + final closeMe = expectAsync0(ioClient.close); + + try { + final request = await ioClient.postUrl(authCodeCall); + final response = await request.close(); + await response.drain(); + } finally { + closeMe(); + } + } + void userPrompt(String url) { final redirectUri = validateUserPromptUri(url); final authCodeCall = Uri( @@ -208,6 +227,34 @@ void main() { callRedirectionEndpoint(authCodeCall); } + void userPromptInvalidHttpVerb(String url) { + final redirectUri = validateUserPromptUri(url); + final authCodeCall = Uri( + scheme: redirectUri.scheme, + host: redirectUri.host, + port: redirectUri.port, + path: redirectUri.path, + queryParameters: { + 'state': Uri.parse(url).queryParameters['state'], + 'code': 'mycode', + }); + postToRedirectionEndpoint(authCodeCall); + } + + void userPromptNonMatchingState(String url) { + final redirectUri = validateUserPromptUri(url); + final authCodeCall = Uri( + scheme: redirectUri.scheme, + host: redirectUri.host, + port: redirectUri.port, + path: redirectUri.path, + queryParameters: { + 'state': 'not-the-right-state', + 'code': 'mycode', + }); + callRedirectionEndpoint(authCodeCall); + } + void userPromptInvalidAuthCodeCallback(String url) { final redirectUri = validateUserPromptUri(url); final authCodeCall = Uri( @@ -224,6 +271,7 @@ void main() { test('successful', () async { final flow = AuthorizationCodeGrantServerFlow( + authProvider, clientId, scopes, mockClient(successFullResponse(manual: false), expectClose: false), @@ -234,6 +282,7 @@ void main() { test('transport-exception', () async { final flow = AuthorizationCodeGrantServerFlow( + authProvider, clientId, scopes, transportFailure, @@ -242,8 +291,49 @@ void main() { await expectLater(flow.run(), throwsA(isTransportException)); }); + test('non-GET request', () async { + final flow = AuthorizationCodeGrantServerFlow( + authProvider, + clientId, + scopes, + mockClient(successFullResponse(manual: false), expectClose: false), + expectAsync1(userPromptInvalidHttpVerb), + ); + await expectLater( + flow.run, + throwsA( + isA().having( + (e) => e.toString(), + 'message', + 'Exception: Invalid response from server (expected GET request callback, got: POST).', + ), + ), + ); + }); + + test('request with invalid state parameter', () async { + final flow = AuthorizationCodeGrantServerFlow( + authProvider, + clientId, + scopes, + mockClient(successFullResponse(manual: false), expectClose: false), + expectAsync1(userPromptNonMatchingState), + ); + await expectLater( + flow.run, + throwsA( + isA().having( + (e) => e.toString(), + 'message', + 'Exception: Invalid response from server (state did not match).', + ), + ), + ); + }); + test('invalid-server-response', () async { final flow = AuthorizationCodeGrantServerFlow( + authProvider, clientId, scopes, mockClient(invalidResponse, expectClose: false), @@ -254,6 +344,7 @@ void main() { test('failed-authentication', () async { final flow = AuthorizationCodeGrantServerFlow( + authProvider, clientId, scopes, mockClient(successFullResponse(manual: false), expectClose: false), diff --git a/third_party/googleapis_auth/test/oauth2_test.dart b/third_party/googleapis_auth/test/oauth2_test.dart index e6863a20..35113299 100644 --- a/third_party/googleapis_auth/test/oauth2_test.dart +++ b/third_party/googleapis_auth/test/oauth2_test.dart @@ -19,6 +19,8 @@ final _defaultResponse = Response('', 500); Future _defaultResponseHandler(Request _) async => _defaultResponse; void main() { + final authProvider = GoogleAuthProvider(); + test('access-token', () { final expiry = DateTime.now().subtract(const Duration(seconds: 1)); final expiryUtc = expiry.toUtc(); @@ -162,8 +164,12 @@ void main() { Future.error(Exception('transport layer exception')); test('refreshCredentials-successful', () async { - final newCredentials = await refreshCredentials(clientId, credentials, - mockClient(expectAsync1(successfulRefresh), expectClose: false)); + final newCredentials = await refreshCredentials( + authProvider, + clientId, + credentials, + mockClient(expectAsync1(successfulRefresh), expectClose: false), + ); final expectedResultUtc = DateTime.now() .toUtc() .add(const Duration(seconds: 3600 - maxExpectedTimeDiffInSeconds)); @@ -181,6 +187,7 @@ void main() { test('refreshCredentials-http-error', () async { await expectLater( refreshCredentials( + authProvider, clientId, credentials, mockClient(serverError, expectClose: false), @@ -198,6 +205,7 @@ void main() { test('refreshCredentials-error-response', () async { await expectLater( refreshCredentials( + authProvider, clientId, credentials, mockClient(refreshErrorResponse, expectClose: false), @@ -268,6 +276,7 @@ void main() { test('up-to-date', () async { final client = autoRefreshingClient( + authProvider, clientId, credentials, mockClient( @@ -287,6 +296,7 @@ void main() { expect( () => autoRefreshingClient( + authProvider, clientId, credentials, mockClient(_defaultResponseHandler, expectClose: false), @@ -300,6 +310,7 @@ void main() { AccessToken('Bearer', 'bar', yesterday), 'refresh', ['s1', 's2']); final client = autoRefreshingClient( + authProvider, clientId, credentials, mockClient(expectAsync1((request) { @@ -320,6 +331,7 @@ void main() { AccessToken('Bearer', 'bar', yesterday), 'refresh', ['s1', 's2']); final client = autoRefreshingClient( + authProvider, clientId, credentials, mockClient(expectAsync1((request) async { @@ -344,6 +356,7 @@ void main() { AccessToken('Bearer', 'bar', yesterday), 'refresh', ['s1']); final client = autoRefreshingClient( + authProvider, clientId, credentials, mockClient( diff --git a/third_party/googleapis_auth/test/src/service_account_credentials_test.dart b/third_party/googleapis_auth/test/src/service_account_credentials_test.dart new file mode 100644 index 00000000..95950c42 --- /dev/null +++ b/third_party/googleapis_auth/test/src/service_account_credentials_test.dart @@ -0,0 +1,37 @@ +import 'package:googleapis_auth/src/service_account_credentials.dart'; +import 'package:test/test.dart'; + +void main() { + group(ServiceAccountCredentials, () { + group('fromJson', () { + test('throws exception if json is not a map', () { + expect( + () => ServiceAccountCredentials.fromJson('[1,2,3]'), + throwsArgumentError, + ); + }); + + test('throws exception if json is not a service account', () { + expect( + () => ServiceAccountCredentials.fromJson({ + 'type': 'not_service_account', + 'client_id': 'client_id', + 'private_key': 'private_key', + 'client_email': 'client_email', + }), + throwsArgumentError, + ); + }); + + test('throws exception if json is missing fields', () { + expect( + () => ServiceAccountCredentials.fromJson({ + 'type': 'service_account', + 'client_id': 'client_id', + }), + throwsArgumentError, + ); + }); + }); + }); +}