feat: add AuthProvider (#1743)
This commit is contained in:
@@ -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<oauth2.AccessCredentials> Function(
|
||||
AuthProvider authProvider,
|
||||
oauth2.ClientId clientId,
|
||||
List<String> scopes,
|
||||
http.Client client,
|
||||
@@ -46,6 +30,7 @@ typedef ObtainAccessCredentials = Future<oauth2.AccessCredentials> Function(
|
||||
);
|
||||
|
||||
typedef RefreshCredentials = Future<oauth2.AccessCredentials> 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<AccessCredentials> loginCI(void Function(String) prompt) async {
|
||||
Future<AccessCredentials> 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<void> login(void Function(String) prompt) async {
|
||||
Future<void> 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<String> 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'package:googleapis_auth/auth_io.dart' show GoogleAuthProvider;
|
||||
export 'microsoft_auth_provider.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(
|
||||
|
||||
@@ -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<int> 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}>.')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<MicrosoftAuthProvider>());
|
||||
});
|
||||
});
|
||||
|
||||
group('when issuer is accounts.google.com', () {
|
||||
setUp(() {
|
||||
when(() => payload.iss).thenReturn('https://accounts.google.com');
|
||||
});
|
||||
|
||||
test('returns AuthProvider.google', () {
|
||||
expect(jwt.authProvider, isA<GoogleAuthProvider>());
|
||||
});
|
||||
});
|
||||
|
||||
group('when issuer is unknown', () {
|
||||
setUp(() {
|
||||
when(() => payload.iss).thenReturn('https://example.com');
|
||||
});
|
||||
|
||||
test('throws exception', () {
|
||||
expect(
|
||||
() => jwt.authProvider,
|
||||
throwsA(
|
||||
isA<Exception>().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 = <String>[];
|
||||
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 = <AccessCredentials>[];
|
||||
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<http.Client>());
|
||||
expect(client, isA<AuthenticatedClient>());
|
||||
@@ -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<UserAlreadyLoggedInException>()),
|
||||
);
|
||||
|
||||
@@ -373,7 +476,7 @@ void main() {
|
||||
.thenAnswer((_) async => null);
|
||||
|
||||
await expectLater(
|
||||
auth.login((_) {}),
|
||||
auth.login(googleAuthProvider, prompt: (_) {}),
|
||||
throwsA(isA<UserNotFoundException>()),
|
||||
);
|
||||
|
||||
@@ -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<UserNotFoundException>()),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>.')),
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-80
@@ -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<AutoRefreshingAuthClient> clientViaApplicationDefaultCredentials({
|
||||
required List<String> 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<AutoRefreshingAuthClient> clientViaApplicationDefaultCredentials({
|
||||
/// {@macro googleapis_auth_not_close_the_baseClient}
|
||||
/// {@macro googleapis_auth_listen_port}
|
||||
Future<AutoRefreshingAuthClient> clientViaUserConsent(
|
||||
AuthProvider authProvider,
|
||||
ClientId clientId,
|
||||
List<String> scopes,
|
||||
PromptUserForConsent userPrompt, {
|
||||
@@ -130,6 +52,7 @@ Future<AutoRefreshingAuthClient> clientViaUserConsent(
|
||||
}
|
||||
|
||||
final flow = AuthorizationCodeGrantServerFlow(
|
||||
authProvider,
|
||||
clientId,
|
||||
scopes,
|
||||
baseClient,
|
||||
@@ -150,6 +73,7 @@ Future<AutoRefreshingAuthClient> clientViaUserConsent(
|
||||
}
|
||||
return AutoRefreshingClient(
|
||||
baseClient,
|
||||
authProvider,
|
||||
clientId,
|
||||
credentials,
|
||||
closeUnderlyingClient: closeUnderlyingClient,
|
||||
@@ -172,6 +96,7 @@ Future<AutoRefreshingAuthClient> clientViaUserConsent(
|
||||
/// {@macro googleapis_auth_close_the_client}
|
||||
/// {@macro googleapis_auth_not_close_the_baseClient}
|
||||
Future<AutoRefreshingAuthClient> clientViaUserConsentManual(
|
||||
AuthProvider authProvider,
|
||||
ClientId clientId,
|
||||
List<String> scopes,
|
||||
PromptUserForConsentManual userPrompt, {
|
||||
@@ -185,6 +110,7 @@ Future<AutoRefreshingAuthClient> clientViaUserConsentManual(
|
||||
}
|
||||
|
||||
final flow = AuthorizationCodeGrantManualFlow(
|
||||
authProvider,
|
||||
clientId,
|
||||
scopes,
|
||||
baseClient,
|
||||
@@ -205,6 +131,7 @@ Future<AutoRefreshingAuthClient> clientViaUserConsentManual(
|
||||
|
||||
return AutoRefreshingClient(
|
||||
baseClient,
|
||||
authProvider,
|
||||
clientId,
|
||||
credentials,
|
||||
closeUnderlyingClient: closeUnderlyingClient,
|
||||
@@ -232,6 +159,7 @@ Future<AutoRefreshingAuthClient> clientViaUserConsentManual(
|
||||
/// on the Google Cloud console.
|
||||
/// {@endtemplate}
|
||||
Future<AccessCredentials> obtainAccessCredentialsViaUserConsent(
|
||||
AuthProvider authProvider,
|
||||
ClientId clientId,
|
||||
List<String> scopes,
|
||||
Client client,
|
||||
@@ -240,6 +168,7 @@ Future<AccessCredentials> obtainAccessCredentialsViaUserConsent(
|
||||
int listenPort = 0,
|
||||
}) =>
|
||||
AuthorizationCodeGrantServerFlow(
|
||||
authProvider,
|
||||
clientId,
|
||||
scopes,
|
||||
client,
|
||||
@@ -261,6 +190,7 @@ Future<AccessCredentials> obtainAccessCredentialsViaUserConsent(
|
||||
///
|
||||
/// {@macro googleapis_auth_user_consent_return}
|
||||
Future<AccessCredentials> obtainAccessCredentialsViaUserConsentManual(
|
||||
AuthProvider authProvider,
|
||||
ClientId clientId,
|
||||
List<String> scopes,
|
||||
Client client,
|
||||
@@ -268,6 +198,7 @@ Future<AccessCredentials> obtainAccessCredentialsViaUserConsentManual(
|
||||
String? hostedDomain,
|
||||
}) =>
|
||||
AuthorizationCodeGrantManualFlow(
|
||||
authProvider,
|
||||
clientId,
|
||||
scopes,
|
||||
client,
|
||||
|
||||
@@ -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';
|
||||
|
||||
+4
-3
@@ -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<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
|
||||
File file,
|
||||
AuthProvider authProvider,
|
||||
String fileSource,
|
||||
List<String> scopes,
|
||||
Client baseClient,
|
||||
@@ -39,8 +38,10 @@ Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
|
||||
);
|
||||
return AutoRefreshingClient(
|
||||
baseClient,
|
||||
authProvider,
|
||||
clientId,
|
||||
await refreshCredentials(
|
||||
authProvider,
|
||||
clientId,
|
||||
AccessCredentials(
|
||||
// Hack: Create empty credentials that have expired.
|
||||
|
||||
+13
-10
@@ -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<AccessCredentials> refreshCredentials(
|
||||
AuthProvider authProvider,
|
||||
ClientId clientId,
|
||||
AccessCredentials credentials,
|
||||
Client client,
|
||||
@@ -116,12 +116,15 @@ Future<AccessCredentials> 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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> 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<AccessCredentials> obtainAccessCredentialsViaCodeExchange(
|
||||
AuthProvider authProvider,
|
||||
http.Client client,
|
||||
ClientId clientId,
|
||||
String code, {
|
||||
@@ -121,6 +120,7 @@ Future<AccessCredentials> obtainAccessCredentialsViaCodeExchange(
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': redirectUrl,
|
||||
},
|
||||
authProvider: authProvider,
|
||||
);
|
||||
final accessToken = parseAccessToken(jsonMap);
|
||||
|
||||
|
||||
Vendored
+6
@@ -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<String> scopes;
|
||||
final http.Client _client;
|
||||
|
||||
AuthorizationCodeGrantAbstractFlow(
|
||||
this.authProvider,
|
||||
this.clientId,
|
||||
this.scopes,
|
||||
this._client, {
|
||||
@@ -25,9 +28,11 @@ abstract class AuthorizationCodeGrantAbstractFlow implements BaseFlow {
|
||||
Future<AccessCredentials> 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,
|
||||
|
||||
+2
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+5
-6
@@ -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<Map<String, dynamic>> oauthTokenRequest(
|
||||
Map<String, String> postValues,
|
||||
) async {
|
||||
Map<String, String> postValues, {
|
||||
required AuthProvider authProvider,
|
||||
}) async {
|
||||
final body = Stream<List<int>>.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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,6 +31,7 @@ final _browserFlowRedirectMatcher = predicate<String>((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<void> 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<Exception>().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<Exception>().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),
|
||||
|
||||
+15
-2
@@ -19,6 +19,8 @@ final _defaultResponse = Response('', 500);
|
||||
Future<Response> _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<Response>.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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user