Skip to content

Commit

Permalink
feat: add package:jwt (#225)
Browse files Browse the repository at this point in the history
  • Loading branch information
felangel authored Apr 4, 2023
1 parent 8a2ec10 commit d5e4472
Show file tree
Hide file tree
Showing 17 changed files with 627 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/jwt/.gitignore
Original file line number Diff line number Diff line change
@@ -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 changes: 23 additions & 0 deletions packages/jwt/README.md
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/jwt/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.4.0.0.yaml
14 changes: 14 additions & 0 deletions packages/jwt/build.yaml
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions packages/jwt/example/main.dart
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/jwt/lib/jwt.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export 'src/jwt.dart';
export 'src/models/models.dart';
219 changes: 219 additions & 0 deletions packages/jwt/lib/src/jwt.dart
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions packages/jwt/lib/src/models/jwt.dart
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions packages/jwt/lib/src/models/jwt_header.dart
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions packages/jwt/lib/src/models/jwt_header.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit d5e4472

Please sign in to comment.