feat: Add CLI support for authenticating with new API keys (#3646)

This commit is contained in:
Mac
2026-03-25 11:41:35 -06:00
committed by GitHub
parent 91536938a3
commit a884ae8595
9 changed files with 318 additions and 481 deletions
+6
View File
@@ -4,6 +4,12 @@
cspell:words pubspec erickzanardo xcframeworks cupertino codesign codecov rkishan appbundle proto tlsv kingdomseed Peetee Aditya
-->
## 1.6.91 (March 25, 2026)
- 🔐 Support API keys for authentication ([docs](https://docs.shorebird.dev/account/api-keys/))
- `login:ci` command has been deprecated
- Create API keys through the [web console](https://console.shorebird.dev)
## 1.6.90 (March 24, 2026)
- 🐦 Support for Flutter 3.41.5 & Dart 3.11.3
+67 -107
View File
@@ -42,16 +42,6 @@ const microsoftJwtIssuerPrefix = 'https://login.microsoftonline.com/';
/// The environment variable that holds the Shorebird CI token.
const shorebirdTokenEnvVar = 'SHOREBIRD_TOKEN';
/// Callback for obtaining access credentials.
typedef ObtainAccessCredentials =
Future<oauth2.AccessCredentials> Function(
oauth2.ClientId clientId,
List<String> scopes,
http.Client client,
void Function(String) userPrompt, {
oauth2.AuthEndpoints authEndpoints,
});
/// Callback for refreshing access credentials.
typedef RefreshCredentials =
Future<oauth2.AccessCredentials> Function(
@@ -196,6 +186,27 @@ class AuthenticatedClient extends http.BaseClient {
}
}
/// An HTTP client that authenticates requests using an API key.
///
/// Unlike [AuthenticatedClient], this client does not perform any token
/// refresh or exchange the API key is sent directly in the Authorization
/// header and the server handles validation.
class ApiKeyClient extends http.BaseClient {
/// Creates a new [ApiKeyClient].
ApiKeyClient({required String apiKey, required http.Client httpClient})
: _apiKey = apiKey,
_baseClient = httpClient;
final String _apiKey;
final http.Client _baseClient;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
request.headers['Authorization'] = 'Bearer $_apiKey';
return _baseClient.send(request);
}
}
/// An OAuth 2.0 authentication provider.
class Auth {
/// Creates a new [Auth] instance.
@@ -203,16 +214,12 @@ class 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,
@@ -225,10 +232,10 @@ class Auth {
final http.Client _httpClient;
final String _credentialsDir;
final Uri _authServiceUri;
final ObtainAccessCredentials _obtainAccessCredentials;
final ObtainCredentialsViaLoopbackLogin _obtainCredentialsViaLoopbackLogin;
final CodePushClientBuilder _buildCodePushClient;
CiToken? _token;
String? _apiKey;
/// The path to the credentials file.
String get credentialsFilePath {
@@ -237,8 +244,8 @@ class Auth {
/// The underlying HTTP client.
http.Client get client {
if (_credentials == null && _token == null) {
return _httpClient;
if (_apiKey != null) {
return ApiKeyClient(apiKey: _apiKey!, httpClient: _httpClient);
}
if (_token != null) {
@@ -249,70 +256,30 @@ class Auth {
);
}
return AuthenticatedClient.credentials(
credentials: _credentials!,
httpClient: _httpClient,
authServiceUri: _authServiceUri,
onRefreshCredentials: _flushCredentials,
);
}
/// Gets a CI token for the current user.
Future<CiToken> loginCI(
AuthProvider authProvider, {
required void Function(String) prompt,
}) async {
final client = http.Client();
try {
final credentials = await _obtainCredentials(
authProvider,
client: client,
prompt: prompt,
if (_credentials != null) {
return AuthenticatedClient.credentials(
credentials: _credentials!,
httpClient: _httpClient,
authServiceUri: _authServiceUri,
onRefreshCredentials: _flushCredentials,
);
final codePushClient = _buildCodePushClient(
httpClient: AuthenticatedClient.credentials(
credentials: credentials,
httpClient: _httpClient,
authServiceUri: _authServiceUri,
),
hostedUri: shorebirdEnv.hostedUri,
);
final user = await codePushClient.getCurrentUser();
if (user == null) {
throw UserNotFoundException(email: credentials.email!);
}
if (credentials.refreshToken == null) {
throw Exception('No refresh token found.');
}
return CiToken(
refreshToken: credentials.refreshToken!,
authProvider: authProvider,
);
} finally {
client.close();
}
return _httpClient;
}
/// Logs in the user.
Future<void> login(
AuthProvider authProvider, {
required void Function(String) prompt,
}) async {
/// Logs in the user via the Shorebird loopback OAuth flow.
Future<void> login({required void Function(String) prompt}) async {
if (isAuthenticated) {
// Because isAuthenticated is checks for the presence of either an email
// or a CI token, and because this method is for logging in without a CI
// token, we can safely assume that _email is not null.
throw UserAlreadyLoggedInException(email: _email!);
throw UserAlreadyLoggedInException(email: _email);
}
final client = http.Client();
try {
_credentials = await _obtainCredentials(
authProvider,
client: client,
prompt: prompt,
_credentials = await _obtainCredentialsViaLoopbackLogin(
httpClient: client,
authBaseUrl: _authServiceUri,
userPrompt: prompt,
);
final codePushClient = _buildCodePushClient(
@@ -332,30 +299,6 @@ 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.
///
/// If a Shorebird refresh token is available, revokes the server-side
@@ -401,26 +344,43 @@ class Auth {
String? get email => _email;
/// Whether the user is authenticated.
bool get isAuthenticated => _email != null || _token != null;
bool get isAuthenticated =>
_email != null || _token != null || _apiKey != null;
void _loadCredentials() {
final envToken = platform.environment[shorebirdTokenEnvVar];
if (envToken != null) {
final trimmed = envToken.trim();
logger.detail('[env] $shorebirdTokenEnvVar detected');
// New API key format pass through directly, no refresh needed.
if (trimmed.startsWith('sb_api_')) {
_apiKey = trimmed;
logger.detail('[env] $shorebirdTokenEnvVar parsed as API key');
return;
}
// Legacy CiToken format still supported, but deprecated.
try {
_token = CiToken.fromBase64(envToken.trim());
_token = CiToken.fromBase64(trimmed);
logger.warn(
'SHOREBIRD_TOKEN contains a legacy CI token from '
'`shorebird login:ci`. '
'This format is deprecated and will stop working in a future '
'release. '
'Create an API key at https://console.shorebird.dev instead.',
);
} on FormatException catch (e) {
logger
..err('''
Failed to parse CI token from environment. This likely means that your CI token is incorrectly formatted.
Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar environment variable, and try again.''')
..err(
'Failed to parse $shorebirdTokenEnvVar. Expected an API key '
'(sb_api_...) or a legacy CI token.',
)
..detail(e.toString());
rethrow;
}
logger.detail('[env] $shorebirdTokenEnvVar parsed');
logger.detail('[env] $shorebirdTokenEnvVar parsed as legacy CiToken');
return;
}
@@ -482,11 +442,11 @@ extension JwtClaims on oauth2.AccessCredentials {
/// Thrown when an already authenticated user attempts to log in or sign up.
class UserAlreadyLoggedInException implements Exception {
/// {@macro user_already_logged_in_exception}
UserAlreadyLoggedInException({required this.email});
UserAlreadyLoggedInException({this.email});
/// The email of the already authenticated user, as derived from the stored
/// auth credentials.
final String email;
/// The email of the already authenticated user, or `null` when
/// authenticated via an environment variable (API key / CI token).
final String? email;
}
/// {@template user_not_found_exception}
@@ -1,91 +1,28 @@
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;
/// {@template login_ci_command}
/// `shorebird login:ci`
/// Login as a CI user.
/// Deprecated directs users to API keys instead.
/// {@endtemplate}
class LoginCiCommand extends ShorebirdCommand {
/// {@macro login_ci_command}
LoginCiCommand() {
argParser.addOption(
'provider',
abbr: 'p',
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.';
String get description => 'Login as a CI user (deprecated).';
@override
String get name => 'login:ci';
@override
Future<int> run() async {
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: _supportedProviders,
display: (p) => p.displayName,
);
}
logger.info(
'''
${lightYellow.wrap('⚠ shorebird login:ci is deprecated.')}
final CiToken ciToken;
try {
ciToken = await auth.loginCI(provider, prompt: prompt);
} on UserNotFoundException catch (error) {
logger
..err('''
We could not find a Shorebird account for ${error.email}.''')
..info(
'''If you have not yet created an account, go to "${link(uri: Uri.parse('https://console.shorebird.dev'))}" to create one. If you believe this is an error, please reach out to us via Discord, we're happy to help!''',
);
return ExitCode.software.code;
} on Exception catch (error) {
logger.err(error.toString());
return ExitCode.software.code;
}
To authenticate in CI, create an API key at ${link(uri: Uri.parse('https://console.shorebird.dev'))} and set it as your ${lightCyan.wrap('SHOREBIRD_TOKEN')} environment variable.
logger.info('''
🎉 ${lightGreen.wrap('Success! Use the following token to login on a CI server:')}
${lightCyan.wrap(ciToken.toBase64())}
Example:
${lightCyan.wrap('export $shorebirdTokenEnvVar="\$SHOREBIRD_TOKEN" && shorebird patch android')}
''');
Existing tokens from login:ci will continue to work for now, but will stop working in a future release.''',
);
return ExitCode.success.code;
}
/// Prompt the user to visit the provided [url] to authorize the CLI.
void prompt(String url) {
logger.info('''
The Shorebird CLI needs your authorization to manage apps, releases, and patches on your behalf.
In a browser, visit this URL to log in:
${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))}
Waiting for your authorization...''');
}
}
@@ -2,7 +2,6 @@ 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';
/// {@template login_command}
/// `shorebird login`
@@ -18,8 +17,13 @@ class LoginCommand extends ShorebirdCommand {
@override
Future<int> run() async {
if (auth.isAuthenticated) {
final emailDisplay = auth.email;
logger
..info('You are already logged in as <${auth.email}>.')
..info(
emailDisplay != null
? 'You are already logged in as <$emailDisplay>.'
: 'You are already authenticated via API key.',
)
..info(
'Run ${lightCyan.wrap('shorebird logout')} to log out and try again.',
);
@@ -27,7 +31,7 @@ class LoginCommand extends ShorebirdCommand {
}
try {
await auth.login(AuthProvider.shorebird, prompt: prompt);
await auth.login(prompt: prompt);
} on UserNotFoundException catch (error) {
final consoleUri = Uri.https('console.shorebird.dev');
logger
+1 -1
View File
@@ -1,2 +1,2 @@
// Generated code. Do not modify.
const packageVersion = '1.6.90';
const packageVersion = '1.6.91';
+1 -1
View File
@@ -1,6 +1,6 @@
name: shorebird_cli
description: Command-line tool to interact with Shorebird's services.
version: 1.6.90
version: 1.6.91
repository: https://github.com/shorebirdtech/shorebird
resolution: workspace
@@ -222,16 +222,6 @@ void main() {
buildCodePushClient: ({Uri? hostedUri, http.Client? httpClient}) {
return codePushClient;
},
obtainAccessCredentials:
(
clientId,
scopes,
client,
userPrompt, {
AuthEndpoints authEndpoints = const GoogleAuthEndpoints(),
}) async {
return accessCredentials;
},
obtainCredentialsViaLoopbackLogin:
({
required http.Client httpClient,
@@ -833,7 +823,7 @@ void main() {
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
);
await runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
);
final client = auth.client;
expect(client, isA<http.Client>());
@@ -849,6 +839,71 @@ void main() {
expect(request.headers['Authorization'], equals('Bearer $idToken'));
});
group('when SHOREBIRD_TOKEN is an API key', () {
setUp(() {
when(() => platform.environment).thenReturn(<String, String>{
shorebirdTokenEnvVar: 'sb_api_abc123',
});
});
test('parses as API key and sets isAuthenticated', () {
auth = buildAuth();
expect(auth.isAuthenticated, isTrue);
expect(auth.email, isNull);
verify(
() => logger.detail('[env] $shorebirdTokenEnvVar detected'),
).called(1);
verify(
() => logger.detail(
'[env] $shorebirdTokenEnvVar parsed as API key',
),
).called(1);
});
test('trims whitespace from API key', () {
when(() => platform.environment).thenReturn(<String, String>{
shorebirdTokenEnvVar: ' sb_api_abc123 \n',
});
auth = buildAuth();
expect(auth.isAuthenticated, isTrue);
});
test('returns an ApiKeyClient from client getter', () {
auth = buildAuth();
final client = auth.client;
expect(client, isA<ApiKeyClient>());
});
test('ApiKeyClient sends correct Authorization header', () async {
when(() => httpClient.send(any())).thenAnswer(
(_) async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
);
auth = buildAuth();
final client = auth.client;
await runWithOverrides(
() => client.get(Uri.parse('https://example.com')),
);
final captured = verify(() => httpClient.send(captureAny())).captured;
expect(captured, hasLength(1));
final request = captured.first as http.BaseRequest;
expect(
request.headers['Authorization'],
equals('Bearer sb_api_abc123'),
);
});
test('takes priority over credentials file', () {
writeCredentials();
auth = buildAuth();
expect(auth.isAuthenticated, isTrue);
expect(auth.email, isNull);
expect(auth.client, isA<ApiKeyClient>());
});
});
group('when token is invalid', () {
setUp(() {
when(() => platform.environment).thenReturn(<String, String>{
@@ -857,7 +912,7 @@ void main() {
});
test(
'logs and throws error when token string is not valid base64',
'logs and throws error when token string is not valid',
() async {
expect(buildAuth, throwsA(isFormatException));
verify(
@@ -865,14 +920,14 @@ void main() {
).called(1);
verify(
() => logger.err(
'''
Failed to parse CI token from environment. This likely means that your CI token is incorrectly formatted.
Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar environment variable, and try again.''',
'Failed to parse $shorebirdTokenEnvVar. Expected an API key '
'(sb_api_...) or a legacy CI token.',
),
).called(1);
verifyNever(
() => logger.detail('[env] $shorebirdTokenEnvVar parsed'),
() => logger.detail(
'[env] $shorebirdTokenEnvVar parsed as legacy CiToken',
),
);
},
);
@@ -914,7 +969,18 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
() => logger.detail('[env] $shorebirdTokenEnvVar detected'),
).called(1);
verify(
() => logger.detail('[env] $shorebirdTokenEnvVar parsed'),
() => logger.warn(
'SHOREBIRD_TOKEN contains a legacy CI token from '
'`shorebird login:ci`. '
'This format is deprecated and will stop working in a future '
'release. '
'Create an API key at https://console.shorebird.dev instead.',
),
).called(1);
verify(
() => logger.detail(
'[env] $shorebirdTokenEnvVar parsed as legacy CiToken',
),
).called(1);
});
@@ -933,7 +999,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
'should set the email when claims are valid and current user exists',
() async {
await runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
);
expect(auth.email, email);
expect(auth.isAuthenticated, isTrue);
@@ -942,39 +1008,6 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
},
);
group('with custom auth provider', () {
test(
'''should set the email when claims are valid and current user exists''',
() async {
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);
expect(buildAuth().isAuthenticated, isTrue);
},
);
});
test(
'throws UserAlreadyLoggedInException if user is authenticated',
() async {
@@ -983,7 +1016,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
await expectLater(
runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
),
throwsA(isA<UserAlreadyLoggedInException>()),
);
@@ -993,6 +1026,29 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
},
);
test(
'throws UserAlreadyLoggedInException when authenticated via API key',
() async {
when(() => platform.environment).thenReturn(<String, String>{
shorebirdTokenEnvVar: 'sb_api_abc123',
});
auth = buildAuth();
await expectLater(
runWithOverrides(
() => auth.login(prompt: (_) {}),
),
throwsA(
isA<UserAlreadyLoggedInException>().having(
(e) => e.email,
'email',
isNull,
),
),
);
},
);
group('when login credentials are corrupted', () {
setUp(() {
accessCredentials = oauth2.AccessCredentials(
@@ -1008,7 +1064,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
test('proceeds with login', () async {
expect(auth.email, isNull);
await runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
);
expect(auth.email, equals(email));
expect(auth.isAuthenticated, isTrue);
@@ -1022,7 +1078,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
await expectLater(
runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
),
throwsA(isA<UserNotFoundException>()),
);
@@ -1032,96 +1088,10 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
});
});
group('loginCI', () {
setUp(() {
when(() => platform.environment).thenReturn(<String, String>{
shorebirdTokenEnvVar: ciToken.toBase64(),
});
auth = buildAuth();
});
test(
'returns a CI token and does not set the email or cache credentials',
() async {
final token = await runWithOverrides(
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
);
expect(token.authProvider, ciToken.authProvider);
expect(token.refreshToken, ciToken.refreshToken);
expect(auth.email, isNull);
expect(auth.isAuthenticated, isTrue);
expect(buildAuth().email, isNull);
expect(buildAuth().isAuthenticated, isTrue);
when(() => platform.environment).thenReturn({});
expect(buildAuth().isAuthenticated, isFalse);
},
);
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(
runWithOverrides(
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
),
throwsA(isA<UserNotFoundException>()),
);
expect(auth.email, isNull);
});
group('when credentials are missing a refresh token', () {
setUp(() {
accessCredentials = oauth2.AccessCredentials(
accessToken,
null,
scopes,
idToken: idToken,
);
});
test('throws if credentials are missing a refresh token', () async {
await expectLater(
runWithOverrides(
() => auth.loginCI(AuthProvider.google, prompt: (_) {}),
),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'toString',
'Exception: No refresh token found.',
),
),
);
});
});
});
group('logout', () {
test('clears session and wipes state', () async {
await runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
);
expect(auth.email, email);
expect(auth.isAuthenticated, isTrue);
@@ -1139,9 +1109,59 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
expect(buildAuth().isAuthenticated, isFalse);
});
test('clears credentials file when it exists', () async {
await runWithOverrides(
() => auth.login(prompt: (_) {}),
);
expect(File(auth.credentialsFilePath).existsSync(), isTrue);
when(
() => httpClient.post(any(), headers: any(named: 'headers')),
).thenAnswer(
(_) async => http.Response('{"ok":true}', 200),
);
await runWithOverrides(() => auth.logout());
expect(File(auth.credentialsFilePath).existsSync(), isFalse);
});
group('when authenticated via API key', () {
setUp(() {
when(() => platform.environment).thenReturn(<String, String>{
shorebirdTokenEnvVar: 'sb_api_abc123',
});
auth = buildAuth();
});
test('remains authenticated because env var is still set', () async {
expect(auth.isAuthenticated, isTrue);
await runWithOverrides(() => auth.logout());
// _apiKey is not cleared by _clearCredentials, so the instance
// still considers itself authenticated.
expect(auth.isAuthenticated, isTrue);
});
});
group('when authenticated via CI token', () {
setUp(() {
when(() => platform.environment).thenReturn(<String, String>{
shorebirdTokenEnvVar: ciToken.toBase64(),
});
auth = buildAuth();
});
test('remains authenticated because env var is still set', () async {
expect(auth.isAuthenticated, isTrue);
await runWithOverrides(() => auth.logout());
// _token is not cleared by _clearCredentials, so the instance
// still considers itself authenticated.
expect(auth.isAuthenticated, isTrue);
});
});
test('revokes server session with refresh token', () async {
await runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
);
when(
@@ -1168,7 +1188,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
test('logs detail when server returns non-2xx', () async {
await runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
);
when(
@@ -1189,7 +1209,7 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
test('clears credentials even if server revocation fails', () async {
await runWithOverrides(
() => auth.login(AuthProvider.google, prompt: (_) {}),
() => auth.login(prompt: (_) {}),
);
expect(auth.isAuthenticated, isTrue);
@@ -1237,6 +1257,14 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e
group('OauthValues', () {
group('clientId', () {
test('returns a ClientId for google', () {
expect(AuthProvider.google.clientId, isNotNull);
});
test('returns a ClientId for microsoft', () {
expect(AuthProvider.microsoft.clientId, isNotNull);
});
test('throws UnsupportedError for shorebird', () {
expect(
() => AuthProvider.shorebird.clientId,
@@ -1,23 +1,16 @@
import 'package:args/args.dart';
import 'package:http/http.dart' as http;
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
import '../mocks.dart';
void main() {
group(LoginCiCommand, () {
const email = 'test@email.com';
late ArgResults results;
late Auth auth;
late http.Client httpClient;
late ShorebirdLogger logger;
late LoginCiCommand command;
@@ -31,156 +24,40 @@ void main() {
);
}
setUpAll(() {
registerFallbackValue(AuthProvider.google);
});
setUp(() {
auth = MockAuth();
httpClient = MockHttpClient();
logger = MockShorebirdLogger();
results = MockArgResults();
when(() => results.wasParsed('provider')).thenReturn(false);
when(() => results['provider']).thenReturn(null);
when(() => auth.client).thenReturn(httpClient);
when(() => auth.loginCI(any(), prompt: any(named: 'prompt'))).thenAnswer(
(_) async => const CiToken(
// "shorebird-token" in base64
refreshToken: 'c2hvcmViaXJkLXRva2Vu', // cspell:disable-line
authProvider: AuthProvider.google,
),
);
when(
() => logger.chooseOne<AuthProvider>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenReturn(AuthProvider.google);
command = runWithOverrides(
() => LoginCiCommand()..testArgResults = results,
);
command = runWithOverrides(LoginCiCommand.new);
});
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.loginCI(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: captureAny(named: 'display'),
),
).thenReturn(provider);
});
test('uses the provider chosen by the user', () async {
await runWithOverrides(() => command.run());
verify(
() => auth.loginCI(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'));
});
});
test('has correct name', () {
expect(command.name, equals('login:ci'));
});
test('exits with code 70 if no user is found', () async {
when(
() => auth.loginCI(any(), prompt: any(named: 'prompt')),
).thenThrow(UserNotFoundException(email: email));
test('has correct description', () {
expect(command.description, contains('deprecated'));
});
test('shows deprecation message and exits with code 0', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.software.code));
verify(
() => logger.err('We could not find a Shorebird account for $email.'),
).called(1);
verify(
() => logger.info(any(that: contains('https://console.shorebird.dev'))),
).called(1);
});
test('exits with code 70 when error occurs', () async {
final error = Exception('oops something went wrong!');
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(), prompt: any(named: 'prompt'))).called(1);
verify(() => logger.err(error.toString())).called(1);
});
test('exits with code 0 when logged in successfully', () async {
const token = CiToken(
// "shorebird-token" in base64
refreshToken: 'c2hvcmViaXJkLXRva2Vu', // cspell:disable-line
authProvider: AuthProvider.google,
);
when(
() => auth.loginCI(any(), prompt: any(named: 'prompt')),
).thenAnswer((_) async => token);
when(() => auth.email).thenReturn(email);
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(() => auth.loginCI(any(), prompt: any(named: 'prompt'))).called(1);
verify(
() => logger.info(
any(that: contains('${lightCyan.wrap(token.toBase64())}')),
),
).called(1);
final captured = verify(
() => logger.info(captureAny()),
).captured;
final message = captured.single as String;
expect(message, contains('shorebird login:ci is deprecated'));
expect(message, contains('console.shorebird.dev'));
expect(message, contains('SHOREBIRD_TOKEN'));
});
test('prompt is correct', () {
const url = 'http://example.com';
runWithOverrides(() => command.prompt(url));
test('does not trigger any auth flow', () async {
await runWithOverrides(command.run);
verify(
() => logger.info('''
The Shorebird CLI needs your authorization to manage apps, releases, and patches on your behalf.
In a browser, visit this URL to log in:
${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))}
Waiting for your authorization...'''),
).called(1);
verifyNoMoreInteractions(auth);
});
});
}
@@ -8,7 +8,6 @@ import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/login_command.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
import '../mocks.dart';
@@ -33,10 +32,6 @@ void main() {
);
}
setUpAll(() {
registerFallbackValue(AuthProvider.shorebird);
});
setUp(() {
applicationConfigHome = Directory.systemTemp.createTempSync();
auth = MockAuth();
@@ -49,12 +44,20 @@ void main() {
() => auth.credentialsFilePath,
).thenReturn(p.join(applicationConfigHome.path, 'credentials.json'));
when(
() => auth.login(any(), prompt: any(named: 'prompt')),
() => auth.login(prompt: any(named: 'prompt')),
).thenAnswer((_) async {});
command = runWithOverrides(LoginCommand.new);
});
test('has correct name', () {
expect(command.name, 'login');
});
test('has correct description', () {
expect(command.description, 'Login as a new Shorebird user.');
});
group('when user is already logged in', () {
setUp(() {
when(() => auth.isAuthenticated).thenReturn(true);
@@ -75,14 +78,36 @@ void main() {
'''Run ${lightCyan.wrap('shorebird logout')} to log out and try again.''',
),
).called(1);
verifyNever(() => auth.login(any(), prompt: any(named: 'prompt')));
verifyNever(() => auth.login(prompt: any(named: 'prompt')));
},
);
});
group('when user is authenticated via API key', () {
setUp(() {
when(() => auth.isAuthenticated).thenReturn(true);
when(() => auth.email).thenReturn(null);
});
test('prints API key message and exits with code 0', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info('You are already authenticated via API key.'),
).called(1);
verify(
() => logger.info(
'''Run ${lightCyan.wrap('shorebird logout')} to log out and try again.''',
),
).called(1);
verifyNever(() => auth.login(prompt: any(named: 'prompt')));
});
});
test('exits with code 70 if no user is found', () async {
when(
() => auth.login(any(), prompt: any(named: 'prompt')),
() => auth.login(prompt: any(named: 'prompt')),
).thenThrow(UserNotFoundException(email: email));
final result = await runWithOverrides(command.run);
@@ -99,26 +124,26 @@ void main() {
test('exits with code 70 when error occurs', () async {
final error = Exception('oops something went wrong!');
when(
() => auth.login(any(), prompt: any(named: 'prompt')),
() => auth.login(prompt: any(named: 'prompt')),
).thenThrow(error);
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.software.code));
verify(() => auth.login(any(), prompt: any(named: 'prompt'))).called(1);
verify(() => auth.login(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(), prompt: any(named: 'prompt')),
() => auth.login(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(), prompt: any(named: 'prompt'))).called(1);
verify(() => auth.login(prompt: any(named: 'prompt'))).called(1);
verify(
() => logger.info(
any(that: contains('You are now logged in as <$email>.')),