From 7238e96c610a344e445bd166f277281be55b030a Mon Sep 17 00:00:00 2001 From: Mac Date: Wed, 25 Feb 2026 19:50:32 -0700 Subject: [PATCH] feat: support RSA JWK key stores for Shorebird auth (#3627) --- cspell.config.yaml | 1 + packages/jwt/example/main.dart | 1 + packages/jwt/lib/jwt.dart | 1 + packages/jwt/lib/src/jwks_format.dart | 19 + packages/jwt/lib/src/jwt.dart | 39 +- .../public_key_store/jwk_key_store.dart | 6 +- .../public_key_store/key_value_key_store.dart | 6 +- .../public_key_store/public_key_store.dart | 67 +++- .../public_key_store/rsa_jwk_key_store.dart | 70 ++++ .../public_key_store/rsa_jwk_key_store.g.dart | 22 ++ packages/jwt/lib/src/models/rsa_jwk.dart | 44 +++ packages/jwt/lib/src/models/rsa_jwk.g.dart | 22 ++ packages/jwt/test/src/jwt_test.dart | 371 ++++++++++++++++++ 13 files changed, 633 insertions(+), 36 deletions(-) create mode 100644 packages/jwt/lib/src/jwks_format.dart create mode 100644 packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.dart create mode 100644 packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.g.dart create mode 100644 packages/jwt/lib/src/models/rsa_jwk.dart create mode 100644 packages/jwt/lib/src/models/rsa_jwk.g.dart diff --git a/cspell.config.yaml b/cspell.config.yaml index 598eeaa1..2448477c 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -119,6 +119,7 @@ words: - udid # Unique Device Identifier - unawaited - unmockable + - unpadded - Unpatchable - unsets - unskipped diff --git a/packages/jwt/example/main.dart b/packages/jwt/example/main.dart index 47fdf345..0af80297 100644 --- a/packages/jwt/example/main.dart +++ b/packages/jwt/example/main.dart @@ -6,6 +6,7 @@ Future main() async { issuer: '', audience: {''}, publicKeysUrl: '', + jwksFormat: jwt.JwksFormat.keyValue, ); print(token); } diff --git a/packages/jwt/lib/jwt.dart b/packages/jwt/lib/jwt.dart index e8df88e6..754882e2 100644 --- a/packages/jwt/lib/jwt.dart +++ b/packages/jwt/lib/jwt.dart @@ -1,2 +1,3 @@ +export 'src/jwks_format.dart'; export 'src/jwt.dart'; export 'src/models/models.dart'; diff --git a/packages/jwt/lib/src/jwks_format.dart b/packages/jwt/lib/src/jwks_format.dart new file mode 100644 index 00000000..9ad0b21e --- /dev/null +++ b/packages/jwt/lib/src/jwks_format.dart @@ -0,0 +1,19 @@ +/// {@template jwks_format} +/// The JWKS response format used by an auth provider's public key endpoint. +/// +/// Each value maps to a specific [PublicKeyStore] subclass that knows how to +/// parse that format. +/// {@endtemplate} +enum JwksFormat { + /// Google's format: a flat JSON object mapping key IDs to PEM certificate + /// strings. + keyValue, + + /// Microsoft Entra ID's format: a JWK Set containing X.509 certificate + /// chains (`x5c`/`x5t` fields). + jwkCertificate, + + /// Shorebird auth's format: a JWK Set with bare RSA public key parameters + /// (`n`, `e`, `kid`, `kty`, `use`; no certificates). + rsaJwk, +} diff --git a/packages/jwt/lib/src/jwt.dart b/packages/jwt/lib/src/jwt.dart index 4ad8d056..bea93d85 100644 --- a/packages/jwt/lib/src/jwt.dart +++ b/packages/jwt/lib/src/jwt.dart @@ -16,7 +16,10 @@ import 'package:ttl_cache/ttl_cache.dart'; @visibleForTesting final publicKeyStores = TtlCache(); -Future _getPublicKeys(String url) async { +Future _getPublicKeys( + String url, { + required JwksFormat jwksFormat, +}) async { final store = publicKeyStores.get(url); if (store != null) { return store; @@ -34,6 +37,7 @@ Future _getPublicKeys(String url) async { final publicKeyStore = PublicKeyStore.tryDeserialize( json.decode(response.body) as Map, + format: jwksFormat, ); if (publicKeyStore == null) { @@ -107,6 +111,7 @@ Future verify( required String issuer, required Set audience, required String publicKeysUrl, + required JwksFormat jwksFormat, }) async { final Jwt jwt; try { @@ -115,7 +120,10 @@ Future verify( throw JwtVerificationFailure(e.message); } - final publicKeys = await _getPublicKeys(publicKeysUrl); + final publicKeys = await _getPublicKeys( + publicKeysUrl, + jwksFormat: jwksFormat, + ); if (publicKeys == null) { throw JwtVerificationFailure( 'Invalid public keys returned by $publicKeysUrl.', @@ -125,12 +133,13 @@ Future verify( await _verifyHeader(jwt.header, publicKeys.keyIds); _verifyPayload(jwt.payload, issuer, audience); - // By using this keystore's key IDs to validate the header above, we've - // guaranteed that there is a public key for this key ID. - final publicKey = publicKeys.getPublicKey(jwt.header.kid)!; + final keyMaterial = publicKeys.getKeyMaterial(jwt.header.kid); + if (keyMaterial == null) { + throw const JwtVerificationFailure('No usable public key for key id.'); + } final bool isValid; try { - isValid = _verifySignature(encodedJwt, publicKey); + isValid = _verifySignature(encodedJwt, keyMaterial); } on Exception { throw const JwtVerificationFailure('JWT signature is malformed.'); } @@ -192,7 +201,7 @@ void _verifyPayload(JwtPayload payload, String issuer, Set audience) { } } -bool _verifySignature(String jwt, String publicKey) { +bool _verifySignature(String jwt, KeyMaterial keyMaterial) { final parts = jwt.split('.'); final encodedHeader = parts[0]; final encodedPayload = parts[1]; @@ -200,13 +209,19 @@ bool _verifySignature(String jwt, String publicKey) { 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; + final RSAPublicKey key; + switch (keyMaterial) { + case PemKeyMaterial(:final pem): + final parser = rsa.RSAPKCSParser(); + final pair = parser.parsePEM(pem); + if (pair.public is! rsa.RSAPublicKey) return false; + final public = pair.public!; + key = RSAPublicKey(public.modulus, BigInt.from(public.publicExponent)); + case RsaKeyMaterial(:final publicKey): + key = publicKey; + } final signer = Signer('SHA-256/RSA'); - final key = RSAPublicKey(public!.modulus, BigInt.from(public.publicExponent)); final param = ParametersWithRandom( PublicKeyParameter(key), SecureRandom('AES/CTR/PRNG'), diff --git a/packages/jwt/lib/src/models/public_key_store/jwk_key_store.dart b/packages/jwt/lib/src/models/public_key_store/jwk_key_store.dart index 05124309..85100334 100644 --- a/packages/jwt/lib/src/models/public_key_store/jwk_key_store.dart +++ b/packages/jwt/lib/src/models/public_key_store/jwk_key_store.dart @@ -26,15 +26,15 @@ class JwkKeyStore extends PublicKeyStore { Iterable get keyIds => keys.map((key) => key.kid); @override - String? getPublicKey(String kid) { + KeyMaterial? getKeyMaterial(String kid) { final key = keys.firstWhereOrNull((key) => key.kid == kid)?.x5c.firstOrNull; if (key == null) { return null; } - return ''' + return PemKeyMaterial(''' -----BEGIN CERTIFICATE----- $key ------END CERTIFICATE-----'''; +-----END CERTIFICATE-----'''); } } diff --git a/packages/jwt/lib/src/models/public_key_store/key_value_key_store.dart b/packages/jwt/lib/src/models/public_key_store/key_value_key_store.dart index 9d2e7af2..eff09cfe 100644 --- a/packages/jwt/lib/src/models/public_key_store/key_value_key_store.dart +++ b/packages/jwt/lib/src/models/public_key_store/key_value_key_store.dart @@ -26,5 +26,9 @@ class KeyValueKeyStore extends PublicKeyStore { Iterable get keyIds => keys.keys; @override - String? getPublicKey(String kid) => keys[kid]; + KeyMaterial? getKeyMaterial(String kid) { + final pem = keys[kid]; + if (pem == null) return null; + return PemKeyMaterial(pem); + } } diff --git a/packages/jwt/lib/src/models/public_key_store/public_key_store.dart b/packages/jwt/lib/src/models/public_key_store/public_key_store.dart index 94c942c9..e6148af5 100644 --- a/packages/jwt/lib/src/models/public_key_store/public_key_store.dart +++ b/packages/jwt/lib/src/models/public_key_store/public_key_store.dart @@ -1,5 +1,35 @@ +import 'package:jwt/src/jwks_format.dart'; import 'package:jwt/src/models/public_key_store/jwk_key_store.dart'; import 'package:jwt/src/models/public_key_store/key_value_key_store.dart'; +import 'package:jwt/src/models/public_key_store/rsa_jwk_key_store.dart'; +import 'package:pointycastle/pointycastle.dart' as pointycastle; + +/// {@template key_material} +/// The material needed to verify a JWT signature. +/// {@endtemplate} +sealed class KeyMaterial {} + +/// {@template pem_key_material} +/// Key material represented as a PEM-encoded string. +/// {@endtemplate} +class PemKeyMaterial extends KeyMaterial { + /// {@macro pem_key_material} + PemKeyMaterial(this.pem); + + /// The PEM-encoded key string. + final String pem; +} + +/// {@template rsa_key_material} +/// Key material represented as a raw RSA public key. +/// {@endtemplate} +class RsaKeyMaterial extends KeyMaterial { + /// {@macro rsa_key_material} + RsaKeyMaterial(this.publicKey); + + /// The RSA public key. + final pointycastle.RSAPublicKey publicKey; +} /// {@template public_key_store} /// A store for the public keys. @@ -8,30 +38,27 @@ abstract class PublicKeyStore { /// {@macro public_key_store} const PublicKeyStore(); - /// Attempts to deserialize a [PublicKeyStore] from a JSON object. This will - /// return the appropriate subclass of [PublicKeyStore] if the JSON object - /// contains the necessary fields. - static PublicKeyStore? tryDeserialize(Map json) { - if (json.containsKey('keys')) { - try { - return JwkKeyStore.fromJson(json); - } on Exception { - // Swallow deserialization exceptions and return null. - } - } else { - try { - return KeyValueKeyStore.fromJson(json); - } on Exception { - // Swallow deserialization exceptions and return null. - } + /// Attempts to deserialize a [PublicKeyStore] from a JSON object for the + /// given [format]. + static PublicKeyStore? tryDeserialize( + Map json, { + required JwksFormat format, + }) { + try { + return switch (format) { + JwksFormat.keyValue => KeyValueKeyStore.fromJson(json), + JwksFormat.jwkCertificate => JwkKeyStore.fromJson(json), + JwksFormat.rsaJwk => RsaJwkKeyStore.fromJson(json), + }; + } on Exception { + // Swallow deserialization exceptions and return null. + return null; } - - return null; } /// The key IDs contained in this store. Iterable get keyIds; - /// Returns a public key for the given key ID, if one exists. - String? getPublicKey(String kid); + /// Returns the key material for the given key ID, if one exists. + KeyMaterial? getKeyMaterial(String kid); } diff --git a/packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.dart b/packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.dart new file mode 100644 index 00000000..1b27da17 --- /dev/null +++ b/packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:jwt/src/models/public_key_store/public_key_store.dart'; +import 'package:jwt/src/models/rsa_jwk.dart'; +import 'package:pointycastle/pointycastle.dart' as pointycastle; + +part 'rsa_jwk_key_store.g.dart'; + +/// {@template rsa_jwk_key_store} +/// A collection of bare RSA JSON Web Keys, as produced by Shorebird's auth +/// service. +/// +/// Unlike [JwkKeyStore], which wraps X.509 certificates, this key store +/// converts base64url-encoded JWK parameters (`n`, `e`) directly into a +/// PointyCastle [pointycastle.RSAPublicKey]. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class RsaJwkKeyStore extends PublicKeyStore { + /// {@macro rsa_jwk_key_store} + RsaJwkKeyStore({required this.keys}); + + /// The collection of RSA JWKs. + final List keys; + + /// Decodes a JSON object into an [RsaJwkKeyStore]. + static RsaJwkKeyStore fromJson(Map json) => + _$RsaJwkKeyStoreFromJson(json); + + @override + Iterable get keyIds => keys.map((key) => key.kid); + + @override + KeyMaterial? getKeyMaterial(String kid) { + final key = keys.firstWhereOrNull((key) => key.kid == kid); + if (key == null) return null; + + // Validate key type. + if (key.kty != 'RSA') return null; + + // Only accept keys intended for signature verification. + if (key.use != 'sig') return null; + + // Validate algorithm: accept RS256 or absent (verifier defaults to RS256). + // Reject any other algorithm to prevent algorithm confusion attacks. + if (key.alg != null && key.alg != 'RS256') return null; + + final modulus = _decodeBigInt(key.n); + final exponent = _decodeBigInt(key.e); + + return RsaKeyMaterial(pointycastle.RSAPublicKey(modulus, exponent)); + } +} + +/// Decodes a base64url-encoded unsigned big-endian integer (as used in JWK +/// `n` and `e` fields) into a [BigInt]. +BigInt _decodeBigInt(String base64UrlValue) { + final padded = base64UrlValue.padRight( + base64UrlValue.length + (4 - base64UrlValue.length % 4) % 4, + '=', + ); + final bytes = base64Url.decode(padded); + + var result = BigInt.zero; + for (final byte in bytes) { + result = (result << 8) | BigInt.from(byte); + } + return result; +} diff --git a/packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.g.dart b/packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.g.dart new file mode 100644 index 00000000..8e43f611 --- /dev/null +++ b/packages/jwt/lib/src/models/public_key_store/rsa_jwk_key_store.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, document_ignores + +part of 'rsa_jwk_key_store.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RsaJwkKeyStore _$RsaJwkKeyStoreFromJson(Map json) => + $checkedCreate('RsaJwkKeyStore', json, ($checkedConvert) { + final val = RsaJwkKeyStore( + keys: $checkedConvert( + 'keys', + (v) => (v as List) + .map((e) => RsaJwk.fromJson(e as Map)) + .toList(), + ), + ); + return val; + }); diff --git a/packages/jwt/lib/src/models/rsa_jwk.dart b/packages/jwt/lib/src/models/rsa_jwk.dart new file mode 100644 index 00000000..8e127bdc --- /dev/null +++ b/packages/jwt/lib/src/models/rsa_jwk.dart @@ -0,0 +1,44 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'rsa_jwk.g.dart'; + +/// {@template rsa_jwk} +/// An RSA JSON Web Key (JWK) as produced by Shorebird's auth service. +/// +/// Contains only the bare RSA public key fields exported by `jose.exportJWK()` +/// (`kty`, `n`, `e`, `kid`, `use`, and optionally `alg`) — without the X.509 +/// certificate fields (`x5c`, `x5t`) present in [Jwk]. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class RsaJwk { + /// {@macro rsa_jwk} + const RsaJwk({ + required this.kty, + required this.use, + required this.kid, + required this.n, + required this.e, + this.alg, + }); + + /// Decodes a JSON object into an [RsaJwk]. + factory RsaJwk.fromJson(Map json) => _$RsaJwkFromJson(json); + + /// Key type (must be `"RSA"`). + final String kty; + + /// Key use (e.g., `"sig"`). + final String use; + + /// Key ID. + final String kid; + + /// RSA modulus (base64url-encoded, unpadded). + final String n; + + /// RSA public exponent (base64url-encoded, unpadded). + final String e; + + /// Algorithm — optional per RFC 7517 §4.4. + final String? alg; +} diff --git a/packages/jwt/lib/src/models/rsa_jwk.g.dart b/packages/jwt/lib/src/models/rsa_jwk.g.dart new file mode 100644 index 00000000..19e9aa5d --- /dev/null +++ b/packages/jwt/lib/src/models/rsa_jwk.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, document_ignores + +part of 'rsa_jwk.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RsaJwk _$RsaJwkFromJson(Map json) => + $checkedCreate('RsaJwk', json, ($checkedConvert) { + final val = RsaJwk( + kty: $checkedConvert('kty', (v) => v as String), + use: $checkedConvert('use', (v) => v as String), + kid: $checkedConvert('kid', (v) => v as String), + n: $checkedConvert('n', (v) => v as String), + e: $checkedConvert('e', (v) => v as String), + alg: $checkedConvert('alg', (v) => v as String?), + ); + return val; + }); diff --git a/packages/jwt/test/src/jwt_test.dart b/packages/jwt/test/src/jwt_test.dart index a96592fe..755ed233 100644 --- a/packages/jwt/test/src/jwt_test.dart +++ b/packages/jwt/test/src/jwt_test.dart @@ -1,11 +1,85 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; import 'package:clock/clock.dart'; import 'package:http/http.dart'; import 'package:jwt/jwt.dart'; +import 'package:jwt/src/models/public_key_store/public_key_store.dart'; +import 'package:jwt/src/models/public_key_store/rsa_jwk_key_store.dart'; import 'package:path/path.dart' as p; +import 'package:pointycastle/pointycastle.dart' as pc; import 'package:test/test.dart'; +/// Encodes a [BigInt] as an unpadded base64url string (JWK `n`/`e` format). +String _encodeBigInt(BigInt value) { + var hex = value.toRadixString(16); + if (hex.length.isOdd) hex = '0$hex'; + final bytes = []; + for (var i = 0; i < hex.length; i += 2) { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + return base64Url.encode(Uint8List.fromList(bytes)).replaceAll('=', ''); +} + +pc.SecureRandom _secureRandom() { + final sr = pc.SecureRandom('AES/CTR/PRNG'); + final random = Random.secure(); + final seeds = List.generate(32, (_) => random.nextInt(256)); + sr.seed(pc.KeyParameter(Uint8List.fromList(seeds))); + return sr; +} + +pc.AsymmetricKeyPair _generateRsaKeyPair() { + final keyGen = pc.KeyGenerator('RSA'); + keyGen.init( + pc.ParametersWithRandom( + pc.RSAKeyGeneratorParameters(BigInt.parse('65537'), 2048, 64), + _secureRandom(), + ), + ); + final pair = keyGen.generateKeyPair(); + return pc.AsymmetricKeyPair( + pair.publicKey as pc.RSAPublicKey, + pair.privateKey as pc.RSAPrivateKey, + ); +} + +/// Creates a signed JWT from the given [header], [payload], and [privateKey]. +String _createSignedJwt({ + required Map header, + required Map payload, + required pc.RSAPrivateKey privateKey, +}) { + final encodedHeader = base64Url + .encode(utf8.encode(json.encode(header))) + .replaceAll('=', ''); + final encodedPayload = base64Url + .encode(utf8.encode(json.encode(payload))) + .replaceAll('=', ''); + final signingInput = '$encodedHeader.$encodedPayload'; + + final signer = pc.Signer('SHA-256/RSA'); + signer.init( + true, + pc.ParametersWithRandom( + pc.PrivateKeyParameter(privateKey), + _secureRandom(), + ), + ); + final signature = + signer.generateSignature( + Uint8List.fromList(utf8.encode(signingInput)), + ) + as pc.RSASignature; + final encodedSignature = base64Url + .encode(signature.bytes) + .replaceAll('=', ''); + + return '$signingInput.$encodedSignature'; +} + void main() { const token = // cspell: disable-next-line '''eyJhbGciOiJSUzI1NiIsImtpZCI6ImMxMGM5MGJhNGMzNjYzNTE2ZTA3MDdkMGU5YTg5NDgxMDYyODUxNTgiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXktYXBwIiwiYXVkIjoibXktYXBwIiwiYXV0aF90aW1lIjoxNjQzNjg0MjY2LCJ1c2VyX2lkIjoiRzR1MzdXdk90dmVWR0pRb1pCWGpxcHVWazZWMiIsInN1YiI6Ikc0dTM3V3ZPdHZlVkdKUW9aQlhqcXB1Vms2VjIiLCJpYXQiOjE2NDM2ODQyNjYsImV4cCI6MTY0MzY4Nzg2NiwiZW1haWwiOiJ0ZXN0QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7ImVtYWlsIjpbInRlc3RAZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoicGFzc3dvcmQifX0.bUWnX_XmR1d9EmeFeYSsK_CHU1u9NPIHgyaQueZ6urYOtxvuL_QodjPl0c9CBJwctwPnxVyRmkeNCw0oF9xBgph0NApLL4FIG6vpDPZfW9txZBYr8xIvaqvmD0diACENAQdjRT2XmyEdQ2-U7SsTonybHmLoU9FMQTjAgw4NCALQvExfB6rtQ9GDsOBt1xoBkB3Vo7a5OmugZ1aHXF69b8As6137-Dggf5qx5R3oLRFovICMMesQziE3vGi-WKcbQxSeiD-9a6ShPAhk41XiyjFGDEOtUCQo63uwQnMw3g0KVtC6bzIyFq-E91vhxumxXzxPYC-kg7iUYiSZy7Y-Aw'''; @@ -30,6 +104,12 @@ void main() { final expiresAt = DateTime.fromMillisecondsSinceEpoch(1643687866 * 1000); final validTime = expiresAt.subtract(const Duration(minutes: 15)); + // Test values for RsaJwkKeyStore unit tests. + final testModulus = BigInt.parse('12345678901234567890'); + final testExponent = BigInt.from(65537); + final testModulusB64 = _encodeBigInt(testModulus); + final testExponentB64 = _encodeBigInt(testExponent); + late String keyStoreResponseBody; setUp(() { @@ -117,6 +197,147 @@ void main() { }); }); + group('RsaJwkKeyStore', () { + test('deserializes and returns expected key IDs', () { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + }, + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': 'key-2', + 'n': testModulusB64, + 'e': testExponentB64, + }, + ], + }); + expect(store.keyIds, containsAll(['key-1', 'key-2'])); + }); + + test('getKeyMaterial returns RsaKeyMaterial with correct values', () { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + }, + ], + }); + final material = store.getKeyMaterial('key-1'); + expect(material, isA()); + final rsaMaterial = material! as RsaKeyMaterial; + expect(rsaMaterial.publicKey.modulus, equals(testModulus)); + expect(rsaMaterial.publicKey.exponent, equals(testExponent)); + }); + + test('unknown kid returns null', () { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + }, + ], + }); + expect(store.getKeyMaterial('nonexistent'), isNull); + }); + + test('accepts key with alg RS256', () { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + 'alg': 'RS256', + }, + ], + }); + expect(store.getKeyMaterial('key-1'), isA()); + }); + + test('accepts key with absent alg', () { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + }, + ], + }); + expect(store.getKeyMaterial('key-1'), isA()); + }); + + test('rejects key with wrong alg', () { + for (final wrongAlg in ['RS512', 'ES256', 'HS256']) { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + 'alg': wrongAlg, + }, + ], + }); + expect( + store.getKeyMaterial('key-1'), + isNull, + reason: 'alg=$wrongAlg should be rejected', + ); + } + }); + + test('rejects key with wrong kty', () { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'EC', + 'use': 'sig', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + }, + ], + }); + expect(store.getKeyMaterial('key-1'), isNull); + }); + + test('rejects key with wrong use', () { + final store = RsaJwkKeyStore.fromJson({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'enc', + 'kid': 'key-1', + 'n': testModulusB64, + 'e': testExponentB64, + }, + ], + }); + expect(store.getKeyMaterial('key-1'), isNull); + }); + }); + group('verify', () { group('when key store is key-value', () { const issuer = 'https://securetoken.google.com/my-app'; @@ -133,6 +354,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ), throwsA( isA().having( @@ -153,6 +375,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ), throwsA( isA().having( @@ -172,6 +395,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ), throwsA( isA().having( @@ -193,6 +417,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ), throwsA( isA().having( @@ -214,6 +439,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ), throwsA( isA().having( @@ -243,6 +469,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ), throwsA( isA().having( @@ -272,6 +499,37 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.jwkCertificate, + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + '''Invalid public keys returned by https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com.''', + ), + ), + ); + }); + }); + + test('throws exception if invalid keys are provided ' + 'by the publicKeysUrl (RsaJwkKeyStore)', () async { + getOverride = (Uri uri) async { + return Response( + '{"keys": 456}', + HttpStatus.ok, + headers: {'cache-control': 'max-age=3600'}, + ); + }; + + await withClock(Clock.fixed(validTime), () async { + await expectLater( + () => verify( + tokenWithNoMatchingKid, + audience: {audience}, + issuer: issuer, + publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.rsaJwk, ), throwsA( isA().having( @@ -292,6 +550,7 @@ void main() { audience: {'invalid-audience'}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ); fail('should throw'); } on Exception catch (error) { @@ -315,6 +574,7 @@ void main() { audience: {audience}, issuer: 'https://invalid/issuer', publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ); fail('should throw'); } on Exception catch (error) { @@ -337,6 +597,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ); expect(jwt, isA()); }); @@ -349,6 +610,7 @@ void main() { audience: {'other-audience', audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ); expect(jwt, isA()); }); @@ -361,6 +623,7 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: keyValuePublicKeysUrl, + jwksFormat: JwksFormat.keyValue, ); expect(jwt, isA()); }); @@ -386,12 +649,120 @@ void main() { audience: {audience}, issuer: issuer, publicKeysUrl: jwkPublicKeysUrl, + jwksFormat: JwksFormat.jwkCertificate, ); expect(jwt, isA()); }); }); }); }); + + group('when key store is RSA JWK store', () { + late pc.AsymmetricKeyPair rsaKeyPair; + late String rsaJwkToken; + late String rsaJwkKeyStoreJson; + const rsaJwkPublicKeysUrl = + 'https://auth.example.com/.well-known/jwks.json'; + const rsaJwkKid = 'test-rsa-key-1'; + const rsaJwkIssuer = 'https://auth.example.com'; + const rsaJwkAudience = 'test-audience'; + + setUpAll(() { + rsaKeyPair = _generateRsaKeyPair(); + + final publicKey = rsaKeyPair.publicKey; + final nB64 = _encodeBigInt(publicKey.modulus!); + final eB64 = _encodeBigInt(publicKey.exponent!); + + final now = DateTime(2024, 6, 1); + final iat = now.millisecondsSinceEpoch ~/ 1000; + final exp = + now.add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000; + + rsaJwkToken = _createSignedJwt( + header: {'alg': 'RS256', 'kid': rsaJwkKid, 'typ': 'JWT'}, + payload: { + 'iss': rsaJwkIssuer, + 'aud': rsaJwkAudience, + 'sub': 'test-user-123', + 'iat': iat, + 'exp': exp, + }, + privateKey: rsaKeyPair.privateKey, + ); + + rsaJwkKeyStoreJson = json.encode({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': rsaJwkKid, + 'n': nB64, + 'e': eB64, + }, + ], + }); + }); + + setUp(() { + keyStoreResponseBody = rsaJwkKeyStoreJson; + }); + + test('can verify a valid jwt', () async { + final time = DateTime(2024, 6, 1, 0, 15); + await withClock(Clock.fixed(time), () async { + final jwt = await verify( + rsaJwkToken, + audience: {rsaJwkAudience}, + issuer: rsaJwkIssuer, + publicKeysUrl: rsaJwkPublicKeysUrl, + jwksFormat: JwksFormat.rsaJwk, + ); + expect(jwt, isA()); + expect(jwt.payload.sub, equals('test-user-123')); + expect(jwt.payload.iss, equals(rsaJwkIssuer)); + expect(jwt.payload.aud, equals(rsaJwkAudience)); + }); + }); + + test('rejects a jwt signed with a different key', () async { + // Provide a different public key in the JWKS so signature won't match. + final wrongKeyPair = _generateRsaKeyPair(); + final wrongPublic = wrongKeyPair.publicKey; + keyStoreResponseBody = json.encode({ + 'keys': [ + { + 'kty': 'RSA', + 'use': 'sig', + 'kid': rsaJwkKid, + 'n': _encodeBigInt(wrongPublic.modulus!), + 'e': _encodeBigInt(wrongPublic.exponent!), + }, + ], + }); + publicKeyStores.clear(); + + final time = DateTime(2024, 6, 1, 0, 15); + await withClock(Clock.fixed(time), () async { + await expectLater( + () => verify( + rsaJwkToken, + audience: {rsaJwkAudience}, + issuer: rsaJwkIssuer, + publicKeysUrl: rsaJwkPublicKeysUrl, + jwksFormat: JwksFormat.rsaJwk, + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'Invalid signature.', + ), + ), + ); + }); + }); + }); }); group('base64Padded', () {