Skip to content

Commit a5e4243

Browse files
[google_sign_in] Implement disconnect for Android (#9991)
Adds the missing implementation of `disconnect` using the new `revokeAccess` API, updating `play-services-auth` to the version containing the new API. Fixes flutter/flutter#169612 ## Pre-Review Checklist [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 0fc759d commit a5e4243

File tree

10 files changed

+426
-33
lines changed

10 files changed

+426
-33
lines changed

packages/google_sign_in/google_sign_in_android/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 7.2.0
2+
3+
* Adds support for `disconnect`.
4+
15
## 7.1.0
26

37
* Adds support for the `clearAuthorizationToken` method.

packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import com.google.android.gms.auth.api.identity.AuthorizationResult;
3535
import com.google.android.gms.auth.api.identity.ClearTokenRequest;
3636
import com.google.android.gms.auth.api.identity.Identity;
37+
import com.google.android.gms.auth.api.identity.RevokeAccessRequest;
3738
import com.google.android.gms.common.api.ApiException;
3839
import com.google.android.gms.common.api.Scope;
3940
import com.google.android.libraries.identity.googleid.GetGoogleIdOption;
@@ -59,6 +60,9 @@ public class GoogleSignInPlugin implements FlutterPlugin, ActivityAware {
5960
private @Nullable BinaryMessenger messenger;
6061
private ActivityPluginBinding activityPluginBinding;
6162

63+
// The account type to use to create an Account object for a Google Sign In account.
64+
private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
65+
6266
private void initInstance(@NonNull BinaryMessenger messenger, @NonNull Context context) {
6367
initWithDelegate(
6468
messenger,
@@ -384,7 +388,7 @@ public void authorize(
384388
}
385389
if (params.getAccountEmail() != null) {
386390
authorizationRequestBuilder.setAccount(
387-
new Account(params.getAccountEmail(), "com.google"));
391+
new Account(params.getAccountEmail(), GOOGLE_ACCOUNT_TYPE));
388392
}
389393
AuthorizationRequest authorizationRequest = authorizationRequestBuilder.build();
390394
authorizationClientFactory
@@ -455,6 +459,28 @@ public void authorize(
455459
}
456460
}
457461

462+
@Override
463+
public void revokeAccess(
464+
@NonNull PlatformRevokeAccessRequest params,
465+
@NonNull Function1<? super Result<Unit>, Unit> callback) {
466+
List<Scope> scopes = new ArrayList<>();
467+
for (String scope : params.getScopes()) {
468+
scopes.add(new Scope(scope));
469+
}
470+
authorizationClientFactory
471+
.create(context)
472+
.revokeAccess(
473+
RevokeAccessRequest.builder()
474+
.setAccount(new Account(params.getAccountEmail(), GOOGLE_ACCOUNT_TYPE))
475+
.setScopes(scopes)
476+
.build())
477+
.addOnSuccessListener(unused -> ResultUtilsKt.completeWithUnitSuccess(callback))
478+
.addOnFailureListener(
479+
e ->
480+
ResultUtilsKt.completeWithUnitError(
481+
callback, new FlutterError("revokeAccess failed", e.getMessage(), null)));
482+
}
483+
458484
@Override
459485
public boolean onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
460486
if (requestCode == REQUEST_CODE_AUTHORIZE) {

packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,53 @@ data class GetCredentialRequestGoogleIdOptionParams(
275275
override fun hashCode(): Int = toList().hashCode()
276276
}
277277

278+
/**
279+
* Parameters for revoking authorization.
280+
*
281+
* Corresponds to the native RevokeAccessRequest.
282+
* https://developers.google.com/android/reference/com/google/android/gms/auth/api/identity/RevokeAccessRequest
283+
*
284+
* Generated class from Pigeon that represents data sent in messages.
285+
*/
286+
data class PlatformRevokeAccessRequest(
287+
/** The email for the Google account to revoke authorizations for. */
288+
val accountEmail: String,
289+
/**
290+
* A list of requested scopes.
291+
*
292+
* Per docs, all granted scopes will be revoked, not only the ones passed here. However, at
293+
* least one currently-granted scope must be provided.
294+
*/
295+
val scopes: List<String>
296+
) {
297+
companion object {
298+
fun fromList(pigeonVar_list: List<Any?>): PlatformRevokeAccessRequest {
299+
val accountEmail = pigeonVar_list[0] as String
300+
val scopes = pigeonVar_list[1] as List<String>
301+
return PlatformRevokeAccessRequest(accountEmail, scopes)
302+
}
303+
}
304+
305+
fun toList(): List<Any?> {
306+
return listOf(
307+
accountEmail,
308+
scopes,
309+
)
310+
}
311+
312+
override fun equals(other: Any?): Boolean {
313+
if (other !is PlatformRevokeAccessRequest) {
314+
return false
315+
}
316+
if (this === other) {
317+
return true
318+
}
319+
return MessagesPigeonUtils.deepEquals(toList(), other.toList())
320+
}
321+
322+
override fun hashCode(): Int = toList().hashCode()
323+
}
324+
278325
/**
279326
* Pigeon equivalent of the native GoogleIdTokenCredential.
280327
*
@@ -525,20 +572,23 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
525572
}
526573
}
527574
134.toByte() -> {
575+
return (readValue(buffer) as? List<Any?>)?.let { PlatformRevokeAccessRequest.fromList(it) }
576+
}
577+
135.toByte() -> {
528578
return (readValue(buffer) as? List<Any?>)?.let {
529579
PlatformGoogleIdTokenCredential.fromList(it)
530580
}
531581
}
532-
135.toByte() -> {
582+
136.toByte() -> {
533583
return (readValue(buffer) as? List<Any?>)?.let { GetCredentialFailure.fromList(it) }
534584
}
535-
136.toByte() -> {
585+
137.toByte() -> {
536586
return (readValue(buffer) as? List<Any?>)?.let { GetCredentialSuccess.fromList(it) }
537587
}
538-
137.toByte() -> {
588+
138.toByte() -> {
539589
return (readValue(buffer) as? List<Any?>)?.let { AuthorizeFailure.fromList(it) }
540590
}
541-
138.toByte() -> {
591+
139.toByte() -> {
542592
return (readValue(buffer) as? List<Any?>)?.let { PlatformAuthorizationResult.fromList(it) }
543593
}
544594
else -> super.readValueOfType(type, buffer)
@@ -567,26 +617,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
567617
stream.write(133)
568618
writeValue(stream, value.toList())
569619
}
570-
is PlatformGoogleIdTokenCredential -> {
620+
is PlatformRevokeAccessRequest -> {
571621
stream.write(134)
572622
writeValue(stream, value.toList())
573623
}
574-
is GetCredentialFailure -> {
624+
is PlatformGoogleIdTokenCredential -> {
575625
stream.write(135)
576626
writeValue(stream, value.toList())
577627
}
578-
is GetCredentialSuccess -> {
628+
is GetCredentialFailure -> {
579629
stream.write(136)
580630
writeValue(stream, value.toList())
581631
}
582-
is AuthorizeFailure -> {
632+
is GetCredentialSuccess -> {
583633
stream.write(137)
584634
writeValue(stream, value.toList())
585635
}
586-
is PlatformAuthorizationResult -> {
636+
is AuthorizeFailure -> {
587637
stream.write(138)
588638
writeValue(stream, value.toList())
589639
}
640+
is PlatformAuthorizationResult -> {
641+
stream.write(139)
642+
writeValue(stream, value.toList())
643+
}
590644
else -> super.writeValue(stream, value)
591645
}
592646
}
@@ -615,6 +669,8 @@ interface GoogleSignInApi {
615669
callback: (Result<AuthorizeResult>) -> Unit
616670
)
617671

672+
fun revokeAccess(params: PlatformRevokeAccessRequest, callback: (Result<Unit>) -> Unit)
673+
618674
companion object {
619675
/** The codec used by GoogleSignInApi. */
620676
val codec: MessageCodec<Any?> by lazy { MessagesPigeonCodec() }
@@ -742,6 +798,29 @@ interface GoogleSignInApi {
742798
channel.setMessageHandler(null)
743799
}
744800
}
801+
run {
802+
val channel =
803+
BasicMessageChannel<Any?>(
804+
binaryMessenger,
805+
"dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.revokeAccess$separatedMessageChannelSuffix",
806+
codec)
807+
if (api != null) {
808+
channel.setMessageHandler { message, reply ->
809+
val args = message as List<Any?>
810+
val paramsArg = args[0] as PlatformRevokeAccessRequest
811+
api.revokeAccess(paramsArg) { result: Result<Unit> ->
812+
val error = result.exceptionOrNull()
813+
if (error != null) {
814+
reply.reply(MessagesPigeonUtils.wrapError(error))
815+
} else {
816+
reply.reply(MessagesPigeonUtils.wrapResult(null))
817+
}
818+
}
819+
}
820+
} else {
821+
channel.setMessageHandler(null)
822+
}
823+
}
745824
}
746825
}
747826
}

packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import com.google.android.gms.auth.api.identity.AuthorizationRequest;
4242
import com.google.android.gms.auth.api.identity.AuthorizationResult;
4343
import com.google.android.gms.auth.api.identity.ClearTokenRequest;
44+
import com.google.android.gms.auth.api.identity.RevokeAccessRequest;
4445
import com.google.android.gms.common.api.ApiException;
4546
import com.google.android.gms.common.api.Status;
4647
import com.google.android.gms.tasks.OnSuccessListener;
@@ -72,7 +73,7 @@ public class GoogleSignInTest {
7273
@Mock CustomCredential mockGenericCredential;
7374
@Mock GoogleIdTokenCredential mockGoogleCredential;
7475
@Mock Task<AuthorizationResult> mockAuthorizationTask;
75-
@Mock Task<Void> mockClearTokenTask;
76+
@Mock Task<Void> mockVoidTask;
7677

7778
private GoogleSignInPlugin flutterPlugin;
7879
// Technically this is not the plugin, but in practice almost all of the functionality is in this
@@ -90,8 +91,8 @@ public void setUp() {
9091
.thenReturn(GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL);
9192
when(mockAuthorizationTask.addOnSuccessListener(any())).thenReturn(mockAuthorizationTask);
9293
when(mockAuthorizationTask.addOnFailureListener(any())).thenReturn(mockAuthorizationTask);
93-
when(mockClearTokenTask.addOnSuccessListener(any())).thenReturn(mockClearTokenTask);
94-
when(mockClearTokenTask.addOnFailureListener(any())).thenReturn(mockClearTokenTask);
94+
when(mockVoidTask.addOnSuccessListener(any())).thenReturn(mockVoidTask);
95+
when(mockVoidTask.addOnFailureListener(any())).thenReturn(mockVoidTask);
9596
when(mockAuthorizationIntent.getIntentSender()).thenReturn(mockAuthorizationIntentSender);
9697
when(mockActivityPluginBinding.getActivity()).thenReturn(mockActivity);
9798

@@ -1149,10 +1150,40 @@ public void clearCredentialState_reportsFailure() {
11491150
callbackCaptor.getValue().onError(mock(ClearCredentialException.class));
11501151
}
11511152

1153+
@Test
1154+
public void revokeAccess_callsClient() {
1155+
final List<String> scopes = new ArrayList<>(List.of("openid"));
1156+
final String accountEmail = "[email protected]";
1157+
PlatformRevokeAccessRequest params = new PlatformRevokeAccessRequest(accountEmail, scopes);
1158+
when(mockAuthorizationClient.revokeAccess(any())).thenReturn(mockVoidTask);
1159+
plugin.revokeAccess(
1160+
params,
1161+
ResultCompat.asCompatCallback(
1162+
reply -> {
1163+
return null;
1164+
}));
1165+
1166+
ArgumentCaptor<RevokeAccessRequest> requestCaptor =
1167+
ArgumentCaptor.forClass(RevokeAccessRequest.class);
1168+
verify(mockAuthorizationClient).revokeAccess(requestCaptor.capture());
1169+
1170+
@SuppressWarnings("unchecked")
1171+
ArgumentCaptor<OnSuccessListener<Void>> callbackCaptor =
1172+
ArgumentCaptor.forClass(OnSuccessListener.class);
1173+
verify(mockVoidTask).addOnSuccessListener(callbackCaptor.capture());
1174+
callbackCaptor.getValue().onSuccess(null);
1175+
1176+
RevokeAccessRequest request = requestCaptor.getValue();
1177+
assertEquals(scopes.size(), request.getScopes().size());
1178+
assertEquals(scopes.get(0), request.getScopes().get(0).getScopeUri());
1179+
// Account is mostly opaque, so just verify that one was set.
1180+
assertNotNull(request.getAccount());
1181+
}
1182+
11521183
@Test
11531184
public void clearAuthorizationToken_callsClient() {
11541185
final String testToken = "testToken";
1155-
when(mockAuthorizationClient.clearToken(any())).thenReturn(mockClearTokenTask);
1186+
when(mockAuthorizationClient.clearToken(any())).thenReturn(mockVoidTask);
11561187
plugin.clearAuthorizationToken(
11571188
testToken,
11581189
ResultCompat.asCompatCallback(
@@ -1167,7 +1198,7 @@ public void clearAuthorizationToken_callsClient() {
11671198
@SuppressWarnings("unchecked")
11681199
ArgumentCaptor<OnSuccessListener<Void>> callbackCaptor =
11691200
ArgumentCaptor.forClass(OnSuccessListener.class);
1170-
verify(mockClearTokenTask).addOnSuccessListener(callbackCaptor.capture());
1201+
verify(mockVoidTask).addOnSuccessListener(callbackCaptor.capture());
11711202
callbackCaptor.getValue().onSuccess(null);
11721203

11731204
ClearTokenRequest request = authRequestCaptor.getValue();

packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
2121
String? _serverClientId;
2222
String? _hostedDomain;
2323
String? _nonce;
24+
// A cache of accounts that have been successfully authenticated via this
25+
// plugin instance, and one of the scopes that has been authorized for it.
26+
final Map<String, String> _cachedAccounts = <String, String>{};
2427

2528
/// Registers this class as the default instance of [GoogleSignInPlatform].
2629
static void registerWith() {
@@ -114,10 +117,26 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
114117

115118
@override
116119
Future<void> disconnect(DisconnectParams params) async {
117-
// TODO(stuartmorgan): Implement this once Credential Manager adds the
118-
// necessary API (or temporarily implement it with the deprecated SDK if
119-
// it becomes a significant issue before the API is added).
120-
// https://github.com/flutter/flutter/issues/169612
120+
// AuthorizationClient requires an account, and at least one currently
121+
// granted scope, to request revocation. The app-facing API currently
122+
// does not take any parameters, and is documented to revoke all authorized
123+
// accounts, so disconnect every account that has been authorized.
124+
// TODO(stuartmorgan): Consider deprecating the account-less API at the
125+
// app-facing level, and have it instead be an account-level method, to
126+
// better align with the current SDKs.
127+
for (final MapEntry<String, String> entry in _cachedAccounts.entries) {
128+
// Because revokeAccess removes all authorizations for the app, not just
129+
// the scopes provided, (per
130+
// https://developer.android.com/identity/authorization#revoke-permissions)
131+
// an arbitrary granted scope is used here.
132+
await _hostApi.revokeAccess(
133+
PlatformRevokeAccessRequest(
134+
accountEmail: entry.key,
135+
scopes: <String>[entry.value],
136+
),
137+
);
138+
}
139+
_cachedAccounts.clear();
121140
await signOut(const SignOutParams());
122141
}
123142

@@ -215,6 +234,10 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
215234
details: authnResult.details,
216235
);
217236
case GetCredentialSuccess():
237+
// Store a preliminary entry using the 'openid' scope, which in practice
238+
// always seems to be granted at authentication time, so that an account
239+
// that is authenticated but never authorized can still be disconnected.
240+
_cachedAccounts[authnResult.credential.id] = 'openid';
218241
return authnResult.credential;
219242
}
220243
}
@@ -223,10 +246,11 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
223246
AuthorizationRequestDetails request, {
224247
required bool requestOfflineAccess,
225248
}) async {
249+
final String? email = request.email;
226250
final AuthorizeResult result = await _hostApi.authorize(
227251
PlatformAuthorizationRequest(
228252
scopes: request.scopes,
229-
accountEmail: request.email,
253+
accountEmail: email,
230254
hostedDomain: _hostedDomain,
231255
serverClientIdForForcedRefreshToken:
232256
requestOfflineAccess ? _serverClientId : null,
@@ -263,6 +287,19 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
263287
if (accessToken == null) {
264288
return (accessToken: null, serverAuthCode: null);
265289
}
290+
// Update the account entry with a scope that was reported as granted,
291+
// just in case for some reason 'openid' isn't valid. If the request
292+
// wasn't associated with an account, then it won't be available to
293+
// disconnect.
294+
// TODO(stuartmorgan): If this becomes an issue, see if there is an
295+
// indirect way to get the associated email address that's not
296+
// deprecated.
297+
if (email != null) {
298+
final String? scope = result.grantedScopes.firstOrNull;
299+
if (scope != null) {
300+
_cachedAccounts[email] = scope;
301+
}
302+
}
266303
return (
267304
accessToken: accessToken,
268305
serverAuthCode: result.serverAuthCode,

0 commit comments

Comments
 (0)