From a884ae8595c7fb30e13cfabb5811e989712b9dbc Mon Sep 17 00:00:00 2001 From: Mac Date: Wed, 25 Mar 2026 11:41:35 -0600 Subject: [PATCH] feat: Add CLI support for authenticating with new API keys (#3646) --- RELEASE_NOTES.md | 6 + packages/shorebird_cli/lib/src/auth/auth.dart | 174 ++++------ .../lib/src/commands/login_ci_command.dart | 79 +---- .../lib/src/commands/login_command.dart | 10 +- packages/shorebird_cli/lib/src/version.dart | 2 +- packages/shorebird_cli/pubspec.yaml | 2 +- .../test/src/auth/auth_test.dart | 318 ++++++++++-------- .../src/commands/login_ci_command_test.dart | 159 +-------- .../test/src/commands/login_command_test.dart | 49 ++- 9 files changed, 318 insertions(+), 481 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cb27d522..19eb8d81 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index 6dac4ebe..64baa46f 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -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 Function( - oauth2.ClientId clientId, - List scopes, - http.Client client, - void Function(String) userPrompt, { - oauth2.AuthEndpoints authEndpoints, - }); - /// Callback for refreshing access credentials. typedef RefreshCredentials = Future 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 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 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 login( - AuthProvider authProvider, { - required void Function(String) prompt, - }) async { + /// Logs in the user via the Shorebird loopback OAuth flow. + Future 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 _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} diff --git a/packages/shorebird_cli/lib/src/commands/login_ci_command.dart b/packages/shorebird_cli/lib/src/commands/login_ci_command.dart index c9cd46b9..b502218a 100644 --- a/packages/shorebird_cli/lib/src/commands/login_ci_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_ci_command.dart @@ -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 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...'''); - } } diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 9ec8bb72..97f035cc 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -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 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 diff --git a/packages/shorebird_cli/lib/src/version.dart b/packages/shorebird_cli/lib/src/version.dart index d017f514..3d027849 100644 --- a/packages/shorebird_cli/lib/src/version.dart +++ b/packages/shorebird_cli/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '1.6.90'; +const packageVersion = '1.6.91'; diff --git a/packages/shorebird_cli/pubspec.yaml b/packages/shorebird_cli/pubspec.yaml index 4dacc378..5ff65495 100644 --- a/packages/shorebird_cli/pubspec.yaml +++ b/packages/shorebird_cli/pubspec.yaml @@ -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 diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 960fb79e..3fb120bd 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -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()); @@ -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({ + 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({ + 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()); + }); + + 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()); + }); + }); + group('when token is invalid', () { setUp(() { when(() => platform.environment).thenReturn({ @@ -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()), ); @@ -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({ + shorebirdTokenEnvVar: 'sb_api_abc123', + }); + auth = buildAuth(); + + await expectLater( + runWithOverrides( + () => auth.login(prompt: (_) {}), + ), + throwsA( + isA().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()), ); @@ -1032,96 +1088,10 @@ Please regenerate using `shorebird login:ci`, update the $shorebirdTokenEnvVar e }); }); - group('loginCI', () { - setUp(() { - when(() => platform.environment).thenReturn({ - 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()), - ); - - 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().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({ + 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({ + 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, diff --git a/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart b/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart index 65c4cabe..dd7aa5fc 100644 --- a/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_ci_command_test.dart @@ -1,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( - 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( - 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( - 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); }); }); } diff --git a/packages/shorebird_cli/test/src/commands/login_command_test.dart b/packages/shorebird_cli/test/src/commands/login_command_test.dart index 1ce77e54..034d5d70 100644 --- a/packages/shorebird_cli/test/src/commands/login_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_command_test.dart @@ -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>.')),