feat: add package:jwt (#225)

This commit is contained in:
Felix Angelov
2023-04-04 12:39:42 -05:00
committed by GitHub
parent 8a2ec108ef
commit d5e4472862
17 changed files with 627 additions and 0 deletions
+3
View File
@@ -42,6 +42,9 @@ jobs:
shorebird_code_push_protocol:
- ./.github/actions/dart_package
- packages/shorebird_code_push_protocol/**
jwt:
- ./.github/actions/dart_package
- packages/jwt/**
- uses: dorny/paths-filter@v2
name: Build Detection
+7
View File
@@ -0,0 +1,7 @@
# See https://www.dartlang.org/guides/libraries/private-files
# Files and directories created by pub
.dart_tool/
.packages
build/
pubspec.lock
+23
View File
@@ -0,0 +1,23 @@
# jwt
[![License: MIT][license_badge]][license_link]
A Dart JWT Library.
```dart
import 'package:jwt/jwt.dart' as jwt;
Future<void> main() async {
// Verify and extract a JWT token.
final Jwt token = await jwt.verify(
'<TOKEN>',
issuer: '<ISSUER>',
audience: '<AUDIENCE>',
publicKeysUrl: '<PUBLIC_KEYS_URL>',
);
}
```
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
+1
View File
@@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.4.0.0.yaml
+14
View File
@@ -0,0 +1,14 @@
targets:
$default:
builders:
source_gen|combining_builder:
options:
ignore_for_file:
- implicit_dynamic_parameter
- require_trailing_commas
- cast_nullable_to_non_nullable
- lines_longer_than_80_chars
json_serializable:
options:
field_rename: snake
checked: true
+12
View File
@@ -0,0 +1,12 @@
// ignore_for_file: avoid_print
import 'package:jwt/jwt.dart' as jwt;
Future<void> main() async {
final token = await jwt.verify(
'<TOKEN>',
issuer: '<ISSUER>',
audience: '<AUDIENCE>',
publicKeysUrl: '<PUBLIC_KEYS_URL>',
);
print(token);
}
+2
View File
@@ -0,0 +1,2 @@
export 'src/jwt.dart';
export 'src/models/models.dart';
+219
View File
@@ -0,0 +1,219 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:http/http.dart' as http;
import 'package:jwt/jwt.dart';
import 'package:meta/meta.dart';
import 'package:pointycastle/pointycastle.dart';
import 'package:rsa_pkcs/rsa_pkcs.dart' as rsa;
/// {@template public_key_store}
/// A store for the public keys.
/// {@endtemplate}
class PublicKeyStore {
/// {@macro public_key_store}
const PublicKeyStore({required this.keys, required this.expiration});
/// Map of all public key id/value pairs.
final Map<String, String> keys;
/// Expiration time.
final DateTime expiration;
}
PublicKeyStore? _publicKeyStore;
Future<Map<String, String>> _getPublicKeys(String url) async {
if (_publicKeyStore?.expiration.isAfter(clock.now()) ?? false) {
return _publicKeyStore!.keys;
}
final get = getOverride ?? http.get;
final response = await get(Uri.parse(url));
if (response.statusCode != HttpStatus.ok) {
throw const JwtVerificationFailure('Could not fetch public keys.');
}
final maxAgeRegExp = RegExp(r'max-age=(\d+)');
final match = maxAgeRegExp.firstMatch(response.headers['cache-control']!);
final maxAge = int.parse(match!.group(1)!);
final publicKeys = (json.decode(response.body) as Map<String, dynamic>)
.cast<String, String>();
_publicKeyStore = PublicKeyStore(
keys: publicKeys,
expiration: clock.now().add(Duration(seconds: maxAge)),
);
return publicKeys;
}
/// Typedef for a function that returns the public keys asynchronously.
typedef GetPublicKeys = Future<Map<String, String>> Function();
/// {@template jwt_verification_failure}
/// An exception thrown during JWT verification.
/// {@endtemplate}
class JwtVerificationFailure implements Exception {
/// {@macro jwt_verification_failure}
const JwtVerificationFailure(this.reason);
/// The reason for the verification failure.
final String reason;
@override
String toString() => 'JwtVerificationFailure: $reason';
}
/// Verify the provided [jwt].
Future<Jwt> verify(
String jwt, {
required String issuer,
required String audience,
required String publicKeysUrl,
}) async {
final parts = jwt.split('.');
if (parts.length != 3) {
throw const JwtVerificationFailure('JWT is malformed');
}
final publicKeys = await _getPublicKeys(publicKeysUrl);
final JwtHeader header;
try {
header = JwtHeader.fromJson(_decodePart(parts[0]));
} catch (_) {
throw const JwtVerificationFailure('JWT header is malformed.');
}
await _verifyHeader(header, publicKeys);
final JwtPayload payload;
try {
payload = JwtPayload.fromJson(_decodePart(parts[1]));
} catch (_) {
throw const JwtVerificationFailure('JWT payload is malformed.');
}
_verifyPayload(payload, issuer, audience);
final isValid = _verifySignature(jwt, publicKeys[header.kid]!);
if (!isValid) {
throw const JwtVerificationFailure('Invalid signature.');
}
return Jwt(
header: header,
payload: payload,
signature: parts[2],
claims: _decodePart(parts[1]),
);
}
Map<String, dynamic> _decodePart(String part) {
final normalized = base64.normalize(part);
final base64Decoded = base64.decode(normalized);
final utf8Decoded = utf8.decode(base64Decoded);
final jsonDecoded = json.decode(utf8Decoded) as Map<String, dynamic>;
return jsonDecoded;
}
Future<void> _verifyHeader(
JwtHeader header,
Map<String, dynamic> publicKeys,
) async {
if (header.typ != 'JWT') {
throw const JwtVerificationFailure('Invalid token type.');
}
if (header.alg != 'RS256') {
throw const JwtVerificationFailure('Invalid algorithm.');
}
if (!publicKeys.containsKey(header.kid)) {
throw const JwtVerificationFailure('Invalid key id.');
}
}
void _verifyPayload(JwtPayload payload, String issuer, String audience) {
final now = clock.now();
final exp = DateTime.fromMillisecondsSinceEpoch(payload.exp * 1000);
if (exp.isBefore(now)) {
throw const JwtVerificationFailure('Token has expired.');
}
final iat = DateTime.fromMillisecondsSinceEpoch(payload.iat * 1000);
if (iat.isAfter(now)) {
throw const JwtVerificationFailure('Token issued at a future time.');
}
final authTime = DateTime.fromMillisecondsSinceEpoch(payload.authTime * 1000);
if (authTime.isAfter(now)) {
throw const JwtVerificationFailure('Authenticated at a future time.');
}
if (payload.aud != audience) {
throw const JwtVerificationFailure('Invalid audience.');
}
if (payload.iss != issuer) {
throw const JwtVerificationFailure('Invalid issuer.');
}
if (payload.sub.isEmpty) {
throw const JwtVerificationFailure('Invalid subject.');
}
}
bool _verifySignature(String jwt, String publicKey) {
final parts = jwt.split('.');
final encodedHeader = parts[0];
final encodedPayload = parts[1];
final signature = parts[2];
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;
try {
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'),
);
signer.init(false, param);
final rsaSignature = RSASignature(Uint8List.fromList(sign));
return signer.verifySignature(Uint8List.fromList(body), rsaSignature);
} catch (_) {
return false;
}
}
/// Visible for testing only
@visibleForTesting
String base64Padded(String value) {
final mod = value.length % 4;
if (mod == 0) {
return value;
} else if (mod == 3) {
return value.padRight(value.length + 1, '=');
} else if (mod == 2) {
return value.padRight(value.length + 2, '=');
} else {
return value; // let it fail when decoding
}
}
/// Override for http.get.
/// Used for testing purposes only.
@visibleForTesting
Future<http.Response> Function(Uri uri)? getOverride;
+26
View File
@@ -0,0 +1,26 @@
import 'package:jwt/jwt.dart';
/// {@template jwt}
/// A JWT (json web token)
/// {@endtemplate}
class Jwt {
/// {@macro jwt}
const Jwt({
required this.header,
required this.payload,
required this.signature,
this.claims = const <String, dynamic>{},
});
/// {@macro jwt_header}
final JwtHeader header;
/// {@macro jwt_payload}
final JwtPayload payload;
/// JWT signature.
final String signature;
/// Token claims.
final Map<String, dynamic> claims;
}
@@ -0,0 +1,30 @@
import 'package:json_annotation/json_annotation.dart';
part 'jwt_header.g.dart';
/// {@template jwt_header}
/// A JWT header which contains the algorithm and token type.
/// {@endtemplate}
@JsonSerializable(createToJson: false)
class JwtHeader {
/// {@macro jwt_header}
const JwtHeader({
required this.alg,
required this.kid,
required this.typ,
});
/// Decode a [JwtHeader] from a `Map<String, dynamic>`.
factory JwtHeader.fromJson(Map<String, dynamic> json) {
return _$JwtHeaderFromJson(json);
}
/// Signature or encryption algorithm.
final String alg;
/// Key ID.
final String kid;
/// Type of token.
final String typ;
}
@@ -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
part of 'jwt_header.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
JwtHeader _$JwtHeaderFromJson(Map<String, dynamic> json) => $checkedCreate(
'JwtHeader',
json,
($checkedConvert) {
final val = JwtHeader(
alg: $checkedConvert('alg', (v) => v as String),
kid: $checkedConvert('kid', (v) => v as String),
typ: $checkedConvert('typ', (v) => v as String),
);
return val;
},
);
@@ -0,0 +1,42 @@
import 'package:json_annotation/json_annotation.dart';
part 'jwt_payload.g.dart';
/// {@template jwt_payload}
/// A JWT payload which contains data.
/// {@endtemplate}
@JsonSerializable(createToJson: false)
class JwtPayload {
/// {@macro jwt_payload}
JwtPayload({
required this.exp,
required this.iat,
required this.aud,
required this.iss,
required this.sub,
required this.authTime,
});
/// Decode a [JwtPayload] from a `Map<String, dynamic>`.
factory JwtPayload.fromJson(Map<String, dynamic> json) {
return _$JwtPayloadFromJson(json);
}
/// Expiration time (seconds since Unix epoch).
final int exp;
/// Issued at (seconds since Unix epoch).
final int iat;
/// Audience (who or what the token is intended for).
final String aud;
/// Issuer (who created and signed this token).
final String iss;
/// Subject (whom the token refers to).
final String sub;
/// Time when authentication occurred.
final int authTime;
}
@@ -0,0 +1,26 @@
// 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
part of 'jwt_payload.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
JwtPayload _$JwtPayloadFromJson(Map<String, dynamic> json) => $checkedCreate(
'JwtPayload',
json,
($checkedConvert) {
final val = JwtPayload(
exp: $checkedConvert('exp', (v) => v as int),
iat: $checkedConvert('iat', (v) => v as int),
aud: $checkedConvert('aud', (v) => v as String),
iss: $checkedConvert('iss', (v) => v as String),
sub: $checkedConvert('sub', (v) => v as String),
authTime: $checkedConvert('auth_time', (v) => v as int),
);
return val;
},
fieldKeyMap: const {'authTime': 'auth_time'},
);
+3
View File
@@ -0,0 +1,3 @@
export 'jwt.dart';
export 'jwt_header.dart';
export 'jwt_payload.dart';
+21
View File
@@ -0,0 +1,21 @@
name: jwt
description: A Dart Json Web Token Library
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=2.19.0 <3.0.0"
dependencies:
clock: ^1.1.0
http: ^0.13.4
json_annotation: ^4.4.0
meta: ^1.7.0
pointycastle: ^3.5.0
rsa_pkcs: ^2.0.0
dev_dependencies:
build_runner: ^2.0.0
json_serializable: ^6.1.4
test: ^1.19.2
very_good_analysis: ^4.0.0
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyCtJXKQDbTq6Cp/zqmTO
BF2+K1aRYevhUglKCcLMJ0P16LJEIznnOIB0SZuP2jcFHwiMZ9PyzpocOAPUZZue
rFxKyxcCP8zS/mpHwrSxpxlgm/4oChvYW7U4rH7E/Hb4r2iMqlkgRsCnyvN/EKdG
e1TkVNnK3dcX+CFgGOjkhAQc5/dz9pThb5oGM36kfnHSlkprGZtS+ijYXsqMJWr7
410uNaVjFZnp53B9atcWhnfQ4ev3elR0TV+2TZIXd5n/wF2aX44dSB2KQE3aRDSX
4dZHp/a3xWeeLhcfLkKe5HYUSPgx91742y6TLJd1stOWTg+y2G1A1kaDBEaUVtRV
FwIDAQAB
-----END PUBLIC KEY-----
+167
View File
@@ -0,0 +1,167 @@
// ignore_for_file: prefer_const_constructors
import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:http/http.dart';
import 'package:jwt/jwt.dart';
import 'package:test/test.dart';
void main() {
const token =
'''eyJhbGciOiJSUzI1NiIsImtpZCI6ImMxMGM5MGJhNGMzNjYzNTE2ZTA3MDdkMGU5YTg5NDgxMDYyODUxNTgiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXktYXBwIiwiYXVkIjoibXktYXBwIiwiYXV0aF90aW1lIjoxNjQzNjg0MjY2LCJ1c2VyX2lkIjoiRzR1MzdXdk90dmVWR0pRb1pCWGpxcHVWazZWMiIsInN1YiI6Ikc0dTM3V3ZPdHZlVkdKUW9aQlhqcXB1Vms2VjIiLCJpYXQiOjE2NDM2ODQyNjYsImV4cCI6MTY0MzY4Nzg2NiwiZW1haWwiOiJ0ZXN0QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7ImVtYWlsIjpbInRlc3RAZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoicGFzc3dvcmQifX0.bUWnX_XmR1d9EmeFeYSsK_CHU1u9NPIHgyaQueZ6urYOtxvuL_QodjPl0c9CBJwctwPnxVyRmkeNCw0oF9xBgph0NApLL4FIG6vpDPZfW9txZBYr8xIvaqvmD0diACENAQdjRT2XmyEdQ2-U7SsTonybHmLoU9FMQTjAgw4NCALQvExfB6rtQ9GDsOBt1xoBkB3Vo7a5OmugZ1aHXF69b8As6137-Dggf5qx5R3oLRFovICMMesQziE3vGi-WKcbQxSeiD-9a6ShPAhk41XiyjFGDEOtUCQo63uwQnMw3g0KVtC6bzIyFq-E91vhxumxXzxPYC-kg7iUYiSZy7Y-Aw''';
const publicKeysUrl =
'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';
const issuer = 'https://securetoken.google.com/my-app';
const audience = 'my-app';
final publicKey = File('test/fixtures/public_key.pem').readAsStringSync();
final body = json.encode(
{'c10c90ba4c3663516e0707d0e9a8948106285158': publicKey},
);
final expiresAt = DateTime.fromMillisecondsSinceEpoch(1643687866 * 1000);
final validTime = expiresAt.subtract(Duration(minutes: 15));
group('verify', () {
test('can be instantiated', () {
expect(verify, isNotNull);
});
test('can verify an expired jwt', () async {
getOverride = (Uri uri) async {
return Response(
body,
HttpStatus.ok,
headers: {'cache-control': 'max-age=3600'},
);
};
try {
await verify(
token,
audience: audience,
issuer: issuer,
publicKeysUrl: publicKeysUrl,
);
fail('should throw');
} catch (error) {
expect(
error,
isA<JwtVerificationFailure>().having(
(e) => e.reason,
'reason',
'Token has expired.',
),
);
}
});
test('can verify an invalid audience', () async {
await withClock(Clock.fixed(validTime), () async {
getOverride = (Uri uri) async {
return Response(
body,
HttpStatus.ok,
headers: {'cache-control': 'max-age=3600'},
);
};
try {
await verify(
token,
audience: 'invalid-audience',
issuer: issuer,
publicKeysUrl: publicKeysUrl,
);
fail('should throw');
} catch (error) {
expect(
error,
isA<JwtVerificationFailure>().having(
(e) => e.reason,
'reason',
'Invalid audience.',
),
);
}
});
});
test('can verify an invalid issuer', () async {
await withClock(Clock.fixed(validTime), () async {
getOverride = (Uri uri) async {
return Response(
body,
HttpStatus.ok,
headers: {'cache-control': 'max-age=3600'},
);
};
try {
await verify(
token,
audience: audience,
issuer: 'https://invalid/issuer',
publicKeysUrl: publicKeysUrl,
);
fail('should throw');
} catch (error) {
expect(
error,
isA<JwtVerificationFailure>().having(
(e) => e.reason,
'reason',
'Invalid issuer.',
),
);
}
});
});
test('can verify a valid jwt', () async {
await withClock(Clock.fixed(validTime), () async {
getOverride = (Uri uri) async {
return Response(
body,
HttpStatus.ok,
headers: {'cache-control': 'max-age=3600'},
);
};
final jwt = await verify(
token,
audience: audience,
issuer: issuer,
publicKeysUrl: publicKeysUrl,
);
expect(jwt, isA<Jwt>());
});
});
});
group('base64Padded', () {
test('does not add padding when mod 4 == 0', () {
const value = 'aaaa';
expect(base64Padded(value), equals(value));
});
test('does not add padding when mod 4 == 1', () {
const value = 'aaaaa';
expect(base64Padded(value), equals(value));
});
test('adds padding when mod 4 == 3', () {
const value = 'aaaaaaa';
expect(base64Padded(value), equals('$value='));
});
test('adds padding when mod 4 == 2', () {
const value = 'aaaaaa';
expect(base64Padded(value), equals('$value=='));
});
});
group('JwtVerificationFailure', () {
test('toString is correct', () {
const reason = 'reason';
final failure = JwtVerificationFailure(reason);
expect(failure.toString(), equals('JwtVerificationFailure: $reason'));
});
});
}