From 5a865c22993d1365e95284b7576ccfb950097f15 Mon Sep 17 00:00:00 2001 From: Mac Date: Thu, 5 Mar 2026 14:56:50 -0700 Subject: [PATCH] feat: Support new Auth service in CLI (#3638) Co-authored-by: Eric Seidel --- packages/shorebird_cli/lib/src/auth/auth.dart | 140 ++- .../lib/src/auth/shorebird_oauth.dart | 252 +++++ .../lib/src/commands/login_ci_command.dart | 14 +- .../lib/src/commands/login_command.dart | 26 +- .../shorebird_cli/lib/src/shorebird_env.dart | 14 + .../test/src/auth/auth_test.dart | 450 ++++++++- .../test/src/auth/shorebird_oauth_test.dart | 857 ++++++++++++++++++ .../test/src/commands/login_command_test.dart | 71 +- .../test/src/shorebird_env_test.dart | 40 + 9 files changed, 1706 insertions(+), 158 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/auth/shorebird_oauth.dart create mode 100644 packages/shorebird_cli/test/src/auth/shorebird_oauth_test.dart diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index 100c7e0e..df5f282c 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -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 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 _tryRefreshCredentials( - oauth2.ClientId clientId, + Future _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 _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) => [], }; } diff --git a/packages/shorebird_cli/lib/src/auth/shorebird_oauth.dart b/packages/shorebird_cli/lib/src/auth/shorebird_oauth.dart new file mode 100644 index 00000000..51485fb5 --- /dev/null +++ b/packages/shorebird_cli/lib/src/auth/shorebird_oauth.dart @@ -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 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 _waitForCallback( + HttpServer server, { + required String callbackPath, + required Duration timeout, +}) async { + final completer = Completer(); + 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 _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( + '

Authentication complete.

' + '

You can close this window.

', + ); + 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 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 _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": "", +/// "refresh_token": "sb_rt_...", +/// "token_type": "Bearer", +/// "expires_in": 900 +/// } +/// ``` +oauth2.AccessCredentials _parseTokenResponse( + String responseBody, { + required Uri expectedIssuer, +}) { + final json = jsonDecode(responseBody) as Map; + 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, + ); +} 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 fdddb7fb..c9cd46b9 100644 --- a/packages/shorebird_cli/lib/src/commands/login_ci_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_ci_command.dart @@ -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 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, ); } diff --git a/packages/shorebird_cli/lib/src/commands/login_command.dart b/packages/shorebird_cli/lib/src/commands/login_command.dart index 8579365f..9ec8bb72 100644 --- a/packages/shorebird_cli/lib/src/commands/login_command.dart +++ b/packages/shorebird_cli/lib/src/commands/login_command.dart @@ -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 diff --git a/packages/shorebird_cli/lib/src/shorebird_env.dart b/packages/shorebird_cli/lib/src/shorebird_env.dart index 317cb8bb..ca8f1fa0 100644 --- a/packages/shorebird_cli/lib/src/shorebird_env.dart +++ b/packages/shorebird_cli/lib/src/shorebird_env.dart @@ -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. diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index f6be788c..7f7c30e0 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -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 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().having( - (e) => e.toString(), - 'message', - 'Exception: Unknown jwt issuer: https://example.com', + runWithOverrides(() { + expect( + () => jwt.authProvider, + throwsA( + isA().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 = []; @@ -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 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({}); + 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 = []; + 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 = []; + 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()); expect(client, isA()); @@ -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()), ); @@ -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()), ); @@ -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()), ); @@ -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().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().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()), + ); + }); + }); + }); } diff --git a/packages/shorebird_cli/test/src/auth/shorebird_oauth_test.dart b/packages/shorebird_cli/test/src/auth/shorebird_oauth_test.dart new file mode 100644 index 00000000..ae9e5f38 --- /dev/null +++ b/packages/shorebird_cli/test/src/auth/shorebird_oauth_test.dart @@ -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 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; + 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().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().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().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().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().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().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()), + ); + }); + + 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( + 'Server Error', + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); + + 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; + 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().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().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()), + ); + }); + + 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().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().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( + 'Server Error', + HttpStatus.ok, + ), + ); + + await expectLater( + refreshShorebirdCredentials( + oauth2.AccessCredentials( + AccessToken('Bearer', '', DateTime.timestamp()), + 'sb_rt_old', + [], + ), + httpClient, + authBaseUrl: authBaseUrl, + ), + throwsA(isA()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); + + group('ShorebirdAuthException', () { + test('toString includes message', () { + const exception = ShorebirdAuthException('test error'); + expect( + exception.toString(), + equals('ShorebirdAuthException: test error'), + ); + }); + }); +} 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 84d65c24..1ce77e54 100644 --- a/packages/shorebird_cli/test/src/commands/login_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/login_command_test.dart @@ -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( - 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( - 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( - 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', () { diff --git a/packages/shorebird_cli/test/src/shorebird_env_test.dart b/packages/shorebird_cli/test/src/shorebird_env_test.dart index c172cac7..86b0b448 100644 --- a/packages/shorebird_cli/test/src/shorebird_env_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_env_test.dart @@ -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'});