Auth: Improve AuthorizationService (#3421)

This commit is contained in:
DaVinci9196 2026-07-08 04:14:34 +08:00 committed by GitHub
parent a46b32f515
commit 4425514509
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 215 additions and 105 deletions

View file

@ -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;
@ -238,6 +240,11 @@ public class AuthRequest extends HttpFormClient.Request {
return this;
}
public AuthRequest includeGrantedScopes(String includeGrantedScopes) {
this.includeGrantedScopes = includeGrantedScopes;
return this;
}
public AuthRequest oauth2Prompt(String oauth2Prompt) {
this.oauth2Prompt = oauth2Prompt;
return this;

View file

@ -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 class AuthManager {
.oauth2Prompt(oauth2Prompt)
.oauth2IncludeProfile(includeProfile)
.oauth2IncludeEmail(includeEmail)
.includeGrantedScopes(includeGrantedScopes)
.itCaveatTypes(itCaveatTypes)
.tokenRequestOptions(tokenRequestOptions)
.systemPartition(isSystemApp())

View file

@ -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)
)
}
runCatching {
callback?.onAuthorized(Status.SUCCESS, result.also { Log.d(TAG, "authorize: result:$it") })
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)) }
}
}
}
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
}
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
}
callback?.onVerifed(Status.CANCELED, null)
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) }
}
}
override fun revokeAccess(callback: IStatusCallback?, request: RevokeAccessRequest?) {
Log.d(TAG, "Method: revokeAccess called, request:$request")
lifecycleScope.launchWhenStarted {
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
}
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)
}
AccountUtils.get(context).removeSelectedAccount(packageName)
}.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()
}
}
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, "revokeAccess called, request=$request")
lifecycleScope.launchWhenStarted {
runCatching { performRevoke(request) }.onFailure { Log.w(TAG, "revokeAccess failed", it) }
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)
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)
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
}
}
runCatching { callback?.onResult(Status.SUCCESS) }
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}")
}
}
}
override fun clearToken(callback: IStatusCallback?, request: ClearTokenRequest?) {
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) }
}
}
private class InvalidAccountException(message: String) : Exception(message)
}

View file

@ -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(

View file

@ -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
}

View file

@ -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<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)
@ -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) }