Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Auth.verifyIdToken() #54

Merged
merged 1 commit into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/dart_firebase_admin/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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()`
Expand Down
51 changes: 44 additions & 7 deletions packages/dart_firebase_admin/lib/src/utils/jwt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class EmulatorSignatureVerifier implements SignatureVerifier {
Future<void> verify(String token) async {
// Signature checks skipped for emulator; no need to fetch public keys.
try {
return await verifyJwtSignature(
verifyJwtSignature(
token,
SecretKey(''),
);
Expand Down Expand Up @@ -95,25 +95,61 @@ 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<bool> verify(String token) {
throw UnimplementedError();
// verifyJwtSignature(token);
Future<void> 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<void> verifyJwtSignature(
void verifyJwtSignature(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

String token,
JWTKey key, {
Duration? issueAt,
Audience? audience,
String? subject,
String? issuer,
String? jwtId,
}) async {
}) {
try {
JWT.verify(
token,
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion packages/dart_firebase_admin/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
118 changes: 118 additions & 0 deletions packages/dart_firebase_admin/test/auth/jwt_test.dart
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a real key?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the one used in dart_jsonwebtoken's own tests.

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<JwtError>()),
);
});
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<JwtError>()),
);
});
});
}

class _TestKeyFetcher implements KeyFetcher {
@override
Future<Map<String, String>> 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}';
}
Loading