Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/google_sign_in/google_sign_in_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 7.2.0

* Adds support for `disconnect`.

## 7.1.0

* Adds support for the `clearAuthorizationToken` method.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import com.google.android.gms.auth.api.identity.AuthorizationResult;
import com.google.android.gms.auth.api.identity.ClearTokenRequest;
import com.google.android.gms.auth.api.identity.Identity;
import com.google.android.gms.auth.api.identity.RevokeAccessRequest;
import com.google.android.gms.common.api.ApiException;
import com.google.android.gms.common.api.Scope;
import com.google.android.libraries.identity.googleid.GetGoogleIdOption;
Expand All @@ -59,6 +60,9 @@ public class GoogleSignInPlugin implements FlutterPlugin, ActivityAware {
private @Nullable BinaryMessenger messenger;
private ActivityPluginBinding activityPluginBinding;

// The account type to use to create an Account object for a Google Sign In account.
private static final String GOOGLE_ACCOUNT_TYPE = "com.google";

private void initInstance(@NonNull BinaryMessenger messenger, @NonNull Context context) {
initWithDelegate(
messenger,
Expand Down Expand Up @@ -384,7 +388,7 @@ public void authorize(
}
if (params.getAccountEmail() != null) {
authorizationRequestBuilder.setAccount(
new Account(params.getAccountEmail(), "com.google"));
new Account(params.getAccountEmail(), GOOGLE_ACCOUNT_TYPE));
}
AuthorizationRequest authorizationRequest = authorizationRequestBuilder.build();
authorizationClientFactory
Expand Down Expand Up @@ -455,6 +459,28 @@ public void authorize(
}
}

@Override
public void revokeAccess(
@NonNull PlatformRevokeAccessRequest params,
@NonNull Function1<? super Result<Unit>, Unit> callback) {
List<Scope> scopes = new ArrayList<>();
for (String scope : params.getScopes()) {
scopes.add(new Scope(scope));
}
authorizationClientFactory
.create(context)
.revokeAccess(
RevokeAccessRequest.builder()
.setAccount(new Account(params.getAccountEmail(), GOOGLE_ACCOUNT_TYPE))
.setScopes(scopes)
.build())
.addOnSuccessListener(unused -> ResultUtilsKt.completeWithUnitSuccess(callback))
.addOnFailureListener(
e ->
ResultUtilsKt.completeWithUnitError(
callback, new FlutterError("revokeAccess failed", e.getMessage(), null)));
}

@Override
public boolean onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == REQUEST_CODE_AUTHORIZE) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,53 @@ data class GetCredentialRequestGoogleIdOptionParams(
override fun hashCode(): Int = toList().hashCode()
}

/**
* Parameters for revoking authorization.
*
* Corresponds to the native RevokeAccessRequest.
* https://developers.google.com/android/reference/com/google/android/gms/auth/api/identity/RevokeAccessRequest
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class PlatformRevokeAccessRequest(
/** The email for the Google account to revoke authorizations for. */
val accountEmail: String,
/**
* A list of requested scopes.
*
* Per docs, all granted scopes will be revoked, not only the ones passed here. However, at
* least one currently-granted scope must be provided.
*/
val scopes: List<String>
) {
companion object {
fun fromList(pigeonVar_list: List<Any?>): PlatformRevokeAccessRequest {
val accountEmail = pigeonVar_list[0] as String
val scopes = pigeonVar_list[1] as List<String>
return PlatformRevokeAccessRequest(accountEmail, scopes)
}
}

fun toList(): List<Any?> {
return listOf(
accountEmail,
scopes,
)
}

override fun equals(other: Any?): Boolean {
if (other !is PlatformRevokeAccessRequest) {
return false
}
if (this === other) {
return true
}
return MessagesPigeonUtils.deepEquals(toList(), other.toList())
}

override fun hashCode(): Int = toList().hashCode()
}

/**
* Pigeon equivalent of the native GoogleIdTokenCredential.
*
Expand Down Expand Up @@ -525,20 +572,23 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { PlatformRevokeAccessRequest.fromList(it) }
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformGoogleIdTokenCredential.fromList(it)
}
}
135.toByte() -> {
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { GetCredentialFailure.fromList(it) }
}
136.toByte() -> {
137.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { GetCredentialSuccess.fromList(it) }
}
137.toByte() -> {
138.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { AuthorizeFailure.fromList(it) }
}
138.toByte() -> {
139.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { PlatformAuthorizationResult.fromList(it) }
}
else -> super.readValueOfType(type, buffer)
Expand Down Expand Up @@ -567,26 +617,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(133)
writeValue(stream, value.toList())
}
is PlatformGoogleIdTokenCredential -> {
is PlatformRevokeAccessRequest -> {
stream.write(134)
writeValue(stream, value.toList())
}
is GetCredentialFailure -> {
is PlatformGoogleIdTokenCredential -> {
stream.write(135)
writeValue(stream, value.toList())
}
is GetCredentialSuccess -> {
is GetCredentialFailure -> {
stream.write(136)
writeValue(stream, value.toList())
}
is AuthorizeFailure -> {
is GetCredentialSuccess -> {
stream.write(137)
writeValue(stream, value.toList())
}
is PlatformAuthorizationResult -> {
is AuthorizeFailure -> {
stream.write(138)
writeValue(stream, value.toList())
}
is PlatformAuthorizationResult -> {
stream.write(139)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
Expand Down Expand Up @@ -615,6 +669,8 @@ interface GoogleSignInApi {
callback: (Result<AuthorizeResult>) -> Unit
)

fun revokeAccess(params: PlatformRevokeAccessRequest, callback: (Result<Unit>) -> Unit)

companion object {
/** The codec used by GoogleSignInApi. */
val codec: MessageCodec<Any?> by lazy { MessagesPigeonCodec() }
Expand Down Expand Up @@ -742,6 +798,29 @@ interface GoogleSignInApi {
channel.setMessageHandler(null)
}
}
run {
val channel =
BasicMessageChannel<Any?>(
binaryMessenger,
"dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.revokeAccess$separatedMessageChannelSuffix",
codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val paramsArg = args[0] as PlatformRevokeAccessRequest
api.revokeAccess(paramsArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
reply.reply(MessagesPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import com.google.android.gms.auth.api.identity.AuthorizationRequest;
import com.google.android.gms.auth.api.identity.AuthorizationResult;
import com.google.android.gms.auth.api.identity.ClearTokenRequest;
import com.google.android.gms.auth.api.identity.RevokeAccessRequest;
import com.google.android.gms.common.api.ApiException;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.tasks.OnSuccessListener;
Expand Down Expand Up @@ -72,7 +73,7 @@ public class GoogleSignInTest {
@Mock CustomCredential mockGenericCredential;
@Mock GoogleIdTokenCredential mockGoogleCredential;
@Mock Task<AuthorizationResult> mockAuthorizationTask;
@Mock Task<Void> mockClearTokenTask;
@Mock Task<Void> mockVoidTask;

private GoogleSignInPlugin flutterPlugin;
// Technically this is not the plugin, but in practice almost all of the functionality is in this
Expand All @@ -90,8 +91,8 @@ public void setUp() {
.thenReturn(GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL);
when(mockAuthorizationTask.addOnSuccessListener(any())).thenReturn(mockAuthorizationTask);
when(mockAuthorizationTask.addOnFailureListener(any())).thenReturn(mockAuthorizationTask);
when(mockClearTokenTask.addOnSuccessListener(any())).thenReturn(mockClearTokenTask);
when(mockClearTokenTask.addOnFailureListener(any())).thenReturn(mockClearTokenTask);
when(mockVoidTask.addOnSuccessListener(any())).thenReturn(mockVoidTask);
when(mockVoidTask.addOnFailureListener(any())).thenReturn(mockVoidTask);
when(mockAuthorizationIntent.getIntentSender()).thenReturn(mockAuthorizationIntentSender);
when(mockActivityPluginBinding.getActivity()).thenReturn(mockActivity);

Expand Down Expand Up @@ -1149,10 +1150,40 @@ public void clearCredentialState_reportsFailure() {
callbackCaptor.getValue().onError(mock(ClearCredentialException.class));
}

@Test
public void revokeAccess_callsClient() {
final List<String> scopes = new ArrayList<>(List.of("openid"));
final String accountEmail = "[email protected]";
PlatformRevokeAccessRequest params = new PlatformRevokeAccessRequest(accountEmail, scopes);
when(mockAuthorizationClient.revokeAccess(any())).thenReturn(mockVoidTask);
plugin.revokeAccess(
params,
ResultCompat.asCompatCallback(
reply -> {
return null;
}));

ArgumentCaptor<RevokeAccessRequest> requestCaptor =
ArgumentCaptor.forClass(RevokeAccessRequest.class);
verify(mockAuthorizationClient).revokeAccess(requestCaptor.capture());

@SuppressWarnings("unchecked")
ArgumentCaptor<OnSuccessListener<Void>> callbackCaptor =
ArgumentCaptor.forClass(OnSuccessListener.class);
verify(mockVoidTask).addOnSuccessListener(callbackCaptor.capture());
callbackCaptor.getValue().onSuccess(null);

RevokeAccessRequest request = requestCaptor.getValue();
assertEquals(scopes.size(), request.getScopes().size());
assertEquals(scopes.get(0), request.getScopes().get(0).getScopeUri());
// Account is mostly opaque, so just verify that one was set.
assertNotNull(request.getAccount());
}

@Test
public void clearAuthorizationToken_callsClient() {
final String testToken = "testToken";
when(mockAuthorizationClient.clearToken(any())).thenReturn(mockClearTokenTask);
when(mockAuthorizationClient.clearToken(any())).thenReturn(mockVoidTask);
plugin.clearAuthorizationToken(
testToken,
ResultCompat.asCompatCallback(
Expand All @@ -1167,7 +1198,7 @@ public void clearAuthorizationToken_callsClient() {
@SuppressWarnings("unchecked")
ArgumentCaptor<OnSuccessListener<Void>> callbackCaptor =
ArgumentCaptor.forClass(OnSuccessListener.class);
verify(mockClearTokenTask).addOnSuccessListener(callbackCaptor.capture());
verify(mockVoidTask).addOnSuccessListener(callbackCaptor.capture());
callbackCaptor.getValue().onSuccess(null);

ClearTokenRequest request = authRequestCaptor.getValue();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
String? _serverClientId;
String? _hostedDomain;
String? _nonce;
// A cache of accounts that have been successfully authenticated via this
// plugin instance, and one of the scopes that has been authorized for it.
final Map<String, String> _cachedAccounts = <String, String>{};

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

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

Expand Down Expand Up @@ -215,6 +234,10 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
details: authnResult.details,
);
case GetCredentialSuccess():
// Store a preliminary entry using the 'openid' scope, which in practice
// always seems to be granted at authentication time, so that an account
// that is authenticated but never authorized can still be disconnected.
_cachedAccounts[authnResult.credential.id] = 'openid';
return authnResult.credential;
}
}
Expand All @@ -223,10 +246,11 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
AuthorizationRequestDetails request, {
required bool requestOfflineAccess,
}) async {
final String? email = request.email;
final AuthorizeResult result = await _hostApi.authorize(
PlatformAuthorizationRequest(
scopes: request.scopes,
accountEmail: request.email,
accountEmail: email,
hostedDomain: _hostedDomain,
serverClientIdForForcedRefreshToken:
requestOfflineAccess ? _serverClientId : null,
Expand Down Expand Up @@ -263,6 +287,19 @@ class GoogleSignInAndroid extends GoogleSignInPlatform {
if (accessToken == null) {
return (accessToken: null, serverAuthCode: null);
}
// Update the account entry with a scope that was reported as granted,
// just in case for some reason 'openid' isn't valid. If the request
// wasn't associated with an account, then it won't be available to
// disconnect.
// TODO(stuartmorgan): If this becomes an issue, see if there is an
// indirect way to get the associated email address that's not
// deprecated.
if (email != null) {
final String? scope = result.grantedScopes.firstOrNull;
if (scope != null) {
_cachedAccounts[email] = scope;
}
}
return (
accessToken: accessToken,
serverAuthCode: result.serverAuthCode,
Expand Down
Loading