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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object, Object> dynamicFields;

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ class SignInConfigurationService : Service() {
}

private fun getAuthOptions(packageName: String): Set<String>? {
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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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<String?, GoogleSignInAccount?> {
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<String?, GoogleSignInAccount?> {
val authManager = getOAuthManager(context, packageName, options, account, includeGrantedScopes)
var authResponse = withContext(Dispatchers.IO) {
if (options?.includeUnacceptableScope == true || !permitted) {
authManager.setTokenRequestOptions(consentRequestOptions)
Expand All @@ -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
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 10 additions & 0 deletions play-services-core/src/main/res/drawable/ic_delete.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM8.46,11.88l1.41,-1.41L12,12.59l2.12,-2.12 1.41,1.41L13.41,14l2.12,2.12 -1.41,1.41L12,15.41l-2.12,2.12 -1.41,-1.41L10.59,14l-2.13,-2.12zM15.5,4l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_delete"
android:contentDescription="@null"
android:importantForAccessibility="no" />
8 changes: 8 additions & 0 deletions play-services-core/src/main/res/navigation/nav_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,21 @@
<action
android:id="@+id/openGameManagerSettings"
app:destination="@id/gameManagerFragment" />
<action
android:id="@+id/openPasskeyManagerSettings"
app:destination="@id/passkeyManagerFragment" />
</fragment>

<fragment
android:id="@+id/gameManagerFragment"
android:name="org.microg.gms.ui.GameProfileFragment"
android:label="@string/pref_game_accounts_title"/>

<fragment
android:id="@+id/passkeyManagerFragment"
android:name="org.microg.gms.ui.PasskeyManagerFragment"
android:label="@string/pref_passkey_manager_title" />

<!-- Device registration -->

<fragment
Expand Down
15 changes: 15 additions & 0 deletions play-services-core/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -403,4 +403,19 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要

<string name="credentials_service_sign_in_with_google_label">用 Google 登录</string>
<string name="credentials_service_remote_custom_subtitle">安全密钥、智能手机或平板</string>

<!-- Passkey management -->
<string name="pref_passkey_manager_title">管理通行密钥</string>
<string name="pref_passkey_manager_empty_title">暂无已保存的通行密钥</string>
<string name="pref_passkey_manager_description">第三方应用注册通行密钥时,microG 会在本地保存对应记录。若应用内删除密钥时未同步通知 microG,本地会留下残留记录,可在此手动清理。</string>
<string name="pref_passkey_manager_delete_dialog_title">删除通行密钥</string>
<string name="pref_passkey_manager_delete_dialog_message">即将删除\"%1$s\"上账号\"%2$s\"的通行密钥。删除后该密钥无法恢复,下次登录需重新注册。</string>
<string name="pref_passkey_manager_delete_dialog_confirm">删除</string>
<string name="pref_passkey_manager_unknown_user">未知用户</string>
<string name="pref_passkey_manager_transport_screen_lock">屏幕锁</string>
<string name="pref_passkey_manager_transport_usb">USB</string>
<string name="pref_passkey_manager_transport_nfc">NFC</string>
<string name="pref_passkey_manager_transport_bluetooth">蓝牙</string>
<string name="pref_passkey_manager_transport_hybrid">混合</string>
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
</resources>
Loading
Loading