feat: Support new Auth service in CLI (#3638)
Co-authored-by: Eric Seidel <eric@shorebird.dev>
This commit is contained in:
@@ -13,11 +13,13 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:scoped_deps/scoped_deps.dart';
|
||||
import 'package:shorebird_cli/src/auth/ci_token.dart';
|
||||
import 'package:shorebird_cli/src/auth/endpoints/endpoints.dart';
|
||||
import 'package:shorebird_cli/src/auth/shorebird_oauth.dart' as shorebird_oauth;
|
||||
import 'package:shorebird_cli/src/http_client/http_client.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_cli/src/platform.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_command.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_env.dart';
|
||||
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
|
||||
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||
|
||||
@@ -59,6 +61,15 @@ typedef RefreshCredentials =
|
||||
oauth2.AuthEndpoints authEndpoints,
|
||||
});
|
||||
|
||||
/// Callback for obtaining Shorebird access credentials via loopback login.
|
||||
typedef ObtainCredentialsViaLoopbackLogin =
|
||||
Future<oauth2.AccessCredentials> Function({
|
||||
required http.Client httpClient,
|
||||
required Uri authBaseUrl,
|
||||
required void Function(String) userPrompt,
|
||||
Duration timeout,
|
||||
});
|
||||
|
||||
/// Callback when credentials are refreshed.
|
||||
typedef OnRefreshCredentials =
|
||||
void Function(oauth2.AccessCredentials credentials);
|
||||
@@ -70,12 +81,14 @@ class AuthenticatedClient extends http.BaseClient {
|
||||
AuthenticatedClient.credentials({
|
||||
required http.Client httpClient,
|
||||
required oauth2.AccessCredentials credentials,
|
||||
required Uri authServiceUri,
|
||||
OnRefreshCredentials? onRefreshCredentials,
|
||||
RefreshCredentials refreshCredentials = oauth2.refreshCredentials,
|
||||
}) : this._(
|
||||
httpClient: httpClient,
|
||||
onRefreshCredentials: onRefreshCredentials,
|
||||
credentials: credentials,
|
||||
authServiceUri: authServiceUri,
|
||||
refreshCredentials: refreshCredentials,
|
||||
);
|
||||
|
||||
@@ -84,17 +97,20 @@ class AuthenticatedClient extends http.BaseClient {
|
||||
AuthenticatedClient.token({
|
||||
required http.Client httpClient,
|
||||
required CiToken token,
|
||||
required Uri authServiceUri,
|
||||
OnRefreshCredentials? onRefreshCredentials,
|
||||
RefreshCredentials refreshCredentials = oauth2.refreshCredentials,
|
||||
}) : this._(
|
||||
httpClient: httpClient,
|
||||
token: token,
|
||||
authServiceUri: authServiceUri,
|
||||
onRefreshCredentials: onRefreshCredentials,
|
||||
refreshCredentials: refreshCredentials,
|
||||
);
|
||||
|
||||
AuthenticatedClient._({
|
||||
required http.Client httpClient,
|
||||
required Uri authServiceUri,
|
||||
OnRefreshCredentials? onRefreshCredentials,
|
||||
oauth2.AccessCredentials? credentials,
|
||||
CiToken? token,
|
||||
@@ -103,11 +119,13 @@ class AuthenticatedClient extends http.BaseClient {
|
||||
_credentials = credentials,
|
||||
_onRefreshCredentials = onRefreshCredentials,
|
||||
_refreshCredentials = refreshCredentials,
|
||||
_authServiceUri = authServiceUri,
|
||||
_token = token;
|
||||
|
||||
final http.Client _baseClient;
|
||||
final OnRefreshCredentials? _onRefreshCredentials;
|
||||
final RefreshCredentials _refreshCredentials;
|
||||
final Uri _authServiceUri;
|
||||
oauth2.AccessCredentials? _credentials;
|
||||
final CiToken? _token;
|
||||
|
||||
@@ -117,29 +135,23 @@ class AuthenticatedClient extends http.BaseClient {
|
||||
|
||||
if (credentials == null) {
|
||||
final token = _token!;
|
||||
credentials = _credentials = await _tryRefreshCredentials(
|
||||
token.authProvider.clientId,
|
||||
credentials = _credentials = await _refreshForProvider(
|
||||
token.authProvider,
|
||||
oauth2.AccessCredentials(
|
||||
// This isn't relevant for a refresh operation.
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
token.refreshToken,
|
||||
token.authProvider.scopes,
|
||||
),
|
||||
_baseClient,
|
||||
authEndpoints: token.authProvider.authEndpoints,
|
||||
);
|
||||
_onRefreshCredentials?.call(credentials);
|
||||
}
|
||||
|
||||
if (credentials.accessToken.hasExpired && credentials.idToken != null) {
|
||||
final jwt = Jwt.parse(credentials.idToken!);
|
||||
final authProvider = jwt.authProvider;
|
||||
|
||||
credentials = _credentials = await _tryRefreshCredentials(
|
||||
authProvider.clientId,
|
||||
credentials = _credentials = await _refreshForProvider(
|
||||
jwt.authProvider,
|
||||
credentials,
|
||||
_baseClient,
|
||||
authEndpoints: authProvider.authEndpoints,
|
||||
);
|
||||
_onRefreshCredentials?.call(credentials);
|
||||
}
|
||||
@@ -149,18 +161,26 @@ class AuthenticatedClient extends http.BaseClient {
|
||||
return _baseClient.send(request);
|
||||
}
|
||||
|
||||
Future<oauth2.AccessCredentials> _tryRefreshCredentials(
|
||||
oauth2.ClientId clientId,
|
||||
Future<oauth2.AccessCredentials> _refreshForProvider(
|
||||
AuthProvider authProvider,
|
||||
oauth2.AccessCredentials credentials,
|
||||
http.Client client, {
|
||||
required oauth2.AuthEndpoints authEndpoints,
|
||||
}) async {
|
||||
) async {
|
||||
try {
|
||||
// Shorebird uses its own refresh flow; Google and Microsoft use the
|
||||
// standard OAuth refresh. This branching can be removed once the
|
||||
// Google/Microsoft providers are fully removed from the CLI.
|
||||
if (authProvider == AuthProvider.shorebird) {
|
||||
return await shorebird_oauth.refreshShorebirdCredentials(
|
||||
credentials,
|
||||
_baseClient,
|
||||
authBaseUrl: _authServiceUri,
|
||||
);
|
||||
}
|
||||
return await _refreshCredentials(
|
||||
clientId,
|
||||
authProvider.clientId,
|
||||
credentials,
|
||||
client,
|
||||
authEndpoints: authEndpoints,
|
||||
_baseClient,
|
||||
authEndpoints: authProvider.authEndpoints,
|
||||
);
|
||||
} on Exception catch (e, s) {
|
||||
logger
|
||||
@@ -182,14 +202,20 @@ class Auth {
|
||||
Auth({
|
||||
http.Client? httpClient,
|
||||
String? credentialsDir,
|
||||
Uri? authServiceUri,
|
||||
ObtainAccessCredentials? obtainAccessCredentials,
|
||||
ObtainCredentialsViaLoopbackLogin? obtainCredentialsViaLoopbackLogin,
|
||||
CodePushClientBuilder? buildCodePushClient,
|
||||
}) : _httpClient = httpClient ?? _defaultHttpClient,
|
||||
_credentialsDir =
|
||||
credentialsDir ?? applicationConfigHome(executableName),
|
||||
_authServiceUri = authServiceUri ?? shorebirdEnv.authServiceUri,
|
||||
_obtainAccessCredentials =
|
||||
obtainAccessCredentials ??
|
||||
oauth2.obtainAccessCredentialsViaUserConsent,
|
||||
_obtainCredentialsViaLoopbackLogin =
|
||||
obtainCredentialsViaLoopbackLogin ??
|
||||
shorebird_oauth.obtainCredentialsViaLoopbackLogin,
|
||||
_buildCodePushClient = buildCodePushClient ?? CodePushClient.new {
|
||||
_loadCredentials();
|
||||
}
|
||||
@@ -198,7 +224,9 @@ class Auth {
|
||||
|
||||
final http.Client _httpClient;
|
||||
final String _credentialsDir;
|
||||
final Uri _authServiceUri;
|
||||
final ObtainAccessCredentials _obtainAccessCredentials;
|
||||
final ObtainCredentialsViaLoopbackLogin _obtainCredentialsViaLoopbackLogin;
|
||||
final CodePushClientBuilder _buildCodePushClient;
|
||||
CiToken? _token;
|
||||
|
||||
@@ -214,12 +242,17 @@ class Auth {
|
||||
}
|
||||
|
||||
if (_token != null) {
|
||||
return AuthenticatedClient.token(token: _token!, httpClient: _httpClient);
|
||||
return AuthenticatedClient.token(
|
||||
token: _token!,
|
||||
httpClient: _httpClient,
|
||||
authServiceUri: _authServiceUri,
|
||||
);
|
||||
}
|
||||
|
||||
return AuthenticatedClient.credentials(
|
||||
credentials: _credentials!,
|
||||
httpClient: _httpClient,
|
||||
authServiceUri: _authServiceUri,
|
||||
onRefreshCredentials: _flushCredentials,
|
||||
);
|
||||
}
|
||||
@@ -231,19 +264,19 @@ class Auth {
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final credentials = await _obtainAccessCredentials(
|
||||
authProvider.clientId,
|
||||
authProvider.scopes,
|
||||
client,
|
||||
prompt,
|
||||
authEndpoints: authProvider.authEndpoints,
|
||||
final credentials = await _obtainCredentials(
|
||||
authProvider,
|
||||
client: client,
|
||||
prompt: prompt,
|
||||
);
|
||||
|
||||
final codePushClient = _buildCodePushClient(
|
||||
httpClient: AuthenticatedClient.credentials(
|
||||
credentials: credentials,
|
||||
httpClient: _httpClient,
|
||||
authServiceUri: _authServiceUri,
|
||||
),
|
||||
hostedUri: shorebirdEnv.hostedUri,
|
||||
);
|
||||
final user = await codePushClient.getCurrentUser();
|
||||
if (user == null) {
|
||||
@@ -276,15 +309,16 @@ class Auth {
|
||||
|
||||
final client = http.Client();
|
||||
try {
|
||||
_credentials = await _obtainAccessCredentials(
|
||||
authProvider.clientId,
|
||||
authProvider.scopes,
|
||||
client,
|
||||
prompt,
|
||||
authEndpoints: authProvider.authEndpoints,
|
||||
_credentials = await _obtainCredentials(
|
||||
authProvider,
|
||||
client: client,
|
||||
prompt: prompt,
|
||||
);
|
||||
|
||||
final codePushClient = _buildCodePushClient(httpClient: this.client);
|
||||
final codePushClient = _buildCodePushClient(
|
||||
httpClient: this.client,
|
||||
hostedUri: shorebirdEnv.hostedUri,
|
||||
);
|
||||
|
||||
final user = await codePushClient.getCurrentUser();
|
||||
if (user == null) {
|
||||
@@ -298,6 +332,30 @@ class Auth {
|
||||
}
|
||||
}
|
||||
|
||||
Future<oauth2.AccessCredentials> _obtainCredentials(
|
||||
AuthProvider authProvider, {
|
||||
required http.Client client,
|
||||
required void Function(String) prompt,
|
||||
}) async {
|
||||
// Shorebird uses its own login flow; Google and Microsoft use the
|
||||
// standard OAuth consent flow. This branching can be removed once the
|
||||
// Google/Microsoft providers are fully removed from the CLI.
|
||||
if (authProvider == AuthProvider.shorebird) {
|
||||
return _obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: client,
|
||||
authBaseUrl: _authServiceUri,
|
||||
userPrompt: prompt,
|
||||
);
|
||||
}
|
||||
return _obtainAccessCredentials(
|
||||
authProvider.clientId,
|
||||
authProvider.scopes,
|
||||
client,
|
||||
prompt,
|
||||
authEndpoints: authProvider.authEndpoints,
|
||||
);
|
||||
}
|
||||
|
||||
/// Logs out the user.
|
||||
void logout() => _clearCredentials();
|
||||
|
||||
@@ -413,7 +471,9 @@ class UserNotFoundException implements Exception {
|
||||
extension OauthAuthProvider on Jwt {
|
||||
/// Get the [AuthProvider] from the JWT issuer.
|
||||
AuthProvider get authProvider {
|
||||
if (payload.iss == googleJwtIssuer) {
|
||||
if (payload.iss == shorebirdEnv.jwtIssuer) {
|
||||
return AuthProvider.shorebird;
|
||||
} else if (payload.iss == googleJwtIssuer) {
|
||||
return AuthProvider.google;
|
||||
} else if (payload.iss.startsWith(microsoftJwtIssuerPrefix)) {
|
||||
return AuthProvider.microsoft;
|
||||
@@ -426,11 +486,14 @@ extension OauthAuthProvider on Jwt {
|
||||
/// Extension on [AuthProvider] which exposes OAuth 2.0 values.
|
||||
extension OauthValues on AuthProvider {
|
||||
/// The OAuth 2.0 endpoints for the provider.
|
||||
///
|
||||
/// This getter only exists to support the Google and Microsoft OAuth flows.
|
||||
/// It can be removed once those providers are fully removed from the CLI.
|
||||
oauth2.AuthEndpoints get authEndpoints => switch (this) {
|
||||
(AuthProvider.google) => const oauth2.GoogleAuthEndpoints(),
|
||||
(AuthProvider.microsoft) => MicrosoftAuthEndpoints(),
|
||||
(AuthProvider.shorebird) => throw UnsupportedError(
|
||||
'Shorebird auth is not yet supported in the CLI',
|
||||
'Shorebird auth does not use OAuth endpoints',
|
||||
),
|
||||
};
|
||||
|
||||
@@ -460,9 +523,7 @@ extension OauthValues on AuthProvider {
|
||||
'4fc38981-4ec4-4bd9-a755-e6ad9a413054',
|
||||
);
|
||||
case AuthProvider.shorebird:
|
||||
throw UnsupportedError(
|
||||
'Shorebird auth is not yet supported in the CLI',
|
||||
);
|
||||
throw UnsupportedError('Shorebird auth does not use a client ID');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,8 +539,7 @@ extension OauthValues on AuthProvider {
|
||||
// Required to get refresh tokens.
|
||||
'offline_access',
|
||||
],
|
||||
(AuthProvider.shorebird) => throw UnsupportedError(
|
||||
'Shorebird auth is not yet supported in the CLI',
|
||||
),
|
||||
// Shorebird auth doesn't use scopes.
|
||||
(AuthProvider.shorebird) => [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:googleapis_auth/auth_io.dart' as oauth2;
|
||||
import 'package:googleapis_auth/googleapis_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:jwt/jwt.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Exception thrown when the Shorebird auth flow fails.
|
||||
class ShorebirdAuthException implements Exception {
|
||||
/// Creates a [ShorebirdAuthException] with the given [message].
|
||||
const ShorebirdAuthException(this.message);
|
||||
|
||||
/// The error message.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ShorebirdAuthException: $message';
|
||||
}
|
||||
|
||||
/// Implements the full loopback login flow for Shorebird auth.
|
||||
///
|
||||
/// 1. Binds a local HTTP server on localhost with a random port.
|
||||
/// 2. Constructs the login URL pointing to the auth service.
|
||||
/// 3. Calls [userPrompt] with the login URL.
|
||||
/// 4. Waits for the auth service to redirect back with an auth code.
|
||||
/// 5. Exchanges the auth code for tokens via the auth service's /token endpoint.
|
||||
/// 6. Returns the tokens as [oauth2.AccessCredentials].
|
||||
Future<oauth2.AccessCredentials> obtainCredentialsViaLoopbackLogin({
|
||||
required http.Client httpClient,
|
||||
required Uri authBaseUrl,
|
||||
required void Function(String) userPrompt,
|
||||
Duration timeout = const Duration(minutes: 5),
|
||||
}) async {
|
||||
HttpServer server;
|
||||
try {
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
} on SocketException {
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv6, 0);
|
||||
}
|
||||
try {
|
||||
final port = server.port;
|
||||
const callbackPath = '/callback';
|
||||
final loginUrl = authBaseUrl.replace(
|
||||
path: p.url.join(authBaseUrl.path, 'login'),
|
||||
queryParameters: {'continue': 'http://localhost:$port$callbackPath'},
|
||||
);
|
||||
|
||||
userPrompt(loginUrl.toString());
|
||||
|
||||
final request = await _waitForCallback(
|
||||
server,
|
||||
callbackPath: callbackPath,
|
||||
timeout: timeout,
|
||||
);
|
||||
final code = await _extractAuthCode(request);
|
||||
|
||||
return await _exchangeAuthCode(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
code: code,
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Listens on [server] for a request to [callbackPath] and returns it.
|
||||
///
|
||||
/// Responds to all other requests with 404 so they don't hang.
|
||||
Future<HttpRequest> _waitForCallback(
|
||||
HttpServer server, {
|
||||
required String callbackPath,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final completer = Completer<HttpRequest>();
|
||||
final subscription = server.listen((request) {
|
||||
if (request.uri.path == callbackPath) {
|
||||
completer.complete(request);
|
||||
} else {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
unawaited(request.response.close());
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await completer.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
throw const ShorebirdAuthException(
|
||||
'Timed out waiting for authentication response.',
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a success page to the browser and extracts the auth code from the
|
||||
/// callback [request].
|
||||
///
|
||||
/// Throws [ShorebirdAuthException] if the callback contains an error or is
|
||||
/// missing the auth code.
|
||||
Future<String> _extractAuthCode(HttpRequest request) async {
|
||||
final code = request.uri.queryParameters['code'];
|
||||
final error = request.uri.queryParameters['error'];
|
||||
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.html
|
||||
..write(
|
||||
'<html><body><h1>Authentication complete.</h1> '
|
||||
'<p>You can close this window.</p></body></html>',
|
||||
);
|
||||
await request.response.close();
|
||||
|
||||
if (error != null) {
|
||||
throw ShorebirdAuthException(
|
||||
'Authentication failed: $error',
|
||||
);
|
||||
}
|
||||
|
||||
if (code == null) {
|
||||
throw const ShorebirdAuthException(
|
||||
'Authentication failed: no auth code received.',
|
||||
);
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/// Refreshes Shorebird tokens using the refresh token.
|
||||
///
|
||||
/// POSTs to the auth service's /token endpoint with
|
||||
/// `grant_type=refresh_token` and returns new [oauth2.AccessCredentials]
|
||||
/// including a rotated refresh token.
|
||||
Future<oauth2.AccessCredentials> refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials credentials,
|
||||
http.Client httpClient, {
|
||||
required Uri authBaseUrl,
|
||||
}) async {
|
||||
final refreshToken = credentials.refreshToken;
|
||||
if (refreshToken == null) {
|
||||
throw const ShorebirdAuthException('No refresh token available.');
|
||||
}
|
||||
|
||||
final tokenUrl = authBaseUrl.replace(
|
||||
path: p.url.join(authBaseUrl.path, 'token'),
|
||||
);
|
||||
|
||||
final response = await httpClient.post(
|
||||
tokenUrl,
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw ShorebirdAuthException(
|
||||
'Token refresh failed (${response.statusCode}): ${response.body}',
|
||||
);
|
||||
}
|
||||
|
||||
return _parseTokenResponse(response.body, expectedIssuer: authBaseUrl);
|
||||
}
|
||||
|
||||
/// Exchanges an auth code for tokens by POSTing to the auth service's
|
||||
/// /token endpoint.
|
||||
Future<oauth2.AccessCredentials> _exchangeAuthCode({
|
||||
required http.Client httpClient,
|
||||
required Uri authBaseUrl,
|
||||
required String code,
|
||||
}) async {
|
||||
final tokenUrl = authBaseUrl.replace(
|
||||
path: p.url.join(authBaseUrl.path, 'token'),
|
||||
);
|
||||
|
||||
final response = await httpClient.post(
|
||||
tokenUrl,
|
||||
body: {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw ShorebirdAuthException(
|
||||
'Token exchange failed (${response.statusCode}): ${response.body}',
|
||||
);
|
||||
}
|
||||
|
||||
return _parseTokenResponse(response.body, expectedIssuer: authBaseUrl);
|
||||
}
|
||||
|
||||
/// Parses the JSON token response from the auth service into
|
||||
/// [oauth2.AccessCredentials].
|
||||
///
|
||||
/// Validates that the `access_token` is a well-formed JWT and that its
|
||||
/// issuer matches [expectedIssuer].
|
||||
///
|
||||
/// Expected JSON shape:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "access_token": "<JWT>",
|
||||
/// "refresh_token": "sb_rt_...",
|
||||
/// "token_type": "Bearer",
|
||||
/// "expires_in": 900
|
||||
/// }
|
||||
/// ```
|
||||
oauth2.AccessCredentials _parseTokenResponse(
|
||||
String responseBody, {
|
||||
required Uri expectedIssuer,
|
||||
}) {
|
||||
final json = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final accessTokenValue = json['access_token'] as String;
|
||||
final refreshToken = json['refresh_token'] as String?;
|
||||
final tokenType = json['token_type'] as String? ?? 'Bearer';
|
||||
final expiresIn = json['expires_in'] as int;
|
||||
|
||||
// Validate the access token is a well-formed JWT.
|
||||
final Jwt jwt;
|
||||
try {
|
||||
jwt = Jwt.parse(accessTokenValue);
|
||||
} on FormatException catch (e) {
|
||||
throw ShorebirdAuthException('Invalid access token: ${e.message}');
|
||||
}
|
||||
|
||||
// Validate the issuer matches the expected auth service.
|
||||
final issuer = expectedIssuer.toString();
|
||||
if (jwt.payload.iss != issuer) {
|
||||
throw ShorebirdAuthException(
|
||||
'Token issuer mismatch: expected $issuer, '
|
||||
'got ${jwt.payload.iss}',
|
||||
);
|
||||
}
|
||||
|
||||
final expiry = clock.now().add(Duration(seconds: expiresIn)).toUtc();
|
||||
|
||||
return oauth2.AccessCredentials(
|
||||
AccessToken(tokenType, accessTokenValue, expiry),
|
||||
refreshToken,
|
||||
// Shorebird auth doesn't use scopes.
|
||||
[],
|
||||
// The access token IS the JWT — setting idToken ensures
|
||||
// AuthenticatedClient.send() picks it up as the Bearer token.
|
||||
idToken: accessTokenValue,
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,18 @@ class LoginCiCommand extends ShorebirdCommand {
|
||||
argParser.addOption(
|
||||
'provider',
|
||||
abbr: 'p',
|
||||
allowed: api.AuthProvider.values.map((e) => e.name),
|
||||
allowed: _supportedProviders.map((e) => e.name),
|
||||
defaultsTo: api.AuthProvider.google.name,
|
||||
help: 'The authentication provider to use. Defaults to Google.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Providers supported for CI login. Shorebird auth tokens cannot be used
|
||||
/// for CI authentication.
|
||||
static final _supportedProviders = api.AuthProvider.values
|
||||
.where((p) => p != api.AuthProvider.shorebird)
|
||||
.toList();
|
||||
|
||||
@override
|
||||
String get description => 'Login as a CI user.';
|
||||
|
||||
@@ -31,11 +37,13 @@ class LoginCiCommand extends ShorebirdCommand {
|
||||
Future<int> run() async {
|
||||
final api.AuthProvider provider;
|
||||
if (results.wasParsed('provider')) {
|
||||
provider = api.AuthProvider.values.byName(results['provider'] as String);
|
||||
provider = api.AuthProvider.values.byName(
|
||||
results['provider'] as String,
|
||||
);
|
||||
} else {
|
||||
provider = logger.chooseOne(
|
||||
'Choose an auth provider',
|
||||
choices: api.AuthProvider.values,
|
||||
choices: _supportedProviders,
|
||||
display: (p) => p.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,24 +2,13 @@ import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:shorebird_cli/src/auth/auth.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_command.dart';
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'
|
||||
as api;
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
|
||||
|
||||
/// {@template login_command}
|
||||
/// `shorebird login`
|
||||
/// Login as a new Shorebird user.
|
||||
/// {@endtemplate}
|
||||
class LoginCommand extends ShorebirdCommand {
|
||||
/// {@macro login_command}
|
||||
LoginCommand() {
|
||||
argParser.addOption(
|
||||
'provider',
|
||||
abbr: 'p',
|
||||
allowed: api.AuthProvider.values.map((e) => e.name),
|
||||
help: 'The authentication provider to use.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String get description => 'Login as a new Shorebird user.';
|
||||
|
||||
@@ -37,19 +26,8 @@ class LoginCommand extends ShorebirdCommand {
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
|
||||
final api.AuthProvider provider;
|
||||
if (results.wasParsed('provider')) {
|
||||
provider = api.AuthProvider.values.byName(results['provider'] as String);
|
||||
} else {
|
||||
provider = logger.chooseOne(
|
||||
'Choose an auth provider',
|
||||
choices: api.AuthProvider.values,
|
||||
display: (p) => p.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.login(provider, prompt: prompt);
|
||||
await auth.login(AuthProvider.shorebird, prompt: prompt);
|
||||
} on UserNotFoundException catch (error) {
|
||||
final consoleUri = Uri.https('console.shorebird.dev');
|
||||
logger
|
||||
|
||||
@@ -228,6 +228,20 @@ class ShorebirdEnv {
|
||||
return module?['androidPackage'] as String?;
|
||||
}
|
||||
|
||||
/// The base URL for the Shorebird auth service. Can be overridden with the
|
||||
/// `AUTH_SERVICE_URL` environment variable. Defaults to
|
||||
/// `https://auth.shorebird.dev`.
|
||||
Uri get authServiceUri => Uri.parse(
|
||||
platform.environment['AUTH_SERVICE_URL'] ?? 'https://auth.shorebird.dev',
|
||||
);
|
||||
|
||||
/// The expected JWT issuer for Shorebird-issued tokens. Can be overridden
|
||||
/// with the `SHOREBIRD_JWT_ISSUER` environment variable. Defaults to
|
||||
/// `https://auth.shorebird.dev`.
|
||||
String get jwtIssuer =>
|
||||
platform.environment['SHOREBIRD_JWT_ISSUER'] ??
|
||||
'https://auth.shorebird.dev';
|
||||
|
||||
/// The base URL for the Shorebird code push server that overrides the default
|
||||
/// used by [CodePushClient]. If none is provided, [CodePushClient] will use
|
||||
/// its default.
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:shorebird_cli/src/http_client/http_client.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_cli/src/platform.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_env.dart';
|
||||
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
@@ -26,13 +27,18 @@ import '../mocks.dart';
|
||||
const googleJwtIssuer = 'https://accounts.google.com';
|
||||
const microsoftJwtIssuer =
|
||||
'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0';
|
||||
const shorebirdJwtIssuer = 'https://auth.shorebird.dev';
|
||||
|
||||
void main() {
|
||||
group('scoped', () {
|
||||
test('creates instance with default constructor', () {
|
||||
final instance = runScoped(
|
||||
() => auth,
|
||||
values: {authRef, httpClientRef.overrideWith(MockHttpClient.new)},
|
||||
values: {
|
||||
authRef,
|
||||
httpClientRef.overrideWith(MockHttpClient.new),
|
||||
shorebirdEnvRef.overrideWith(ShorebirdEnv.new),
|
||||
},
|
||||
);
|
||||
expect(
|
||||
instance.credentialsFilePath,
|
||||
@@ -63,6 +69,16 @@ void main() {
|
||||
group('OauthAuthProvider', () {
|
||||
late Jwt jwt;
|
||||
late JwtPayload payload;
|
||||
late ShorebirdEnv shorebirdEnv;
|
||||
|
||||
R runWithOverrides<R>(R Function() body) {
|
||||
return runScoped(
|
||||
body,
|
||||
values: {
|
||||
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
payload = MockJwtPayload();
|
||||
@@ -71,6 +87,8 @@ void main() {
|
||||
payload: payload,
|
||||
signature: 'signature',
|
||||
);
|
||||
shorebirdEnv = MockShorebirdEnv();
|
||||
when(() => shorebirdEnv.jwtIssuer).thenReturn(shorebirdJwtIssuer);
|
||||
});
|
||||
|
||||
group('authProvider', () {
|
||||
@@ -80,7 +98,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('returns AuthProvider.microsoft', () {
|
||||
expect(jwt.authProvider, equals(AuthProvider.microsoft));
|
||||
runWithOverrides(() {
|
||||
expect(jwt.authProvider, equals(AuthProvider.microsoft));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +110,24 @@ void main() {
|
||||
});
|
||||
|
||||
test('returns AuthProvider.google', () {
|
||||
expect(jwt.authProvider, equals(AuthProvider.google));
|
||||
runWithOverrides(() {
|
||||
expect(jwt.authProvider, equals(AuthProvider.google));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('when issuer is auth.shorebird.dev', () {
|
||||
setUp(() {
|
||||
when(() => payload.iss).thenReturn(shorebirdJwtIssuer);
|
||||
});
|
||||
|
||||
test('returns AuthProvider.shorebird', () {
|
||||
runWithOverrides(() {
|
||||
expect(
|
||||
jwt.authProvider,
|
||||
equals(AuthProvider.shorebird),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,16 +137,18 @@ void main() {
|
||||
});
|
||||
|
||||
test('throws exception', () {
|
||||
expect(
|
||||
() => jwt.authProvider,
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(e) => e.toString(),
|
||||
'message',
|
||||
'Exception: Unknown jwt issuer: https://example.com',
|
||||
runWithOverrides(() {
|
||||
expect(
|
||||
() => jwt.authProvider,
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(e) => e.toString(),
|
||||
'message',
|
||||
'Exception: Unknown jwt issuer: https://example.com',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -123,6 +162,23 @@ void main() {
|
||||
refreshToken: refreshToken,
|
||||
authProvider: AuthProvider.google,
|
||||
);
|
||||
// Decoded payload:
|
||||
// {
|
||||
// "iss": "https://auth.shorebird.dev",
|
||||
// "aud": "shorebird",
|
||||
// "sub": "12345",
|
||||
// "email": "test@email.com",
|
||||
// "email_verified": true,
|
||||
// "iat": 1234,
|
||||
// "exp": 6789
|
||||
// }
|
||||
// cspell:disable-next-line
|
||||
const shorebirdIdToken =
|
||||
'''eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGguc2hvcmViaXJkLmRldiIsImF1ZCI6InNob3JlYmlyZCIsInN1YiI6IjEyMzQ1IiwiZW1haWwiOiJ0ZXN0QGVtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJpYXQiOjEyMzQsImV4cCI6Njc4OX0.dGVzdA''';
|
||||
const shorebirdCiToken = CiToken(
|
||||
refreshToken: 'sb_rt_test',
|
||||
authProvider: AuthProvider.shorebird,
|
||||
);
|
||||
const email = 'test@email.com';
|
||||
const user = PrivateUser(id: 42, email: email, jwtIssuer: googleJwtIssuer);
|
||||
const scopes = <String>[];
|
||||
@@ -139,9 +195,11 @@ void main() {
|
||||
late ShorebirdLogger logger;
|
||||
late Auth auth;
|
||||
late Platform platform;
|
||||
late ShorebirdEnv shorebirdEnv;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeBaseRequest());
|
||||
registerFallbackValue(Uri.parse(''));
|
||||
});
|
||||
|
||||
R runWithOverrides<R>(R Function() body) {
|
||||
@@ -151,6 +209,7 @@ void main() {
|
||||
httpClientRef.overrideWith(() => httpClient),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
platformRef.overrideWith(() => platform),
|
||||
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -173,6 +232,15 @@ void main() {
|
||||
}) async {
|
||||
return accessCredentials;
|
||||
},
|
||||
obtainCredentialsViaLoopbackLogin:
|
||||
({
|
||||
required http.Client httpClient,
|
||||
required Uri authBaseUrl,
|
||||
required void Function(String) userPrompt,
|
||||
Duration timeout = const Duration(minutes: 5),
|
||||
}) async {
|
||||
return accessCredentials;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -195,9 +263,15 @@ void main() {
|
||||
codePushClient = MockCodePushClient();
|
||||
logger = MockShorebirdLogger();
|
||||
platform = MockPlatform();
|
||||
shorebirdEnv = MockShorebirdEnv();
|
||||
|
||||
when(() => codePushClient.getCurrentUser()).thenAnswer((_) async => user);
|
||||
when(() => platform.environment).thenReturn(<String, String>{});
|
||||
when(() => shorebirdEnv.jwtIssuer).thenReturn(shorebirdJwtIssuer);
|
||||
when(
|
||||
() => shorebirdEnv.authServiceUri,
|
||||
).thenReturn(Uri.parse('https://auth.shorebird.dev'));
|
||||
when(() => shorebirdEnv.hostedUri).thenReturn(null);
|
||||
|
||||
auth = buildAuth();
|
||||
});
|
||||
@@ -224,6 +298,7 @@ void main() {
|
||||
() => AuthenticatedClient.token(
|
||||
token: ciToken,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
refreshCredentials:
|
||||
(
|
||||
clientId,
|
||||
@@ -249,6 +324,7 @@ void main() {
|
||||
final client = AuthenticatedClient.token(
|
||||
token: ciToken,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
refreshCredentials:
|
||||
(
|
||||
@@ -297,6 +373,7 @@ void main() {
|
||||
client = AuthenticatedClient.token(
|
||||
token: ciToken,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
refreshCredentials:
|
||||
(
|
||||
@@ -336,6 +413,7 @@ void main() {
|
||||
final client = AuthenticatedClient.token(
|
||||
token: ciToken,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
refreshCredentials:
|
||||
(
|
||||
@@ -359,6 +437,116 @@ void main() {
|
||||
request = captured.last as http.BaseRequest;
|
||||
expect(request.headers['Authorization'], equals('Bearer $idToken'));
|
||||
});
|
||||
|
||||
group('when token is Shorebird', () {
|
||||
test(
|
||||
'refreshes via Shorebird and uses new token',
|
||||
() async {
|
||||
when(() => httpClient.send(any())).thenAnswer(
|
||||
(_) async => http.StreamedResponse(
|
||||
const Stream.empty(),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': shorebirdIdToken,
|
||||
'refresh_token': 'sb_rt_new',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
final onRefreshCredentialsCalls = <oauth2.AccessCredentials>[];
|
||||
final client = AuthenticatedClient.token(
|
||||
token: shorebirdCiToken,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
);
|
||||
|
||||
await runWithOverrides(
|
||||
() => client.get(
|
||||
Uri.parse('https://example.com'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(onRefreshCredentialsCalls, hasLength(1));
|
||||
expect(
|
||||
onRefreshCredentialsCalls.first.refreshToken,
|
||||
equals('sb_rt_new'),
|
||||
);
|
||||
final captured = verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured;
|
||||
expect(captured, hasLength(1));
|
||||
final request = captured.first as http.BaseRequest;
|
||||
expect(
|
||||
request.headers['Authorization'],
|
||||
equals('Bearer $shorebirdIdToken'),
|
||||
);
|
||||
verify(
|
||||
() => httpClient.post(
|
||||
Uri.parse('https://auth.shorebird.dev/token'),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
group('when Shorebird refresh fails', () {
|
||||
late AuthenticatedClient client;
|
||||
setUp(() {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenThrow(Exception('refresh failed'));
|
||||
|
||||
client = AuthenticatedClient.token(
|
||||
token: shorebirdCiToken,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
);
|
||||
});
|
||||
|
||||
test('exits and logs correctly', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(
|
||||
() => client.get(
|
||||
Uri.parse('https://example.com'),
|
||||
),
|
||||
),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
verify(
|
||||
() => logger.err(
|
||||
'Failed to refresh credentials.',
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.info(
|
||||
'''Try logging out with ${lightBlue.wrap('shorebird logout')} and logging in again.''',
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.detail('Exception: refresh failed'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('credentials', () {
|
||||
@@ -387,6 +575,7 @@ void main() {
|
||||
final client = AuthenticatedClient.credentials(
|
||||
credentials: expiredCredentials,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
refreshCredentials:
|
||||
(
|
||||
@@ -447,6 +636,7 @@ void main() {
|
||||
client = AuthenticatedClient.credentials(
|
||||
credentials: expiredCredentials,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
refreshCredentials:
|
||||
(
|
||||
@@ -486,6 +676,7 @@ void main() {
|
||||
final client = AuthenticatedClient.credentials(
|
||||
credentials: accessCredentials,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
);
|
||||
|
||||
@@ -499,6 +690,138 @@ void main() {
|
||||
final request = captured.first as http.BaseRequest;
|
||||
expect(request.headers['Authorization'], equals('Bearer $idToken'));
|
||||
});
|
||||
|
||||
group('when expired credentials have Shorebird issuer', () {
|
||||
test(
|
||||
'refreshes via Shorebird and uses new token',
|
||||
() async {
|
||||
when(() => httpClient.send(any())).thenAnswer(
|
||||
(_) async => http.StreamedResponse(
|
||||
const Stream.empty(),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': shorebirdIdToken,
|
||||
'refresh_token': 'sb_rt_rotated',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
final onRefreshCredentialsCalls = <oauth2.AccessCredentials>[];
|
||||
final expiredShorebirdCredentials = oauth2.AccessCredentials(
|
||||
oauth2.AccessToken(
|
||||
'Bearer',
|
||||
'accessToken',
|
||||
DateTime.now().subtract(const Duration(minutes: 1)).toUtc(),
|
||||
),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
idToken: shorebirdIdToken,
|
||||
);
|
||||
|
||||
final client = AuthenticatedClient.credentials(
|
||||
credentials: expiredShorebirdCredentials,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
onRefreshCredentials: onRefreshCredentialsCalls.add,
|
||||
);
|
||||
|
||||
await runWithOverrides(
|
||||
() => client.get(
|
||||
Uri.parse('https://example.com'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(onRefreshCredentialsCalls, hasLength(1));
|
||||
expect(
|
||||
onRefreshCredentialsCalls.first.refreshToken,
|
||||
equals('sb_rt_rotated'),
|
||||
);
|
||||
final captured = verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured;
|
||||
expect(captured, hasLength(1));
|
||||
final request = captured.first as http.BaseRequest;
|
||||
expect(
|
||||
request.headers['Authorization'],
|
||||
equals('Bearer $shorebirdIdToken'),
|
||||
);
|
||||
verify(
|
||||
() => httpClient.post(
|
||||
Uri.parse('https://auth.shorebird.dev/token'),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('when Shorebird credential refresh fails', () {
|
||||
late AuthenticatedClient client;
|
||||
setUp(() {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenThrow(Exception('refresh failed'));
|
||||
|
||||
final expiredShorebirdCredentials = oauth2.AccessCredentials(
|
||||
oauth2.AccessToken(
|
||||
'Bearer',
|
||||
'accessToken',
|
||||
DateTime.now().subtract(const Duration(minutes: 1)).toUtc(),
|
||||
),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
idToken: shorebirdIdToken,
|
||||
);
|
||||
|
||||
client = AuthenticatedClient.credentials(
|
||||
credentials: expiredShorebirdCredentials,
|
||||
httpClient: httpClient,
|
||||
authServiceUri: Uri.parse('https://auth.shorebird.dev'),
|
||||
);
|
||||
});
|
||||
|
||||
test('exits and logs correctly', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(
|
||||
() => client.get(
|
||||
Uri.parse('https://example.com'),
|
||||
),
|
||||
),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
verify(
|
||||
() => logger.err(
|
||||
'Failed to refresh credentials.',
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.info(
|
||||
'''Try logging out with ${lightBlue.wrap('shorebird logout')} and logging in again.''',
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.detail('Exception: refresh failed'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -509,7 +832,9 @@ void main() {
|
||||
(_) async =>
|
||||
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
|
||||
);
|
||||
await auth.login(AuthProvider.google, prompt: (_) {});
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
);
|
||||
final client = auth.client;
|
||||
expect(client, isA<http.Client>());
|
||||
expect(client, isA<AuthenticatedClient>());
|
||||
@@ -607,7 +932,9 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
test(
|
||||
'should set the email when claims are valid and current user exists',
|
||||
() async {
|
||||
await auth.login(AuthProvider.google, prompt: (_) {});
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(buildAuth().email, email);
|
||||
@@ -619,7 +946,27 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
test(
|
||||
'''should set the email when claims are valid and current user exists''',
|
||||
() async {
|
||||
await auth.login(AuthProvider.microsoft, prompt: (_) {});
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.microsoft, prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(buildAuth().email, email);
|
||||
expect(buildAuth().isAuthenticated, isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('with Shorebird auth provider', () {
|
||||
test(
|
||||
'should set the email when login succeeds',
|
||||
() async {
|
||||
await runWithOverrides(
|
||||
() => auth.login(
|
||||
AuthProvider.shorebird,
|
||||
prompt: (_) {},
|
||||
),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
expect(buildAuth().email, email);
|
||||
@@ -635,7 +982,9 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
auth = buildAuth();
|
||||
|
||||
await expectLater(
|
||||
auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
),
|
||||
throwsA(isA<UserAlreadyLoggedInException>()),
|
||||
);
|
||||
|
||||
@@ -658,7 +1007,9 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
test('proceeds with login', () async {
|
||||
expect(auth.email, isNull);
|
||||
await auth.login(AuthProvider.google, prompt: (_) {});
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, equals(email));
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
});
|
||||
@@ -670,7 +1021,9 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
await expectLater(
|
||||
auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
),
|
||||
throwsA(isA<UserNotFoundException>()),
|
||||
);
|
||||
|
||||
@@ -690,7 +1043,9 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
test(
|
||||
'returns a CI token and does not set the email or cache credentials',
|
||||
() async {
|
||||
final token = await auth.loginCI(AuthProvider.google, prompt: (_) {});
|
||||
final token = await runWithOverrides(
|
||||
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
);
|
||||
expect(token.authProvider, ciToken.authProvider);
|
||||
expect(token.refreshToken, ciToken.refreshToken);
|
||||
expect(auth.email, isNull);
|
||||
@@ -702,13 +1057,34 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
},
|
||||
);
|
||||
|
||||
group('with Shorebird auth provider', () {
|
||||
test(
|
||||
'returns a CI token with Shorebird provider',
|
||||
() async {
|
||||
final token = await runWithOverrides(
|
||||
() => auth.loginCI(
|
||||
AuthProvider.shorebird,
|
||||
prompt: (_) {},
|
||||
),
|
||||
);
|
||||
expect(
|
||||
token.authProvider,
|
||||
AuthProvider.shorebird,
|
||||
);
|
||||
expect(token.refreshToken, refreshToken);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when user does not exist', () async {
|
||||
when(
|
||||
() => codePushClient.getCurrentUser(),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
await expectLater(
|
||||
auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
runWithOverrides(
|
||||
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
),
|
||||
throwsA(isA<UserNotFoundException>()),
|
||||
);
|
||||
|
||||
@@ -727,7 +1103,9 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
test('throws if credentials are missing a refresh token', () async {
|
||||
await expectLater(
|
||||
auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
runWithOverrides(
|
||||
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
|
||||
),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(e) => e.toString(),
|
||||
@@ -742,7 +1120,9 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
|
||||
group('logout', () {
|
||||
test('clears session and wipes state', () async {
|
||||
await auth.login(AuthProvider.google, prompt: (_) {});
|
||||
await runWithOverrides(
|
||||
() => auth.login(AuthProvider.google, prompt: (_) {}),
|
||||
);
|
||||
expect(auth.email, email);
|
||||
expect(auth.isAuthenticated, isTrue);
|
||||
|
||||
@@ -761,4 +1141,30 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('OauthValues', () {
|
||||
group('clientId', () {
|
||||
test('throws UnsupportedError for shorebird', () {
|
||||
expect(
|
||||
() => AuthProvider.shorebird.clientId,
|
||||
throwsA(
|
||||
isA<UnsupportedError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
'Shorebird auth does not use a client ID',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('authEndpoints', () {
|
||||
test('throws UnsupportedError for shorebird', () {
|
||||
expect(
|
||||
() => AuthProvider.shorebird.authEndpoints,
|
||||
throwsA(isA<UnsupportedError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:googleapis_auth/auth_io.dart' as oauth2;
|
||||
import 'package:googleapis_auth/googleapis_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shorebird_cli/src/auth/shorebird_oauth.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class MockHttpClient extends Mock implements http.Client {}
|
||||
|
||||
/// Builds a JWT string with the given [issuer] for testing.
|
||||
///
|
||||
/// The token has a valid 3-part structure (header.payload.signature) that
|
||||
/// can be parsed by `Jwt.parse()`.
|
||||
String _buildTestJwt({String issuer = 'https://auth.shorebird.dev'}) {
|
||||
String b64(Map<String, dynamic> json) =>
|
||||
base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', '');
|
||||
|
||||
final header = b64({'alg': 'RS256', 'kid': '1234', 'typ': 'JWT'});
|
||||
final payload = b64({
|
||||
'iss': issuer,
|
||||
'aud': 'shorebird',
|
||||
'sub': '12345',
|
||||
'email': 'test@email.com',
|
||||
'iat': 1234,
|
||||
'exp': 6789,
|
||||
});
|
||||
return '$header.$payload.dGVzdA';
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(Uri.parse(''));
|
||||
});
|
||||
|
||||
group('obtainCredentialsViaLoopbackLogin', () {
|
||||
late MockHttpClient httpClient;
|
||||
final authBaseUrl = Uri.parse('https://auth.shorebird.dev');
|
||||
|
||||
setUp(() {
|
||||
httpClient = MockHttpClient();
|
||||
});
|
||||
|
||||
test('returns credentials on happy path', () async {
|
||||
final testJwt = _buildTestJwt();
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': testJwt,
|
||||
'refresh_token': 'sb_rt_test',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
final credentials = await obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
// Simulate the browser redirect with an auth code.
|
||||
unawaited(
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(credentials.accessToken.type, equals('Bearer'));
|
||||
expect(credentials.accessToken.data, equals(testJwt));
|
||||
expect(credentials.refreshToken, equals('sb_rt_test'));
|
||||
expect(credentials.idToken, equals(testJwt));
|
||||
expect(credentials.scopes, isEmpty);
|
||||
|
||||
final captured = verify(
|
||||
() => httpClient.post(
|
||||
captureAny(),
|
||||
headers: any(named: 'headers'),
|
||||
body: captureAny(named: 'body'),
|
||||
),
|
||||
).captured;
|
||||
final tokenUrl = captured[0] as Uri;
|
||||
expect(tokenUrl.path, contains('/token'));
|
||||
final body = captured[1] as Map<String, String>;
|
||||
expect(body['grant_type'], equals('authorization_code'));
|
||||
expect(body['code'], equals('test_code'));
|
||||
});
|
||||
|
||||
test('constructs correct login URL with continue parameter', () async {
|
||||
final testJwt = _buildTestJwt();
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': testJwt,
|
||||
'refresh_token': 'rt',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
late String capturedUrl;
|
||||
await obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
capturedUrl = url;
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
unawaited(
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final loginUri = Uri.parse(capturedUrl);
|
||||
expect(loginUri.host, equals('auth.shorebird.dev'));
|
||||
expect(loginUri.path, contains('/login'));
|
||||
expect(
|
||||
loginUri.queryParameters['continue'],
|
||||
allOf(
|
||||
startsWith('http://localhost:'),
|
||||
contains('/callback'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles authBaseUrl with trailing slash', () async {
|
||||
final authBaseUrlWithSlash = Uri.parse('https://auth.shorebird.dev/v1/');
|
||||
final testJwt = _buildTestJwt(issuer: 'https://auth.shorebird.dev/v1/');
|
||||
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': testJwt,
|
||||
'refresh_token': 'rt',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
late String capturedUrl;
|
||||
await obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrlWithSlash,
|
||||
userPrompt: (url) {
|
||||
capturedUrl = url;
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
unawaited(
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final loginUri = Uri.parse(capturedUrl);
|
||||
expect(loginUri.path, equals('/v1/login'));
|
||||
|
||||
final captured = verify(
|
||||
() => httpClient.post(
|
||||
captureAny(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).captured;
|
||||
final tokenUrl = captured[0] as Uri;
|
||||
expect(tokenUrl.path, equals('/v1/token'));
|
||||
});
|
||||
|
||||
test('ignores non-callback requests like favicon', () async {
|
||||
final testJwt = _buildTestJwt();
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': testJwt,
|
||||
'refresh_token': 'sb_rt_test',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
final credentials = await obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
final callbackUri = Uri.parse(continueUrl);
|
||||
final baseUrl = 'http://localhost:${callbackUri.port}';
|
||||
// Send a favicon request first — should be ignored.
|
||||
// Use .ignore() because the server may close before responding.
|
||||
http.get(Uri.parse('$baseUrl/favicon.ico')).ignore();
|
||||
// Then send the actual callback with auth code.
|
||||
unawaited(
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(credentials.accessToken.type, equals('Bearer'));
|
||||
expect(credentials.refreshToken, equals('sb_rt_test'));
|
||||
});
|
||||
|
||||
test('throws when redirect contains error parameter', () async {
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
unawaited(
|
||||
http.get(
|
||||
Uri.parse('$continueUrl?error=invalid_redirect'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('invalid_redirect'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when redirect has no code parameter', () async {
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
unawaited(
|
||||
http.get(Uri.parse(continueUrl)),
|
||||
);
|
||||
},
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('no auth code received'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when token exchange returns non-200', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response('Unauthorized', HttpStatus.unauthorized),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
// Use .ignore() to suppress connection errors when the server
|
||||
// closes after the token exchange failure.
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')).ignore();
|
||||
},
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Token exchange failed (401)'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on timeout when no redirect arrives', () async {
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (_) {
|
||||
// Do nothing — simulate the browser never redirecting.
|
||||
},
|
||||
timeout: const Duration(milliseconds: 100),
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Timed out'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when access_token is not a valid JWT', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': 'not_a_jwt',
|
||||
'refresh_token': 'sb_rt_test',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')).ignore();
|
||||
},
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Invalid access token'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when JWT issuer does not match expected issuer', () async {
|
||||
final wrongIssuerJwt = _buildTestJwt(issuer: 'https://evil.example.com');
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': wrongIssuerJwt,
|
||||
'refresh_token': 'sb_rt_test',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')).ignore();
|
||||
},
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Token issuer mismatch'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on network error during token exchange', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenThrow(
|
||||
const SocketException('Connection refused'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')).ignore();
|
||||
},
|
||||
),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when response body is not valid JSON', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
'<html>Server Error</html>',
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')).ignore();
|
||||
},
|
||||
),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when response is missing access_token', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'refresh_token': 'sb_rt_test',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')).ignore();
|
||||
},
|
||||
),
|
||||
throwsA(isA<TypeError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when response is missing expires_in', () async {
|
||||
final testJwt = _buildTestJwt();
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': testJwt,
|
||||
'refresh_token': 'sb_rt_test',
|
||||
'token_type': 'Bearer',
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
obtainCredentialsViaLoopbackLogin(
|
||||
httpClient: httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
userPrompt: (url) {
|
||||
final loginUri = Uri.parse(url);
|
||||
final continueUrl = loginUri.queryParameters['continue']!;
|
||||
http.get(Uri.parse('$continueUrl?code=test_code')).ignore();
|
||||
},
|
||||
),
|
||||
throwsA(isA<TypeError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('refreshShorebirdCredentials', () {
|
||||
late MockHttpClient httpClient;
|
||||
final authBaseUrl = Uri.parse('https://auth.shorebird.dev');
|
||||
|
||||
setUp(() {
|
||||
httpClient = MockHttpClient();
|
||||
});
|
||||
|
||||
test('returns new credentials with rotated refresh token', () async {
|
||||
final testJwt = _buildTestJwt();
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': testJwt,
|
||||
'refresh_token': 'sb_rt_new',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
final credentials = await refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
);
|
||||
|
||||
expect(credentials.accessToken.type, equals('Bearer'));
|
||||
expect(credentials.accessToken.data, equals(testJwt));
|
||||
expect(credentials.refreshToken, equals('sb_rt_new'));
|
||||
expect(credentials.idToken, equals(testJwt));
|
||||
expect(credentials.scopes, isEmpty);
|
||||
|
||||
final captured = verify(
|
||||
() => httpClient.post(
|
||||
captureAny(),
|
||||
headers: any(named: 'headers'),
|
||||
body: captureAny(named: 'body'),
|
||||
),
|
||||
).captured;
|
||||
final tokenUrl = captured[0] as Uri;
|
||||
expect(tokenUrl.path, contains('/token'));
|
||||
final body = captured[1] as Map<String, String>;
|
||||
expect(body['grant_type'], equals('refresh_token'));
|
||||
expect(body['refresh_token'], equals('sb_rt_old'));
|
||||
});
|
||||
|
||||
test('throws when no refresh token is available', () async {
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
null,
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('No refresh token available'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when token refresh returns 401', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
'Token expired',
|
||||
HttpStatus.unauthorized,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_expired',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Token refresh failed (401)'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws with message on network error', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenThrow(
|
||||
const SocketException('Connection refused'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_test',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when access_token is not a valid JWT', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': 'not_a_jwt',
|
||||
'refresh_token': 'sb_rt_new',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Invalid access token'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when JWT issuer does not match expected issuer', () async {
|
||||
final wrongIssuerJwt = _buildTestJwt(issuer: 'https://evil.example.com');
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': wrongIssuerJwt,
|
||||
'refresh_token': 'sb_rt_new',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(
|
||||
isA<ShorebirdAuthException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Token issuer mismatch'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when response body is not valid JSON', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
'<html>Server Error</html>',
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when response is missing access_token', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'refresh_token': 'sb_rt_new',
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 900,
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(isA<TypeError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when response is missing expires_in', () async {
|
||||
final testJwt = _buildTestJwt();
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'access_token': testJwt,
|
||||
'refresh_token': 'sb_rt_new',
|
||||
'token_type': 'Bearer',
|
||||
}),
|
||||
HttpStatus.ok,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
refreshShorebirdCredentials(
|
||||
oauth2.AccessCredentials(
|
||||
AccessToken('Bearer', '', DateTime.timestamp()),
|
||||
'sb_rt_old',
|
||||
[],
|
||||
),
|
||||
httpClient,
|
||||
authBaseUrl: authBaseUrl,
|
||||
),
|
||||
throwsA(isA<TypeError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ShorebirdAuthException', () {
|
||||
test('toString includes message', () {
|
||||
const exception = ShorebirdAuthException('test error');
|
||||
expect(
|
||||
exception.toString(),
|
||||
equals('ShorebirdAuthException: test error'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:args/args.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -18,7 +17,6 @@ void main() {
|
||||
group(LoginCommand, () {
|
||||
const email = 'test@email.com';
|
||||
|
||||
late ArgResults results;
|
||||
late Auth auth;
|
||||
late http.Client httpClient;
|
||||
late Directory applicationConfigHome;
|
||||
@@ -36,18 +34,15 @@ void main() {
|
||||
}
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(AuthProvider.google);
|
||||
registerFallbackValue(AuthProvider.shorebird);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
applicationConfigHome = Directory.systemTemp.createTempSync();
|
||||
results = MockArgResults();
|
||||
auth = MockAuth();
|
||||
httpClient = MockHttpClient();
|
||||
logger = MockShorebirdLogger();
|
||||
|
||||
when(() => results.wasParsed('provider')).thenReturn(false);
|
||||
when(() => results['provider']).thenReturn(null);
|
||||
when(() => auth.isAuthenticated).thenReturn(false);
|
||||
when(() => auth.client).thenReturn(httpClient);
|
||||
when(
|
||||
@@ -57,69 +52,7 @@ void main() {
|
||||
() => auth.login(any(), prompt: any(named: 'prompt')),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
when(
|
||||
() => logger.chooseOne<AuthProvider>(
|
||||
any(),
|
||||
choices: any(named: 'choices'),
|
||||
display: any(named: 'display'),
|
||||
),
|
||||
).thenReturn(AuthProvider.google);
|
||||
|
||||
command = runWithOverrides(
|
||||
() => LoginCommand()..testArgResults = results,
|
||||
);
|
||||
});
|
||||
|
||||
group('provider', () {
|
||||
group('when provider is passed as an arg', () {
|
||||
const provider = AuthProvider.google;
|
||||
|
||||
setUp(() {
|
||||
when(() => results.wasParsed('provider')).thenReturn(true);
|
||||
when(() => results['provider']).thenReturn(provider.name);
|
||||
});
|
||||
|
||||
test('uses the passed provider', () async {
|
||||
await runWithOverrides(() => command.run());
|
||||
|
||||
verify(
|
||||
() => auth.login(provider, prompt: any(named: 'prompt')),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when provider is not passed as an arg', () {
|
||||
const provider = AuthProvider.microsoft;
|
||||
|
||||
setUp(() {
|
||||
when(() => results.wasParsed('provider')).thenReturn(false);
|
||||
when(
|
||||
() => logger.chooseOne<AuthProvider>(
|
||||
any(),
|
||||
choices: any(named: 'choices'),
|
||||
display: any(named: 'display'),
|
||||
),
|
||||
).thenReturn(provider);
|
||||
});
|
||||
|
||||
test('uses the provider chosen by the user', () async {
|
||||
await runWithOverrides(() => command.run());
|
||||
|
||||
verify(
|
||||
() => auth.login(provider, prompt: any(named: 'prompt')),
|
||||
).called(1);
|
||||
final captured =
|
||||
verify(
|
||||
() => logger.chooseOne<AuthProvider>(
|
||||
any(),
|
||||
choices: any(named: 'choices'),
|
||||
display: captureAny(named: 'display'),
|
||||
),
|
||||
).captured.single
|
||||
as String Function(AuthProvider);
|
||||
expect(captured(AuthProvider.google), contains('Google'));
|
||||
});
|
||||
});
|
||||
command = runWithOverrides(LoginCommand.new);
|
||||
});
|
||||
|
||||
group('when user is already logged in', () {
|
||||
|
||||
@@ -873,6 +873,46 @@ base_url: https://example.com''');
|
||||
});
|
||||
});
|
||||
|
||||
group('authServiceUri', () {
|
||||
test('returns default URI when env var is not set', () {
|
||||
when(() => platform.environment).thenReturn({});
|
||||
expect(
|
||||
runWithOverrides(() => shorebirdEnv.authServiceUri),
|
||||
equals(Uri.parse('https://auth.shorebird.dev')),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns URI from env var when set', () {
|
||||
when(() => platform.environment).thenReturn({
|
||||
'AUTH_SERVICE_URL': 'https://custom-auth.example.com',
|
||||
});
|
||||
expect(
|
||||
runWithOverrides(() => shorebirdEnv.authServiceUri),
|
||||
equals(Uri.parse('https://custom-auth.example.com')),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('jwtIssuer', () {
|
||||
test('returns default issuer when env var is not set', () {
|
||||
when(() => platform.environment).thenReturn({});
|
||||
expect(
|
||||
runWithOverrides(() => shorebirdEnv.jwtIssuer),
|
||||
equals('https://auth.shorebird.dev'),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns issuer from env var when set', () {
|
||||
when(() => platform.environment).thenReturn({
|
||||
'SHOREBIRD_JWT_ISSUER': 'https://custom-issuer.example.com',
|
||||
});
|
||||
expect(
|
||||
runWithOverrides(() => shorebirdEnv.jwtIssuer),
|
||||
equals('https://custom-issuer.example.com'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isRunningOnCI', () {
|
||||
test('returns true if BOT variable is "true"', () {
|
||||
when(() => platform.environment).thenReturn({'BOT': 'true'});
|
||||
|
||||
Reference in New Issue
Block a user