From 282ff7d08618046a0b7b242a50c1a8598f8c9932 Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Wed, 22 Apr 2026 14:50:00 +0800 Subject: [PATCH 1/2] Auth: Improve AuthorizationService --- .../identity/AuthorizationService.kt | 266 ++++++++++++------ .../identity/IdentitySignInService.kt | 3 +- .../auth/signin/SignInConfigurationService.kt | 4 +- 3 files changed, 184 insertions(+), 89 deletions(-) diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/AuthorizationService.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/AuthorizationService.kt index 286b543485..40e852ce80 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/AuthorizationService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/AuthorizationService.kt @@ -5,6 +5,7 @@ package org.microg.gms.auth.credentials.identity +import android.accounts.Account import android.accounts.AccountManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE @@ -27,6 +28,7 @@ import com.google.android.gms.auth.api.identity.internal.IVerifyWithGoogleCallba import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.auth.api.signin.internal.SignInConfiguration import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.api.CommonStatusCodes import com.google.android.gms.common.api.Scope import com.google.android.gms.common.api.Status import com.google.android.gms.common.api.internal.IStatusCallback @@ -35,11 +37,15 @@ import com.google.android.gms.common.internal.GetServiceRequest import com.google.android.gms.common.internal.IGmsCallbacks import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request import org.microg.gms.BaseService import org.microg.gms.auth.AuthConstants import org.microg.gms.auth.credentials.FEATURES import org.microg.gms.auth.signin.AuthSignInActivity import org.microg.gms.auth.signin.SignInConfigurationService +import org.microg.gms.auth.signin.checkAccountAuthStatus import org.microg.gms.auth.signin.getOAuthManager import org.microg.gms.auth.signin.getServerAuthTokenManager import org.microg.gms.auth.signin.performSignIn @@ -48,18 +54,19 @@ import org.microg.gms.common.AccountUtils import org.microg.gms.common.Constants import org.microg.gms.common.GmsService import org.microg.gms.common.PackageUtils +import java.util.Locale +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger private const val TAG = "AuthorizationService" +private const val REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke" class AuthorizationService : BaseService(TAG, GmsService.AUTH_API_IDENTITY_AUTHORIZATION) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { Log.d(TAG, "handleServiceRequest start ") - val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) - ?: throw IllegalArgumentException("Missing package name") - val connectionInfo = ConnectionInfo() - connectionInfo.features = FEATURES + val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) ?: throw IllegalArgumentException("Missing package name") + val connectionInfo = ConnectionInfo().apply { features = FEATURES } callback.onPostInitCompleteWithConnectionInfo( ConnectionResult.SUCCESS, AuthorizationServiceImpl(this, packageName, this.lifecycle).asBinder(), connectionInfo ) @@ -68,115 +75,200 @@ class AuthorizationService : BaseService(TAG, GmsService.AUTH_API_IDENTITY_AUTHO class AuthorizationServiceImpl(val context: Context, val packageName: String, override val lifecycle: Lifecycle) : IAuthorizationService.Stub(), LifecycleOwner { - companion object{ + companion object { private val nextRequestCode = AtomicInteger(0) + private val httpClient: OkHttpClient by lazy { + OkHttpClient.Builder().connectTimeout(10, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build() + } } override fun authorize(callback: IAuthorizationCallback?, request: AuthorizationRequest?) { - Log.d(TAG, "Method: authorize called, packageName:$packageName request:$request") + Log.d(TAG, "authorize called, packageName=$packageName request=$request") lifecycleScope.launchWhenStarted { - val requestAccount = request?.account - val account = requestAccount ?: AccountUtils.get(context).getSelectedAccount(packageName) - val googleSignInOptions = GoogleSignInOptions.Builder().apply { - request?.requestedScopes?.forEach { requestScopes(it) } - if (request?.idTokenRequested == true && request.serverClientId != null) { - if (account?.name != requestAccount?.name) { - requestEmail().requestProfile() - } - requestIdToken(request.serverClientId) - } - if (request?.serverAuthCodeRequested == true && request.serverClientId != null) requestServerAuthCode(request.serverClientId, request.forceCodeForRefreshToken) - }.build() - Log.d(TAG, "authorize: account: ${account?.name}") - val result = if (account != null) { - val (accessToken, signInAccount) = performSignIn(context, packageName, googleSignInOptions, account, false) - if (requestAccount != null) { - AccountUtils.get(context).saveSelectedAccount(packageName, requestAccount) - } - AuthorizationResult( - signInAccount?.serverAuthCode, - accessToken, - signInAccount?.idToken, - signInAccount?.grantedScopes?.toList().orEmpty().map { it.scopeUri }, - signInAccount, - null - ) - } else { - val options = GoogleSignInOptions.Builder(googleSignInOptions).apply { - val defaultAccount = SignInConfigurationService.getDefaultAccount(context, packageName) - defaultAccount?.name?.let { setAccountName(it) } - }.build() - val intent = Intent(context, AuthSignInActivity::class.java).apply { - `package` = Constants.GMS_PACKAGE_NAME - putExtra("config", SignInConfiguration(packageName, options)) - } - AuthorizationResult( - null, - null, - null, - request?.requestedScopes.orEmpty().map { it.scopeUri }, - null, - PendingIntent.getActivity(context, nextRequestCode.incrementAndGet(), intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) - ) + try { + val result = performAuthorize(request) + Log.d(TAG, "authorize resolved: ${if (result.pendingIntent != null) "pendingIntent" else "silent"}, grantedScopes=${result.grantedScopes.size}") + runCatching { callback?.onAuthorized(Status.SUCCESS, result) } + } catch (e: InvalidAccountException) { + Log.w(TAG, "authorize: invalid account", e) + runCatching { callback?.onAuthorized(Status(CommonStatusCodes.INVALID_ACCOUNT), null) } + } catch (e: Exception) { + Log.w(TAG, "authorize failed, falling back to PendingIntent", e) + runCatching { callback?.onAuthorized(Status.SUCCESS, buildPendingIntentResult(request)) } } - runCatching { - callback?.onAuthorized(Status.SUCCESS, result.also { Log.d(TAG, "authorize: result:$it") }) + } + } + + private suspend fun performAuthorize(request: AuthorizationRequest?): AuthorizationResult { + require(request?.requestedScopes?.isNotEmpty() == true) { "requestedScopes cannot be null or empty" } + + val requestAccount = request!!.account + val candidate = requestAccount ?: AccountUtils.get(context).getSelectedAccount(packageName) ?: SignInConfigurationService.getDefaultAccount(context, packageName) + if (candidate == null || request.forceCodeForRefreshToken) { + return buildPendingIntentResult(request) + } + + val account = AccountManager.get(context).getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE).firstOrNull { it == candidate } ?: run { + AccountUtils.get(context).removeSelectedAccount(packageName) + return buildPendingIntentResult(request) + } + + val hostedDomain = request.hostedDomainFilter + if (!hostedDomain.isNullOrEmpty() && !account.name.lowercase(Locale.ROOT).endsWith("@${hostedDomain.lowercase(Locale.ROOT)}")) { + throw InvalidAccountException("account ${account.name} does not match hostedDomainFilter=$hostedDomain") + } + + val crossAccount = requestAccount != null && account.name != requestAccount.name + val options = buildSignInOptions(request, crossAccount) + val (accessToken, signInAccount) = performSignIn(context, packageName, options, account, false) + if (accessToken == null || signInAccount == null) { + return buildPendingIntentResult(request) + } + + if (requestAccount != null) { + AccountUtils.get(context).saveSelectedAccount(packageName, requestAccount) + } + + return AuthorizationResult( + signInAccount.serverAuthCode, + accessToken, + signInAccount.idToken, + signInAccount.grantedScopes.toList().map { it.scopeUri }, + signInAccount, + null, + ) + } + + private fun buildSignInOptions(request: AuthorizationRequest, crossAccount: Boolean): GoogleSignInOptions { + return GoogleSignInOptions.Builder().apply { + request.requestedScopes?.forEach { requestScopes(it) } + val clientId = request.serverClientId + if (request.idTokenRequested && clientId != null) { + if (crossAccount) requestEmail().requestProfile() + requestIdToken(clientId) + } + if (request.serverAuthCodeRequested && clientId != null) { + requestServerAuthCode(clientId, request.forceCodeForRefreshToken) + } + }.build() + } + + private suspend fun buildPendingIntentResult(request: AuthorizationRequest?): AuthorizationResult { + val defaultAccountName = SignInConfigurationService.getDefaultAccount(context, packageName)?.name + val options = GoogleSignInOptions.Builder().apply { + request?.requestedScopes?.forEach { requestScopes(it) } + val clientId = request?.serverClientId + if (request?.idTokenRequested == true && clientId != null) { + requestEmail().requestProfile().requestIdToken(clientId) } + if (request?.serverAuthCodeRequested == true && clientId != null) { + requestServerAuthCode(clientId, request.forceCodeForRefreshToken) + } + defaultAccountName?.let { setAccountName(it) } + }.build() + val intent = Intent(context, AuthSignInActivity::class.java).apply { + `package` = Constants.GMS_PACKAGE_NAME + putExtra("config", SignInConfiguration(packageName, options)) } + val pendingIntent = PendingIntent.getActivity( + context, + nextRequestCode.incrementAndGet(), + intent, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE, + ) + return AuthorizationResult( + null, null, null, + request?.requestedScopes.orEmpty().map { it.scopeUri }, + null, + pendingIntent, + ) } override fun verifyWithGoogle(callback: IVerifyWithGoogleCallback?, request: VerifyWithGoogleRequest?) { - Log.d(TAG, "unimplemented Method: verifyWithGoogle: request:$request") + Log.d(TAG, "verifyWithGoogle called, request=$request") lifecycleScope.launchWhenStarted { - val account = AccountUtils.get(context).getSelectedAccount(packageName) ?: SignInConfigurationService.getDefaultAccount(context, packageName) - if (account == null) { - Log.d(TAG, "Method: authorize called, but account is null") - callback?.onVerifed(Status.CANCELED, null) - return@launchWhenStarted + val result = runCatching { performVerify(request) }.onFailure { Log.w(TAG, "verifyWithGoogle failed", it) }.getOrNull() + val status = if (result != null) Status.SUCCESS else Status.CANCELED + runCatching { callback?.onVerifed(status, result) } + } + } + + private suspend fun performVerify(request: VerifyWithGoogleRequest?): VerifyWithGoogleResult? { + val req = request?.takeIf { it.requestedScopes?.isNotEmpty() == true } ?: return null + val account = AccountUtils.get(context).getSelectedAccount(packageName) ?: SignInConfigurationService.getDefaultAccount(context, packageName) ?: return null + + val options = GoogleSignInOptions.Builder().apply { + req.requestedScopes?.forEach { requestScopes(it) } + if (req.offlineAccess && req.serverClientId != null) { + requestServerAuthCode(req.serverClientId) } - if (request?.offlineAccess == true && request.serverClientId != null) { - val googleSignInOptions = GoogleSignInOptions.Builder().apply { - request.requestedScopes?.forEach { requestScopes(it) } - requestServerAuthCode(request.serverClientId) - }.build() - val authResponse = getServerAuthTokenManager(context, packageName, googleSignInOptions, account)?.let { - withContext(Dispatchers.IO) { it.requestAuth(true) } - } - callback?.onVerifed(Status.SUCCESS, VerifyWithGoogleResult().apply { - serverAuthToken = authResponse?.auth - grantedScopes = authResponse?.grantedScopes?.split(" ")?.map { Scope(it) }?.toList() ?: googleSignInOptions.scopeUris.toList() - }) - return@launchWhenStarted + }.build() + + if (req.offlineAccess && req.serverClientId != null) { + val authResponse = getServerAuthTokenManager(context, packageName, options, account)?.let { + withContext(Dispatchers.IO) { it.requestAuth(true) } + } ?: return null + if (authResponse.auth == null) return null + return VerifyWithGoogleResult().apply { + serverAuthToken = authResponse.auth + grantedScopes = authResponse.grantedScopes?.split(" ")?.map { Scope(it) } ?: options.scopeUris.toList() } - callback?.onVerifed(Status.CANCELED, null) } + + val granted = checkAccountAuthStatus(context, packageName, options.scopes.toList(), account) + if (!granted) return null + return VerifyWithGoogleResult().apply { grantedScopes = options.scopeUris.toList() } } override fun revokeAccess(callback: IStatusCallback?, request: RevokeAccessRequest?) { - Log.d(TAG, "Method: revokeAccess called, request:$request") + Log.d(TAG, "revokeAccess called, request=$request") lifecycleScope.launchWhenStarted { + runCatching { performRevoke(request) }.onFailure { Log.w(TAG, "revokeAccess failed", it) } + runCatching { callback?.onResult(Status.SUCCESS) } + } + } + + private suspend fun performRevoke(request: RevokeAccessRequest?) { + val account: Account? = request?.account + ?: AccountUtils.get(context).getSelectedAccount(packageName) + ?: SignInConfigurationService.getDefaultAccount(context, packageName) + + if (account != null) { val authOptions = SignInConfigurationService.getAuthOptions(context, packageName) - val authAccount = request?.account - if (authOptions.isNotEmpty() && authAccount != null) { - val authManager = getOAuthManager(context, packageName, authOptions.first(), authAccount) - val token = authManager.peekAuthToken() - if (token != null) { - // todo "https://oauth2.googleapis.com/revoke" - authManager.invalidateAuthToken(token) - authManager.isPermitted = false - } + for (options in authOptions) { + val authManager = getOAuthManager(context, packageName, options, account) + val token = authManager.peekAuthToken() ?: continue + runCatching { revokeTokenRemotely(token) }.onFailure { Log.w(TAG, "remote revoke failed (continuing local invalidate)", it) } + authManager.invalidateAuthToken(token) + authManager.isPermitted = false + } + } + + AccountUtils.get(context).removeSelectedAccount(packageName) + SignInConfigurationService.setAuthInfo(context, packageName, null, null) + } + + private suspend fun revokeTokenRemotely(token: String) { + withContext(Dispatchers.IO) { + val body = FormBody.Builder().add("token", token).build() + val request = Request.Builder().url(REVOKE_ENDPOINT).post(body).build() + httpClient.newCall(request).execute().use { response -> + Log.d(TAG, "revoke endpoint status=${response.code}") } - AccountUtils.get(context).removeSelectedAccount(packageName) - runCatching { callback?.onResult(Status.SUCCESS) } } } override fun clearToken(callback: IStatusCallback?, request: ClearTokenRequest?) { - Log.d(TAG, "Method: clearToken called, request:$request") - request?.token?.let { - AccountManager.get(context).invalidateAuthToken(AuthConstants.DEFAULT_ACCOUNT_TYPE, it) + Log.d(TAG, "clearToken called, request=$request") + lifecycleScope.launchWhenStarted { + runCatching { + request?.token?.takeIf { it.isNotEmpty() }?.let { + AccountManager.get(context).invalidateAuthToken(AuthConstants.DEFAULT_ACCOUNT_TYPE, it) + } + }.onFailure { Log.w(TAG, "clearToken failed", it) } + runCatching { callback?.onResult(Status.SUCCESS) } } - runCatching { callback?.onResult(Status.SUCCESS) } } + private class InvalidAccountException(message: String) : Exception(message) } \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/IdentitySignInService.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/IdentitySignInService.kt index 815c3c4b60..bde915b130 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/IdentitySignInService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/credentials/identity/IdentitySignInService.kt @@ -143,8 +143,9 @@ class IdentitySignInServiceImpl(private val context: Context, private val client } } AccountUtils.get(context).removeSelectedAccount(clientPackageName) + SignInConfigurationService.setAuthInfo(context, clientPackageName, null, null) + callback.onResult(Status.SUCCESS) } - callback.onResult(Status.SUCCESS) } override fun getSignInIntent( diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/SignInConfigurationService.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/SignInConfigurationService.kt index 8035f323fc..557e5e0b11 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/SignInConfigurationService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/SignInConfigurationService.kt @@ -96,7 +96,9 @@ class SignInConfigurationService : Service() { } private fun getAuthOptions(packageName: String): Set? { - val data = preferences.getStringSet(DEFAULT_SIGN_IN_OPTIONS_PREFIX + getPackageNameSuffix(packageName), null) + val key = DEFAULT_SIGN_IN_OPTIONS_PREFIX + getPackageNameSuffix(packageName) + val data = runCatching { preferences.getStringSet(key, null) }.getOrNull() + ?: runCatching { preferences.getString(key, null) }.getOrNull()?.let { setOf(it) } if (data.isNullOrEmpty()) return null return data } From 27353b3757b785c25f1b8e70361ac1b38cbb991a Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Thu, 30 Apr 2026 11:37:11 +0800 Subject: [PATCH 2/2] 'ServerAuthToken' must be single-use --- .../src/main/kotlin/org/microg/gms/auth/signin/extensions.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt index f2d62ad334..0008437499 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt @@ -94,8 +94,9 @@ fun getServerAuthTokenManager(context: Context, packageName: String, options: Go val serverAuthTokenManager = AuthManager(context, account.name, packageName, "oauth2:server:client_id:${options.serverClientId}:api_scope:${options.scopeUris.joinToString(" ")}") serverAuthTokenManager.includeEmail = if (options.includeEmail) "1" else "0" serverAuthTokenManager.includeProfile = if (options.includeProfile) "1" else "0" - serverAuthTokenManager.forceRefreshToken = options.isForceCodeForRefreshToken - serverAuthTokenManager.setOauth2Prompt("auto") + // authorization codes must be single-use + serverAuthTokenManager.forceRefreshToken = true + serverAuthTokenManager.setOauth2Prompt(if (options.isForceCodeForRefreshToken) "consent" else "auto") serverAuthTokenManager.setItCaveatTypes("2") return serverAuthTokenManager }