diff --git a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md index 90aeabceebe..810573ad697 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.12.2 + +* Adds server auth code retrieval to google_sign_in_web. +* Adds `web_only` library to access web-only methods more easily. + ## 0.12.1 * Enables FedCM on browsers that support this authentication mechanism. diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart index fdea1b7f7ca..e7c7c75739c 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart @@ -250,6 +250,24 @@ void main() { expect(arguments.elementAt(1), someAccessToken); }); }); + + group('requestServerAuthCode', () { + const String someAuthCode = '50m3_4u7h_c0d3'; + + setUp(() { + plugin.initWithParams(options); + }); + + testWidgets('passes-through call to gis client', (_) async { + mockito + .when(mockGis.requestServerAuthCode()) + .thenAnswer((_) => Future.value(someAuthCode)); + + final String? serverAuthCode = await plugin.requestServerAuthCode(); + + expect(serverAuthCode, someAuthCode); + }); + }); }); group('userDataEvents', () { diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart index 35ef487c9bb..f142140e6c8 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart @@ -1,7 +1,9 @@ -// Mocks generated by Mockito 5.4.0 from annotations +// Mocks generated by Mockito 5.4.1 from annotations // in google_sign_in_web_integration_tests/integration_test/google_sign_in_web_test.dart. // Do not manually edit this file. +// @dart=2.19 + // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i4; @@ -47,6 +49,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValueForMissingStub: _i4.Future<_i2.GoogleSignInUserData?>.value(), ) as _i4.Future<_i2.GoogleSignInUserData?>); + @override _i4.Future renderButton( Object? parent, @@ -63,6 +66,17 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future requestServerAuthCode() => (super.noSuchMethod( + Invocation.method( + #requestServerAuthCode, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( Invocation.method( @@ -73,6 +87,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValueForMissingStub: _i4.Future<_i2.GoogleSignInUserData?>.value(), ) as _i4.Future<_i2.GoogleSignInUserData?>); + @override _i2.GoogleSignInTokenData getTokens() => (super.noSuchMethod( Invocation.method( @@ -94,6 +109,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { ), ), ) as _i2.GoogleSignInTokenData); + @override _i4.Future signOut() => (super.noSuchMethod( Invocation.method( @@ -103,6 +119,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override _i4.Future disconnect() => (super.noSuchMethod( Invocation.method( @@ -112,6 +129,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override _i4.Future isSignedIn() => (super.noSuchMethod( Invocation.method( @@ -121,6 +139,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValue: _i4.Future.value(false), returnValueForMissingStub: _i4.Future.value(false), ) as _i4.Future); + @override _i4.Future clearAuthCache() => (super.noSuchMethod( Invocation.method( @@ -130,6 +149,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override _i4.Future requestScopes(List? scopes) => (super.noSuchMethod( Invocation.method( @@ -139,6 +159,7 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValue: _i4.Future.value(false), returnValueForMissingStub: _i4.Future.value(false), ) as _i4.Future); + @override _i4.Future canAccessScopes( List? scopes, diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.dart new file mode 100644 index 00000000000..508e291bf2a --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.dart @@ -0,0 +1,72 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:google_sign_in_web/google_sign_in_web.dart' + show GoogleSignInPlugin; +import 'package:google_sign_in_web/src/gis_client.dart'; +import 'package:google_sign_in_web/web_only.dart' as web; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart' as mockito; + +import 'web_only_test.mocks.dart'; + +// Mock GisSdkClient so we can simulate any response from the JS side. +@GenerateMocks([], customMocks: >[ + MockSpec(onMissingStub: OnMissingStub.returnDefault), +]) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('non-web plugin instance', () { + setUp(() { + GoogleSignInPlatform.instance = NonWebImplementation(); + }); + + testWidgets('renderButton throws', (WidgetTester _) async { + expect(() { + web.renderButton(); + }, throwsAssertionError); + }); + + testWidgets('requestServerAuthCode throws', (WidgetTester _) async { + expect(() async { + await web.requestServerAuthCode(); + }, throwsAssertionError); + }); + }); + + group('web plugin instance', () { + const String someAuthCode = '50m3_4u7h_c0d3'; + late MockGisSdkClient mockGis; + + setUp(() { + mockGis = MockGisSdkClient(); + GoogleSignInPlatform.instance = GoogleSignInPlugin( + debugOverrideLoader: true, + debugOverrideGisSdkClient: mockGis, + )..initWithParams( + const SignInInitParameters( + clientId: 'does-not-matter', + ), + ); + }); + + testWidgets('call reaches GIS API', (WidgetTester _) async { + mockito + .when(mockGis.requestServerAuthCode()) + .thenAnswer((_) => Future.value(someAuthCode)); + + final String? serverAuthCode = await web.requestServerAuthCode(); + + expect(serverAuthCode, someAuthCode); + }); + }); +} + +/// Fake non-web implementation used to verify that the web_only methods +/// throw when the wrong type of instance is configured. +class NonWebImplementation extends GoogleSignInPlatform {} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.mocks.dart new file mode 100644 index 00000000000..b3c76dd6ce6 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.mocks.dart @@ -0,0 +1,179 @@ +// Mocks generated by Mockito 5.4.1 from annotations +// in google_sign_in_web_integration_tests/integration_test/web_only_test.dart. +// Do not manually edit this file. + +// @dart=2.19 + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' + as _i2; +import 'package:google_sign_in_web/src/button_configuration.dart' as _i5; +import 'package:google_sign_in_web/src/gis_client.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeGoogleSignInTokenData_0 extends _i1.SmartFake + implements _i2.GoogleSignInTokenData { + _FakeGoogleSignInTokenData_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [GisSdkClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { + @override + _i4.Future<_i2.GoogleSignInUserData?> signInSilently() => (super.noSuchMethod( + Invocation.method( + #signInSilently, + [], + ), + returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), + returnValueForMissingStub: + _i4.Future<_i2.GoogleSignInUserData?>.value(), + ) as _i4.Future<_i2.GoogleSignInUserData?>); + + @override + _i4.Future renderButton( + Object? parent, + _i5.GSIButtonConfiguration? options, + ) => + (super.noSuchMethod( + Invocation.method( + #renderButton, + [ + parent, + options, + ], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future requestServerAuthCode() => (super.noSuchMethod( + Invocation.method( + #requestServerAuthCode, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( + Invocation.method( + #signIn, + [], + ), + returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), + returnValueForMissingStub: + _i4.Future<_i2.GoogleSignInUserData?>.value(), + ) as _i4.Future<_i2.GoogleSignInUserData?>); + + @override + _i2.GoogleSignInTokenData getTokens() => (super.noSuchMethod( + Invocation.method( + #getTokens, + [], + ), + returnValue: _FakeGoogleSignInTokenData_0( + this, + Invocation.method( + #getTokens, + [], + ), + ), + returnValueForMissingStub: _FakeGoogleSignInTokenData_0( + this, + Invocation.method( + #getTokens, + [], + ), + ), + ) as _i2.GoogleSignInTokenData); + + @override + _i4.Future signOut() => (super.noSuchMethod( + Invocation.method( + #signOut, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future disconnect() => (super.noSuchMethod( + Invocation.method( + #disconnect, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future isSignedIn() => (super.noSuchMethod( + Invocation.method( + #isSignedIn, + [], + ), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.Future); + + @override + _i4.Future clearAuthCache() => (super.noSuchMethod( + Invocation.method( + #clearAuthCache, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future requestScopes(List? scopes) => (super.noSuchMethod( + Invocation.method( + #requestScopes, + [scopes], + ), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.Future); + + @override + _i4.Future canAccessScopes( + List? scopes, + String? accessToken, + ) => + (super.noSuchMethod( + Invocation.method( + #canAccessScopes, + [ + scopes, + accessToken, + ], + ), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.Future); +} diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index cee94d345f0..8d7a341387d 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -300,4 +300,11 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { @override Stream? get userDataEvents => _userDataController.stream; + + /// Requests server auth code from GIS Client per: + /// https://developers.google.com/identity/oauth2/web/guides/use-code-model#initialize_a_code_client + Future requestServerAuthCode() async { + await initialized; + return _gisClient.requestServerAuthCode(); + } } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart index cf79de8a3a2..a5ea642bc54 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart @@ -51,6 +51,14 @@ class GisSdkClient { onResponse: _onTokenResponse, onError: _onTokenError, ); + + _codeClient = _initializeCodeClient( + clientId, + hostedDomain: hostedDomain, + onResponse: _onCodeResponse, + onError: _onCodeError, + scopes: initialScopes, + ); } void _logIfEnabled(String message, [List? more]) { @@ -63,6 +71,7 @@ class GisSdkClient { void _configureStreams() { _tokenResponses = StreamController.broadcast(); _credentialResponses = StreamController.broadcast(); + _codeResponses = StreamController.broadcast(); _tokenResponses.stream.listen((TokenResponse response) { _lastTokenResponse = response; @@ -73,6 +82,13 @@ class GisSdkClient { _lastTokenResponse = null; }); + _codeResponses.stream.listen((CodeResponse response) { + _lastCodeResponse = response; + }, onError: (Object error) { + _logIfEnabled('Error on CodeResponse:', [error.toString()]); + _lastCodeResponse = null; + }); + _credentialResponses.stream.listen((CredentialResponse response) { _lastCredentialResponse = response; }, onError: (Object error) { @@ -174,6 +190,39 @@ class GisSdkClient { _tokenResponses.addError(getProperty(error!, 'type')); } +// Creates a `oauth2.CodeClient` used for authorization (scope) requests. + CodeClient _initializeCodeClient( + String clientId, { + String? hostedDomain, + required List scopes, + required CodeClientCallbackFn onResponse, + required ErrorCallbackFn onError, + }) { + // Create a Token Client for authorization calls. + final CodeClientConfig codeConfig = CodeClientConfig( + client_id: clientId, + hosted_domain: hostedDomain, + callback: allowInterop(_onCodeResponse), + error_callback: allowInterop(_onCodeError), + scope: scopes.join(' '), + select_account: true, + ux_mode: UxMode.popup, + ); + return oauth2.initCodeClient(codeConfig); + } + + void _onCodeResponse(CodeResponse response) { + if (response.error != null) { + _codeResponses.addError(response.error!); + } else { + _codeResponses.add(response); + } + } + + void _onCodeError(Object? error) { + _codeResponses.addError(getProperty(error!, 'type')); + } + /// Attempts to sign-in the user using the OneTap UX flow. /// /// If the user consents, to OneTap, the [GoogleSignInUserData] will be @@ -239,6 +288,14 @@ class GisSdkClient { return id.renderButton(parent, convertButtonConfiguration(options)!); } + /// Requests a server auth code per: + /// https://developers.google.com/identity/oauth2/web/guides/use-code-model#initialize_a_code_client + Future requestServerAuthCode() async { + _codeClient.requestCode(); + final CodeResponse response = await _codeResponses.stream.first; + return response.code; + } + // TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727 // /// Starts an oauth2 "implicit" flow to authorize requests. @@ -306,6 +363,7 @@ class GisSdkClient { return utils.gisResponsesToTokenData( _lastCredentialResponse, _lastTokenResponse, + _lastCodeResponse, ); } @@ -351,6 +409,7 @@ class GisSdkClient { _lastCredentialResponse = null; _lastTokenResponse = null; _requestedUserData = null; + _lastCodeResponse = null; } /// Requests the list of [scopes] passed in to the client. @@ -402,16 +461,19 @@ class GisSdkClient { // The Google Identity Services client for oauth requests. late TokenClient _tokenClient; + late CodeClient _codeClient; // Streams of credential and token responses. late StreamController _credentialResponses; late StreamController _tokenResponses; + late StreamController _codeResponses; // The last-seen credential and token responses CredentialResponse? _lastCredentialResponse; TokenResponse? _lastTokenResponse; // Expiration timestamp for the lastTokenResponse, which only has an `expires_in` field. DateTime? _lastTokenResponseExpiration; + CodeResponse? _lastCodeResponse; /// The StreamController onto which the GIS Client propagates user authentication events. /// 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 609a387d117..05ed6a877d1 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 @@ -99,9 +99,13 @@ DateTime? getCredentialResponseExpirationTimestamp( /// Converts responses from the GIS library into TokenData for the plugin. GoogleSignInTokenData gisResponsesToTokenData( - CredentialResponse? credentialResponse, TokenResponse? tokenResponse) { + CredentialResponse? credentialResponse, + TokenResponse? tokenResponse, [ + CodeResponse? codeResponse, +]) { return GoogleSignInTokenData( idToken: credentialResponse?.credential, accessToken: tokenResponse?.access_token, + serverAuthCode: codeResponse?.code, ); } diff --git a/packages/google_sign_in/google_sign_in_web/lib/web_only.dart b/packages/google_sign_in/google_sign_in_web/lib/web_only.dart new file mode 100644 index 00000000000..34f153d0aef --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/web_only.dart @@ -0,0 +1,48 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This library exposes web-only methods of [GoogleSignInPlatform.instance]. +/// +/// The exported methods will assert that the [GoogleSignInPlatform.instance] +/// is an instance of class [GoogleSignInPlugin] (the web implementation of +/// `google_sign_in` provided by this package). +library web_only; + +import 'package:flutter/widgets.dart' show Widget; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' + show GoogleSignInPlatform; + +import 'google_sign_in_web.dart' show GoogleSignInPlugin; +import 'src/button_configuration.dart' show GSIButtonConfiguration; + +// Export the configuration types for the renderButton method. +export 'src/button_configuration.dart' + show + GSIButtonConfiguration, + GSIButtonLogoAlignment, + GSIButtonShape, + GSIButtonSize, + GSIButtonText, + GSIButtonTheme, + GSIButtonType; + +// Asserts that the instance of the platform is for the web. +GoogleSignInPlugin get _plugin { + assert(GoogleSignInPlatform.instance is GoogleSignInPlugin, + 'The current GoogleSignInPlatform instance is not for web.'); + + return GoogleSignInPlatform.instance as GoogleSignInPlugin; +} + +/// Render the GIS Sign-In Button widget with [configuration]. +Widget renderButton({GSIButtonConfiguration? configuration}) { + return _plugin.renderButton(configuration: configuration); +} + +/// Requests server auth code from the GIS Client. +/// +/// See: https://developers.google.com/identity/oauth2/web/guides/use-code-model +Future requestServerAuthCode() async { + return _plugin.requestServerAuthCode(); +} 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 e9836a35cef..7ce54286bea 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android, iOS and Web. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.12.1 +version: 0.12.2 environment: sdk: ">=3.1.0 <4.0.0"