diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart index 98f2e3c3ac1c..72841c5165ee 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart @@ -6,13 +6,19 @@ import 'package:google_identity_services_web/id.dart'; import 'jsify_as.dart'; -/// A JWT token with null `credential`. +/// A CredentialResponse with null `credential`. final CredentialResponse nullCredential = jsifyAs({ 'credential': null, }); -/// A JWT token for predefined values. +/// A CredentialResponse wrapping a known good JWT Token as its `credential`. +final CredentialResponse goodCredential = + jsifyAs({ + 'credential': goodJwtToken, +}); + +/// A JWT token with predefined values. /// /// 'email': 'adultman@example.com', /// 'sub': '123456', @@ -20,15 +26,21 @@ final CredentialResponse nullCredential = /// 'picture': 'https://thispersondoesnotexist.com/image?x=.jpg', /// /// Signed with HS256 and the private key: 'symmetric-encryption-is-weak' -final CredentialResponse okCredential = - jsifyAs({ - 'credential': - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwibmFtZSI6IlZpbmNlbnQgQWR1bHRtYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly90aGlzcGVyc29uZG9lc25vdGV4aXN0LmNvbS9pbWFnZT94PS5qcGcifQ.lqzULA_U3YzEl_-fL7YLU-kFXmdD2ttJLTv-UslaNQ4', -}); +const String goodJwtToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$goodPayload.lqzULA_U3YzEl_-fL7YLU-kFXmdD2ttJLTv-UslaNQ4'; + +/// The payload of a JWT token that contains predefined values. +/// +/// 'email': 'adultman@example.com', +/// 'sub': '123456', +/// 'name': 'Vincent Adultman', +/// 'picture': 'https://thispersondoesnotexist.com/image?x=.jpg', +const String goodPayload = + 'eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwibmFtZSI6IlZpbmNlbnQgQWR1bHRtYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly90aGlzcGVyc29uZG9lc25vdGV4aXN0LmNvbS9pbWFnZT94PS5qcGcifQ'; -// More encrypted credential responses may be created on https://jwt.io. +// More encrypted JWT Tokens may be created on https://jwt.io. // -// First, decode the credential that's listed above, modify to your heart's +// First, decode the `goodJwtToken` above, modify to your heart's // content, and add a new credential here. // -// (It can also be done with `package:jose` and `dart:convert`.) +// (New tokens can also be created with `package:jose` and `dart:convert`.) diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart index f809e1661d35..82701e587be1 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/oauth2.dart'; @@ -18,6 +20,7 @@ void main() { group('gisResponsesToTokenData', () { testWidgets('null objects -> no problem', (_) async { final GoogleSignInTokenData tokens = gisResponsesToTokenData(null, null); + expect(tokens.accessToken, isNull); expect(tokens.idToken, isNull); expect(tokens.serverAuthCode, isNull); @@ -36,6 +39,7 @@ void main() { }); final GoogleSignInTokenData tokens = gisResponsesToTokenData(credential, token); + expect(tokens.accessToken, expectedAccessToken); expect(tokens.idToken, expectedIdToken); expect(tokens.serverAuthCode, isNull); @@ -44,13 +48,13 @@ void main() { group('gisResponsesToUserData', () { testWidgets('happy case', (_) async { - final GoogleSignInUserData data = gisResponsesToUserData(okCredential)!; + final GoogleSignInUserData data = gisResponsesToUserData(goodCredential)!; expect(data.displayName, 'Vincent Adultman'); expect(data.id, '123456'); expect(data.email, 'adultman@example.com'); expect(data.photoUrl, 'https://thispersondoesnotexist.com/image?x=.jpg'); - expect(data.idToken, okCredential.credential); + expect(data.idToken, goodJwtToken); }); testWidgets('null response -> null', (_) async { @@ -58,8 +62,7 @@ void main() { }); testWidgets('null response.credential -> null', (_) async { - final CredentialResponse response = nullCredential; - expect(gisResponsesToUserData(response), isNull); + expect(gisResponsesToUserData(nullCredential), isNull); }); testWidgets('invalid payload -> null', (_) async { @@ -70,4 +73,101 @@ void main() { expect(gisResponsesToUserData(response), isNull); }); }); + + group('getJwtTokenPayload', () { + testWidgets('happy case -> data', (_) async { + final Map? data = getJwtTokenPayload(goodJwtToken); + + expect(data, isNotNull); + expect(data, containsPair('name', 'Vincent Adultman')); + expect(data, containsPair('email', 'adultman@example.com')); + expect(data, containsPair('sub', '123456')); + expect( + data, + containsPair( + 'picture', + 'https://thispersondoesnotexist.com/image?x=.jpg', + )); + }); + + testWidgets('null Token -> null', (_) async { + final Map? data = getJwtTokenPayload(null); + + expect(data, isNull); + }); + + testWidgets('Token not matching the format -> null', (_) async { + final Map? data = getJwtTokenPayload('1234.4321'); + + expect(data, isNull); + }); + + testWidgets('Bad token that matches the format -> null', (_) async { + final Map? data = getJwtTokenPayload('1234.abcd.4321'); + + expect(data, isNull); + }); + }); + + group('decodeJwtPayload', () { + testWidgets('Good payload -> data', (_) async { + final Map? data = decodeJwtPayload(goodPayload); + + expect(data, isNotNull); + expect(data, containsPair('name', 'Vincent Adultman')); + expect(data, containsPair('email', 'adultman@example.com')); + expect(data, containsPair('sub', '123456')); + expect( + data, + containsPair( + 'picture', + 'https://thispersondoesnotexist.com/image?x=.jpg', + )); + }); + + testWidgets('Proper JSON payload -> data', (_) async { + final String payload = base64.encode(utf8.encode('{"properJson": true}')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNotNull); + expect(data, containsPair('properJson', true)); + }); + + testWidgets('Not-normalized base-64 payload -> data', (_) async { + // This is the payload generated by the "Proper JSON payload" test, but + // we remove the leading "=" symbols so it's length is not a multiple of 4 + // anymore! + final String payload = 'eyJwcm9wZXJKc29uIjogdHJ1ZX0='.replaceAll('=', ''); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNotNull); + expect(data, containsPair('properJson', true)); + }); + + testWidgets('Invalid JSON payload -> null', (_) async { + final String payload = base64.encode(utf8.encode('{properJson: false}')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + + testWidgets('Non JSON payload -> null', (_) async { + final String payload = base64.encode(utf8.encode('not-json')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + + testWidgets('Non base-64 payload -> null', (_) async { + const String payload = 'not-base-64-at-all'; + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + }); } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart index 0b4e3416f7b5..345244a110e6 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart @@ -2,10 +2,57 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:jwt_decoder/jwt_decoder.dart' as jwt; + +/// A codec that can encode/decode JWT payloads. +/// +/// See https://www.rfc-editor.org/rfc/rfc7519#section-3 +final Codec jwtCodec = json.fuse(utf8).fuse(base64); + +/// A RegExp that can match, and extract parts from a JWT Token. +/// +/// A JWT token consists of 3 base-64 encoded parts of data separated by periods: +/// +/// header.payload.signature +/// +/// More info: https://regexr.com/789qc +final RegExp jwtTokenRegexp = RegExp( + r'^(?
[^\.\s]+)\.(?[^\.\s]+)\.(?[^\.\s]+)$'); + +/// Decodes the `claims` of a JWT token and returns them as a Map. +/// +/// JWT `claims` are stored as a JSON object in the `payload` part of the token. +/// +/// (This method does not validate the signature of the token.) +/// +/// See https://www.rfc-editor.org/rfc/rfc7519#section-3 +Map? getJwtTokenPayload(String? token) { + if (token != null) { + final RegExpMatch? match = jwtTokenRegexp.firstMatch(token); + if (match != null) { + return decodeJwtPayload(match.namedGroup('payload')); + } + } + + return null; +} + +/// Decodes a JWT payload using the [jwtCodec]. +Map? decodeJwtPayload(String? payload) { + try { + // Payload must be normalized before passing it to the codec + return (jwtCodec.decode(base64.normalize(payload!)) + as Map?) + ?.cast(); + } catch (_) { + // Do nothing, we always return null for any failure. + } + return null; +} /// Converts a [CredentialResponse] into a [GoogleSignInUserData]. /// @@ -18,7 +65,7 @@ GoogleSignInUserData? gisResponsesToUserData( } final Map? payload = - jwt.JwtDecoder.tryDecode(credentialResponse.credential!); + getJwtTokenPayload(credentialResponse.credential); if (payload == null) { return null; diff --git a/packages/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml index af38042bfb31..40e8b0381e67 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -26,7 +26,6 @@ dependencies: google_sign_in_platform_interface: ^2.2.0 http: ^0.13.5 js: ^0.6.3 - jwt_decoder: ^2.0.1 dev_dependencies: flutter_test: