Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import android.accounts.AccountManager
import android.accounts.AccountManagerFuture
import android.accounts.AuthenticatorException
import android.accounts.OperationCanceledException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
Expand All @@ -33,7 +31,6 @@ import com.auth0.jwt.exceptions.JWTDecodeException
import com.auth0.jwt.interfaces.DecodedJWT
import com.google.android.fhir.sync.HttpAuthenticationMethod
import com.google.android.fhir.sync.HttpAuthenticator as FhirAuthenticator
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.IOException
import java.net.UnknownHostException
import java.util.Base64
Expand Down Expand Up @@ -61,17 +58,14 @@ constructor(
val oAuthService: OAuthService,
val dispatcherProvider: DispatcherProvider,
val accountManager: AccountManager,
@ApplicationContext val context: Context,
) : FhirAuthenticator {

private val authConfiguration by lazy { configService.provideAuthConfiguration() }
private var isLoginPageRendered = false

fun getAccessToken(): String {
val account = findAccount() ?: return ""
val accessToken = accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE) ?: ""
if (isTokenActive(accessToken)) {
isLoginPageRendered = false
return accessToken
}

Expand Down Expand Up @@ -115,17 +109,10 @@ constructor(
val token = bundle.getString(AccountManager.KEY_AUTHTOKEN)
setAuthToken(account, AUTH_TOKEN_TYPE, token)
}
bundle.containsKey(AccountManager.KEY_INTENT) -> {
val launchIntent = bundle.get(AccountManager.KEY_INTENT) as? Intent

// Deletes session PIN to allow reset
secureSharedPreference.deleteSessionPin()

if (launchIntent != null && !isLoginPageRendered) {
context.startActivity(launchIntent.putExtra(CANCEL_BACKGROUND_SYNC, true))
isLoginPageRendered = true
}
}
bundle.containsKey(AccountManager.KEY_INTENT) ->
// Interactive re-auth is required. The UI decides whether/when to redirect to login;
// see AppMainActivity.redirectIfSessionExpired.
Timber.w("Interactive re-auth required; redirect deferred to UI")
}
}

Expand All @@ -140,7 +127,10 @@ constructor(
}
}

fun isCurrentRefreshTokenActive() = isTokenActive(accountManager.getPassword(findAccount()))
fun isCurrentRefreshTokenActive(): Boolean {
val account = findAccount() ?: return false
return isTokenActive(accountManager.getPassword(account))
}

private fun buildOAuthPayload(grantType: String) =
mutableMapOf(
Expand Down
1 change: 1 addition & 0 deletions android/engine/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
<string name="percentage" translatable="false">%</string>
<string name="logging_out">Logging out. Please wait…</string>
<string name="session_expired">Session has been expired and must login again</string>
<string name="session_expired_form_saved">Your form has been saved successfully. Your session has expired, please log in again to continue.</string>
<string name="sex">Sex</string>
<string name="age">Age</string>
<string name="dob">DOB</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ package org.smartregister.fhircore.engine.auth

import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.AccountManagerCallback
import android.accounts.AccountManagerFuture
import android.accounts.AuthenticatorException
import android.accounts.OperationCanceledException
import android.content.Intent
import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.test.core.app.ApplicationProvider
import com.auth0.jwt.exceptions.JWTDecodeException
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
Expand Down Expand Up @@ -77,7 +77,6 @@ class TokenAuthenticatorTest : RobolectricTest() {
private val oAuthService: OAuthService = mockk()
private lateinit var tokenAuthenticator: TokenAuthenticator
private val accountManager = mockk<AccountManager>()
private val context = ApplicationProvider.getApplicationContext<HiltTestApplication>()
private val sampleUsername = "demo"

@Before
Expand All @@ -92,7 +91,6 @@ class TokenAuthenticatorTest : RobolectricTest() {
oAuthService = oAuthService,
dispatcherProvider = dispatcherProvider,
accountManager = accountManager,
context = context,
),
)
}
Expand Down Expand Up @@ -195,7 +193,6 @@ class TokenAuthenticatorTest : RobolectricTest() {
oAuthService = oAuthService,
dispatcherProvider = dispatcherProvider,
accountManager = accountManager,
context = context,
),
)

Expand Down Expand Up @@ -377,7 +374,6 @@ class TokenAuthenticatorTest : RobolectricTest() {
oAuthService = oAuthService,
dispatcherProvider = dispatcherProvider,
accountManager = accountManager,
context = context,
),
)

Expand Down Expand Up @@ -447,6 +443,47 @@ class TokenAuthenticatorTest : RobolectricTest() {
Assert.assertTrue(tokenAuthenticator.isCurrentRefreshTokenActive())
}

@Test
fun testHandleKeyIntentDoesNotClearSessionPin() {
val spiedSecureSharedPreference = spyk(secureSharedPreference)
val authenticator =
spyk(
TokenAuthenticator(
secureSharedPreference = spiedSecureSharedPreference,
configService = configService,
oAuthService = oAuthService,
dispatcherProvider = dispatcherProvider,
accountManager = accountManager,
),
)
val account = Account(sampleUsername, PROVIDER)
val keyIntentBundle = Bundle().apply { putParcelable(AccountManager.KEY_INTENT, Intent()) }
val resultFuture =
mockk<AccountManagerFuture<Bundle>> { every { result } returns keyIntentBundle }
val callbacks = mutableListOf<AccountManagerCallback<Bundle>>()

every { authenticator.findAccount() } returns account
every { authenticator.isTokenActive(any()) } returns false
every { accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE) } returns "expiredAccessToken"
every { accountManager.invalidateAuthToken(any(), any()) } just runs
every {
accountManager.getAuthToken(
account,
AUTH_TOKEN_TYPE,
any<Bundle>(),
true,
capture(callbacks),
any(),
)
} returns resultFuture

authenticator.getAccessToken()
callbacks.last().run(resultFuture)

// KEY_INTENT now only logs; the UI owns the redirect, so nothing local is cleared here
verify(exactly = 0) { spiedSecureSharedPreference.deleteSessionPin() }
}

companion object {
private const val SCOPE = "openid"
private const val PROVIDER = "provider"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
Expand All @@ -40,13 +41,15 @@ import io.sentry.android.navigation.SentryNavigationListener
import java.time.Instant
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.hl7.fhir.r4.model.IdType
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig
import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions
import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
import org.smartregister.fhircore.engine.datastore.ProtoDataStore
import org.smartregister.fhircore.engine.domain.model.LauncherType
import org.smartregister.fhircore.engine.rulesengine.services.LocationCoordinate
Expand All @@ -58,6 +61,9 @@ import org.smartregister.fhircore.engine.ui.base.AlertDialogue
import org.smartregister.fhircore.engine.ui.base.AlertIntent
import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity
import org.smartregister.fhircore.engine.util.DispatcherProvider
import org.smartregister.fhircore.engine.util.SecureSharedPreference
import org.smartregister.fhircore.engine.util.extension.isDeviceOnline
import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory
import org.smartregister.fhircore.engine.util.extension.parcelable
import org.smartregister.fhircore.engine.util.extension.serializable
import org.smartregister.fhircore.engine.util.extension.showToast
Expand All @@ -66,6 +72,7 @@ import org.smartregister.fhircore.engine.util.location.PermissionUtils
import org.smartregister.fhircore.quest.R
import org.smartregister.fhircore.quest.event.AppEvent
import org.smartregister.fhircore.quest.event.EventBus
import org.smartregister.fhircore.quest.ui.login.LoginActivity
import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireActivity
import org.smartregister.fhircore.quest.ui.shared.ActivityOnResultType
import org.smartregister.fhircore.quest.ui.shared.ON_RESULT_TYPE
Expand All @@ -84,6 +91,10 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler,

@Inject lateinit var dispatcherProvider: DispatcherProvider

@Inject lateinit var tokenAuthenticator: TokenAuthenticator

@Inject lateinit var secureSharedPreference: SecureSharedPreference

val appMainViewModel by viewModels<AppMainViewModel>()
private val sentryNavListener =
SentryNavigationListener(enableNavigationBreadcrumbs = true, enableNavigationTracing = true)
Expand All @@ -110,12 +121,17 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler,
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
activityResult: ActivityResult ->
val onResultType = activityResult.data?.extras?.getString(ON_RESULT_TYPE)
if (
activityResult.resultCode == Activity.RESULT_OK &&
!onResultType.isNullOrBlank() &&
ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE
) {
lifecycleScope.launch { onSubmitQuestionnaire(activityResult) }
lifecycleScope.launch {
if (
activityResult.resultCode == Activity.RESULT_OK &&
!onResultType.isNullOrBlank() &&
ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE
) {
onSubmitQuestionnaire(activityResult)

// Session may have expired while the questionnaire was open
redirectIfSessionExpired(delayMillis = 3000)
}
}
}

Expand Down Expand Up @@ -160,6 +176,7 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler,
super.onResume()
findNavController(R.id.nav_host).addOnDestinationChangedListener(sentryNavListener)
syncListenerManager.registerSyncListener(this, lifecycle)
redirectIfSessionExpired()
}

override fun onPause() {
Expand Down Expand Up @@ -292,7 +309,7 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler,
currentSyncJobStatus = syncJobStatus,
)
}
is CurrentSyncJobStatus.Failed ->
is CurrentSyncJobStatus.Failed -> {
appMainViewModel.run {
onEvent(
AppMainEvent.UpdateSyncState(
Expand All @@ -306,12 +323,54 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler,
currentSyncJobStatus = syncJobStatus,
)
}
// A sync can fail because the session expired; prompt re-login when that is the cause
redirectIfSessionExpired()
}
else -> {
// Do Nothing
}
}
}

private var redirectingToLogin = false

/**
* Routes the user to login when the device is online and the refresh token has expired. Guarded
* so repeated triggers (e.g. sync failures, resume) do not stack dialogs.
*/
private fun redirectIfSessionExpired(delayMillis: Long = 0) {
if (redirectingToLogin) return
if (isDeviceOnline() && !tokenAuthenticator.isCurrentRefreshTokenActive()) {
redirectingToLogin = true
lifecycleScope.launch {
delay(delayMillis) // Let the user see saved data before redirecting (post-questionnaire)
showSessionExpiredDialog()
}
}
}

private fun showSessionExpiredDialog() {
AlertDialogue.showAlert(
context = this,
alertIntent = AlertIntent.CONFIRM,
message = getString(org.smartregister.fhircore.engine.R.string.session_expired_form_saved),
title = getString(org.smartregister.fhircore.engine.R.string.session_expired),
confirmButton =
AlertDialogButton(
listener = { dialog ->
dialog.dismiss()
// Force full re-login (fetches fresh tokens); a PIN unlock would not
secureSharedPreference.deleteSessionPin()
launchActivityWithNoBackStackHistory<LoginActivity>(
bundle = bundleOf(TokenAuthenticator.CANCEL_BACKGROUND_SYNC to true),
)
},
text = org.smartregister.fhircore.engine.R.string.questionnaire_alert_ack_button_title,
),
cancellable = false,
)
}

private fun overrideOnBackPressListener() {
onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.smartregister.fhircore.quest.ui.main

import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.compose.material.ExperimentalMaterialApi
Expand All @@ -32,13 +33,16 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.spyk
import io.mockk.unmockkStatic
import java.io.Serializable
import java.time.OffsetDateTime
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
Expand All @@ -49,6 +53,7 @@ import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig
import org.smartregister.fhircore.engine.sync.SyncState
import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator
import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.extension.isDeviceOnline
import org.smartregister.fhircore.quest.app.fakes.Faker
import org.smartregister.fhircore.quest.event.AppEvent
import org.smartregister.fhircore.quest.event.EventBus
Expand All @@ -75,6 +80,14 @@ class AppMainActivityTest : ActivityRobolectricTest() {
hiltRule.inject()
appMainActivity = spyk(Robolectric.buildActivity(AppMainActivity::class.java).create().get())
every { appMainActivity.eventBus } returns eventBus
// The session-expired redirect runs on resume/sync; keep it inert for these tests
mockkStatic(Context::isDeviceOnline)
every { appMainActivity.isDeviceOnline() } returns false
}

@After
fun unmockStatics() {
unmockkStatic(Context::isDeviceOnline)
}

@Test
Expand Down
Loading