Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(shorebird_cli): expose user #224

Merged
merged 5 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions packages/shorebird_cli/lib/src/auth/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import 'dart:io';
import 'package:googleapis_auth/auth_io.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/auth/jwt.dart';
import 'package:shorebird_cli/src/auth/models/models.dart';
import 'package:shorebird_cli/src/config/config.dart';

export 'package:googleapis_auth/googleapis_auth.dart' show AccessCredentials;
export 'package:shorebird_cli/src/auth/models/models.dart' show User;

final _clientId = ClientId(
/// Shorebird CLI's OAuth 2.0 identifier.
Expand Down Expand Up @@ -50,12 +52,12 @@ class Auth {
final credentialsFilePath = p.join(shorebirdConfigDir, _credentialsFileName);

http.Client get client {
if (credentials == null) return _httpClient;
return autoRefreshingClient(_clientId, credentials!, _httpClient);
if (_credentials == null) return _httpClient;
return autoRefreshingClient(_clientId, _credentials!, _httpClient);
}

Future<void> login(void Function(String) prompt) async {
if (credentials != null) return;
if (_credentials != null) return;

final client = http.Client();
try {
Expand All @@ -65,6 +67,7 @@ class Auth {
client,
prompt,
);
_user = _credentials?.toUser();
_flushCredentials(_credentials!);
} finally {
client.close();
Expand All @@ -75,7 +78,11 @@ class Auth {

AccessCredentials? _credentials;

AccessCredentials? get credentials => _credentials;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user is exposed instead of credentials

User? _user;

User? get user => _user;

bool get isAuthenticated => _user != null;

void _loadCredentials() {
final credentialsFile = File(credentialsFilePath);
Expand All @@ -86,6 +93,7 @@ class Auth {
_credentials = AccessCredentials.fromJson(
json.decode(contents) as Map<String, dynamic>,
);
_user = _credentials?.toUser();
} catch (_) {}
}
}
Expand All @@ -98,6 +106,7 @@ class Auth {

void _clearCredentials() {
_credentials = null;
_user = null;

final credentialsFile = File(credentialsFilePath);
if (credentialsFile.existsSync()) {
Expand All @@ -109,3 +118,17 @@ class Auth {
_httpClient.close();
}
}

extension on AccessCredentials {
User toUser() {
final claims = Jwt.decodeClaims(idToken ?? '');
felangel marked this conversation as resolved.
Show resolved Hide resolved

if (claims == null) throw Exception('Invalid JWT');

try {
return User(email: claims['email'] as String);
} catch (_) {
throw Exception('Malformed claims');
}
}
}
22 changes: 22 additions & 0 deletions packages/shorebird_cli/lib/src/auth/jwt.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'dart:convert';

/// Jwt Utilities
class Jwt {
/// Decode and extract claims from a JWT token.
static Map<String, dynamic>? decodeClaims(String value) {
final parts = value.split('.');
if (parts.length != 3) return null;
try {
return _decodePart(parts[1]);
} catch (_) {}
return null;
}
}

Map<String, dynamic> _decodePart(String part) {
final normalized = base64.normalize(part);
final base64Decoded = base64.decode(normalized);
final utf8Decoded = utf8.decode(base64Decoded);
final jsonDecoded = json.decode(utf8Decoded) as Map<String, dynamic>;
return jsonDecoded;
}
1 change: 1 addition & 0 deletions packages/shorebird_cli/lib/src/auth/models/models.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'user.dart';
10 changes: 10 additions & 0 deletions packages/shorebird_cli/lib/src/auth/models/user.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// {@template user}
/// A shorebird user account.
/// {@endtemplate}
class User {
/// {@macro user}
const User({required this.email});

/// The user's email address.
final String email;
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Defaults to the name in "pubspec.yaml".''',

@override
Future<int>? run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in.');
return ExitCode.noUser.code;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Defaults to the app_id in "shorebird.yaml".''',

@override
Future<int>? run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in.');
return ExitCode.noUser.code;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ListAppsCommand extends ShorebirdCommand with ShorebirdConfigMixin {

@override
Future<int>? run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in.');
return ExitCode.noUser.code;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/shorebird_cli/lib/src/commands/build_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class BuildCommand extends ShorebirdCommand

@override
Future<int> run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger
..err('You must be logged in to build.')
..err("Run 'shorebird login' to log in and try again.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class CreateChannelsCommand extends ShorebirdCommand with ShorebirdConfigMixin {

@override
Future<int>? run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in to view channels.');
return ExitCode.noUser.code;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class ListChannelsCommand extends ShorebirdCommand with ShorebirdConfigMixin {

@override
Future<int>? run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in to view channels.');
return ExitCode.noUser.code;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/shorebird_cli/lib/src/commands/init_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class InitCommand extends ShorebirdCommand

@override
Future<int> run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in.');
return ExitCode.noUser.code;
}
Expand Down
7 changes: 3 additions & 4 deletions packages/shorebird_cli/lib/src/commands/login_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ class LoginCommand extends ShorebirdCommand {

@override
Future<int> run() async {
final credentials = auth.credentials;
if (credentials != null) {
if (auth.isAuthenticated) {
logger
..info('You are already logged in.')
..info('You are already logged in as <${auth.user!.email}>.')
..info("Run 'shorebird logout' to log out and try again.");
return ExitCode.success.code;
}
Expand All @@ -30,7 +29,7 @@ class LoginCommand extends ShorebirdCommand {
await auth.login(prompt);
logger.info('''

🎉 ${lightGreen.wrap('Welcome to Shorebird! You are now logged in.')}
🎉 ${lightGreen.wrap('Welcome to Shorebird! You are now logged in as <${auth.user!.email}>.')}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Making a TODO to add this to doctor.


🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}.
🚪 To logout use: "${lightCyan.wrap('shorebird logout')}".''');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class LogoutCommand extends ShorebirdCommand {

@override
Future<int> run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.info('You are already logged out.');
return ExitCode.success.code;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/shorebird_cli/lib/src/commands/patch_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class PatchCommand extends ShorebirdCommand
return ExitCode.config.code;
}

if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in to publish.');
return ExitCode.noUser.code;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ make smaller updates to your app.
return ExitCode.config.code;
}

if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger.err('You must be logged in to release.');
return ExitCode.noUser.code;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/shorebird_cli/lib/src/commands/run_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class RunCommand extends ShorebirdCommand

@override
Future<int> run() async {
if (auth.credentials == null) {
if (!auth.isAuthenticated) {
logger
..err('You must be logged in to run.')
..err("Run 'shorebird login' to log in and try again.");
Expand Down
73 changes: 43 additions & 30 deletions packages/shorebird_cli/test/src/auth/auth_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,25 @@ import 'package:test/test.dart';

class _MockHttpClient extends Mock implements http.Client {}

class _MockAccessCredentials extends Mock implements AccessCredentials {}

void main() {
group('Auth', () {
final credentials = AccessCredentials(
AccessToken('Bearer', 'token', DateTime.now().toUtc()),
'refreshToken',
[],
);
const idToken =
'''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg''';
const email = 'test@email.com';

late http.Client httpClient;
late AccessCredentials accessCredentials;
late Auth auth;

setUp(() {
httpClient = _MockHttpClient();
accessCredentials = _MockAccessCredentials();
auth = Auth(
httpClient: httpClient,
obtainAccessCredentials: (clientId, scopes, client, userPrompt) async {
return credentials;
return accessCredentials;
},
)..logout();
});
Expand All @@ -47,42 +49,53 @@ void main() {
});

group('login', () {
test('should set the credentials', () async {
test('should set the user when claims are valid', () async {
when(() => accessCredentials.idToken).thenReturn(idToken);
await auth.login((_) {});
expect(auth.user, isA<User>().having((u) => u.email, 'email', email));
expect(auth.isAuthenticated, isTrue);
expect(
auth.credentials,
isA<AccessCredentials>().having(
(c) => c.accessToken.data,
'accessToken',
credentials.accessToken.data,
),
Auth().user,
isA<User>().having((u) => u.email, 'email', email),
);
expect(
Auth().credentials,
isA<AccessCredentials>().having(
(c) => c.accessToken.data,
'accessToken',
credentials.accessToken.data,
),
expect(Auth().isAuthenticated, isTrue);
});

test('should not set the user when token is null', () async {
when(() => accessCredentials.idToken).thenReturn(null);
await expectLater(auth.login((_) {}), throwsA(isException));
expect(auth.user, isNull);
expect(auth.isAuthenticated, isFalse);
});

test('should not set the user when token is empty', () async {
when(() => accessCredentials.idToken).thenReturn('');
await expectLater(auth.login((_) {}), throwsA(isException));
expect(auth.user, isNull);
expect(auth.isAuthenticated, isFalse);
});

test('should not set the user when token claims are malformed', () async {
when(() => accessCredentials.idToken).thenReturn(
'''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.LaR0JfOiDrS1AuABC38kzxpSjRLJ_OtfOkZ8hL6I1GPya-cJYwsmqhi5eMBwEbpYHcJhguG5l56XM6dW8xjdK7JbUN6_53gHBosSnL-Ccf29oW71Ado9sxO17YFQyihyMofJ_v78BPVy2H5O10hNjRn_M0JnnAe0Fvd2VrInlIE''',
);
await expectLater(auth.login((_) {}), throwsA(isException));
expect(auth.user, isNull);
expect(auth.isAuthenticated, isFalse);
});
});

group('logout', () {
test('clears session and wipes state', () async {
await auth.login((_) {});
expect(
auth.credentials,
isA<AccessCredentials>().having(
(c) => c.accessToken.data,
'accessToken',
credentials.accessToken.data,
),
);
expect(auth.user, isA<User>().having((u) => u.email, 'email', email));
expect(auth.isAuthenticated, isTrue);

auth.logout();
expect(auth.credentials, isNull);
expect(Auth().credentials, isNull);
expect(auth.user, isNull);
expect(auth.isAuthenticated, isFalse);
expect(Auth().user, isNull);
expect(Auth().isAuthenticated, isFalse);
});
});

Expand Down
25 changes: 25 additions & 0 deletions packages/shorebird_cli/test/src/auth/jwt_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:shorebird_cli/src/auth/jwt.dart';
import 'package:test/test.dart';

void main() {
group('Jwt', () {
group('decodeClaims', () {
test('returns null jwt does not contain 3 segments', () {
expect(Jwt.decodeClaims('invalid'), isNull);
});

test('returns null when jwt payload segment is malformed', () {
expect(Jwt.decodeClaims('this.is.invalid'), isNull);
});

test('returns correct claims when jwt payload segment is valid', () {
expect(
Jwt.decodeClaims(
'''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg''',
),
equals({'email': 'test@email.com'}),
);
});
});
});
}
Loading