From a46b32f515a4c132c38025e32f4ead3663aec5ff Mon Sep 17 00:00:00 2001 From: DaVinci9196 <150454414+DaVinci9196@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:12:46 +0800 Subject: [PATCH 1/2] Settings: Added Passkey Management Page (#3445) Co-authored-by: Marvin W --- .../org/microg/gms/ui/AccountsFragment.kt | 7 + .../microg/gms/ui/PasskeyManagerFragment.kt | 157 ++++++++++++++++++ .../src/main/res/drawable/ic_delete.xml | 10 ++ .../main/res/layout/widget_passkey_delete.xml | 7 + .../src/main/res/navigation/nav_settings.xml | 8 + .../src/main/res/values-zh-rCN/strings.xml | 15 ++ .../src/main/res/values-zh-rTW/strings.xml | 14 ++ .../src/main/res/values/strings.xml | 15 ++ .../res/xml/preferences_passkey_manager.xml | 23 +++ .../org/microg/gms/fido/core/Database.kt | 43 +++++ .../screenlock/ScreenLockCredentialStore.kt | 15 ++ .../gms/fido/core/ui/AuthenticatorActivity.kt | 21 +++ 12 files changed, 335 insertions(+) create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/ui/PasskeyManagerFragment.kt create mode 100644 play-services-core/src/main/res/drawable/ic_delete.xml create mode 100644 play-services-core/src/main/res/layout/widget_passkey_delete.xml create mode 100644 play-services-core/src/main/res/xml/preferences_passkey_manager.xml diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt index 8acb796e44..aff7807ad8 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt @@ -142,6 +142,7 @@ class AccountsFragment : PreferenceFragmentCompat() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { menu.add(0, MENU_GAMES_MANAGED, 0, org.microg.gms.base.core.R.string.menu_game_managed) + menu.add(0, MENU_PASSKEY_MANAGER, 1, R.string.pref_passkey_manager_title) super.onCreateOptionsMenu(menu, inflater) } @@ -152,11 +153,17 @@ class AccountsFragment : PreferenceFragmentCompat() { true } + MENU_PASSKEY_MANAGER -> { + findNavController().navigate(requireContext(), R.id.openPasskeyManagerSettings) + true + } + else -> super.onOptionsItemSelected(item) } } companion object { private const val MENU_GAMES_MANAGED = Menu.FIRST + private const val MENU_PASSKEY_MANAGER = Menu.FIRST + 1 } } diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/PasskeyManagerFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/PasskeyManagerFragment.kt new file mode 100644 index 0000000000..f3fa28807c --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/PasskeyManagerFragment.kt @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.ui + +import android.content.Context +import android.os.Bundle +import android.text.format.DateUtils +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import com.google.android.gms.R +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.microg.gms.fido.core.Database +import org.microg.gms.fido.core.KnownRegistration +import org.microg.gms.fido.core.transport.Transport +import org.microg.gms.fido.core.transport.screenlock.ScreenLockCredentialStore +import org.microg.gms.profile.Build + +class PasskeyManagerFragment : PreferenceFragmentCompat() { + + private lateinit var category: PreferenceCategory + private lateinit var emptyPlaceholder: Preference + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_passkey_manager) + category = preferenceScreen.findPreference(PREFCAT_PASSKEYS) ?: return + emptyPlaceholder = preferenceScreen.findPreference(PREF_PASSKEYS_NONE) ?: return + } + + override fun onResume() { + super.onResume() + updateContent() + } + + private fun updateContent() { + val ctx = requireContext().applicationContext + lifecycleScope.launchWhenResumed { + val list = withContext(Dispatchers.IO) { + runCatching { Database(ctx).getAllKnownRegistrations() } + .onFailure { Log.w(TAG, "Failed to load passkeys", it) } + .getOrDefault(emptyList()) + } + category.removeAll() + if (list.isEmpty()) { + category.addPreference(emptyPlaceholder) + } else { + list.forEachIndexed { index, item -> + category.addPreference(buildPasskeyPreference(ctx, item, index)) + } + } + } + } + + private fun buildPasskeyPreference(ctx: Context, item: KnownRegistration, order: Int): Preference = + Preference(ctx).apply { + key = "pref_passkey_${item.rpId}_${item.credentialId}" + this.order = order + isIconSpaceReserved = false + widgetLayoutResource = R.layout.widget_passkey_delete + title = item.rpId + summary = buildSummary(ctx, item) + setOnPreferenceClickListener { + confirmDelete(item) + true + } + } + + private fun confirmDelete(item: KnownRegistration) { + val displayUser = formatPasskeyUser(requireContext(), item.userJson) + AlertDialog.Builder(requireContext()) + .setTitle(R.string.pref_passkey_manager_delete_dialog_title) + .setMessage(getString(R.string.pref_passkey_manager_delete_dialog_message, item.rpId, displayUser)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.pref_passkey_manager_delete_dialog_confirm) { _, _ -> + performDelete(item) + } + .show() + } + + private fun performDelete(item: KnownRegistration) { + val ctx = requireContext().applicationContext + lifecycleScope.launchWhenResumed { + val ok = withContext(Dispatchers.IO) { + runCatching { + if (Build.VERSION.SDK_INT >= 23) { + val keyId = Base64.decode(item.credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + ScreenLockCredentialStore(ctx).deleteKey(item.rpId, keyId) + } + Database(ctx).deleteKnownRegistration(item.rpId, item.credentialId) + }.onFailure { Log.w(TAG, "Failed to delete passkey", it) }.isSuccess + } + Log.d(TAG, "performDelete ok? = $ok") + updateContent() + } + } + + companion object { + private const val TAG = "PasskeyManager" + private const val PREFCAT_PASSKEYS = "prefcat_passkeys" + private const val PREF_PASSKEYS_NONE = "pref_passkeys_none" + } +} + +internal fun formatPasskeyUser(context: Context, userJson: String?): String { + if (userJson.isNullOrBlank()) return context.getString(R.string.pref_passkey_manager_unknown_user) + return try { + val entity = PublicKeyCredentialUserEntity.parseJson(userJson) + val displayName = entity.displayName?.takeIf { it.isNotBlank() } + val name = entity.name?.takeIf { it.isNotBlank() } + when { + displayName != null && name != null && displayName != name -> "$displayName ($name)" + displayName != null -> displayName + name != null -> name + else -> context.getString(R.string.pref_passkey_manager_unknown_user) + } + } catch (e: Exception) { + context.getString(R.string.pref_passkey_manager_unknown_user) + } +} + +private fun buildSummary(context: Context, item: KnownRegistration): String { + val user = formatPasskeyUser(context, item.userJson) + val time = DateUtils.getRelativeTimeSpanString( + item.timestamp, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS + ).toString() + val transportLabel = context.getString(transportLabelRes(item.transport)) + val credId = context.getString( + R.string.pref_passkey_manager_credential_id_format_internal, + truncateCredentialId(item.credentialId) + ) + return "$user\n$time · $transportLabel\n$credId" +} + +private fun transportLabelRes(transport: Transport): Int = when (transport) { + Transport.SCREEN_LOCK -> R.string.pref_passkey_manager_transport_screen_lock + Transport.USB -> R.string.pref_passkey_manager_transport_usb + Transport.NFC -> R.string.pref_passkey_manager_transport_nfc + Transport.BLUETOOTH -> R.string.pref_passkey_manager_transport_bluetooth + Transport.HYBRID -> R.string.pref_passkey_manager_transport_hybrid +} + +private fun truncateCredentialId(id: String): String { + if (id.length <= 16) return id + return id.substring(0, 6) + "…" + id.substring(id.length - 6) +} diff --git a/play-services-core/src/main/res/drawable/ic_delete.xml b/play-services-core/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000000..4db9470c46 --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/play-services-core/src/main/res/layout/widget_passkey_delete.xml b/play-services-core/src/main/res/layout/widget_passkey_delete.xml new file mode 100644 index 0000000000..693c630bc4 --- /dev/null +++ b/play-services-core/src/main/res/layout/widget_passkey_delete.xml @@ -0,0 +1,7 @@ + + diff --git a/play-services-core/src/main/res/navigation/nav_settings.xml b/play-services-core/src/main/res/navigation/nav_settings.xml index 7aacdbf48f..b1b11dd439 100644 --- a/play-services-core/src/main/res/navigation/nav_settings.xml +++ b/play-services-core/src/main/res/navigation/nav_settings.xml @@ -56,6 +56,9 @@ + + + 用 Google 登录 安全密钥、智能手机或平板 + + + 管理通行密钥 + 暂无已保存的通行密钥 + 第三方应用注册通行密钥时,microG 会在本地保存对应记录。若应用内删除密钥时未同步通知 microG,本地会留下残留记录,可在此手动清理。 + 删除通行密钥 + 即将删除\"%1$s\"上账号\"%2$s\"的通行密钥。删除后该密钥无法恢复,下次登录需重新注册。 + 删除 + 未知用户 + 屏幕锁 + USB + NFC + 蓝牙 + 混合 + ID: %1$s diff --git a/play-services-core/src/main/res/values-zh-rTW/strings.xml b/play-services-core/src/main/res/values-zh-rTW/strings.xml index 1ece62ea27..ea8d2a9c68 100644 --- a/play-services-core/src/main/res/values-zh-rTW/strings.xml +++ b/play-services-core/src/main/res/values-zh-rTW/strings.xml @@ -406,4 +406,18 @@ 確定要刪除此帳戶嗎? 為確保您已安裝的應用程式正常運作,請授權 microG Companion 安裝來自其他來源的應用程式。 與您分享位置的人始終可看到:\n·您的姓名和相片\n·您裝置的近期位置,即使您不在使用 Google 服務\n·您裝置的電量以及是否正在充電\n·您的抵達和離開時間(若他們新增位置分享通知) + + 管理通行密鑰 + 尚無已儲存的通行密鑰 + 第三方應用註冊通行密鑰時,microG 會在本地保存對應記錄。若應用內刪除密鑰時未同步通知 microG,本地會留下殘留記錄,可在此手動清理。 + 刪除通行密鑰 + 即將刪除\"%1$s\"上帳號\"%2$s\"的通行密鑰。刪除後該密鑰無法復原,下次登入需重新註冊。 + 刪除 + 未知使用者 + 螢幕鎖 + USB + NFC + 藍牙 + 混合 + ID: %1$s diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml index ed08fecd18..48d05db18b 100644 --- a/play-services-core/src/main/res/values/strings.xml +++ b/play-services-core/src/main/res/values/strings.xml @@ -460,4 +460,19 @@ Please set up a password, PIN, or pattern lock screen." Turn off Enable Location Sharing People you share your location with can always see:\n·Your name and photo\n·Your device\'s recent location,even when you\'re not using a Google service\n·Your device\'s battery power,and if it\'s charging\n·Your arrival and departure time,if they add a Location Sharing notification + + + Manage passkeys + No saved passkeys + When third-party apps register passkeys, microG keeps a local record. If an app deletes a passkey without notifying microG, the record can remain stranded. You can remove such residual entries here. + Delete passkey + Delete passkey for \"%2$s\" on \"%1$s\". This cannot be undone; you will need to register a new passkey to sign in again. + Delete + Unknown user + Screen lock + USB + NFC + Bluetooth + Hybrid + ID: %1$s diff --git a/play-services-core/src/main/res/xml/preferences_passkey_manager.xml b/play-services-core/src/main/res/xml/preferences_passkey_manager.xml new file mode 100644 index 0000000000..0327b29b5f --- /dev/null +++ b/play-services-core/src/main/res/xml/preferences_passkey_manager.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt index 7a1133fd86..aaf0449cb7 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt @@ -16,6 +16,14 @@ import androidx.core.database.getStringOrNull import org.microg.gms.fido.core.transport.Transport import org.microg.gms.fido.core.ui.TAG +data class KnownRegistration( + val rpId: String, + val credentialId: String, + val userJson: String?, + val transport: Transport, + val timestamp: Long +) + class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VERSION) { fun isPrivileged(packageName: String, signatureDigest: String): Boolean = readableDatabase.use { @@ -57,6 +65,41 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE result } + fun getAllKnownRegistrations(): List = readableDatabase.use { db -> + val cursor = db.query( + TABLE_KNOWN_REGISTRATIONS, + arrayOf(COLUMN_RP_ID, COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT, COLUMN_TIMESTAMP), + null, null, null, null, + "$COLUMN_TIMESTAMP DESC" + ) + val result = mutableListOf() + cursor.use { c -> + while (c.moveToNext()) { + val rpId = c.getStringOrNull(0) ?: continue + val credentialId = c.getStringOrNull(1) ?: continue + val userJson = c.getStringOrNull(2) + val transportName = c.getStringOrNull(3) ?: continue + val timestamp = c.getLongOrNull(4) ?: 0L + val transport = try { + Transport.valueOf(transportName) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Skipping registration with unknown transport: $transportName") + continue + } + result.add(KnownRegistration(rpId, credentialId, userJson, transport, timestamp)) + } + } + result + } + + fun deleteKnownRegistration(rpId: String, credentialId: String): Int = writableDatabase.use { db -> + db.delete( + TABLE_KNOWN_REGISTRATIONS, + "$COLUMN_RP_ID = ? AND $COLUMN_CREDENTIAL_ID = ?", + arrayOf(rpId, credentialId) + ) + } + fun insertPrivileged(packageName: String, signatureDigest: String) = writableDatabase.use { it.insertWithOnConflict(TABLE_PRIVILEGED_APPS, null, ContentValues().apply { put(COLUMN_PACKAGE_NAME, packageName) diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt index b42f5fb8a8..dbc0ce40a4 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt @@ -105,6 +105,21 @@ class ScreenLockCredentialStore(val context: Context) { fun containsKey(rpId: String, keyId: ByteArray): Boolean = keyStore.containsAlias(getAlias(rpId, keyId)) + fun deleteKey(rpId: String, keyId: ByteArray): Boolean { + val alias = getAlias(rpId, keyId) + return try { + if (keyStore.containsAlias(alias)) { + keyStore.deleteEntry(alias) + true + } else { + false + } + } catch (e: Exception) { + Log.w(TAG, "deleteKey failed for alias $alias", e) + false + } + } + companion object { const val TAG = "FidoLockStore" } diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt index b55e38d98f..7a8af68e6e 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt @@ -12,9 +12,13 @@ import android.os.Build.VERSION.SDK_INT import android.os.Bundle import android.util.Base64 import android.util.Log +import android.util.TypedValue +import android.view.View import android.widget.Toast import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.NavHostFragment @@ -178,6 +182,8 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { } } + runCatching { setAuthenticatorUiBackgroundOpaque() } + val arguments = AuthenticatorActivityFragmentData().apply { this.appName = appName this.isFirst = true @@ -336,6 +342,21 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { return shouldStartTransportInstantly(SCREEN_LOCK) } + @RequiresApi(21) + private fun setAuthenticatorUiBackgroundOpaque() { + // FIXME: When we migrate to bottom sheet, revisit all the theming matters + val value = TypedValue() + val backgroundColor = if (theme.resolveAttribute(android.R.attr.colorBackground, value, true)) { + if (value.resourceId != 0) ContextCompat.getColor(this, value.resourceId) else value.data + } else { + Color.WHITE + } + window.setBackgroundDrawable(backgroundColor.toDrawable()) + window.statusBarColor = backgroundColor + window.navigationBarColor = backgroundColor + findViewById(R.id.fragment_container)?.setBackgroundColor(backgroundColor) + } + @RequiresApi(24) fun startTransportHandling(transport: Transport, instant: Boolean = false, pinRequested: Boolean = false, authenticatorPin: String? = null, credentialIdString: String? = null): Job = lifecycleScope.launchWhenResumed { val options = options ?: return@launchWhenResumed From 44255145099c4dddef94b50564f011ff3908d84a Mon Sep 17 00:00:00 2001 From: DaVinci9196 <150454414+DaVinci9196@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:14:34 +0800 Subject: [PATCH 2/2] Auth: Improve AuthorizationService (#3421) --- .../java/org/microg/gms/auth/AuthRequest.java | 7 + .../java/org/microg/gms/auth/AuthManager.java | 2 + .../identity/AuthorizationService.kt | 267 ++++++++++++------ .../identity/IdentitySignInService.kt | 3 +- .../auth/signin/SignInConfigurationService.kt | 4 +- .../org/microg/gms/auth/signin/extensions.kt | 25 +- 6 files changed, 209 insertions(+), 99 deletions(-) diff --git a/play-services-base/core/src/main/java/org/microg/gms/auth/AuthRequest.java b/play-services-base/core/src/main/java/org/microg/gms/auth/AuthRequest.java index 302b80f843..b7a4b82a54 100644 --- a/play-services-base/core/src/main/java/org/microg/gms/auth/AuthRequest.java +++ b/play-services-base/core/src/main/java/org/microg/gms/auth/AuthRequest.java @@ -94,6 +94,8 @@ public class AuthRequest extends HttpFormClient.Request { public String oauth2IncludeProfile; @RequestContent("oauth2_include_email") public String oauth2IncludeEmail; + @RequestContent("include_granted_scopes") + public String includeGrantedScopes; @HttpFormClient.RequestContentDynamic public Map dynamicFields; @@ -238,6 +240,11 @@ public AuthRequest oauth2IncludeEmail(String oauth2IncludeEmail) { return this; } + public AuthRequest includeGrantedScopes(String includeGrantedScopes) { + this.includeGrantedScopes = includeGrantedScopes; + return this; + } + public AuthRequest oauth2Prompt(String oauth2Prompt) { this.oauth2Prompt = oauth2Prompt; return this; diff --git a/play-services-core/src/main/java/org/microg/gms/auth/AuthManager.java b/play-services-core/src/main/java/org/microg/gms/auth/AuthManager.java index 9b1d8760c1..a517e8f845 100644 --- a/play-services-core/src/main/java/org/microg/gms/auth/AuthManager.java +++ b/play-services-core/src/main/java/org/microg/gms/auth/AuthManager.java @@ -54,6 +54,7 @@ public class AuthManager { private String tokenRequestOptions; public String includeEmail; public String includeProfile; + public String includeGrantedScopes; public boolean isGmsApp; public boolean ignoreStoredPermission = false; public boolean forceRefreshToken = false; @@ -339,6 +340,7 @@ public AuthResponse requestAuth(boolean legacy) throws IOException { .oauth2Prompt(oauth2Prompt) .oauth2IncludeProfile(includeProfile) .oauth2IncludeEmail(includeEmail) + .includeGrantedScopes(includeGrantedScopes) .itCaveatTypes(itCaveatTypes) .tokenRequestOptions(tokenRequestOptions) .systemPartition(isSystemApp()) 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..31ac662b19 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,201 @@ 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 includeGrantedScopes = if (request.offlineAccess) "0" else "1" + val (accessToken, signInAccount) = performSignIn(context, packageName, options, account, false, includeGrantedScopes = includeGrantedScopes) + 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 } 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 c764050a32..01cd931994 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 @@ -70,34 +70,39 @@ val consentRequestOptions: String? Base64.encodeToString(requestOptions.encode(), Base64.DEFAULT) }.getOrNull() -fun getOAuthManager(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account): AuthManager { +fun getOAuthManager(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account, includeGrantedScopes: String? = null): AuthManager { val scopes = options?.scopes.orEmpty().sortedBy { it.scopeUri }.toMutableList().apply { if (options?.includeGame == true) { add(Scope(Scopes.GAMES_LITE)) } } - return AuthManager(context, account.name, packageName, "oauth2:${scopes.joinToString(" ")}") + return AuthManager(context, account.name, packageName, "oauth2:${scopes.joinToString(" ")}").also { + it.includeGrantedScopes = includeGrantedScopes ?: "1" + } } fun getCookiesManager(context: Context, packageName: String, account: Account): AuthManager { return AuthManager(context, account.name, packageName, "weblogin:url=https://accounts.google.com") } -fun getIdTokenManager(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account): AuthManager? { +fun getIdTokenManager(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account, includeGrantedScopes: String? = null): AuthManager? { if (options?.isIdTokenRequested != true || options.serverClientId == null) return null val idTokenManager = AuthManager(context, account.name, packageName, "audience:server:client_id:${options.serverClientId}") idTokenManager.includeEmail = if (options.includeEmail) "1" else "0" idTokenManager.includeProfile = if (options.includeProfile) "1" else "0" + idTokenManager.includeGrantedScopes = includeGrantedScopes ?: "0" return idTokenManager } -fun getServerAuthTokenManager(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account): AuthManager? { +fun getServerAuthTokenManager(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account, includeGrantedScopes: String? = null): AuthManager? { if (options?.isServerAuthCodeRequested != true || options.serverClientId == null) return null 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.includeGrantedScopes = includeGrantedScopes ?: "1" + serverAuthTokenManager.setOauth2Prompt(if (options.isForceCodeForRefreshToken) "consent" else "auto") serverAuthTokenManager.setItCaveatTypes("2") return serverAuthTokenManager } @@ -109,8 +114,8 @@ suspend fun checkAccountAuthStatus(context: Context, packageName: String, scopeL return withContext(Dispatchers.IO) { authManager.requestAuth(true) }.auth != null } -suspend fun performSignIn(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account, permitted: Boolean = false, idNonce: String? = null): Pair { - val authManager = getOAuthManager(context, packageName, options, account) +suspend fun performSignIn(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account, permitted: Boolean = false, idNonce: String? = null, includeGrantedScopes: String? = null): Pair { + val authManager = getOAuthManager(context, packageName, options, account, includeGrantedScopes) var authResponse = withContext(Dispatchers.IO) { if (options?.includeUnacceptableScope == true || !permitted) { authManager.setTokenRequestOptions(consentRequestOptions) @@ -129,7 +134,7 @@ suspend fun performSignIn(context: Context, packageName: String, options: Google } if (authResponse.auth == null) return Pair(null, null) Log.d(TAG, "id token requested: ${options?.isIdTokenRequested == true}, serverClientId = ${options?.serverClientId}, permitted = ${authManager.isPermitted}") - val idTokenResponse = getIdTokenManager(context, packageName, options, account)?.let { + val idTokenResponse = getIdTokenManager(context, packageName, options, account, includeGrantedScopes)?.let { if (idNonce != null) { it.setTokenRequestOptions(Base64.encodeToString(RequestOptions.build { remote = 1 @@ -141,7 +146,7 @@ suspend fun performSignIn(context: Context, packageName: String, options: Google consentResult?.let { result -> it.putDynamicFiled(CONSENT_RESULT, result) } withContext(Dispatchers.IO) { it.requestAuth(true) } } - val serverAuthTokenResponse = getServerAuthTokenManager(context, packageName, options, account)?.let { + val serverAuthTokenResponse = getServerAuthTokenManager(context, packageName, options, account, includeGrantedScopes)?.let { it.isPermitted = authResponse.auth != null consentResult?.let { result -> it.putDynamicFiled(CONSENT_RESULT, result) } withContext(Dispatchers.IO) { it.requestAuth(true) }