feat(jwt): allow deserialization of unvalidated JWTs (#1741)

Co-authored-by: Felix Angelov <felix@shorebird.dev>
This commit is contained in:
Bryan Oltman
2024-02-20 15:18:52 -05:00
committed by GitHub
parent e9680d0da1
commit 5451a9305b
10 changed files with 147 additions and 115 deletions
+9
View File
@@ -0,0 +1,9 @@
import 'dart:convert';
/// Converts one of the three base64-encoded parts of a JWT to a JSON object.
Map<String, dynamic> decodeJwtPart(String part) {
final normalized = base64.normalize(part);
final base64Decoded = base64.decode(normalized);
final utf8Decoded = utf8.decode(base64Decoded);
return json.decode(utf8Decoded) as Map<String, dynamic>;
}
+12 -36
View File
@@ -67,56 +67,32 @@ class JwtVerificationFailure implements Exception {
String toString() => 'JwtVerificationFailure: $reason';
}
/// Verify the provided [jwt].
/// Verify the encoded [encodedJwt].
Future<Jwt> verify(
String jwt, {
String encodedJwt, {
required String issuer,
required Set<String> audience,
required String publicKeysUrl,
}) async {
final parts = jwt.split('.');
if (parts.length != 3) {
throw const JwtVerificationFailure('JWT is malformed');
final Jwt jwt;
try {
jwt = Jwt.parse(encodedJwt);
} on FormatException catch (e) {
throw JwtVerificationFailure(e.message);
}
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);
await _verifyHeader(jwt.header, publicKeys);
_verifyPayload(jwt.payload, issuer, audience);
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]!);
final isValid = _verifySignature(encodedJwt, publicKeys[jwt.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;
// If we've made it this far, the JWT is now verified.
return jwt;
}
Future<void> _verifyHeader(
+33
View File
@@ -1,4 +1,5 @@
import 'package:jwt/jwt.dart';
import 'package:jwt/src/encoding.dart';
/// {@template jwt}
/// A JWT (json web token)
@@ -12,6 +13,38 @@ class Jwt {
this.claims = const <String, dynamic>{},
});
/// Decodes a JWT string of the format `header.payload.signature`. This does
/// _not_ perform any verification that the JWT is valid.
factory Jwt.parse(String string) {
final parts = string.split('.');
if (parts.length != 3) {
throw const FormatException('Invalid JWT format');
}
final JwtHeader header;
try {
header = JwtHeader.fromJson(decodeJwtPart(parts[0]));
} catch (_) {
throw const FormatException('JWT header is malformed.');
}
final JwtPayload payload;
try {
payload = JwtPayload.fromJson(decodeJwtPart(parts[1]));
} catch (_) {
throw const FormatException('JWT payload is malformed.');
}
final signature = parts[2];
return Jwt(
header: header,
payload: payload,
signature: signature,
claims: decodeJwtPart(parts[1]),
);
}
/// {@macro jwt_header}
final JwtHeader header;
+25 -9
View File
@@ -37,24 +37,40 @@ void main() {
);
};
try {
await verify(
await expectLater(
() => verify(
token,
audience: {audience},
issuer: issuer,
publicKeysUrl: publicKeysUrl,
);
fail('should throw');
} catch (error) {
expect(
error,
),
throwsA(
isA<JwtVerificationFailure>().having(
(e) => e.reason,
'reason',
'Token has expired.',
),
);
}
),
);
});
test('throws a JwtVerificationFailure if string is not valid jwt',
() async {
await expectLater(
() => verify(
'not.a.jwt',
audience: {audience},
issuer: issuer,
publicKeysUrl: publicKeysUrl,
),
throwsA(
isA<JwtVerificationFailure>().having(
(e) => e.reason,
'reason',
'JWT header is malformed.',
),
),
);
});
test('can verify an invalid audience', () async {
@@ -5,9 +5,9 @@ import 'package:cli_util/cli_util.dart';
import 'package:googleapis_auth/auth_io.dart' as oauth2;
import 'package:googleapis_auth/googleapis_auth.dart';
import 'package:http/http.dart' as http;
import 'package:jwt/jwt.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/auth/jwt.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/command_runner.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart';
@@ -279,17 +279,20 @@ class Auth {
}
}
extension on oauth2.AccessCredentials {
extension JwtClaims on oauth2.AccessCredentials {
String? get email {
final token = idToken;
if (token == null) return null;
final claims = Jwt.decodeClaims(token);
final Jwt jwt;
try {
jwt = Jwt.parse(token);
} catch (_) {
return null;
}
if (claims == null) return null;
return claims['email'] as String?;
return jwt.claims['email'] as String?;
}
}
@@ -1,22 +0,0 @@
import 'dart:convert';
/// Jwt Utilities
class Jwt {
/// Decode and extract claims from a JWT token.
static Map<String, dynamic>? decodeClaims(String value) {
final parts = value.split('.');
if (parts.length != 3) return null;
try {
return _decodePart(parts[1]);
} catch (_) {}
return null;
}
}
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;
}
+32 -9
View File
@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.2"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: c9c85fedbe2188b95133cbe960e16f5f448860f7133330e272edbbca5893ddc6
url: "https://pub.dev"
source: hosted
version: "1.5.2"
async:
dependency: transitive
description:
@@ -237,10 +245,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.0"
file:
dependency: transitive
description:
@@ -292,10 +300,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.0"
http_multi_server:
dependency: transitive
description:
@@ -368,6 +376,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.7.1"
jwt:
dependency: "direct main"
description:
path: "../jwt"
relative: true
source: path
version: "1.0.0+1"
logging:
dependency: transitive
description:
@@ -512,6 +527,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.0"
rsa_pkcs:
dependency: transitive
description:
name: rsa_pkcs
sha256: "6e1e03563d2cbd9758d8a562f738b759763d464fd65d32da132153230a0c0395"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
scoped:
dependency: "direct main"
description:
@@ -737,18 +760,18 @@ packages:
dependency: transitive
description:
name: web
sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad"
sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
version: "0.4.2"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2"
sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23"
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "2.4.3"
webkit_inspection_protocol:
dependency: transitive
description:
@@ -790,4 +813,4 @@ packages:
source: hosted
version: "2.1.1"
sdks:
dart: ">=3.3.0 <4.0.0"
dart: ">=3.2.0 <4.0.0"
+2
View File
@@ -24,6 +24,8 @@ dependencies:
io: ^1.0.4
json_annotation: ^4.8.0
json_path: ^0.7.0
jwt:
path: ../jwt
mason_logger: ^0.2.12
meta: ^1.9.0
path: ^1.8.3
@@ -37,9 +37,28 @@ void main() {
});
});
group('Auth', () {
group('JwtClaims', () {
group('email', () {
test('returns null when idToken is not a valid jwt', () {
final credentials = AccessCredentials(
AccessToken(
'Bearer',
'accessToken',
DateTime.now().add(const Duration(minutes: 10)).toUtc(),
),
'',
[],
idToken: 'not a valid jwt',
);
expect(credentials.email, isNull);
});
});
});
group(Auth, () {
const idToken =
'''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg''';
'''eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI1MjMzMDIyMzMyOTMtZWlhNWFudG0wdGd2ZWsyNDB0NDZvcmN0a3RpYWJyZWsuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMjM0NSIsImhkIjoic2hvcmViaXJkLmRldiIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWF0IjoxMjM0LCJleHAiOjY3ODl9.MYbITALvKsGYTYjw1o7AQ0ObkqRWVBSr9cFYJrvA46g''';
const email = 'test@email.com';
const user = User(id: 42, email: email);
const refreshToken = '';
@@ -317,9 +336,8 @@ void main() {
expect(client, isA<AuthenticatedClient>());
});
test(
'returns a plain http client '
'when credentials are not present.', () async {
test('returns a plain http client when credentials are not present.',
() async {
final client = auth.client;
expect(client, isA<http.Client>());
expect(client, isNot(isA<AutoRefreshingAuthClient>()));
@@ -327,9 +345,8 @@ void main() {
});
group('login', () {
test(
'should set the email when claims are valid '
'and current user exists', () async {
test('should set the email when claims are valid and current user exists',
() async {
await auth.login((_) {});
expect(auth.email, email);
expect(auth.isAuthenticated, isTrue);
@@ -1,25 +0,0 @@
import 'package:shorebird_cli/src/auth/jwt.dart';
import 'package:test/test.dart';
void main() {
group('Jwt', () {
group('decodeClaims', () {
test('returns null jwt does not contain 3 segments', () {
expect(Jwt.decodeClaims('invalid'), isNull);
});
test('returns null when jwt payload segment is malformed', () {
expect(Jwt.decodeClaims('this.is.invalid'), isNull);
});
test('returns correct claims when jwt payload segment is valid', () {
expect(
Jwt.decodeClaims(
'''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg''',
),
equals({'email': 'test@email.com'}),
);
});
});
});
}