diff --git a/app/build.gradle b/app/build.gradle index e25d223cb..9833f9ac5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -173,6 +173,7 @@ dependencies { implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-crashlytics-ndk' + // https://github.com/openid/AppAuth-Android/blob/master/LICENSE Apache v2.0 implementation 'net.openid:appauth:0.11.1' android.defaultConfig.manifestPlaceholders = [appAuthRedirectScheme: 'org.linphone'] diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 74a60260f..74a8e2dd2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -111,11 +111,6 @@ android:launchMode="singleTask" android:resizeableActivity="true" /> - - - goToSingleSignOnActivity(address) + goToSingleSignOnFragment(address) } } } private fun goToLoginFragment(identity: String) { + Log.i( + "$TAG Going to Linphone credentials based authentication fragment for SIP account [$identity]" + ) val action = LandingFragmentDirections.actionLandingFragmentToLoginFragment(identity) findNavController().navigate(action) } - private fun goToSingleSignOnActivity(identity: String) { - startActivity(Intent(requireContext(), OpenIdActivity::class.java)) - requireActivity().finish() + private fun goToSingleSignOnFragment(identity: String) { + Log.i("$TAG Going to Single Sign On fragment for SIP account [$identity]") + val action = LandingFragmentDirections.actionLandingFragmentToSingleSignOnFragment(identity) + findNavController().navigate(action) } private fun goToRegisterFragment() { diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/SingleSignOnFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/SingleSignOnFragment.kt new file mode 100644 index 000000000..9b893e0c2 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/assistant/fragment/SingleSignOnFragment.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.assistant.fragment + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.AssistantSingleSignOnFragmentBinding +import org.linphone.ui.assistant.AssistantActivity +import org.linphone.ui.assistant.viewmodel.SingleSignOnViewModel + +@UiThread +class SingleSignOnFragment : Fragment() { + companion object { + private const val TAG = "[Single Sign On Fragment]" + + private const val ACTIVITY_RESULT_ID = 666 + } + + private lateinit var binding: AssistantSingleSignOnFragmentBinding + + private lateinit var viewModel: SingleSignOnViewModel + + private val args: SingleSignOnFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = AssistantSingleSignOnFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + + viewModel = ViewModelProvider(this)[SingleSignOnViewModel::class.java] + binding.viewModel = viewModel + + val identity = args.sipIdentity + Log.i("$TAG SIP Identity found in arguments is [$identity]") + viewModel.preFilledUser = identity + + viewModel.singleSignOnProcessCompletedEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i("$TAG Process complete, leaving assistant") + requireActivity().finish() + } + } + + viewModel.startAuthIntentEvent.observe(viewLifecycleOwner) { + it.consume { intent -> + Log.i("$TAG Starting auth intent activity") + startActivityForResult(intent, ACTIVITY_RESULT_ID) + } + } + + viewModel.onErrorEvent.observe(viewLifecycleOwner) { + it.consume { errorMessage -> + (requireActivity() as AssistantActivity).showRedToast(errorMessage, R.drawable.x) + findNavController().popBackStack() + } + } + + viewModel.setUp() + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == ACTIVITY_RESULT_ID && data != null) { + val resp = AuthorizationResponse.fromIntent(data) + val ex = AuthorizationException.fromIntent(data) + viewModel.processAuthIntentResponse(resp, ex) + } + + super.onActivityResult(requestCode, resultCode, data) + } +} diff --git a/app/src/main/java/org/linphone/ui/sso/OpenIdActivity.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/SingleSignOnViewModel.kt similarity index 68% rename from app/src/main/java/org/linphone/ui/sso/OpenIdActivity.kt rename to app/src/main/java/org/linphone/ui/assistant/viewmodel/SingleSignOnViewModel.kt index dec1ac7f7..49e4b2ec1 100644 --- a/app/src/main/java/org/linphone/ui/sso/OpenIdActivity.kt +++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/SingleSignOnViewModel.kt @@ -17,107 +17,93 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.linphone.ui.sso +package org.linphone.ui.assistant.viewmodel import android.content.Intent import android.net.Uri -import android.os.Bundle -import android.view.View import androidx.annotation.UiThread -import androidx.databinding.DataBindingUtil -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import java.io.File import kotlinx.coroutines.launch import net.openid.appauth.AuthState -import net.openid.appauth.AuthState.AuthStateAction import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationService import net.openid.appauth.AuthorizationServiceConfiguration -import net.openid.appauth.AuthorizationServiceConfiguration.RetrieveConfigurationCallback import net.openid.appauth.ResponseTypeValues -import org.linphone.R +import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.tools.Log -import org.linphone.databinding.SingleSignOnOpenIdActivityBinding -import org.linphone.ui.GenericActivity +import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.TimestampUtils -@UiThread -class OpenIdActivity : GenericActivity() { +class SingleSignOnViewModel : ViewModel() { companion object { - private const val TAG = "[Open ID Activity]" + private const val TAG = "[Single Sign On ViewModel]" private const val WELL_KNOWN = "https://sso.onhexagone.com//realms/ONHEXAGONE/.well-known/openid-configuration" private const val CLIENT_ID = "account" private const val SCOPE = "openid email profile" private const val REDIRECT_URI = "org.linphone:/openidcallback" - private const val ACTIVITY_RESULT_ID = 666 } - private lateinit var binding: SingleSignOnOpenIdActivityBinding + val singleSignOnProcessCompletedEvent = MutableLiveData>() + + val startAuthIntentEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + val onErrorEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + var preFilledUser: String = "" private lateinit var authState: AuthState private lateinit var authService: AuthorizationService - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding = DataBindingUtil.setContentView(this, R.layout.single_sign_on_open_id_activity) - binding.lifecycleOwner = this - - lifecycleScope.launch { + @UiThread + fun setUp() { + viewModelScope.launch { + Log.i("$TAG Setting up SSO environment, redirect URI is [$REDIRECT_URI]") authState = getAuthState() updateTokenInfo() } + } - binding.setSingleSignOnClickListener { - lifecycleScope.launch { - singleSignOn() - } + @UiThread + fun processAuthIntentResponse(resp: AuthorizationResponse?, ex: AuthorizationException?) { + if (::authState.isInitialized) { + Log.i("$TAG Updating AuthState object after authorization response") + authState.update(resp, ex) } - binding.setRefreshTokenClickListener { - lifecycleScope.launch { - performRefreshToken() - } + if (resp != null) { + Log.i("$TAG Response isn't null, performing request token") + performRequestToken(resp) + } else { + Log.e("$TAG Can't perform request token [$ex]") + onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty())) } } - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == ACTIVITY_RESULT_ID && data != null) { - val resp = AuthorizationResponse.fromIntent(data) - val ex = AuthorizationException.fromIntent(data) - - if (::authState.isInitialized) { - Log.i("$TAG Updating AuthState object after authorization response") - authState.update(resp, ex) - } - - if (resp != null) { - Log.i("$TAG Response isn't null, performing request token") - performRequestToken(resp) - } else { - Log.e("$TAG Can't perform request token [$ex]") - } - } - - super.onActivityResult(requestCode, resultCode, data) - } - + @UiThread private fun singleSignOn() { Log.i("$TAG Fetch from issuer") AuthorizationServiceConfiguration.fetchFromUrl( Uri.parse(WELL_KNOWN), - RetrieveConfigurationCallback { serviceConfiguration, ex -> + AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> if (ex != null) { Log.e("$TAG Failed to fetch configuration") + onErrorEvent.postValue(Event("Failed to fetch configuration")) return@RetrieveConfigurationCallback } if (serviceConfiguration == null) { Log.e("$TAG Service configuration is null!") + onErrorEvent.postValue(Event("Service configuration is null")) return@RetrieveConfigurationCallback } @@ -134,43 +120,25 @@ class OpenIdActivity : GenericActivity() { Uri.parse(REDIRECT_URI) // the redirect URI to which the auth response is sent ) + if (preFilledUser.isNotEmpty()) { + authRequestBuilder.setLoginHint(preFilledUser) + } + val authRequest = authRequestBuilder .setScope(SCOPE) .build() - authService = AuthorizationService(this) + authService = AuthorizationService(coreContext.context) val authIntent = authService.getAuthorizationRequestIntent(authRequest) - startActivityForResult(authIntent, ACTIVITY_RESULT_ID) + startAuthIntentEvent.postValue(Event(authIntent)) } ) } - private fun performRequestToken(response: AuthorizationResponse) { - if (::authService.isInitialized) { - Log.i("$TAG Starting perform token request") - authService.performTokenRequest( - response.createTokenExchangeRequest() - ) { resp, ex -> - if (resp != null) { - Log.i("$TAG Token exchange succeeded!") - - if (::authState.isInitialized) { - Log.i("$TAG Updating AuthState object after token response") - authState.update(resp, ex) - storeAuthStateAsJsonFile() - } - - useToken() - } else { - Log.e("$TAG Failed to perform token request [$ex]") - } - } - } - } - + @UiThread private fun performRefreshToken() { if (::authState.isInitialized) { if (!::authService.isInitialized) { - authService = AuthorizationService(this) + authService = AuthorizationService(coreContext.context) } Log.i("$TAG Starting refresh token request") @@ -190,9 +158,14 @@ class OpenIdActivity : GenericActivity() { Log.e( "$TAG Failed to perform token refresh [$ex], destroying auth_state.json file" ) - val file = File(applicationContext.filesDir.absolutePath, "auth_state.json") - lifecycleScope.launch { + onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty())) + + val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json") + viewModelScope.launch { FileUtils.deleteFile(file.absolutePath) + Log.w( + "$TAG Previous auth_state.json file deleted, starting single sign on process from scratch" + ) singleSignOn() } } @@ -200,6 +173,32 @@ class OpenIdActivity : GenericActivity() { } } + @UiThread + private fun performRequestToken(response: AuthorizationResponse) { + if (::authService.isInitialized) { + Log.i("$TAG Starting perform token request") + authService.performTokenRequest( + response.createTokenExchangeRequest() + ) { resp, ex -> + if (resp != null) { + Log.i("$TAG Token exchange succeeded!") + + if (::authState.isInitialized) { + Log.i("$TAG Updating AuthState object after token response") + authState.update(resp, ex) + storeAuthStateAsJsonFile() + } + + useToken() + } else { + Log.e("$TAG Failed to perform token request [$ex]") + onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty())) + } + } + } + } + + @UiThread private fun useToken() { if (::authState.isInitialized && ::authService.isInitialized) { if (authState.needsTokenRefresh && authState.refreshToken.isNullOrEmpty()) { @@ -207,10 +206,11 @@ class OpenIdActivity : GenericActivity() { return } - Log.i("$TAG Performing action with fresh token") + singleSignOnProcessCompletedEvent.postValue(Event(true)) + /*Log.i("$TAG Performing action with fresh token") authState.performActionWithFreshTokens( authService, - AuthStateAction { accessToken, idToken, ex -> + AuthState.AuthStateAction { accessToken, idToken, ex -> if (ex != null) { Log.e("$TAG Failed to use token [$ex]") return@AuthStateAction @@ -221,13 +221,15 @@ class OpenIdActivity : GenericActivity() { storeAuthStateAsJsonFile() } - ) + )*/ } } + @UiThread private suspend fun getAuthState(): AuthState { - val file = File(applicationContext.filesDir.absolutePath, "auth_state.json") + val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json") if (file.exists()) { + Log.i("$TAG Auth state file found, trying to read it") val content = FileUtils.readFile(file) if (content.isNotEmpty()) { Log.i("$TAG Initializing AuthState from local JSON file") @@ -236,19 +238,23 @@ class OpenIdActivity : GenericActivity() { return AuthState.jsonDeserialize(content) } catch (exception: Exception) { Log.e("$TAG Failed to use serialized AuthState [$exception]") + onErrorEvent.postValue(Event("Failed to read stored AuthState")) } } + } else { + Log.i("$TAG Auth state file not found yet...") } return AuthState() } + @UiThread private fun storeAuthStateAsJsonFile() { Log.i("$TAG Trying to save serialized authState as JSON file") val data = authState.jsonSerializeString() Log.d("$TAG Date to save is [$data]") - val file = File(applicationContext.filesDir.absolutePath, "auth_state.json") - lifecycleScope.launch { + val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json") + viewModelScope.launch { if (FileUtils.dumpStringToFile(data, file)) { Log.i("$TAG Service configuration saved as JSON as [${file.absolutePath}]") } else { @@ -259,20 +265,19 @@ class OpenIdActivity : GenericActivity() { } } + @UiThread private fun updateTokenInfo() { + Log.i("$TAG Updating token info") + if (::authState.isInitialized) { if (authState.isAuthorized) { Log.i("$TAG User is already authenticated!") - binding.sso.visibility = View.GONE - binding.tokenRefresh.visibility = View.GONE - binding.tokenExpires.visibility = View.VISIBLE val expiration = authState.accessTokenExpirationTime if (expiration != null) { if (expiration < System.currentTimeMillis()) { Log.w("$TAG Access token is expired") - binding.tokenExpires.text = "Token expired!" - binding.tokenRefresh.visibility = View.VISIBLE + performRefreshToken() } else { val date = if (TimestampUtils.isToday(expiration, timestampInSecs = false)) { "today" @@ -285,18 +290,23 @@ class OpenIdActivity : GenericActivity() { } val time = TimestampUtils.toString(expiration, timestampInSecs = false) Log.i("$TAG Access token expires [$date] [$time]") - binding.tokenExpires.text = "Token expires $date at $time" + singleSignOnProcessCompletedEvent.postValue(Event(true)) } } else { Log.w("$TAG Access token expiration info not available") - binding.tokenExpires.text = "Can't access token expiration!" + val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json") + viewModelScope.launch { + FileUtils.deleteFile(file.absolutePath) + singleSignOn() + } } } else { - Log.w("$TAG User isn't authenticated yet!") - binding.sso.visibility = View.VISIBLE - binding.tokenRefresh.visibility = View.GONE - binding.tokenExpires.visibility = View.GONE + Log.w("$TAG User isn't authenticated yet") + singleSignOn() } + } else { + Log.i("$TAG Auth state hasn't been created yet") + singleSignOn() } } } diff --git a/app/src/main/res/layout/assistant_single_sign_on_fragment.xml b/app/src/main/res/layout/assistant_single_sign_on_fragment.xml new file mode 100644 index 000000000..70518d18b --- /dev/null +++ b/app/src/main/res/layout/assistant_single_sign_on_fragment.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/single_sign_on_open_id_activity.xml b/app/src/main/res/layout/single_sign_on_open_id_activity.xml deleted file mode 100644 index 7f06df1db..000000000 --- a/app/src/main/res/layout/single_sign_on_open_id_activity.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/assistant_nav_graph.xml b/app/src/main/res/navigation/assistant_nav_graph.xml index f217e088b..6d25cbb9d 100644 --- a/app/src/main/res/navigation/assistant_nav_graph.xml +++ b/app/src/main/res/navigation/assistant_nav_graph.xml @@ -172,6 +172,25 @@ app:popExitAnim="@anim/slide_out_right" app:launchSingleTop="true" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e0fccf67..b6fdd19b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -218,6 +218,7 @@ Grant permissions OK Do it later + Single Sign On To fully enjoy &appName; we need you to grant us the following permissions : Read contacts: To display your contacts and find whom is using &appName;.