feat: Support new Auth service in CLI (#3638)

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