Skip to content
Open
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 @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
)
Expand All @@ -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)
}
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 @@ -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
}
Expand Down