mirror of https://github.com/tasks/tasks
msal for Google Play, AppAuth for F-Droid
parent
a519a06c3b
commit
a4ca8b28aa
@ -1,6 +1,24 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest>
|
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<application/>
|
<application tools:ignore="MissingApplicationIcon">
|
||||||
|
<activity
|
||||||
|
android:name=".auth.MicrosoftAuthenticationActivity"
|
||||||
|
android:theme="@style/TranslucentDialog"/>
|
||||||
|
<activity
|
||||||
|
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||||
|
android:exported="true"
|
||||||
|
tools:node="merge">
|
||||||
|
<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:host="${applicationId}"
|
||||||
|
android:scheme="msauth" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
package org.tasks.auth
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import net.openid.appauth.AuthorizationServiceConfiguration
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
data class IdentityProvider(
|
||||||
|
val name: String,
|
||||||
|
val discoveryEndpoint: Uri,
|
||||||
|
val clientId: String,
|
||||||
|
val redirectUri: Uri,
|
||||||
|
val scope: String
|
||||||
|
) {
|
||||||
|
suspend fun 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())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,157 @@
|
|||||||
|
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.material3.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 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.R
|
||||||
|
import org.tasks.analytics.Constants
|
||||||
|
import org.tasks.analytics.Firebase
|
||||||
|
import org.tasks.data.UUIDHelper
|
||||||
|
import org.tasks.data.dao.CaldavDao
|
||||||
|
import org.tasks.data.entity.CaldavAccount
|
||||||
|
import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT
|
||||||
|
import org.tasks.http.HttpClientFactory
|
||||||
|
import org.tasks.jobs.WorkManager
|
||||||
|
import org.tasks.preferences.fragments.TasksAccountViewModel.Companion.getStringOrNull
|
||||||
|
import org.tasks.security.KeyStoreEncryption
|
||||||
|
import org.tasks.sync.SyncAdapters
|
||||||
|
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
|
||||||
|
@Inject lateinit var firebase: Firebase
|
||||||
|
@Inject lateinit var syncAdapters: SyncAdapters
|
||||||
|
@Inject lateinit var workManager: WorkManager
|
||||||
|
|
||||||
|
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 {
|
||||||
|
caldavDao.update(
|
||||||
|
it.copy(password = encryption.encrypt(authState.jsonSerializeString()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: caldavDao
|
||||||
|
.insert(
|
||||||
|
CaldavAccount(
|
||||||
|
uuid = UUIDHelper.newUUID(),
|
||||||
|
name = email,
|
||||||
|
username = email,
|
||||||
|
password = encryption.encrypt(authState.jsonSerializeString()),
|
||||||
|
accountType = TYPE_MICROSOFT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.also {
|
||||||
|
firebase.logEvent(
|
||||||
|
R.string.event_sync_add_account,
|
||||||
|
R.string.param_type to Constants.SYNC_TYPE_MICROSOFT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncAdapters.sync(true)
|
||||||
|
workManager.updateBackgroundSync()
|
||||||
|
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? = withContext(Dispatchers.IO) {
|
||||||
|
if (accessToken == null) {
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val discovery = AuthorizationServiceDiscovery(
|
||||||
|
JSONObject(
|
||||||
|
intent.getStringExtra(EXTRA_SERVICE_DISCOVERY)!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val userInfo = httpClientFactory
|
||||||
|
.newClient(foreground = false)
|
||||||
|
.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url(discovery.userinfoEndpoint!!.toString())
|
||||||
|
.addHeader("Authorization", "Bearer $accessToken")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
val response = userInfo.body?.string() ?: return@withContext null
|
||||||
|
JSONObject(response).getStringOrNull("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,29 @@
|
|||||||
|
package org.tasks.sync.microsoft
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import net.openid.appauth.AuthState
|
||||||
|
import net.openid.appauth.AuthorizationException
|
||||||
|
import net.openid.appauth.AuthorizationResponse
|
||||||
|
import net.openid.appauth.AuthorizationService
|
||||||
|
import net.openid.appauth.TokenRequest
|
||||||
|
import net.openid.appauth.TokenResponse
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
suspend fun Context.requestTokenRefresh(state: AuthState) =
|
||||||
|
requestToken(state.createTokenRefreshRequest())
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package org.tasks.sync.microsoft
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import net.openid.appauth.AuthState
|
||||||
|
import org.tasks.data.entity.CaldavAccount
|
||||||
|
import org.tasks.security.KeyStoreEncryption
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MicrosoftTokenProvider @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val encryption: KeyStoreEncryption,
|
||||||
|
) {
|
||||||
|
suspend fun getToken(account: CaldavAccount): String {
|
||||||
|
val authState = encryption.decrypt(account.password)?.let { AuthState.jsonDeserialize(it) }
|
||||||
|
?: throw RuntimeException("Missing credentials")
|
||||||
|
if (authState.needsTokenRefresh) {
|
||||||
|
val (token, ex) = context.requestTokenRefresh(authState)
|
||||||
|
authState.update(token, ex)
|
||||||
|
if (authState.isAuthorized) {
|
||||||
|
account.password = encryption.encrypt(authState.jsonSerializeString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!authState.isAuthorized) {
|
||||||
|
throw RuntimeException("Needs authentication")
|
||||||
|
}
|
||||||
|
return authState.accessToken!!
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"client_id" : "9d4babd5-e7ba-4286-ba4b-17274495a901",
|
|
||||||
"authorization_user_agent" : "DEFAULT",
|
|
||||||
"redirect_uri" : "msauth://org.tasks/xVwQTvk42gGm0o6zNvelaYloFcs%3D",
|
|
||||||
"account_mode" : "MULTIPLE",
|
|
||||||
"authorities" : [
|
|
||||||
{
|
|
||||||
"type": "AAD",
|
|
||||||
"audience": {
|
|
||||||
"type": "AzureADandPersonalMicrosoftAccount",
|
|
||||||
"tenant_id": "common"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"logging": {
|
|
||||||
"level": "info",
|
|
||||||
"logcat_enabled": true,
|
|
||||||
"pii_enabled": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.tasks.sync.microsoft
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.microsoft.identity.client.AcquireTokenSilentParameters
|
||||||
|
import com.microsoft.identity.client.PublicClientApplication
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import org.tasks.R
|
||||||
|
import org.tasks.data.entity.CaldavAccount
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MicrosoftTokenProvider @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
fun getToken(account: CaldavAccount): String {
|
||||||
|
val app = PublicClientApplication.createMultipleAccountPublicClientApplication(
|
||||||
|
context,
|
||||||
|
R.raw.microsoft_config
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = try {
|
||||||
|
val msalAccount = app.accounts.firstOrNull { it.username == account.username }
|
||||||
|
?: throw RuntimeException("No matching account found")
|
||||||
|
|
||||||
|
val parameters = AcquireTokenSilentParameters.Builder()
|
||||||
|
.withScopes(MicrosoftSignInViewModel.scopes)
|
||||||
|
.forAccount(msalAccount)
|
||||||
|
.fromAuthority(msalAccount.authority)
|
||||||
|
.forceRefresh(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
app.acquireTokenSilent(parameters)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
|
throw RuntimeException("Authentication failed: ${e.message}")
|
||||||
|
}
|
||||||
|
return result.accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue