mirror of https://github.com/tasks/tasks
Add Microsoft authentication
parent
9522c14891
commit
7327572db4
@ -0,0 +1,22 @@
|
||||
package org.tasks.auth
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
|
||||
data class IdentityProvider(
|
||||
val name: String,
|
||||
val discoveryEndpoint: Uri,
|
||||
val clientId: String,
|
||||
val redirectUri: Uri,
|
||||
val scope: String
|
||||
) {
|
||||
companion object {
|
||||
val MICROSOFT = IdentityProvider(
|
||||
"Microsoft",
|
||||
"https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration".toUri(),
|
||||
"9d4babd5-e7ba-4286-ba4b-17274495a901",
|
||||
"msauth://org.tasks/8wnYBRqh5nnQgFzbIXfxXSs41xE%3D".toUri(),
|
||||
"user.read Tasks.ReadWrite openid offline_access email"
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,141 @@
|
||||
package org.tasks.auth
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_LONG
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color.Companion.White
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.todoroo.astrid.helper.UUIDHelper
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationResponse
|
||||
import net.openid.appauth.AuthorizationServiceDiscovery
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import org.tasks.data.CaldavAccount
|
||||
import org.tasks.data.CaldavAccount.Companion.TYPE_MICROSOFT
|
||||
import org.tasks.data.CaldavDao
|
||||
import org.tasks.http.HttpClientFactory
|
||||
import org.tasks.security.KeyStoreEncryption
|
||||
import org.tasks.sync.microsoft.requestTokenExchange
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MicrosoftAuthenticationActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var caldavDao: CaldavDao
|
||||
@Inject lateinit var encryption: KeyStoreEncryption
|
||||
@Inject lateinit var httpClientFactory: HttpClientFactory
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val authState = AuthState(
|
||||
AuthorizationResponse.fromIntent(intent),
|
||||
AuthorizationException.fromIntent(intent)
|
||||
)
|
||||
authState.authorizationException?.let {
|
||||
error(it.message ?: "Authentication failed")
|
||||
return
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
val (resp, ex) = requestTokenExchange(authState.lastAuthorizationResponse!!)
|
||||
authState.update(resp, ex)
|
||||
if (authState.isAuthorized) {
|
||||
val email = getEmail(authState.accessToken) ?: run {
|
||||
error("Failed to fetch profile")
|
||||
return@launch
|
||||
}
|
||||
caldavDao
|
||||
.getAccount(TYPE_MICROSOFT, email)
|
||||
?.let {
|
||||
it.password = encryption.encrypt(authState.jsonSerializeString())
|
||||
caldavDao.update(it)
|
||||
}
|
||||
?: caldavDao.insert(
|
||||
CaldavAccount().apply {
|
||||
uuid = UUIDHelper.newUUID()
|
||||
name = email
|
||||
username = email
|
||||
password = encryption.encrypt(authState.jsonSerializeString())
|
||||
accountType = TYPE_MICROSOFT
|
||||
}
|
||||
)
|
||||
finish()
|
||||
} else {
|
||||
error(ex?.message ?: "Token exchange failed")
|
||||
}
|
||||
}
|
||||
setContent {
|
||||
var showDialog by remember { mutableStateOf(true) }
|
||||
if (showDialog) {
|
||||
Dialog(
|
||||
onDismissRequest = { showDialog = false },
|
||||
DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.background(White, shape = RoundedCornerShape(8.dp))
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getEmail(accessToken: String?): String? {
|
||||
if (accessToken == null) {
|
||||
return null
|
||||
}
|
||||
val discovery = AuthorizationServiceDiscovery(
|
||||
JSONObject(
|
||||
intent.getStringExtra(EXTRA_SERVICE_DISCOVERY)!!
|
||||
)
|
||||
)
|
||||
val userInfo = withContext(Dispatchers.IO) {
|
||||
httpClientFactory
|
||||
.newClient()
|
||||
.newCall(
|
||||
Request.Builder()
|
||||
.url(discovery.userinfoEndpoint!!.toString())
|
||||
.addHeader("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
)
|
||||
.execute()
|
||||
}
|
||||
val response = userInfo.body?.string() ?: return null
|
||||
return JSONObject(response).getString("email")
|
||||
}
|
||||
|
||||
private fun error(message: String) {
|
||||
Toast.makeText(this@MicrosoftAuthenticationActivity, message, LENGTH_LONG).show()
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_SERVICE_DISCOVERY = "extra_service_discovery"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,132 @@
|
||||
package org.tasks.preferences.fragments
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.todoroo.astrid.service.TaskDeleter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.tasks.LocalBroadcastManager
|
||||
import org.tasks.R
|
||||
import org.tasks.billing.Inventory
|
||||
import org.tasks.data.CaldavAccount
|
||||
import org.tasks.data.CaldavAccount.Companion.isPaymentRequired
|
||||
import org.tasks.data.CaldavDao
|
||||
import org.tasks.preferences.IconPreference
|
||||
import org.tasks.sync.microsoft.MicrosoftSignInViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MicrosoftAccount : BaseAccountPreference() {
|
||||
|
||||
@Inject lateinit var taskDeleter: TaskDeleter
|
||||
@Inject lateinit var inventory: Inventory
|
||||
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
|
||||
@Inject lateinit var caldavDao: CaldavDao
|
||||
|
||||
private val microsoftVM: MicrosoftSignInViewModel by viewModels()
|
||||
private lateinit var microsoftAccountLiveData: LiveData<CaldavAccount>
|
||||
|
||||
val microsoftAccount: CaldavAccount
|
||||
get() = microsoftAccountLiveData.value ?: requireArguments().getParcelable(EXTRA_ACCOUNT)!!
|
||||
|
||||
private val purchaseReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
lifecycleScope.launch {
|
||||
microsoftAccount.let {
|
||||
if (inventory.subscription.value != null && it.error.isPaymentRequired()) {
|
||||
it.error = null
|
||||
caldavDao.update(it)
|
||||
}
|
||||
refreshUi(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPreferenceXml() = R.xml.preferences_google_tasks
|
||||
|
||||
override suspend fun setupPreferences(savedInstanceState: Bundle?) {
|
||||
super.setupPreferences(savedInstanceState)
|
||||
|
||||
microsoftAccountLiveData = caldavDao.watchAccount(
|
||||
arguments?.getParcelable<CaldavAccount>(EXTRA_ACCOUNT)?.id ?: 0
|
||||
)
|
||||
microsoftAccountLiveData.observe(this) { refreshUi(it) }
|
||||
|
||||
findPreference(R.string.reinitialize_account)
|
||||
.setOnPreferenceClickListener { requestLogin() }
|
||||
}
|
||||
|
||||
override suspend fun removeAccount() {
|
||||
taskDeleter.delete(microsoftAccount)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
localBroadcastManager.registerPurchaseReceiver(purchaseReceiver)
|
||||
localBroadcastManager.registerRefreshListReceiver(purchaseReceiver)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
localBroadcastManager.unregisterReceiver(purchaseReceiver)
|
||||
}
|
||||
|
||||
private fun refreshUi(account: CaldavAccount?) {
|
||||
if (account == null) {
|
||||
return
|
||||
}
|
||||
(findPreference(R.string.sign_in_with_google) as IconPreference).apply {
|
||||
if (account.error.isNullOrBlank()) {
|
||||
isVisible = false
|
||||
return@apply
|
||||
}
|
||||
isVisible = true
|
||||
when {
|
||||
account.error.isPaymentRequired() -> {
|
||||
setOnPreferenceClickListener { showPurchaseDialog() }
|
||||
setTitle(R.string.name_your_price)
|
||||
setSummary(R.string.requires_pro_subscription)
|
||||
}
|
||||
account.error.isUnauthorized() -> {
|
||||
setTitle(R.string.sign_in)
|
||||
setSummary(R.string.authentication_required)
|
||||
setOnPreferenceClickListener { requestLogin() }
|
||||
}
|
||||
else -> {
|
||||
this.title = null
|
||||
this.summary = account.error
|
||||
this.onPreferenceClickListener = null
|
||||
}
|
||||
}
|
||||
iconVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLogin(): Boolean {
|
||||
microsoftAccount.username?.let {
|
||||
microsoftVM.signIn(requireActivity()) // should force a specific account
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ACCOUNT = "extra_account"
|
||||
|
||||
fun String?.isUnauthorized(): Boolean =
|
||||
this?.startsWith("401 Unauthorized", ignoreCase = true) == true
|
||||
|
||||
fun newMicrosoftAccountPreference(account: CaldavAccount) =
|
||||
MicrosoftAccount().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(EXTRA_ACCOUNT, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
package org.tasks.sync.microsoft
|
||||
|
||||
import android.content.Context
|
||||
import net.openid.appauth.*
|
||||
import org.tasks.auth.IdentityProvider
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
suspend fun IdentityProvider.retrieveConfig(): AuthorizationServiceConfiguration {
|
||||
return suspendCoroutine { cont ->
|
||||
AuthorizationServiceConfiguration.fetchFromUrl(discoveryEndpoint) { serviceConfiguration, ex ->
|
||||
cont.resumeWith(
|
||||
when {
|
||||
ex != null -> Result.failure(ex)
|
||||
serviceConfiguration != null -> Result.success(serviceConfiguration)
|
||||
else -> Result.failure(IllegalStateException())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Context.requestTokenExchange(response: AuthorizationResponse) =
|
||||
requestToken(response.createTokenExchangeRequest())
|
||||
|
||||
private suspend fun Context.requestToken(tokenRequest: TokenRequest): Pair<TokenResponse?, AuthorizationException?> {
|
||||
val authService = AuthorizationService(this)
|
||||
return try {
|
||||
suspendCoroutine { cont ->
|
||||
authService.performTokenRequest(tokenRequest) { response, ex ->
|
||||
cont.resumeWith(Result.success(Pair(response, ex)))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
authService.dispose()
|
||||
}
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
package org.tasks.sync.microsoft
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import net.openid.appauth.AppAuthConfiguration
|
||||
import net.openid.appauth.AuthorizationRequest
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
import net.openid.appauth.browser.AnyBrowserMatcher
|
||||
import net.openid.appauth.connectivity.DefaultConnectionBuilder
|
||||
import org.tasks.BuildConfig
|
||||
import org.tasks.auth.DebugConnectionBuilder
|
||||
import org.tasks.auth.IdentityProvider
|
||||
import org.tasks.auth.MicrosoftAuthenticationActivity
|
||||
import org.tasks.auth.MicrosoftAuthenticationActivity.Companion.EXTRA_SERVICE_DISCOVERY
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MicrosoftSignInViewModel @Inject constructor(
|
||||
private val debugConnectionBuilder: DebugConnectionBuilder,
|
||||
) : ViewModel() {
|
||||
fun signIn(activity: Activity) {
|
||||
viewModelScope.launch {
|
||||
val idp = IdentityProvider.MICROSOFT
|
||||
val serviceConfig = idp.retrieveConfig()
|
||||
val authRequest = AuthorizationRequest
|
||||
.Builder(
|
||||
serviceConfig,
|
||||
idp.clientId,
|
||||
ResponseTypeValues.CODE,
|
||||
idp.redirectUri
|
||||
)
|
||||
.setScope(idp.scope)
|
||||
.setPrompt(AuthorizationRequest.Prompt.SELECT_ACCOUNT)
|
||||
.build()
|
||||
val intent = Intent(activity, MicrosoftAuthenticationActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
intent.putExtra(
|
||||
EXTRA_SERVICE_DISCOVERY,
|
||||
serviceConfig.discoveryDoc!!.docJson.toString()
|
||||
)
|
||||
|
||||
val authorizationService = AuthorizationService(
|
||||
activity,
|
||||
AppAuthConfiguration.Builder()
|
||||
.setBrowserMatcher(AnyBrowserMatcher.INSTANCE)
|
||||
.setConnectionBuilder(
|
||||
if (BuildConfig.DEBUG) {
|
||||
debugConnectionBuilder
|
||||
} else {
|
||||
DefaultConnectionBuilder.INSTANCE
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
authorizationService.performAuthorizationRequest(
|
||||
authRequest,
|
||||
PendingIntent.getActivity(
|
||||
activity,
|
||||
authRequest.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
|
||||
),
|
||||
authorizationService.createCustomTabsIntentBuilder()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue