feat: support RSA JWK key stores for Shorebird auth (#3627)
This commit is contained in:
@@ -119,6 +119,7 @@ words:
|
||||
- udid # Unique Device Identifier
|
||||
- unawaited
|
||||
- unmockable
|
||||
- unpadded
|
||||
- Unpatchable
|
||||
- unsets
|
||||
- unskipped
|
||||
|
||||
@@ -6,6 +6,7 @@ Future<void> main() async {
|
||||
issuer: '<ISSUER>',
|
||||
audience: {'<AUDIENCE>'},
|
||||
publicKeysUrl: '<PUBLIC_KEYS_URL>',
|
||||
jwksFormat: jwt.JwksFormat.keyValue,
|
||||
);
|
||||
print(token);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'src/jwks_format.dart';
|
||||
export 'src/jwt.dart';
|
||||
export 'src/models/models.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,
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user