feat(jwt): allow deserialization of unvalidated JWTs (#1741)
Co-authored-by: Felix Angelov <felix@shorebird.dev>
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user