diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 311b2dd2..3253ad96 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -42,6 +42,9 @@ jobs: shorebird_code_push_protocol: - ./.github/actions/dart_package - packages/shorebird_code_push_protocol/** + jwt: + - ./.github/actions/dart_package + - packages/jwt/** - uses: dorny/paths-filter@v2 name: Build Detection diff --git a/packages/jwt/.gitignore b/packages/jwt/.gitignore new file mode 100644 index 00000000..526da158 --- /dev/null +++ b/packages/jwt/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/jwt/README.md b/packages/jwt/README.md new file mode 100644 index 00000000..23ef66dc --- /dev/null +++ b/packages/jwt/README.md @@ -0,0 +1,23 @@ +# jwt + +[![License: MIT][license_badge]][license_link] + +A Dart JWT Library. + +```dart +import 'package:jwt/jwt.dart' as jwt; + +Future main() async { + // Verify and extract a JWT token. + final Jwt token = await jwt.verify( + '', + issuer: '', + audience: '', + publicKeysUrl: '', + ); +} + +``` + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT diff --git a/packages/jwt/analysis_options.yaml b/packages/jwt/analysis_options.yaml new file mode 100644 index 00000000..84e34fba --- /dev/null +++ b/packages/jwt/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.4.0.0.yaml diff --git a/packages/jwt/build.yaml b/packages/jwt/build.yaml new file mode 100644 index 00000000..1160343d --- /dev/null +++ b/packages/jwt/build.yaml @@ -0,0 +1,14 @@ +targets: + $default: + builders: + source_gen|combining_builder: + options: + ignore_for_file: + - implicit_dynamic_parameter + - require_trailing_commas + - cast_nullable_to_non_nullable + - lines_longer_than_80_chars + json_serializable: + options: + field_rename: snake + checked: true diff --git a/packages/jwt/example/main.dart b/packages/jwt/example/main.dart new file mode 100644 index 00000000..a8d9747b --- /dev/null +++ b/packages/jwt/example/main.dart @@ -0,0 +1,12 @@ +// ignore_for_file: avoid_print +import 'package:jwt/jwt.dart' as jwt; + +Future main() async { + final token = await jwt.verify( + '', + issuer: '', + audience: '', + publicKeysUrl: '', + ); + print(token); +} diff --git a/packages/jwt/lib/jwt.dart b/packages/jwt/lib/jwt.dart new file mode 100644 index 00000000..e8df88e6 --- /dev/null +++ b/packages/jwt/lib/jwt.dart @@ -0,0 +1,2 @@ +export 'src/jwt.dart'; +export 'src/models/models.dart'; diff --git a/packages/jwt/lib/src/jwt.dart b/packages/jwt/lib/src/jwt.dart new file mode 100644 index 00000000..5e098858 --- /dev/null +++ b/packages/jwt/lib/src/jwt.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:clock/clock.dart'; +import 'package:http/http.dart' as http; +import 'package:jwt/jwt.dart'; +import 'package:meta/meta.dart'; +import 'package:pointycastle/pointycastle.dart'; +import 'package:rsa_pkcs/rsa_pkcs.dart' as rsa; + +/// {@template public_key_store} +/// A store for the public keys. +/// {@endtemplate} +class PublicKeyStore { + /// {@macro public_key_store} + const PublicKeyStore({required this.keys, required this.expiration}); + + /// Map of all public key id/value pairs. + final Map keys; + + /// Expiration time. + final DateTime expiration; +} + +PublicKeyStore? _publicKeyStore; + +Future> _getPublicKeys(String url) async { + if (_publicKeyStore?.expiration.isAfter(clock.now()) ?? false) { + return _publicKeyStore!.keys; + } + + final get = getOverride ?? http.get; + final response = await get(Uri.parse(url)); + + if (response.statusCode != HttpStatus.ok) { + throw const JwtVerificationFailure('Could not fetch public keys.'); + } + final maxAgeRegExp = RegExp(r'max-age=(\d+)'); + final match = maxAgeRegExp.firstMatch(response.headers['cache-control']!); + final maxAge = int.parse(match!.group(1)!); + final publicKeys = (json.decode(response.body) as Map) + .cast(); + + _publicKeyStore = PublicKeyStore( + keys: publicKeys, + expiration: clock.now().add(Duration(seconds: maxAge)), + ); + + return publicKeys; +} + +/// Typedef for a function that returns the public keys asynchronously. +typedef GetPublicKeys = Future> Function(); + +/// {@template jwt_verification_failure} +/// An exception thrown during JWT verification. +/// {@endtemplate} +class JwtVerificationFailure implements Exception { + /// {@macro jwt_verification_failure} + const JwtVerificationFailure(this.reason); + + /// The reason for the verification failure. + final String reason; + + @override + String toString() => 'JwtVerificationFailure: $reason'; +} + +/// Verify the provided [jwt]. +Future verify( + String jwt, { + required String issuer, + required String audience, + required String publicKeysUrl, +}) async { + final parts = jwt.split('.'); + + if (parts.length != 3) { + throw const JwtVerificationFailure('JWT is malformed'); + } + + 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); + + 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]!); + 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; +} + +Future _verifyHeader( + JwtHeader header, + Map publicKeys, +) async { + if (header.typ != 'JWT') { + throw const JwtVerificationFailure('Invalid token type.'); + } + + if (header.alg != 'RS256') { + throw const JwtVerificationFailure('Invalid algorithm.'); + } + + if (!publicKeys.containsKey(header.kid)) { + throw const JwtVerificationFailure('Invalid key id.'); + } +} + +void _verifyPayload(JwtPayload payload, String issuer, String audience) { + final now = clock.now(); + + final exp = DateTime.fromMillisecondsSinceEpoch(payload.exp * 1000); + if (exp.isBefore(now)) { + throw const JwtVerificationFailure('Token has expired.'); + } + + final iat = DateTime.fromMillisecondsSinceEpoch(payload.iat * 1000); + if (iat.isAfter(now)) { + throw const JwtVerificationFailure('Token issued at a future time.'); + } + + final authTime = DateTime.fromMillisecondsSinceEpoch(payload.authTime * 1000); + if (authTime.isAfter(now)) { + throw const JwtVerificationFailure('Authenticated at a future time.'); + } + + if (payload.aud != audience) { + throw const JwtVerificationFailure('Invalid audience.'); + } + + if (payload.iss != issuer) { + throw const JwtVerificationFailure('Invalid issuer.'); + } + + if (payload.sub.isEmpty) { + throw const JwtVerificationFailure('Invalid subject.'); + } +} + +bool _verifySignature(String jwt, String publicKey) { + final parts = jwt.split('.'); + final encodedHeader = parts[0]; + final encodedPayload = parts[1]; + final signature = parts[2]; + final body = utf8.encode('$encodedHeader.$encodedPayload'); + final sign = base64Url.decode(base64Padded(signature)); + + final parser = rsa.RSAPKCSParser(); + final pair = parser.parsePEM(publicKey); + if (pair.public is! rsa.RSAPublicKey) return false; + final public = pair.public; + + try { + final signer = Signer('SHA-256/RSA'); + final key = RSAPublicKey( + public!.modulus, + BigInt.from(public.publicExponent), + ); + final param = ParametersWithRandom( + PublicKeyParameter(key), + SecureRandom('AES/CTR/PRNG'), + ); + signer.init(false, param); + final rsaSignature = RSASignature(Uint8List.fromList(sign)); + return signer.verifySignature(Uint8List.fromList(body), rsaSignature); + } catch (_) { + return false; + } +} + +/// Visible for testing only +@visibleForTesting +String base64Padded(String value) { + final mod = value.length % 4; + if (mod == 0) { + return value; + } else if (mod == 3) { + return value.padRight(value.length + 1, '='); + } else if (mod == 2) { + return value.padRight(value.length + 2, '='); + } else { + return value; // let it fail when decoding + } +} + +/// Override for http.get. +/// Used for testing purposes only. +@visibleForTesting +Future Function(Uri uri)? getOverride; diff --git a/packages/jwt/lib/src/models/jwt.dart b/packages/jwt/lib/src/models/jwt.dart new file mode 100644 index 00000000..1d9c925c --- /dev/null +++ b/packages/jwt/lib/src/models/jwt.dart @@ -0,0 +1,26 @@ +import 'package:jwt/jwt.dart'; + +/// {@template jwt} +/// A JWT (json web token) +/// {@endtemplate} +class Jwt { + /// {@macro jwt} + const Jwt({ + required this.header, + required this.payload, + required this.signature, + this.claims = const {}, + }); + + /// {@macro jwt_header} + final JwtHeader header; + + /// {@macro jwt_payload} + final JwtPayload payload; + + /// JWT signature. + final String signature; + + /// Token claims. + final Map claims; +} diff --git a/packages/jwt/lib/src/models/jwt_header.dart b/packages/jwt/lib/src/models/jwt_header.dart new file mode 100644 index 00000000..6221b045 --- /dev/null +++ b/packages/jwt/lib/src/models/jwt_header.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'jwt_header.g.dart'; + +/// {@template jwt_header} +/// A JWT header which contains the algorithm and token type. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class JwtHeader { + /// {@macro jwt_header} + const JwtHeader({ + required this.alg, + required this.kid, + required this.typ, + }); + + /// Decode a [JwtHeader] from a `Map`. + factory JwtHeader.fromJson(Map json) { + return _$JwtHeaderFromJson(json); + } + + /// Signature or encryption algorithm. + final String alg; + + /// Key ID. + final String kid; + + /// Type of token. + final String typ; +} diff --git a/packages/jwt/lib/src/models/jwt_header.g.dart b/packages/jwt/lib/src/models/jwt_header.g.dart new file mode 100644 index 00000000..04640db4 --- /dev/null +++ b/packages/jwt/lib/src/models/jwt_header.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars + +part of 'jwt_header.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +JwtHeader _$JwtHeaderFromJson(Map json) => $checkedCreate( + 'JwtHeader', + json, + ($checkedConvert) { + final val = JwtHeader( + alg: $checkedConvert('alg', (v) => v as String), + kid: $checkedConvert('kid', (v) => v as String), + typ: $checkedConvert('typ', (v) => v as String), + ); + return val; + }, + ); diff --git a/packages/jwt/lib/src/models/jwt_payload.dart b/packages/jwt/lib/src/models/jwt_payload.dart new file mode 100644 index 00000000..469e2050 --- /dev/null +++ b/packages/jwt/lib/src/models/jwt_payload.dart @@ -0,0 +1,42 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'jwt_payload.g.dart'; + +/// {@template jwt_payload} +/// A JWT payload which contains data. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class JwtPayload { + /// {@macro jwt_payload} + JwtPayload({ + required this.exp, + required this.iat, + required this.aud, + required this.iss, + required this.sub, + required this.authTime, + }); + + /// Decode a [JwtPayload] from a `Map`. + factory JwtPayload.fromJson(Map json) { + return _$JwtPayloadFromJson(json); + } + + /// Expiration time (seconds since Unix epoch). + final int exp; + + /// Issued at (seconds since Unix epoch). + final int iat; + + /// Audience (who or what the token is intended for). + final String aud; + + /// Issuer (who created and signed this token). + final String iss; + + /// Subject (whom the token refers to). + final String sub; + + /// Time when authentication occurred. + final int authTime; +} diff --git a/packages/jwt/lib/src/models/jwt_payload.g.dart b/packages/jwt/lib/src/models/jwt_payload.g.dart new file mode 100644 index 00000000..b2cc38bf --- /dev/null +++ b/packages/jwt/lib/src/models/jwt_payload.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars + +part of 'jwt_payload.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +JwtPayload _$JwtPayloadFromJson(Map json) => $checkedCreate( + 'JwtPayload', + json, + ($checkedConvert) { + final val = JwtPayload( + exp: $checkedConvert('exp', (v) => v as int), + iat: $checkedConvert('iat', (v) => v as int), + aud: $checkedConvert('aud', (v) => v as String), + iss: $checkedConvert('iss', (v) => v as String), + sub: $checkedConvert('sub', (v) => v as String), + authTime: $checkedConvert('auth_time', (v) => v as int), + ); + return val; + }, + fieldKeyMap: const {'authTime': 'auth_time'}, + ); diff --git a/packages/jwt/lib/src/models/models.dart b/packages/jwt/lib/src/models/models.dart new file mode 100644 index 00000000..1876c085 --- /dev/null +++ b/packages/jwt/lib/src/models/models.dart @@ -0,0 +1,3 @@ +export 'jwt.dart'; +export 'jwt_header.dart'; +export 'jwt_payload.dart'; diff --git a/packages/jwt/pubspec.yaml b/packages/jwt/pubspec.yaml new file mode 100644 index 00000000..11bd3906 --- /dev/null +++ b/packages/jwt/pubspec.yaml @@ -0,0 +1,21 @@ +name: jwt +description: A Dart Json Web Token Library +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.19.0 <3.0.0" + +dependencies: + clock: ^1.1.0 + http: ^0.13.4 + json_annotation: ^4.4.0 + meta: ^1.7.0 + pointycastle: ^3.5.0 + rsa_pkcs: ^2.0.0 + +dev_dependencies: + build_runner: ^2.0.0 + json_serializable: ^6.1.4 + test: ^1.19.2 + very_good_analysis: ^4.0.0 diff --git a/packages/jwt/test/fixtures/public_key.pem b/packages/jwt/test/fixtures/public_key.pem new file mode 100644 index 00000000..a7bf8df9 --- /dev/null +++ b/packages/jwt/test/fixtures/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyCtJXKQDbTq6Cp/zqmTO +BF2+K1aRYevhUglKCcLMJ0P16LJEIznnOIB0SZuP2jcFHwiMZ9PyzpocOAPUZZue +rFxKyxcCP8zS/mpHwrSxpxlgm/4oChvYW7U4rH7E/Hb4r2iMqlkgRsCnyvN/EKdG +e1TkVNnK3dcX+CFgGOjkhAQc5/dz9pThb5oGM36kfnHSlkprGZtS+ijYXsqMJWr7 +410uNaVjFZnp53B9atcWhnfQ4ev3elR0TV+2TZIXd5n/wF2aX44dSB2KQE3aRDSX +4dZHp/a3xWeeLhcfLkKe5HYUSPgx91742y6TLJd1stOWTg+y2G1A1kaDBEaUVtRV +FwIDAQAB +-----END PUBLIC KEY----- diff --git a/packages/jwt/test/src/jwt_test.dart b/packages/jwt/test/src/jwt_test.dart new file mode 100644 index 00000000..152eaeee --- /dev/null +++ b/packages/jwt/test/src/jwt_test.dart @@ -0,0 +1,167 @@ +// ignore_for_file: prefer_const_constructors +import 'dart:convert'; +import 'dart:io'; + +import 'package:clock/clock.dart'; +import 'package:http/http.dart'; +import 'package:jwt/jwt.dart'; +import 'package:test/test.dart'; + +void main() { + const token = + '''eyJhbGciOiJSUzI1NiIsImtpZCI6ImMxMGM5MGJhNGMzNjYzNTE2ZTA3MDdkMGU5YTg5NDgxMDYyODUxNTgiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXktYXBwIiwiYXVkIjoibXktYXBwIiwiYXV0aF90aW1lIjoxNjQzNjg0MjY2LCJ1c2VyX2lkIjoiRzR1MzdXdk90dmVWR0pRb1pCWGpxcHVWazZWMiIsInN1YiI6Ikc0dTM3V3ZPdHZlVkdKUW9aQlhqcXB1Vms2VjIiLCJpYXQiOjE2NDM2ODQyNjYsImV4cCI6MTY0MzY4Nzg2NiwiZW1haWwiOiJ0ZXN0QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7ImVtYWlsIjpbInRlc3RAZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoicGFzc3dvcmQifX0.bUWnX_XmR1d9EmeFeYSsK_CHU1u9NPIHgyaQueZ6urYOtxvuL_QodjPl0c9CBJwctwPnxVyRmkeNCw0oF9xBgph0NApLL4FIG6vpDPZfW9txZBYr8xIvaqvmD0diACENAQdjRT2XmyEdQ2-U7SsTonybHmLoU9FMQTjAgw4NCALQvExfB6rtQ9GDsOBt1xoBkB3Vo7a5OmugZ1aHXF69b8As6137-Dggf5qx5R3oLRFovICMMesQziE3vGi-WKcbQxSeiD-9a6ShPAhk41XiyjFGDEOtUCQo63uwQnMw3g0KVtC6bzIyFq-E91vhxumxXzxPYC-kg7iUYiSZy7Y-Aw'''; + const publicKeysUrl = + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; + const issuer = 'https://securetoken.google.com/my-app'; + const audience = 'my-app'; + final publicKey = File('test/fixtures/public_key.pem').readAsStringSync(); + final body = json.encode( + {'c10c90ba4c3663516e0707d0e9a8948106285158': publicKey}, + ); + final expiresAt = DateTime.fromMillisecondsSinceEpoch(1643687866 * 1000); + final validTime = expiresAt.subtract(Duration(minutes: 15)); + + group('verify', () { + test('can be instantiated', () { + expect(verify, isNotNull); + }); + + test('can verify an expired jwt', () async { + getOverride = (Uri uri) async { + return Response( + body, + HttpStatus.ok, + headers: {'cache-control': 'max-age=3600'}, + ); + }; + + try { + await verify( + token, + audience: audience, + issuer: issuer, + publicKeysUrl: publicKeysUrl, + ); + fail('should throw'); + } catch (error) { + expect( + error, + isA().having( + (e) => e.reason, + 'reason', + 'Token has expired.', + ), + ); + } + }); + + test('can verify an invalid audience', () async { + await withClock(Clock.fixed(validTime), () async { + getOverride = (Uri uri) async { + return Response( + body, + HttpStatus.ok, + headers: {'cache-control': 'max-age=3600'}, + ); + }; + try { + await verify( + token, + audience: 'invalid-audience', + issuer: issuer, + publicKeysUrl: publicKeysUrl, + ); + fail('should throw'); + } catch (error) { + expect( + error, + isA().having( + (e) => e.reason, + 'reason', + 'Invalid audience.', + ), + ); + } + }); + }); + + test('can verify an invalid issuer', () async { + await withClock(Clock.fixed(validTime), () async { + getOverride = (Uri uri) async { + return Response( + body, + HttpStatus.ok, + headers: {'cache-control': 'max-age=3600'}, + ); + }; + try { + await verify( + token, + audience: audience, + issuer: 'https://invalid/issuer', + publicKeysUrl: publicKeysUrl, + ); + fail('should throw'); + } catch (error) { + expect( + error, + isA().having( + (e) => e.reason, + 'reason', + 'Invalid issuer.', + ), + ); + } + }); + }); + + test('can verify a valid jwt', () async { + await withClock(Clock.fixed(validTime), () async { + getOverride = (Uri uri) async { + return Response( + body, + HttpStatus.ok, + headers: {'cache-control': 'max-age=3600'}, + ); + }; + final jwt = await verify( + token, + audience: audience, + issuer: issuer, + publicKeysUrl: publicKeysUrl, + ); + expect(jwt, isA()); + }); + }); + }); + + group('base64Padded', () { + test('does not add padding when mod 4 == 0', () { + const value = 'aaaa'; + expect(base64Padded(value), equals(value)); + }); + + test('does not add padding when mod 4 == 1', () { + const value = 'aaaaa'; + expect(base64Padded(value), equals(value)); + }); + + test('adds padding when mod 4 == 3', () { + const value = 'aaaaaaa'; + expect(base64Padded(value), equals('$value=')); + }); + + test('adds padding when mod 4 == 2', () { + const value = 'aaaaaa'; + expect(base64Padded(value), equals('$value==')); + }); + }); + + group('JwtVerificationFailure', () { + test('toString is correct', () { + const reason = 'reason'; + final failure = JwtVerificationFailure(reason); + expect(failure.toString(), equals('JwtVerificationFailure: $reason')); + }); + }); +}