From 67a03c77399b12ee65a8e48409862989c27f17bd Mon Sep 17 00:00:00 2001 From: Romain Laboisse Date: Thu, 10 Oct 2024 07:56:50 -0700 Subject: [PATCH] Fix Auth.verifyIdToken() (#54) Finish implementing the token verification using the updated dart_jsonwebtoken library --- packages/dart_firebase_admin/CHANGELOG.md | 5 + .../lib/src/utils/jwt.dart | 51 ++++++-- packages/dart_firebase_admin/pubspec.yaml | 3 +- .../test/auth/jwt_test.dart | 118 ++++++++++++++++++ 4 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 packages/dart_firebase_admin/test/auth/jwt_test.dart diff --git a/packages/dart_firebase_admin/CHANGELOG.md b/packages/dart_firebase_admin/CHANGELOG.md index 88e887c..b7e28e2 100644 --- a/packages/dart_firebase_admin/CHANGELOG.md +++ b/packages/dart_firebase_admin/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.4.1 + +- Fixes the `Auth.verifyIdToken()` implementation by adding the + token signature verification part. + ## 0.4.0 - 2024-09-11 - Added `firestore.listCollections()` and `doc.listCollections()` diff --git a/packages/dart_firebase_admin/lib/src/utils/jwt.dart b/packages/dart_firebase_admin/lib/src/utils/jwt.dart index ce546cf..ca064ac 100644 --- a/packages/dart_firebase_admin/lib/src/utils/jwt.dart +++ b/packages/dart_firebase_admin/lib/src/utils/jwt.dart @@ -10,7 +10,7 @@ class EmulatorSignatureVerifier implements SignatureVerifier { Future verify(String token) async { // Signature checks skipped for emulator; no need to fetch public keys. try { - return await verifyJwtSignature( + verifyJwtSignature( token, SecretKey(''), ); @@ -95,17 +95,53 @@ class PublicKeySignatureVerifier implements SignatureVerifier { final KeyFetcher keyFetcher; + /// Verifies a JWT token. + /// + /// This verifies the token's signature. The signing key is selected using the + /// 'kid' claim in the token's header. + /// The token's expiration is also verified. @override - Future verify(String token) { - throw UnimplementedError(); - // verifyJwtSignature(token); + Future verify(String token) async { + try { + final jwt = JWT.decode(token); + final kid = jwt.header?['kid'] as String?; + + if (kid == null) { + throw JwtError( + JwtErrorCode.noKidInHeader, + 'no-kid-in-header-error', + ); + } + + final publicKeys = await keyFetcher.fetchPublicKeys(); + final publicKey = publicKeys[kid]; + + if (publicKey == null) { + throw JwtError( + JwtErrorCode.noMatchingKid, + 'no-matching-kid-error', + ); + } + verifyJwtSignature( + token, + RSAPublicKey.cert(publicKey), + issueAt: Duration.zero, // Any past date should be valid + ); + // At this point most JWTException's should have been caught in + // verifyJwtSignature, but we could still get some from JWT.decode above + } on JWTException catch (e) { + throw JwtError( + JwtErrorCode.unknown, + e is JWTUndefinedException ? e.message : '${e.runtimeType}: e.message', + ); + } } } sealed class SecretOrPublicKey {} @internal -Future verifyJwtSignature( +void verifyJwtSignature( String token, JWTKey key, { Duration? issueAt, @@ -113,7 +149,7 @@ Future verifyJwtSignature( String? subject, String? issuer, String? jwtId, -}) async { +}) { try { JWT.verify( token, @@ -160,7 +196,8 @@ enum JwtErrorCode { invalidSignature('invalid-token'), noMatchingKid('no-matching-kid-error'), noKidInHeader('no-kid-error'), - keyFetchError('key-fetch-error'); + keyFetchError('key-fetch-error'), + unknown('unknown'); const JwtErrorCode(this.value); diff --git a/packages/dart_firebase_admin/pubspec.yaml b/packages/dart_firebase_admin/pubspec.yaml index 1096cac..2b9bd64 100644 --- a/packages/dart_firebase_admin/pubspec.yaml +++ b/packages/dart_firebase_admin/pubspec.yaml @@ -10,7 +10,7 @@ environment: dependencies: collection: ^1.18.0 crypto: ^3.0.3 - dart_jsonwebtoken: ^2.11.0 + dart_jsonwebtoken: ^2.14.1 firebaseapis: ^0.2.0 freezed_annotation: ^2.4.1 googleapis_auth: ^1.3.0 @@ -19,6 +19,7 @@ dependencies: meta: ^1.9.1 dev_dependencies: + asn1lib: ^1.5.6 build_runner: ^2.4.7 file: ^7.0.0 freezed: ^2.4.2 diff --git a/packages/dart_firebase_admin/test/auth/jwt_test.dart b/packages/dart_firebase_admin/test/auth/jwt_test.dart new file mode 100644 index 0000000..c79f3e6 --- /dev/null +++ b/packages/dart_firebase_admin/test/auth/jwt_test.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:asn1lib/asn1lib.dart'; +import 'package:dart_firebase_admin/src/utils/jwt.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:test/test.dart'; + +void main() { + group('PublicKeySignatureVerifier', () { + final privateKey = RSAPrivateKey(''' +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj +MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu +NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ +qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg +p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR +ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi +VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV +laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8 +sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H +mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY +dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw +ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ +DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T +N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t +0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv +t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU +AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk +48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL +DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK +xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA +mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh +2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz +et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr +VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD +TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc +dn/RsYEONbwQSjIfMPkvxF+8HQ== +-----END PRIVATE KEY----- +'''); + final keyFetcher = _TestKeyFetcher(); + final payload = { + 'a': '1', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }; + + test('valid kid should pass', () async { + final jwt = JWT( + payload, + header: {'kid': 'key1'}, + ); + final token = jwt.sign( + privateKey, + algorithm: JWTAlgorithm.RS256, + ); + await PublicKeySignatureVerifier(keyFetcher).verify(token); + }); + test('no kid should throw', () async { + final jwt = JWT(payload); + final token = jwt.sign( + privateKey, + algorithm: JWTAlgorithm.RS256, + ); + await expectLater( + PublicKeySignatureVerifier(keyFetcher).verify(token), + throwsA(isA()), + ); + }); + test('invalid kid should throw', () async { + final jwt = JWT( + payload, + header: {'kid': 'key2'}, + ); + final token = jwt.sign( + privateKey, + algorithm: JWTAlgorithm.RS256, + ); + await expectLater( + PublicKeySignatureVerifier(keyFetcher).verify(token), + throwsA(isA()), + ); + }); + }); +} + +class _TestKeyFetcher implements KeyFetcher { + @override + Future> fetchPublicKeys() { + return Future.value({ + 'key1': _publicKeyAsCert(''' +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo +4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u ++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh +kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ +0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg +cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc +mwIDAQAB +-----END PUBLIC KEY----- +'''), + }); + } +} + +String _publicKeyAsCert(String publicKey) { + final lines = LineSplitter.split(publicKey) + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .toList(); + final base64 = lines.sublist(1, lines.length - 1).join(); + final bytes = base64Decode(base64); + final certSequence = ASN1Sequence(); + final root = ASN1Sequence()..add(certSequence); + for (var i = 0; i < 5; i++) { + certSequence.add(ASN1Integer.fromInt(0)); + } + certSequence.add(ASN1Object.fromBytes(bytes)); + return '${lines.first}\n${base64Encode(root.encodedBytes)}\n${lines.last}'; +}