Skip to content
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
47 changes: 43 additions & 4 deletions app/lib/service/openid/jwt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import 'dart:convert';
import 'dart:typed_data';

import 'openid_models.dart';

final _jsonUtf8Base64 = json.fuse(utf8).fuse(base64Url);

/// Pattern for a valid JWT, these must 3 base64 segments separated by dots.
Expand All @@ -31,11 +33,24 @@ final _jwtPattern = RegExp(
///
/// [1]: https://datatracker.ietf.org/doc/html/rfc7519
class JsonWebToken {
/// The concatenated parts that will be used for the signature check.
final String headerAndPayloadEncoded;

/// The decoded header Map.
final Map<String, dynamic> header;

/// The decoded payload Map.
final Map<String, dynamic> payload;

/// The bytes of the signature hash.
final Uint8List signature;

JsonWebToken(this.header, this.payload, this.signature);
JsonWebToken._(
this.headerAndPayloadEncoded,
this.header,
this.payload,
this.signature,
);

/// Parses [token] and returns [JsonWebToken].
///
Expand All @@ -48,10 +63,15 @@ class JsonWebToken {
if (parts.length != 3) {
throw FormatException('Token does not looks like a JWT.');
}
final header = _decodePart(parts[0]);
final payload = _decodePart(parts[1]);
final headerPart = parts[0];
final payloadPart = parts[1];
final signature = base64.decode(base64.normalize(parts[2]));
return JsonWebToken(header, payload, signature);
return JsonWebToken._(
'$headerPart.$payloadPart',
_decodePart(headerPart),
_decodePart(payloadPart),
signature,
);
}

static JsonWebToken? tryParse(String token) {
Expand All @@ -73,11 +93,30 @@ class JsonWebToken {
/// type
late final typ = header['typ'] as String?;

/// key identifier
late final kid = header['kid'] as String?;

/// issued at
late final iat = _tryParseFromSeconds(payload['iat'] as int?);

/// expires
late final exp = _tryParseFromSeconds(payload['exp'] as int?);

/// Verifies the token with the provided JSON Web Keys and
/// returns `true` if the signature is valid.
Future<bool> verifySignature(JsonWebKeyList jwks) async {
final candidates = jwks.selectKeyForSignature(kid: kid, alg: alg);
for (final key in candidates) {
final isValid = await key.verifySignature(
input: headerAndPayloadEncoded,
signature: signature,
);
if (isValid) {
return true;
}
}
return false;
}
}

Map<String, dynamic> _decodePart(String part) {
Expand Down
46 changes: 45 additions & 1 deletion app/lib/service/openid/openid_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import 'dart:typed_data';

import 'package:json_annotation/json_annotation.dart';

import 'openssl_commands.dart';

part 'openid_models.g.dart';

/// The combined data from the OpenID provider, including their signing keys.
Expand Down Expand Up @@ -67,6 +69,19 @@ class JsonWebKeyList {
_$JsonWebKeyListFromJson(json);

Map<String, dynamic> toJson() => _$JsonWebKeyListToJson(this);

/// Selects the keys that match the provided parameters and can
/// be used for signature verification.
List<JsonWebKey> selectKeyForSignature({
String? kid,
String? alg,
}) {
return keys
.where((k) => k.use == null || k.use == 'sig')
.where((k) => kid == null || k.kid == kid)
.where((k) => alg == null || k.alg == alg)
.toList();
}
}

/// The JSON Web Key record.
Expand All @@ -79,7 +94,7 @@ class JsonWebKey {
/// The specific cryptographic algorithm used with the key.
final String alg;

/// How the key was meant to be used; sig represents the signature.
/// How the key was meant to be used; `sig` represents the signature.
final String? use;

// The unique identifier for the key.
Expand Down Expand Up @@ -119,6 +134,35 @@ class JsonWebKey {
_$JsonWebKeyFromJson(json);

Map<String, dynamic> toJson() => _$JsonWebKeyToJson(this);

/// Returns `true` if [input] and [signature] matches.
Future<bool> verifySignature({
required String input,
required Uint8List signature,
}) async {
switch (kty ?? '') {
case 'RSA':
return await _verifyRsaSignature(input, signature);
default:
return false;
}
}

Future<bool> _verifyRsaSignature(String input, Uint8List signature) async {
final modulus = n;
final exponent = e;
if (modulus == null ||
modulus.isEmpty ||
exponent == null ||
exponent.isEmpty) {
return false;
}
return await verifyTextWithRsaSignature(
input: input,
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it have made more sense for verifyTextWithRsaSignature to take a Uint8list - i guess the thing is base64-encoded and thus it should not matter much - it just seems fragile to decode and reencode when you verify a signature...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

verifyTextWithRsaSignature is using openssl, and there is no further encoding: the input will be written into a file, and the file is being verified. We could use Uint8List too, but here the input is really the concatenated token parts, without further coding with base64... I think we should keep it like this for now.

signature: signature,
publicKey: Asn1RsaPublicKey(modulus: modulus, exponent: exponent),
);
}
}

/// Converts bytes to unpadded, URL-safe BASE64-encoded String (nullable values).
Expand Down
90 changes: 90 additions & 0 deletions app/test/service/openid/jwt_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'package:convert/convert.dart';
import 'package:pub_dev/service/openid/jwt.dart';
import 'package:pub_dev/service/openid/openid_models.dart';
import 'package:pub_dev/service/openid/openssl_commands.dart';
import 'package:test/test.dart';

Expand Down Expand Up @@ -87,4 +88,93 @@ void main() {
hex.encode(reference.asDerEncodedBytes));
});
});

group('JWK + JWT verification test', () {
// JWKS and JWT data is coming form the following article:
// https://medium.com/trabe/validate-jwt-tokens-using-jwks-in-java-214f7014b5cf
final jwksData = {
'keys': [
{
'use': 'sig',
'kty': 'RSA',
'kid': 'public:c424b67b-fe28-45d7-b015-f79da50b5b21',
'alg': 'RS256',
'n': 'sttddbg-_yjXzcFpbMJB1fIFam9lQBeXWbTqzJwbuFbspHMsRowa8FaPw44l2C'
'9Q42J3AdQD8CcNj2z7byCTSC5gaDAY30xvZoi5WDWkSjHblMPBUT2cDtw9bIZ6F'
'ocRp46KaKzeoVDv3a0EBg5cdAdrefawfZoruPZCLmyLqXZmBM8RbpYLChb-UFO2'
'5i7e4AoRJ2hNFYg0qM-hRZNwLliDfkafjnOgSu7_w0WDInNzbUuy26rb_yDNGEI'
'ylXHlt0BKcMoeO3sJEwS5EDAkXkvz_7zQ6lgDQ4OLihC4QDwkp7dV2iQxvd7D-X'
'EaSIahiqdHlqR8cUYOJANDVRIufAzzkyK8Shu_MXhVUW7hH3hNjlEh198bCWANH'
'csZWF2_V78Rl-UzCjsAFWtttf6FYpR9Kt-8ILM3aAYTAk3OwsvzSeqTtWLHp96Q'
'E8Bcm1AmZfPWzsd3PpLuSM_wfx4oxDWhdaKQ-HK1hCYLNv2Vity2uNC_tbGxOD9'
'syRujWKS6wFf2b3jFEudV0NUXQ_1Beu8Ir0jHzuA_0D22wgiaSJ9svfpJ7XyoD6'
'fxyHSyhpMsXIDLmnwOPKmD67MFQ7Bv_9H91KZmr34oeh6PVWEwb4wUAkDaCebo6'
'h0gdMoDfZTq9Gn5S-Aq0-_-fIfyN9qrrQ0E1Q_QDhvqXx8eQ1r9smM',
'e': 'AQAB',
},
{
'use': 'sig',
'kty': 'RSA',
'kid': 'public:9b9d0b47-b9ed-4ba6-9180-52fc5b161a3a',
'alg': 'RS256',
'n': '6f4qEUPMmYAyAQnGQOIx1UkIEVPPt1BnhDH70w3Gq6uYpm4hUyRFiM1oZ4_xB2'
'8gTmpR_SJZL31E_yZTLKPwKKsCDyF6YGhFtcyifhsLJc45GW4G4poX8Y34EIYlT'
'63G9vutwNwzistWZZqBm52e-bdUQ7zjmWUGpgkq1GQJZyPz2lvA2bThRqqj94w1'
'hqHSCXuAc90cN-Th0Ss1QhKesud7dIgaJQngjWWXdlPBqNYe1oCI04E3gcWdYRF'
'hKey1lkO0WG4VtQxcMADgCrhFVgicpdYyNVqim7Tf31Is_bcQcbFdmumwxWewT-'
'dC6ur3UAv1A97L567QCwlGDP5DAvH35NmL3w291tUd4q5Vlwz6gsRKqDhUSonIS'
'boWvvY2x_ndH1oE2hXYin4WL3SyCyp-De8d59C5UhC8KPTvA-3h_UfcPvz6DRDd'
'NrKyRdKmn9vQQpTP9jMtK7Tks8qKxK4D4pesUmjiNMsVCo8AwJ-9hMd7TXamE9C'
'ErfDR7jCQONUMetLnitiM7nazCPXkO5tAhJKzQm1o0HvCVptwaa7MksfViK5YPM'
'cCYc9bD1Uujo-782MXqAzdncu0nGKaJXnIsYB0-tFNiNXjuYFQ8KV5k5-Wnn0kg'
'a4CkCHlMU2umR19zFsFwFBdVngOYkCEG46KAgdGDqtj8t4d0GY8tcM',
'e': 'AQAB',
},
],
};
final tokenData = 'eyJhbGciOiJSUzI1NiIsImtpZCI6InB1YmxpYzpjNDI0YjY3Yi1mZTI'
'4LTQ1ZDctYjAxNS1mNzlkYTUwYjViMjEiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOltdLCJjb'
'GllbnRfaWQiOiJteS1jbGllbnQiLCJleHAiOjE1Nzg1MTU3MzYsImV4dCI6e30sImlhdC'
'I6MTU3ODUxMjEzNiwiaXNzIjoiaHR0cDovLzEyNy4wLjAuMTo0NDQ0LyIsImp0aSI6IjQ'
'1NzM4MmQyLTg0NGMtNDM4OS05YWI4LWRmOWRmMjM4ZTdmOSIsIm5iZiI6MTU3ODUxMjEz'
'Niwic2NwIjpbXSwic3ViIjoibXktY2xpZW50In0.pOkdoCm0dVRg6UECdzpaeTdFyia_n'
'mJVT1dTNcwVZx0FOFGBQK4EwUV7Ho-UJY3X-UZANSVYtqtjdBxj10AQfqNl3fGD7c4Zo6'
'A5g0ah0YsWXocLZ7EWgXP2yzgsqT1KhLpffVSFOBfSqRRSov5jjIBor4vMZQqcL00bFbK'
'VNqnaiWRA5_8vM-pbzBBkB8Ajkzec6Gvexc78CFVCvINlybKakM9GdMtQbI-ejz1PkE2J'
'H7PYEWdkOhkzjFgFnDLBMi0_Nqwm25qMT6ugGSix7gg4dYIaVsAzD2fgGrAvLRMhM2L7j'
'q8UN8vmUOd18s8X-cKRQSjgcVBDjPtQyregr_DpW_4LADORN7xGg6LGhnu2jK8CdTOC2Q'
'_QbDtrABRADSt_qpSRQrCjqWCS8NKx_QMJHMv33jDlLhG-gMJ_lsjOIQJks0zD6xuAbzk'
'yvr01UhhJ-iiL9kO1nk84-TSIV4PEQItBqDhZYigHeP3J_mnWlWCVj-kcPxQsa3OA8BQV'
'AZYMA6z-XEdUo1heOrYQJNzQlEo0Je3umDPfiKJdyfCIEwfHRS83XXp-827qYii3rBg1Q'
'fWEaJfdeHWKYbQ5QT1QGBNlFPfXNStXbr7ikRzYE3zfleuyTPcuG7jMkbai6DcAHdO1ya'
'Lpwe_UTY-wWF5Z-N9EXcisOVjUf8jqRuI';

test('verification success with good signature', () async {
final jwks = JsonWebKeyList.fromJson(jwksData);
final token = JsonWebToken.parse(tokenData);

// sanity checks
expect(token.header, {
'alg': 'RS256',
'kid': 'public:c424b67b-fe28-45d7-b015-f79da50b5b21',
'typ': 'JWT'
});
expect(jwks.keys.first.kid, token.header['kid']);
// actual verification
expect(await token.verifySignature(jwks), isTrue);
});

test('verification fail with bad signature', () async {
final jwks = JsonWebKeyList.fromJson(jwksData);
final token = JsonWebToken.parse(tokenData);
token.signature[0] = 1;
expect(await token.verifySignature(jwks), isFalse);
});

test('verification fail with bad key', () async {
final jwks = JsonWebKeyList(keys: []);
final token = JsonWebToken.parse(tokenData);
expect(await token.verifySignature(jwks), isFalse);
});
});
}