diff --git a/packages/jwt/lib/src/encoding.dart b/packages/jwt/lib/src/encoding.dart new file mode 100644 index 00000000..22ac3728 --- /dev/null +++ b/packages/jwt/lib/src/encoding.dart @@ -0,0 +1,9 @@ +import 'dart:convert'; + +/// Converts one of the three base64-encoded parts of a JWT to a JSON object. +Map decodeJwtPart(String part) { + final normalized = base64.normalize(part); + final base64Decoded = base64.decode(normalized); + final utf8Decoded = utf8.decode(base64Decoded); + return json.decode(utf8Decoded) as Map; +} diff --git a/packages/jwt/lib/src/jwt.dart b/packages/jwt/lib/src/jwt.dart index 412c9bde..b12f8489 100644 --- a/packages/jwt/lib/src/jwt.dart +++ b/packages/jwt/lib/src/jwt.dart @@ -67,56 +67,32 @@ class JwtVerificationFailure implements Exception { String toString() => 'JwtVerificationFailure: $reason'; } -/// Verify the provided [jwt]. +/// Verify the encoded [encodedJwt]. Future verify( - String jwt, { + String encodedJwt, { required String issuer, required Set audience, required String publicKeysUrl, }) async { - final parts = jwt.split('.'); - - if (parts.length != 3) { - throw const JwtVerificationFailure('JWT is malformed'); + final Jwt jwt; + try { + jwt = Jwt.parse(encodedJwt); + } on FormatException catch (e) { + throw JwtVerificationFailure(e.message); } final publicKeys = await _getPublicKeys(publicKeysUrl); - final JwtHeader header; - try { - header = JwtHeader.fromJson(_decodePart(parts[0])); - } catch (_) { - throw const JwtVerificationFailure('JWT header is malformed.'); - } - await _verifyHeader(header, publicKeys); + await _verifyHeader(jwt.header, publicKeys); + _verifyPayload(jwt.payload, issuer, audience); - final JwtPayload payload; - try { - payload = JwtPayload.fromJson(_decodePart(parts[1])); - } catch (_) { - throw const JwtVerificationFailure('JWT payload is malformed.'); - } - _verifyPayload(payload, issuer, audience); - - final isValid = _verifySignature(jwt, publicKeys[header.kid]!); + final isValid = _verifySignature(encodedJwt, publicKeys[jwt.header.kid]!); if (!isValid) { throw const JwtVerificationFailure('Invalid signature.'); } - return Jwt( - header: header, - payload: payload, - signature: parts[2], - claims: _decodePart(parts[1]), - ); -} - -Map _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; - return jsonDecoded; + // If we've made it this far, the JWT is now verified. + return jwt; } Future _verifyHeader( diff --git a/packages/jwt/lib/src/models/jwt.dart b/packages/jwt/lib/src/models/jwt.dart index 1d9c925c..02c8213a 100644 --- a/packages/jwt/lib/src/models/jwt.dart +++ b/packages/jwt/lib/src/models/jwt.dart @@ -1,4 +1,5 @@ import 'package:jwt/jwt.dart'; +import 'package:jwt/src/encoding.dart'; /// {@template jwt} /// A JWT (json web token) @@ -12,6 +13,38 @@ class Jwt { this.claims = const {}, }); + /// Decodes a JWT string of the format `header.payload.signature`. This does + /// _not_ perform any verification that the JWT is valid. + factory Jwt.parse(String string) { + final parts = string.split('.'); + if (parts.length != 3) { + throw const FormatException('Invalid JWT format'); + } + + final JwtHeader header; + try { + header = JwtHeader.fromJson(decodeJwtPart(parts[0])); + } catch (_) { + throw const FormatException('JWT header is malformed.'); + } + + final JwtPayload payload; + try { + payload = JwtPayload.fromJson(decodeJwtPart(parts[1])); + } catch (_) { + throw const FormatException('JWT payload is malformed.'); + } + + final signature = parts[2]; + + return Jwt( + header: header, + payload: payload, + signature: signature, + claims: decodeJwtPart(parts[1]), + ); + } + /// {@macro jwt_header} final JwtHeader header; diff --git a/packages/jwt/test/src/jwt_test.dart b/packages/jwt/test/src/jwt_test.dart index fc642a2c..e97c3ca6 100644 --- a/packages/jwt/test/src/jwt_test.dart +++ b/packages/jwt/test/src/jwt_test.dart @@ -37,24 +37,40 @@ void main() { ); }; - try { - await verify( + await expectLater( + () => verify( token, audience: {audience}, issuer: issuer, publicKeysUrl: publicKeysUrl, - ); - fail('should throw'); - } catch (error) { - expect( - error, + ), + throwsA( isA().having( (e) => e.reason, 'reason', 'Token has expired.', ), - ); - } + ), + ); + }); + + test('throws a JwtVerificationFailure if string is not valid jwt', + () async { + await expectLater( + () => verify( + 'not.a.jwt', + audience: {audience}, + issuer: issuer, + publicKeysUrl: publicKeysUrl, + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'JWT header is malformed.', + ), + ), + ); }); test('can verify an invalid audience', () async { diff --git a/packages/shorebird_cli/lib/src/auth/auth.dart b/packages/shorebird_cli/lib/src/auth/auth.dart index 5fbdeae5..9a0569c3 100644 --- a/packages/shorebird_cli/lib/src/auth/auth.dart +++ b/packages/shorebird_cli/lib/src/auth/auth.dart @@ -5,9 +5,9 @@ import 'package:cli_util/cli_util.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; import 'package:scoped/scoped.dart'; -import 'package:shorebird_cli/src/auth/jwt.dart'; import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/command_runner.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; @@ -279,17 +279,20 @@ class Auth { } } -extension on oauth2.AccessCredentials { +extension JwtClaims on oauth2.AccessCredentials { String? get email { final token = idToken; if (token == null) return null; - final claims = Jwt.decodeClaims(token); + final Jwt jwt; + try { + jwt = Jwt.parse(token); + } catch (_) { + return null; + } - if (claims == null) return null; - - return claims['email'] as String?; + return jwt.claims['email'] as String?; } } diff --git a/packages/shorebird_cli/lib/src/auth/jwt.dart b/packages/shorebird_cli/lib/src/auth/jwt.dart deleted file mode 100644 index ac63c1e4..00000000 --- a/packages/shorebird_cli/lib/src/auth/jwt.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; - -/// Jwt Utilities -class Jwt { - /// Decode and extract claims from a JWT token. - static Map? decodeClaims(String value) { - final parts = value.split('.'); - if (parts.length != 3) return null; - try { - return _decodePart(parts[1]); - } catch (_) {} - return null; - } -} - -Map _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; - return jsonDecoded; -} diff --git a/packages/shorebird_cli/pubspec.lock b/packages/shorebird_cli/pubspec.lock index 336a22a3..3f135f84 100644 --- a/packages/shorebird_cli/pubspec.lock +++ b/packages/shorebird_cli/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: c9c85fedbe2188b95133cbe960e16f5f448860f7133330e272edbbca5893ddc6 + url: "https://pub.dev" + source: hosted + version: "1.5.2" async: dependency: transitive description: @@ -237,10 +245,10 @@ packages: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.0" file: dependency: transitive description: @@ -292,10 +300,10 @@ packages: dependency: "direct main" description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.0" http_multi_server: dependency: transitive description: @@ -368,6 +376,13 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.1" + jwt: + dependency: "direct main" + description: + path: "../jwt" + relative: true + source: path + version: "1.0.0+1" logging: dependency: transitive description: @@ -512,6 +527,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + rsa_pkcs: + dependency: transitive + description: + name: rsa_pkcs + sha256: "6e1e03563d2cbd9758d8a562f738b759763d464fd65d32da132153230a0c0395" + url: "https://pub.dev" + source: hosted + version: "2.1.0" scoped: dependency: "direct main" description: @@ -737,18 +760,18 @@ packages: dependency: transitive description: name: web - sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" + sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.4.2" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.3" webkit_inspection_protocol: dependency: transitive description: @@ -790,4 +813,4 @@ packages: source: hosted version: "2.1.1" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.2.0 <4.0.0" diff --git a/packages/shorebird_cli/pubspec.yaml b/packages/shorebird_cli/pubspec.yaml index a797e1fa..bacc502a 100644 --- a/packages/shorebird_cli/pubspec.yaml +++ b/packages/shorebird_cli/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: io: ^1.0.4 json_annotation: ^4.8.0 json_path: ^0.7.0 + jwt: + path: ../jwt mason_logger: ^0.2.12 meta: ^1.9.0 path: ^1.8.3 diff --git a/packages/shorebird_cli/test/src/auth/auth_test.dart b/packages/shorebird_cli/test/src/auth/auth_test.dart index 636cc8d5..798f5310 100644 --- a/packages/shorebird_cli/test/src/auth/auth_test.dart +++ b/packages/shorebird_cli/test/src/auth/auth_test.dart @@ -37,9 +37,28 @@ void main() { }); }); - group('Auth', () { + group('JwtClaims', () { + group('email', () { + test('returns null when idToken is not a valid jwt', () { + final credentials = AccessCredentials( + AccessToken( + 'Bearer', + 'accessToken', + DateTime.now().add(const Duration(minutes: 10)).toUtc(), + ), + '', + [], + idToken: 'not a valid jwt', + ); + + expect(credentials.email, isNull); + }); + }); + }); + + group(Auth, () { const idToken = - '''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg'''; + '''eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMjM0NSIsImhkIjoic2hvcmViaXJkLmRldiIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWF0IjoxMjM0LCJleHAiOjY3ODl9.MYbITALvKsGYTYjw1o7AQ0ObkqRWVBSr9cFYJrvA46g'''; const email = 'test@email.com'; const user = User(id: 42, email: email); const refreshToken = ''; @@ -317,9 +336,8 @@ void main() { expect(client, isA()); }); - test( - 'returns a plain http client ' - 'when credentials are not present.', () async { + test('returns a plain http client when credentials are not present.', + () async { final client = auth.client; expect(client, isA()); expect(client, isNot(isA())); @@ -327,9 +345,8 @@ void main() { }); group('login', () { - test( - 'should set the email when claims are valid ' - 'and current user exists', () async { + test('should set the email when claims are valid and current user exists', + () async { await auth.login((_) {}); expect(auth.email, email); expect(auth.isAuthenticated, isTrue); diff --git a/packages/shorebird_cli/test/src/auth/jwt_test.dart b/packages/shorebird_cli/test/src/auth/jwt_test.dart deleted file mode 100644 index ffff4bbf..00000000 --- a/packages/shorebird_cli/test/src/auth/jwt_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -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'}), - ); - }); - }); - }); -}