feat: support RSA JWK key stores for Shorebird auth (#3627)

This commit is contained in:
Mac
2026-02-25 19:50:32 -07:00
committed by GitHub
parent 4175399623
commit 7238e96c61
13 changed files with 633 additions and 36 deletions
+1
View File
@@ -119,6 +119,7 @@ words:
- udid # Unique Device Identifier
- unawaited
- unmockable
- unpadded
- Unpatchable
- unsets
- unskipped
+1
View File
@@ -6,6 +6,7 @@ Future<void> main() async {
issuer: '<ISSUER>',
audience: {'<AUDIENCE>'},
publicKeysUrl: '<PUBLIC_KEYS_URL>',
jwksFormat: jwt.JwksFormat.keyValue,
);
print(token);
}
+1
View File
@@ -1,2 +1,3 @@
export 'src/jwks_format.dart';
export 'src/jwt.dart';
export 'src/models/models.dart';
+19
View File
@@ -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,
}
+27 -12
View File
@@ -16,7 +16,10 @@ import 'package:ttl_cache/ttl_cache.dart';
@visibleForTesting
final publicKeyStores = TtlCache<String, PublicKeyStore>();
Future<PublicKeyStore?> _getPublicKeys(String url) async {
Future<PublicKeyStore?> _getPublicKeys(
String url, {
required JwksFormat jwksFormat,
}) async {
final store = publicKeyStores.get(url);
if (store != null) {
return store;
@@ -34,6 +37,7 @@ Future<PublicKeyStore?> _getPublicKeys(String url) async {
final publicKeyStore = PublicKeyStore.tryDeserialize(
json.decode(response.body) as Map<String, dynamic>,
format: jwksFormat,
);
if (publicKeyStore == null) {
@@ -107,6 +111,7 @@ Future<Jwt> verify(
required String issuer,
required Set<String> audience,
required String publicKeysUrl,
required JwksFormat jwksFormat,
}) async {
final Jwt jwt;
try {
@@ -115,7 +120,10 @@ Future<Jwt> 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<Jwt> 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<String> 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<RSAPublicKey>(key),
SecureRandom('AES/CTR/PRNG'),
@@ -26,15 +26,15 @@ class JwkKeyStore extends PublicKeyStore {
Iterable<String> 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-----''');
}
}
@@ -26,5 +26,9 @@ class KeyValueKeyStore extends PublicKeyStore {
Iterable<String> 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);
}
}
@@ -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<String, dynamic> 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<String, dynamic> 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<String> 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);
}
@@ -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<RsaJwk> keys;
/// Decodes a JSON object into an [RsaJwkKeyStore].
static RsaJwkKeyStore fromJson(Map<String, dynamic> json) =>
_$RsaJwkKeyStoreFromJson(json);
@override
Iterable<String> 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;
}
@@ -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<String, dynamic> json) =>
$checkedCreate('RsaJwkKeyStore', json, ($checkedConvert) {
final val = RsaJwkKeyStore(
keys: $checkedConvert(
'keys',
(v) => (v as List<dynamic>)
.map((e) => RsaJwk.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
+44
View File
@@ -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<String, dynamic> 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;
}
@@ -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<String, dynamic> 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;
});
+371
View File
@@ -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 = <int>[];
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<int>.generate(32, (_) => random.nextInt(256));
sr.seed(pc.KeyParameter(Uint8List.fromList(seeds)));
return sr;
}
pc.AsymmetricKeyPair<pc.RSAPublicKey, pc.RSAPrivateKey> _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<String, dynamic> header,
required Map<String, dynamic> 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<pc.RSAPrivateKey>(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<RsaKeyMaterial>());
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<RsaKeyMaterial>());
});
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<RsaKeyMaterial>());
});
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<JwtVerificationFailure>().having(
@@ -153,6 +375,7 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.keyValue,
),
throwsA(
isA<JwtVerificationFailure>().having(
@@ -172,6 +395,7 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.keyValue,
),
throwsA(
isA<JwtVerificationFailure>().having(
@@ -193,6 +417,7 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.keyValue,
),
throwsA(
isA<JwtVerificationFailure>().having(
@@ -214,6 +439,7 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.keyValue,
),
throwsA(
isA<JwtVerificationFailure>().having(
@@ -243,6 +469,7 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.keyValue,
),
throwsA(
isA<JwtVerificationFailure>().having(
@@ -272,6 +499,37 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.jwkCertificate,
),
throwsA(
isA<JwtVerificationFailure>().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<JwtVerificationFailure>().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<Jwt>());
});
@@ -349,6 +610,7 @@ void main() {
audience: {'other-audience', audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.keyValue,
);
expect(jwt, isA<Jwt>());
});
@@ -361,6 +623,7 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: keyValuePublicKeysUrl,
jwksFormat: JwksFormat.keyValue,
);
expect(jwt, isA<Jwt>());
});
@@ -386,12 +649,120 @@ void main() {
audience: {audience},
issuer: issuer,
publicKeysUrl: jwkPublicKeysUrl,
jwksFormat: JwksFormat.jwkCertificate,
);
expect(jwt, isA<Jwt>());
});
});
});
});
group('when key store is RSA JWK store', () {
late pc.AsymmetricKeyPair<pc.RSAPublicKey, pc.RSAPrivateKey> 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<Jwt>());
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<JwtVerificationFailure>().having(
(e) => e.reason,
'reason',
'Invalid signature.',
),
),
);
});
});
});
});
group('base64Padded', () {