Files
shorebird/packages/jwt/lib/src/jwt.dart
T
2023-04-04 12:39:42 -05:00

220 lines
6.0 KiB
Dart

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;