mirror of https://github.com/tasks/tasks
Replace Google Sign In with App Auth
parent
47189e0466
commit
26a4b16235
@ -0,0 +1,11 @@
|
||||
{
|
||||
"client_id": "1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf.apps.googleusercontent.com",
|
||||
"redirect_uri": "com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf:/oauth2redirect",
|
||||
"authorization_scope": "openid email profile",
|
||||
"discovery_uri": "https://accounts.google.com/.well-known/openid-configuration",
|
||||
"authorization_endpoint_uri": "",
|
||||
"token_endpoint_uri": "",
|
||||
"registration_endpoint_uri": "",
|
||||
"user_info_endpoint_uri": "",
|
||||
"https_required": true
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
package org.tasks.auth
|
||||
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||
|
||||
class GoogleSignInAccount(
|
||||
private val account: GoogleSignInAccount
|
||||
) : OauthSignIn {
|
||||
override val id: String?
|
||||
get() = account.id
|
||||
|
||||
override val idToken: String?
|
||||
get() = account.idToken
|
||||
|
||||
override val email: String?
|
||||
get() = account.email
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
package org.tasks.auth
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.tasks.R
|
||||
import org.tasks.analytics.Firebase
|
||||
import org.tasks.billing.PurchaseDialog.Companion.FRAG_TAG_PURCHASE_DIALOG
|
||||
import org.tasks.billing.PurchaseDialog.Companion.newPurchaseDialog
|
||||
import org.tasks.caldav.CaldavClientProvider
|
||||
import org.tasks.data.CaldavAccount
|
||||
import org.tasks.gtasks.PlayServices
|
||||
import org.tasks.injection.InjectingAppCompatActivity
|
||||
import org.tasks.ui.Toaster
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SignInActivity : InjectingAppCompatActivity() {
|
||||
|
||||
@Inject lateinit var toaster: Toaster
|
||||
@Inject lateinit var provider: CaldavClientProvider
|
||||
@Inject lateinit var playServices: PlayServices
|
||||
@Inject lateinit var firebase: Firebase
|
||||
|
||||
val viewModel: SignInViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.observe(this, this::onSignIn, this::onError)
|
||||
|
||||
lifecycleScope.launch {
|
||||
playServices
|
||||
.getSignedInAccount()
|
||||
?.let { validate(it) }
|
||||
?: startActivityForResult(playServices.signInIntent, RC_SIGN_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSignIn(account: CaldavAccount?) {
|
||||
account?.let { toaster.longToast(getString(R.string.logged_in, it.name)) }
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun onError(t: Throwable) {
|
||||
if (t is HttpException && t.code == 402) {
|
||||
newPurchaseDialog(true).show(supportFragmentManager, FRAG_TAG_PURCHASE_DIALOG)
|
||||
} else {
|
||||
firebase.reportException(t)
|
||||
toaster.longToast(t.message)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == RC_SIGN_IN) {
|
||||
playServices
|
||||
.signInFromIntent(data)
|
||||
?.let { validate(it) }
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(account: OauthSignIn) = lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewModel.validate(account.id!!, account.email!!, account.idToken!!)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RC_SIGN_IN = 10000
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
package org.tasks.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.lifecycle.ViewModelInject
|
||||
import com.todoroo.astrid.helper.UUIDHelper
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.tasks.R
|
||||
import org.tasks.caldav.CaldavClientProvider
|
||||
import org.tasks.data.CaldavAccount
|
||||
import org.tasks.data.CaldavAccount.Companion.TYPE_TASKS
|
||||
import org.tasks.data.CaldavDao
|
||||
import org.tasks.ui.CompletableViewModel
|
||||
|
||||
class SignInViewModel @ViewModelInject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val provider: CaldavClientProvider,
|
||||
private val caldavDao: CaldavDao
|
||||
) : CompletableViewModel<CaldavAccount?>() {
|
||||
|
||||
suspend fun validate(id: String, email: String, idToken: String) {
|
||||
run {
|
||||
val homeSet = provider
|
||||
.forUrl(
|
||||
"${context.getString(R.string.tasks_caldav_url)}/google_login",
|
||||
token = idToken
|
||||
)
|
||||
.setForeground()
|
||||
.homeSet(token = idToken)
|
||||
val username = "google_$id"
|
||||
caldavDao.getAccount(TYPE_TASKS, username)
|
||||
?.apply {
|
||||
error = null
|
||||
caldavDao.update(this)
|
||||
}
|
||||
?: CaldavAccount().apply {
|
||||
accountType = TYPE_TASKS
|
||||
uuid = UUIDHelper.newUUID()
|
||||
url = homeSet
|
||||
this.username = username
|
||||
name = email
|
||||
caldavDao.insert(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2017 The AppAuth for Android Authors. All Rights Reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the
|
||||
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
|
||||
* express or implied. See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.tasks.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.openid.appauth.*
|
||||
import org.json.JSONException
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthStateManager @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
STORE_NAME,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
private val prefsLock = ReentrantLock()
|
||||
private val currentAuthState = AtomicReference<AuthState>()
|
||||
|
||||
val current: AuthState
|
||||
get() {
|
||||
if (currentAuthState.get() != null) {
|
||||
return currentAuthState.get()
|
||||
}
|
||||
val state = readState()
|
||||
return if (currentAuthState.compareAndSet(null, state)) {
|
||||
state
|
||||
} else {
|
||||
currentAuthState.get()
|
||||
}
|
||||
}
|
||||
|
||||
fun replace(state: AuthState): AuthState {
|
||||
writeState(state)
|
||||
currentAuthState.set(state)
|
||||
return state
|
||||
}
|
||||
|
||||
fun updateAfterAuthorization(
|
||||
response: AuthorizationResponse?,
|
||||
ex: AuthorizationException?
|
||||
): AuthState {
|
||||
val current = current
|
||||
current.update(response, ex)
|
||||
return replace(current)
|
||||
}
|
||||
|
||||
fun updateAfterTokenResponse(
|
||||
response: TokenResponse?,
|
||||
ex: AuthorizationException?
|
||||
): AuthState {
|
||||
val current = current
|
||||
current.update(response, ex)
|
||||
return replace(current)
|
||||
}
|
||||
|
||||
fun updateAfterRegistration(
|
||||
response: RegistrationResponse?,
|
||||
ex: AuthorizationException?
|
||||
): AuthState {
|
||||
val current = current
|
||||
if (ex != null) {
|
||||
return current
|
||||
}
|
||||
current.update(response)
|
||||
return replace(current)
|
||||
}
|
||||
|
||||
private fun readState(): AuthState {
|
||||
prefsLock.lock()
|
||||
return try {
|
||||
val currentState = prefs.getString(KEY_STATE, null)
|
||||
?: return AuthState()
|
||||
try {
|
||||
AuthState.jsonDeserialize(currentState)
|
||||
} catch (ex: JSONException) {
|
||||
Timber.w("Failed to deserialize stored auth state - discarding")
|
||||
AuthState()
|
||||
}
|
||||
} finally {
|
||||
prefsLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeState(state: AuthState?) {
|
||||
prefsLock.lock()
|
||||
try {
|
||||
val editor = prefs.edit()
|
||||
if (state == null) {
|
||||
editor.remove(KEY_STATE)
|
||||
} else {
|
||||
editor.putString(KEY_STATE, state.jsonSerializeString())
|
||||
}
|
||||
check(editor.commit()) { "Failed to write state to shared prefs" }
|
||||
} finally {
|
||||
prefsLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STORE_NAME = "AuthState"
|
||||
private const val KEY_STATE = "state"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package org.tasks.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.*
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.openid.appauth.browser.AnyBrowserMatcher
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class AuthorizationService @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val authStateManager: AuthStateManager,
|
||||
configuration: Configuration
|
||||
) {
|
||||
private val authorizationService = AuthorizationService(
|
||||
context,
|
||||
AppAuthConfiguration.Builder()
|
||||
.setBrowserMatcher(AnyBrowserMatcher.INSTANCE)
|
||||
.setConnectionBuilder(configuration.connectionBuilder)
|
||||
.build())
|
||||
|
||||
fun dispose() {
|
||||
authorizationService.dispose()
|
||||
}
|
||||
|
||||
fun getAuthorizationRequestIntent(
|
||||
request: AuthorizationRequest,
|
||||
customTabsIntent: CustomTabsIntent
|
||||
): Intent {
|
||||
return authorizationService.getAuthorizationRequestIntent(request, customTabsIntent)
|
||||
}
|
||||
|
||||
fun createCustomTabsIntent(uri: Uri, color: Int): CustomTabsIntent {
|
||||
return authorizationService
|
||||
.createCustomTabsIntentBuilder(uri)
|
||||
.setToolbarColor(color)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun performRegistrationRequest(
|
||||
request: RegistrationRequest,
|
||||
callback: (RegistrationResponse?, AuthorizationException?) -> Unit
|
||||
) {
|
||||
authorizationService.performRegistrationRequest(request, callback)
|
||||
}
|
||||
|
||||
suspend fun performTokenRequest(request: TokenRequest, clientAuthentication: ClientAuthentication): TokenResponse? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
suspendCoroutine { cont ->
|
||||
authorizationService.performTokenRequest(request, clientAuthentication) { response, exception ->
|
||||
if (exception != null) {
|
||||
cont.resumeWith(Result.failure(exception))
|
||||
} else {
|
||||
cont.resumeWith(Result.success(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFreshToken(): String? = withContext(Dispatchers.IO) {
|
||||
suspendCoroutine { cont ->
|
||||
authStateManager
|
||||
.current
|
||||
.performActionWithFreshTokens(authorizationService) { _, idToken, exception ->
|
||||
if (exception == null) {
|
||||
cont.resumeWith(Result.success(idToken))
|
||||
} else {
|
||||
cont.resumeWith(Result.failure(exception))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
// discard the authorization and token state, but retain the configuration and
|
||||
// dynamic client registration (if applicable), to save from retrieving them again.
|
||||
val currentState = authStateManager.current
|
||||
val clearedState = AuthState(currentState.authorizationServiceConfiguration!!)
|
||||
if (currentState.lastRegistrationResponse != null) {
|
||||
clearedState.update(currentState.lastRegistrationResponse)
|
||||
}
|
||||
authStateManager.replace(clearedState)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 2016 The AppAuth for Android Authors. All Rights Reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the
|
||||
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
|
||||
* express or implied. See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.tasks.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.openid.appauth.connectivity.ConnectionBuilder
|
||||
import net.openid.appauth.connectivity.DefaultConnectionBuilder
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.tasks.R
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads and validates the demo app configuration from `res/raw/auth_config.json`. Configuration
|
||||
* changes are detected by comparing the hash of the last known configuration to the read
|
||||
* configuration. When a configuration change is detected, the app state is reset.
|
||||
*/
|
||||
@Singleton
|
||||
class Configuration @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private var configJson: JSONObject? = null
|
||||
private var configHash: String? = null
|
||||
|
||||
/**
|
||||
* Returns a description of the configuration error, if the configuration is invalid.
|
||||
*/
|
||||
var configurationError: String? = null
|
||||
var clientId: String? = null
|
||||
private set
|
||||
private var mScope: String? = null
|
||||
private var mRedirectUri: Uri? = null
|
||||
var discoveryUri: Uri? = null
|
||||
private set
|
||||
var authEndpointUri: Uri? = null
|
||||
private set
|
||||
var tokenEndpointUri: Uri? = null
|
||||
private set
|
||||
var registrationEndpointUri: Uri? = null
|
||||
private set
|
||||
var userInfoEndpointUri: Uri? = null
|
||||
private set
|
||||
private var isHttpsRequired = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Indicates whether the configuration has changed from the last known valid state.
|
||||
*/
|
||||
fun hasConfigurationChanged(): Boolean {
|
||||
val lastHash = lastKnownConfigHash
|
||||
return configHash != lastHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the current configuration is valid.
|
||||
*/
|
||||
val isValid: Boolean
|
||||
get() = configurationError == null
|
||||
|
||||
/**
|
||||
* Indicates that the current configuration should be accepted as the "last known valid"
|
||||
* configuration.
|
||||
*/
|
||||
fun acceptConfiguration() {
|
||||
prefs.edit().putString(KEY_LAST_HASH, configHash).apply()
|
||||
}
|
||||
|
||||
val scope: String
|
||||
get() = mScope!!
|
||||
|
||||
val redirectUri: Uri
|
||||
get() = mRedirectUri!!
|
||||
|
||||
val connectionBuilder: ConnectionBuilder = DefaultConnectionBuilder.INSTANCE
|
||||
|
||||
private val lastKnownConfigHash: String?
|
||||
get() = prefs.getString(KEY_LAST_HASH, null)
|
||||
|
||||
@Throws(InvalidConfigurationException::class)
|
||||
private fun readConfiguration() {
|
||||
val configSource = context.resources.openRawResource(R.raw.auth_config).source().buffer()
|
||||
val configData = Buffer()
|
||||
configJson = try {
|
||||
configSource.readAll(configData)
|
||||
JSONObject(configData.readString(StandardCharsets.UTF_8))
|
||||
} catch (ex: IOException) {
|
||||
throw InvalidConfigurationException(
|
||||
"Failed to read configuration: " + ex.message)
|
||||
} catch (ex: JSONException) {
|
||||
throw InvalidConfigurationException(
|
||||
"Unable to parse configuration: " + ex.message)
|
||||
}
|
||||
configHash = configData.sha256().base64()
|
||||
clientId = getConfigString("client_id")
|
||||
mScope = getRequiredConfigString("authorization_scope")
|
||||
mRedirectUri = getRequiredConfigUri("redirect_uri")
|
||||
if (!isRedirectUriRegistered) {
|
||||
throw InvalidConfigurationException(
|
||||
"redirect_uri is not handled by any activity in this app! "
|
||||
+ "Ensure that the appAuthRedirectScheme in your build.gradle file "
|
||||
+ "is correctly configured, or that an appropriate intent filter "
|
||||
+ "exists in your app manifest.")
|
||||
}
|
||||
if (getConfigString("discovery_uri") == null) {
|
||||
authEndpointUri = getRequiredConfigWebUri("authorization_endpoint_uri")
|
||||
tokenEndpointUri = getRequiredConfigWebUri("token_endpoint_uri")
|
||||
userInfoEndpointUri = getRequiredConfigWebUri("user_info_endpoint_uri")
|
||||
if (clientId == null) {
|
||||
registrationEndpointUri = getRequiredConfigWebUri("registration_endpoint_uri")
|
||||
}
|
||||
} else {
|
||||
discoveryUri = getRequiredConfigWebUri("discovery_uri")
|
||||
}
|
||||
isHttpsRequired = configJson!!.optBoolean("https_required", true)
|
||||
}
|
||||
|
||||
private fun getConfigString(propName: String?): String? {
|
||||
var value = configJson!!.optString(propName) ?: return null
|
||||
value = value.trim { it <= ' ' }
|
||||
return if (TextUtils.isEmpty(value)) {
|
||||
null
|
||||
} else value
|
||||
}
|
||||
|
||||
@Throws(InvalidConfigurationException::class)
|
||||
private fun getRequiredConfigString(propName: String): String {
|
||||
return getConfigString(propName)
|
||||
?: throw InvalidConfigurationException(
|
||||
"$propName is required but not specified in the configuration")
|
||||
}
|
||||
|
||||
@Throws(InvalidConfigurationException::class)
|
||||
fun getRequiredConfigUri(propName: String): Uri {
|
||||
val uriStr = getRequiredConfigString(propName)
|
||||
val uri: Uri
|
||||
uri = try {
|
||||
Uri.parse(uriStr)
|
||||
} catch (ex: Throwable) {
|
||||
throw InvalidConfigurationException("$propName could not be parsed", ex)
|
||||
}
|
||||
if (!uri.isHierarchical || !uri.isAbsolute) {
|
||||
throw InvalidConfigurationException(
|
||||
"$propName must be hierarchical and absolute")
|
||||
}
|
||||
if (!TextUtils.isEmpty(uri.encodedUserInfo)) {
|
||||
throw InvalidConfigurationException("$propName must not have user info")
|
||||
}
|
||||
if (!TextUtils.isEmpty(uri.encodedQuery)) {
|
||||
throw InvalidConfigurationException("$propName must not have query parameters")
|
||||
}
|
||||
if (!TextUtils.isEmpty(uri.encodedFragment)) {
|
||||
throw InvalidConfigurationException("$propName must not have a fragment")
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
@Throws(InvalidConfigurationException::class)
|
||||
fun getRequiredConfigWebUri(propName: String): Uri {
|
||||
val uri = getRequiredConfigUri(propName)
|
||||
val scheme = uri.scheme
|
||||
if (TextUtils.isEmpty(scheme) || !("http" == scheme || "https" == scheme)) {
|
||||
throw InvalidConfigurationException(
|
||||
"$propName must have an http or https scheme")
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
// ensure that the redirect URI declared in the configuration is handled by some activity
|
||||
// in the app, by querying the package manager speculatively
|
||||
private val isRedirectUriRegistered: Boolean
|
||||
get() {
|
||||
// ensure that the redirect URI declared in the configuration is handled by some activity
|
||||
// in the app, by querying the package manager speculatively
|
||||
val redirectIntent = Intent()
|
||||
redirectIntent.setPackage(context.packageName)
|
||||
redirectIntent.action = Intent.ACTION_VIEW
|
||||
redirectIntent.addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
redirectIntent.data = mRedirectUri
|
||||
return !context.packageManager.queryIntentActivities(redirectIntent, 0).isEmpty()
|
||||
}
|
||||
|
||||
class InvalidConfigurationException : Exception {
|
||||
internal constructor(reason: String?) : super(reason) {}
|
||||
internal constructor(reason: String?, cause: Throwable?) : super(reason, cause) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "config"
|
||||
private const val KEY_LAST_HASH = "lastHash"
|
||||
}
|
||||
|
||||
init {
|
||||
try {
|
||||
readConfiguration()
|
||||
} catch (ex: InvalidConfigurationException) {
|
||||
configurationError = ex.message
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package org.tasks.auth
|
||||
|
||||
interface OauthSignIn {
|
||||
val idToken: String?
|
||||
val email: String?
|
||||
val id: String?
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright 2015 The AppAuth for Android Authors. All Rights Reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the
|
||||
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
|
||||
* express or implied. See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.tasks.auth
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import net.openid.appauth.*
|
||||
import org.tasks.R
|
||||
import org.tasks.injection.InjectingAppCompatActivity
|
||||
import org.tasks.themes.ThemeColor
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors.newSingleThreadExecutor
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Demonstrates the usage of the AppAuth to authorize a user with an OAuth2 / OpenID Connect
|
||||
* provider. Based on the configuration provided in `res/raw/auth_config.json`, the code
|
||||
* contained here will:
|
||||
*
|
||||
* - Retrieve an OpenID Connect discovery document for the provider, or use a local static
|
||||
* configuration.
|
||||
* - Utilize dynamic client registration, if no static client id is specified.
|
||||
* - Initiate the authorization request using the built-in heuristics or a user-selected browser.
|
||||
*
|
||||
* _NOTE_: From a clean checkout of this project, the authorization service is not configured.
|
||||
* Edit `res/values/auth_config.xml` to provide the required configuration properties. See the
|
||||
* README.md in the app/ directory for configuration instructions, and the adjacent IDP-specific
|
||||
* instructions.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class SignInActivity : InjectingAppCompatActivity() {
|
||||
@Inject lateinit var authService: AuthorizationService
|
||||
@Inject lateinit var authStateManager: AuthStateManager
|
||||
@Inject lateinit var configuration: Configuration
|
||||
@Inject lateinit var themeColor: ThemeColor
|
||||
|
||||
private val viewModel: SignInViewModel by viewModels()
|
||||
|
||||
private val mClientId = AtomicReference<String?>()
|
||||
private val mAuthRequest = AtomicReference<AuthorizationRequest>()
|
||||
private val mAuthIntent = AtomicReference<CustomTabsIntent>()
|
||||
private var mAuthIntentLatch = CountDownLatch(1)
|
||||
private val mExecutor: ExecutorService = newSingleThreadExecutor()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (authStateManager.current.isAuthorized &&
|
||||
!configuration.hasConfigurationChanged()) {
|
||||
Timber.i("User is already authenticated, signing out")
|
||||
authService.signOut()
|
||||
}
|
||||
if (!configuration.isValid) {
|
||||
displayError(configuration.configurationError)
|
||||
return
|
||||
}
|
||||
if (configuration.hasConfigurationChanged()) {
|
||||
// discard any existing authorization state due to the change of configuration
|
||||
Timber.i("Configuration change detected, discarding old state")
|
||||
authStateManager.replace(AuthState())
|
||||
configuration.acceptConfiguration()
|
||||
}
|
||||
mExecutor.submit { initializeAppAuth() }
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
mExecutor.shutdownNow()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
authService.dispose()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == RC_AUTH) {
|
||||
if (resultCode == RESULT_OK) {
|
||||
lifecycleScope.launch {
|
||||
viewModel.handleResult(data!!)
|
||||
authStateManager.current.authorizationException?.let { e ->
|
||||
displayError(e.message)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
displayError(getString(R.string.authorization_cancelled))
|
||||
}
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun startAuth() {
|
||||
// WrongThread inference is incorrect for lambdas
|
||||
// noinspection WrongThread
|
||||
mExecutor.submit { doAuth() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the authorization service configuration if necessary, either from the local
|
||||
* static values or by retrieving an OpenID discovery document.
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun initializeAppAuth() {
|
||||
Timber.i("Initializing AppAuth")
|
||||
if (authStateManager.current.authorizationServiceConfiguration != null) {
|
||||
// configuration is already created, skip to client initialization
|
||||
Timber.i("auth config already established")
|
||||
initializeClient()
|
||||
return
|
||||
}
|
||||
|
||||
// if we are not using discovery, build the authorization service configuration directly
|
||||
// from the static configuration values.
|
||||
if (configuration.discoveryUri == null) {
|
||||
Timber.i("Creating auth config from res/raw/auth_config.json")
|
||||
val config = AuthorizationServiceConfiguration(
|
||||
configuration.authEndpointUri!!,
|
||||
configuration.tokenEndpointUri!!,
|
||||
configuration.registrationEndpointUri)
|
||||
authStateManager.replace(AuthState(config))
|
||||
initializeClient()
|
||||
return
|
||||
}
|
||||
|
||||
// WrongThread inference is incorrect for lambdas
|
||||
// noinspection WrongThread
|
||||
Timber.i("Retrieving OpenID discovery doc")
|
||||
AuthorizationServiceConfiguration.fetchFromUrl(
|
||||
configuration.discoveryUri!!, { config: AuthorizationServiceConfiguration?, ex: AuthorizationException? -> handleConfigurationRetrievalResult(config, ex) },
|
||||
configuration.connectionBuilder)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun handleConfigurationRetrievalResult(
|
||||
config: AuthorizationServiceConfiguration?,
|
||||
ex: AuthorizationException?) {
|
||||
if (config == null) {
|
||||
Timber.i(ex, "Failed to retrieve discovery document")
|
||||
displayError("Failed to retrieve discovery document: " + ex!!.message)
|
||||
return
|
||||
}
|
||||
Timber.i("Discovery document retrieved")
|
||||
authStateManager.replace(AuthState(config))
|
||||
mExecutor.submit { initializeClient() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a dynamic registration request if a client ID is not provided by the static
|
||||
* configuration.
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun initializeClient() {
|
||||
if (configuration.clientId != null) {
|
||||
Timber.i("Using static client ID: %s", configuration.clientId)
|
||||
// use a statically configured client ID
|
||||
mClientId.set(configuration.clientId)
|
||||
runOnUiThread { initializeAuthRequest() }
|
||||
return
|
||||
}
|
||||
val lastResponse = authStateManager.current.lastRegistrationResponse
|
||||
if (lastResponse != null) {
|
||||
Timber.i("Using dynamic client ID: %s", lastResponse.clientId)
|
||||
// already dynamically registered a client ID
|
||||
mClientId.set(lastResponse.clientId)
|
||||
runOnUiThread { initializeAuthRequest() }
|
||||
return
|
||||
}
|
||||
|
||||
// WrongThread inference is incorrect for lambdas
|
||||
// noinspection WrongThread
|
||||
Timber.i("Dynamically registering client")
|
||||
val registrationRequest = RegistrationRequest.Builder(
|
||||
authStateManager.current.authorizationServiceConfiguration!!, listOf(configuration.redirectUri))
|
||||
.setTokenEndpointAuthenticationMethod(ClientSecretBasic.NAME)
|
||||
.build()
|
||||
authService.performRegistrationRequest(
|
||||
registrationRequest) { response: RegistrationResponse?, ex: AuthorizationException? -> handleRegistrationResponse(response, ex) }
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun handleRegistrationResponse(
|
||||
response: RegistrationResponse?,
|
||||
ex: AuthorizationException?) {
|
||||
authStateManager.updateAfterRegistration(response, ex)
|
||||
if (response == null) {
|
||||
Timber.i(ex, "Failed to dynamically register client")
|
||||
displayErrorLater("Failed to register client: " + ex!!.message)
|
||||
return
|
||||
}
|
||||
Timber.i("Dynamically registered client: %s", response.clientId)
|
||||
mClientId.set(response.clientId)
|
||||
initializeAuthRequest()
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the authorization request, using the browser selected in the spinner,
|
||||
* and a user-provided `login_hint` if available.
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun doAuth() {
|
||||
try {
|
||||
mAuthIntentLatch.await()
|
||||
} catch (ex: InterruptedException) {
|
||||
Timber.w("Interrupted while waiting for auth intent")
|
||||
}
|
||||
val intent = authService.getAuthorizationRequestIntent(
|
||||
mAuthRequest.get(),
|
||||
mAuthIntent.get())
|
||||
startActivityForResult(intent, RC_AUTH)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun displayError(error: String?) {
|
||||
Timber.e(error)
|
||||
setResult(RESULT_CANCELED, Intent().putExtra(EXTRA_ERROR, error))
|
||||
finish()
|
||||
}
|
||||
|
||||
// WrongThread inference is incorrect in this case
|
||||
@AnyThread
|
||||
private fun displayErrorLater(error: String) {
|
||||
runOnUiThread { displayError(error) }
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun initializeAuthRequest() {
|
||||
createAuthRequest()
|
||||
warmUpBrowser()
|
||||
startAuth()
|
||||
}
|
||||
|
||||
private fun warmUpBrowser() {
|
||||
mAuthIntentLatch = CountDownLatch(1)
|
||||
mExecutor.execute {
|
||||
Timber.i("Warming up browser instance for auth request")
|
||||
mAuthIntent.set(authService.createCustomTabsIntent(
|
||||
mAuthRequest.get().toUri(),
|
||||
themeColor.primaryColor
|
||||
))
|
||||
mAuthIntentLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAuthRequest() {
|
||||
Timber.i("Creating auth request")
|
||||
val authRequestBuilder = AuthorizationRequest.Builder(
|
||||
authStateManager.current.authorizationServiceConfiguration!!,
|
||||
mClientId.get()!!,
|
||||
ResponseTypeValues.CODE,
|
||||
configuration.redirectUri)
|
||||
.setScope(configuration.scope)
|
||||
mAuthRequest.set(authRequestBuilder.build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ERROR = "extra_error"
|
||||
private const val RC_AUTH = 100
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package org.tasks.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import androidx.hilt.lifecycle.ViewModelInject
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.todoroo.astrid.helper.UUIDHelper
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationResponse
|
||||
import net.openid.appauth.ClientAuthentication.UnsupportedAuthenticationMethod
|
||||
import org.json.JSONObject
|
||||
import org.tasks.R
|
||||
import org.tasks.caldav.CaldavClientProvider
|
||||
import org.tasks.data.CaldavAccount
|
||||
import org.tasks.data.CaldavDao
|
||||
import timber.log.Timber
|
||||
|
||||
class SignInViewModel @ViewModelInject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val authStateManager: AuthStateManager,
|
||||
private val authorizationService: AuthorizationService,
|
||||
private val provider: CaldavClientProvider,
|
||||
private val caldavDao: CaldavDao
|
||||
) : ViewModel() {
|
||||
suspend fun handleResult(intent: Intent): CaldavAccount? {
|
||||
val response = AuthorizationResponse.fromIntent(intent)
|
||||
val ex = AuthorizationException.fromIntent(intent)
|
||||
|
||||
if (response != null || ex != null) {
|
||||
authStateManager.updateAfterAuthorization(response, ex)
|
||||
}
|
||||
|
||||
if (response?.authorizationCode != null) {
|
||||
authStateManager.updateAfterAuthorization(response, ex)
|
||||
exchangeAuthorizationCode(response)
|
||||
}
|
||||
val auth = authStateManager.current
|
||||
if (!auth.isAuthorized) {
|
||||
return null
|
||||
}
|
||||
val idToken = auth.idToken
|
||||
val parts: List<String> = idToken!!.split(".")
|
||||
val payloadJson = JSONObject(String(Base64.decode(parts[1], Base64.DEFAULT)))
|
||||
val sub = payloadJson.getString("sub")
|
||||
val username = "google_$sub"
|
||||
val email = payloadJson.getString("email")
|
||||
val homeSet = provider
|
||||
.forUrl(
|
||||
"${context.getString(R.string.tasks_caldav_url)}/google_login",
|
||||
token = idToken
|
||||
)
|
||||
.setForeground()
|
||||
.homeSet(token = idToken)
|
||||
return caldavDao.getAccount(CaldavAccount.TYPE_TASKS, username)
|
||||
?.apply {
|
||||
error = null
|
||||
caldavDao.update(this)
|
||||
}
|
||||
?: CaldavAccount().apply {
|
||||
accountType = CaldavAccount.TYPE_TASKS
|
||||
uuid = UUIDHelper.newUUID()
|
||||
url = homeSet
|
||||
this.username = username
|
||||
name = email
|
||||
caldavDao.insert(this)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) {
|
||||
val request = authorizationResponse.createTokenExchangeRequest()
|
||||
val clientAuthentication = try {
|
||||
authStateManager.current.clientAuthentication
|
||||
} catch (ex: UnsupportedAuthenticationMethod) {
|
||||
throw ex
|
||||
}
|
||||
try {
|
||||
authorizationService.performTokenRequest(request, clientAuthentication)?.let {
|
||||
authStateManager.updateAfterTokenResponse(it, null)
|
||||
if (authStateManager.current.isAuthorized) {
|
||||
Timber.d("Authorization successful")
|
||||
}
|
||||
}
|
||||
} catch (e: AuthorizationException) {
|
||||
authStateManager.updateAfterTokenResponse(null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"client_id": "363426363175-jdrijf7hql9030klgjcjlpi6k5spviif.apps.googleusercontent.com",
|
||||
"redirect_uri": "com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif:/oauth2redirect",
|
||||
"authorization_scope": "openid email profile",
|
||||
"discovery_uri": "https://accounts.google.com/.well-known/openid-configuration",
|
||||
"authorization_endpoint_uri": "",
|
||||
"token_endpoint_uri": "",
|
||||
"registration_endpoint_uri": "",
|
||||
"user_info_endpoint_uri": "",
|
||||
"https_required": true
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<resources>
|
||||
<string name="app_name">Tasks</string>
|
||||
<string name="tasks_caldav_url">https://caldav.tasks.org</string>
|
||||
<string name="google_sign_in" tools:ignore="TypographyDashes">363426363175-op3tqa3qir2qkm7dtj6jr6mp4hudmsgs.apps.googleusercontent.com</string>
|
||||
</resources>
|
||||
Loading…
Reference in New Issue