Add Microsoft authentication

pull/1997/head
Alex Baker 2 years ago
parent 9522c14891
commit 7327572db4

@ -177,6 +177,12 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.tasks.github.a50fdbf3e289a7fb2fc6" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="msauth" />
</intent-filter>
</activity>
<activity
@ -620,6 +626,10 @@
</intent-filter>
</receiver>
<activity
android:name=".auth.MicrosoftAuthenticationActivity"
android:theme="@style/TranslucentDialog"/>
<activity
android:launchMode="singleTask"
android:exported="true"

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

@ -99,6 +99,9 @@ class CaldavAccount : Parcelable {
val isTasksOrg: Boolean
get() = accountType == TYPE_TASKS
val isMicrosoft: Boolean
get() = accountType == TYPE_MICROSOFT
fun listSettingsClass(): Class<out BaseListSettingsActivity> = when(accountType) {
TYPE_LOCAL -> LocalListSettingsActivity::class.java
TYPE_ETESYNC, TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java
@ -202,6 +205,7 @@ class CaldavAccount : Parcelable {
isEteSyncAccount -> R.string.etesync_v1
uuid.isDavx5() -> R.string.davx5
uuid.isDecSync() -> R.string.decsync
isMicrosoft -> R.string.microsoft
else -> 0
}
@ -212,6 +216,7 @@ class CaldavAccount : Parcelable {
isEtebaseAccount || isEteSyncAccount || uuid.isEteSync() -> R.drawable.ic_etesync
uuid.isDavx5() -> R.drawable.ic_davx5_icon_green_bg
uuid.isDecSync() -> R.drawable.ic_decsync
isMicrosoft -> R.drawable.ic_microsoft_tasks
else -> 0
}
@ -225,6 +230,7 @@ class CaldavAccount : Parcelable {
const val TYPE_OPENTASKS = 3
const val TYPE_TASKS = 4
const val TYPE_ETEBASE = 5
const val TYPE_MICROSOFT = 6
const val SERVER_UNKNOWN = -1
const val SERVER_TASKS = 0

@ -4,6 +4,7 @@ import android.content.Intent
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
@ -31,11 +32,13 @@ import org.tasks.preferences.MainPreferences
import org.tasks.preferences.Preferences
import org.tasks.preferences.PreferencesViewModel
import org.tasks.preferences.fragments.GoogleTasksAccount.Companion.newGoogleTasksAccountPreference
import org.tasks.preferences.fragments.MicrosoftAccount.Companion.newMicrosoftAccountPreference
import org.tasks.preferences.fragments.TasksAccount.Companion.newTasksAccountPreference
import org.tasks.sync.AddAccountDialog
import org.tasks.sync.AddAccountDialog.Companion.EXTRA_SELECTED
import org.tasks.sync.AddAccountDialog.Companion.newAccountDialog
import org.tasks.sync.AddAccountDialog.Platform
import org.tasks.sync.microsoft.MicrosoftSignInViewModel
import org.tasks.widget.AppWidgetManager
import javax.inject.Inject
@ -49,6 +52,7 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
@Inject lateinit var billingClient: BillingClient
private val viewModel: PreferencesViewModel by activityViewModels()
private val microsoftVM: MicrosoftSignInViewModel by viewModels()
override fun getPreferenceXml() = R.xml.preferences
@ -109,9 +113,8 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
Intent(requireContext(), GtasksLoginActivity::class.java),
REQUEST_GOOGLE_TASKS
)
Platform.MICROSOFT -> {
}
Platform.MICROSOFT ->
microsoftVM.signIn(requireActivity())
Platform.DAVX5 ->
context?.openUri(R.string.url_davx5)
Platform.CALDAV ->
@ -203,9 +206,15 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
pref.setOnPreferenceClickListener {
if (account.isTasksOrg) {
(activity as MainPreferences).startPreference(
this,
newTasksAccountPreference(account),
getString(R.string.tasks_org)
this,
newTasksAccountPreference(account),
getString(R.string.tasks_org)
)
} else if (account.isMicrosoft) {
(activity as MainPreferences).startPreference(
this,
newMicrosoftAccountPreference(account),
getString(R.string.microsoft)
)
} else {
val intent = Intent(context, account.accountSettingsClass).apply {

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

@ -735,4 +735,7 @@ File %1$s contained %2$s.\n\n
<string name="hint_customize_edit_body">You can customize this screen by rearranging or removing fields</string>
<string name="enable_reminders">Enable reminders</string>
<string name="enable_reminders_description">Reminders are disabled in Android Settings</string>
<string name="sign_in">Sign in</string>
<string name="sign_in_microsoft_personal_account">Sign in with a personal Microsoft account</string>
<string name="sign_in_email_or_phone">Email or phone number</string>
</resources>

Loading…
Cancel
Save