From b87171cafcd0c29fa4995325f3fc38f5813d5e89 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 9 Nov 2026 14:10:00 -0600 Subject: [PATCH] fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in Ordinary Sign out cleared only the QQQ session; the provider session left in the browser silently signed the same person back in (prompt=login alone was already observed reusing retained SSO on real Authentik). Sign out now runs the provider end-session flow when the server supports it, and every sign-in also sends max_age=0. Stored-session restore after sign-out was checked: not a cause. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + docs/AUTHENTICATION.md | 2 +- .../android/mobileapp/ui/QNavigator.kt | 4 +- .../ui/authentication/QOAuth2Login.kt | 10 +++- .../ui/horseshoe/NavigationSurfaces.kt | 28 ++++++--- .../viewmodel/QViewModelLogoutTest.kt | 57 ++++++++++++++++++- .../io/qrun/mobile/scanner/ScannerActivity.kt | 4 +- 7 files changed, 91 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bca077e..75289d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.0.4 (versionCode 6) - Loading the server's metadata no longer fails with "Error loading QInstance: timeout" on a handheld. The metadata response is about 1.7 MB and takes the server 4 to 8 seconds to build, and the default 10 second read timeout expired. That one call now allows 60 seconds; every other call keeps its short timeout. A timeout now reads "Timed out after 60.0s loading the server's metadata from /qqq/v1/metaData". +- Sign-out now also ends the identity-provider session in the browser when the server supports it (the same flow as "Sign out of company", now behind the single **Sign out** action), and every browser sign-in sends `max_age=0` next to `prompt=login`. Before, ordinary Sign out cleared only the QQQ session, so the provider session left in the browser silently signed the same person straight back in on the next sign-in. Servers without provider logout still get local plus QQQ sign-out. Stored-session restore after sign-out was checked and is not a cause (test added). - Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24) ## 1.0.2 (versionCode 4), candidate diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 460d581..d02b715 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -63,7 +63,7 @@ The fresh-login prompt is a request to the provider, not cryptographic proof of ## Optional company sign-out (implementation awaiting qualification) -Ordinary **Sign out** continues to end only the QQQ session. The scanner also exposes a separate **Sign out of company** action in Account when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`. Other library hosts opt into that separate action with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser. +Since 1.0.4, **Sign out** in Account ends the QQQ session and, when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`, also ends the provider session in the browser (formerly the separate **Sign out of company** action; without that support Sign out stays QQQ-only). Other library hosts opt into provider sign-out with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser. Register the installed package's exact callback separately as a provider **post-logout** redirect, even when it matches the existing login callback. The client sends `{"nativeLogout":{"postLogoutRedirectUri":""}}` to the existing `POST /qqq/v1/logout`. Its optional `nativeLogoutData` contains `issuer`, `endSessionEndpoint`, `postLogoutRedirectUri` and sensitive `idTokenHint`. Old sessions or unsupported servers may legitimately omit this data. The ordinary logout request remains unchanged. diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt index 7f4b597..4d2f6da 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/QNavigator.kt @@ -132,7 +132,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod /******************************************************************************* ** Route sign-out through explicit process-exit confirmation so pending work is not silently abandoned. *******************************************************************************/ - fun requestSignOut() = requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() } + fun requestSignOut(providerSignOut: com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding? = null) = + if (providerSignOut != null) requestCompanySignOut(providerSignOut) + else requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() } /******************************************************************************* ** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out. diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt index fe652d4..1543266 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QOAuth2Login.kt @@ -43,6 +43,13 @@ import kotlin.coroutines.resumeWithException *******************************************************************************/ val LocalOAuth2Discovery = staticCompositionLocalOf AuthorizationServiceConfiguration> { ::discoverOAuth2 } +/******************************************************************************* + ** Sent with every interactive sign-in next to prompt=login. OIDC max_age=0 independently requires the provider to + ** re-authenticate, so a provider session left in the browser cannot silently sign the next person in; the real + ** Authentik test showed prompt=login alone reusing retained SSO (docs/AUTHENTICATION.md). + *******************************************************************************/ +val OAUTH2_FORCE_REAUTH_PARAMETERS: Map = mapOf("max_age" to "0") + /******************************************************************************* ** Browser authorization is standard AppAuth; API1.10 exchanges the code and validates the retained original nonce. *******************************************************************************/ @@ -93,7 +100,8 @@ fun QOAuth2Login(viewModel: QViewModel, metadata: OAuth2AuthenticationMetaData, val request = AuthorizationRequest.Builder( configuration, binding.clientId, ResponseTypeValues.CODE, Uri.parse(binding.redirectUri) - ).setScope(binding.scopes).setPrompt(binding.prompt).setResponseMode("query").build() + ).setScope(binding.scopes).setPrompt(binding.prompt).setAdditionalParameters(OAUTH2_FORCE_REAUTH_PARAMETERS) + .setResponseMode("query").build() check(request.codeVerifierChallengeMethod == "S256") if (viewModel.beginOAuth2Browser( generation, binding, requireNotNull(request.state), diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt index 04b63c1..8cfc7da 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/NavigationSurfaces.kt @@ -24,6 +24,7 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.oauth2LogoutBinding /******************************************************************************* @@ -34,10 +35,20 @@ val LocalOpenDiagnostics = compositionLocalOf<(() -> Unit)?> { null } val LocalBusinessNavigationEnabled = compositionLocalOf { true } /******************************************************************************* - ** Expose account actions while keeping optional company sign-out separate from ordinary local sign-out. + ** Expose account actions; sign-out also ends the provider session where the host and server support it. + *******************************************************************************/ +/******************************************************************************* + ** Sign-out ends the identity-provider session too whenever the host enables it and the server supports it; + ** otherwise a session left in the browser silently signs the next sign-in back in. Null means local-only sign-out. + *******************************************************************************/ +fun providerSignOutBinding(binding: OAuth2LogoutBinding?, serverSupportsProviderLogout: Boolean): OAuth2LogoutBinding? = + if (serverSupportsProviderLogout) binding else null + +/******************************************************************************* + ** Resolve the provider sign-out binding for the current server, or null when it is disabled or unsupported. *******************************************************************************/ @Composable -internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) { +fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? { val companyLogout = if (LocalCompanySignOutEnabled.current) ((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let { oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName) @@ -53,6 +64,12 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) { supportsCompanyLogout = false } } + return providerSignOutBinding(companyLogout, supportsCompanyLogout) +} + +@Composable +internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) { + val providerSignOut = rememberProviderSignOutBinding(model) AlertDialog( onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) }, text = { @@ -60,13 +77,8 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) { Text(if (model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in") TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = { dismiss() - model.qNavigator.requestSignOut() + model.qNavigator.requestSignOut(providerSignOut) }) { Text("Sign out") } - if (companyLogout != null && supportsCompanyLogout) - TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = { - dismiss() - model.qNavigator.requestCompanySignOut(companyLogout) - }) { Text("Sign out of company") } } }, confirmButton = { TextButton(onClick = dismiss) { Text("Close") } }) } diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt index f942eda..19557bb 100644 --- a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt @@ -13,7 +13,9 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAu import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAUTH2_FORCE_REAUTH_PARAMETERS import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding +import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.providerSignOutBinding import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase import kotlinx.coroutines.Dispatchers @@ -145,12 +147,60 @@ class QViewModelLogoutTest { assertNull(json.decodeFromString("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName) } + /******************************************************************************* + ** After sign-out (ordinary or provider) a restarted app finds no stored session and does not resume one. + *******************************************************************************/ + @Test + fun restartAfterSignOutDoesNotRestoreTheSession() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + for (provider in listOf(false, true)) { + val store = MockPreferencesDataStore() + val repository = LogoutRepository() + val model = signedInModel(repository, store) + assertEquals("session-1", store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY]) + if (provider) assertNotNull(companyLogout(model)) + else { model.requestLogout(); model.logOut().join() } + assertNull(store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY]?.takeIf { it.isNotEmpty() }) + val restartedRepository = LogoutRepository() + val restarted = QViewModel(restartedRepository, store) + advanceUntilIdle() + assertFalse(restarted.isAuthenticated) + assertEquals("", restarted.sessionUUID) + assertEquals(0, restartedRepository.metaDataCalls) + } + } finally { + Dispatchers.resetMain() + } + } + + /******************************************************************************* + ** Sign-out reaches the identity provider whenever the server supports it, never silently local-only. + *******************************************************************************/ + @Test + fun signOutUsesProviderLogoutWhenSupported() { + val binding = OAuth2LogoutBinding(loginBinding, REDIRECT) + assertEquals(binding, providerSignOutBinding(binding, true)) + assertNull(providerSignOutBinding(binding, false)) + assertNull(providerSignOutBinding(null, true)) + } + + /******************************************************************************* + ** Every interactive sign-in forces re-authentication, so a lingering provider session cannot sign the next user in. + *******************************************************************************/ + @Test + fun authorizationRequestForcesReauthentication() { + assertEquals(mapOf("max_age" to "0"), OAUTH2_FORCE_REAUTH_PARAMETERS) + assertEquals("login", loginBinding.prompt) + } + /******************************************************************************* ** Complete a native browser login against the fake repository so sign-out tests start authenticated. *******************************************************************************/ - private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel { + private fun TestScope.signedInModel(repository: LogoutRepository, store: MockPreferencesDataStore = MockPreferencesDataStore()): QViewModel { QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER)) - val model = QViewModel(repository, MockPreferencesDataStore()) + val model = QViewModel(repository, store) advanceUntilIdle() val generation = requireNotNull(model.prepareOAuth2Login(loginBinding)) assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER)) @@ -182,6 +232,7 @@ class QViewModelLogoutTest { private class LogoutRepository : QQQRepository { val calls = mutableListOf() var activeSession = "" + var metaDataCalls = 0 override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData( "oauth", values = OAuth2AuthenticationMetaData.Values( @@ -214,7 +265,7 @@ class QViewModelLogoutTest { override fun setEnvironment(environment: Environment) {} override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used") - override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance = QInstance() + override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance { metaDataCalls++; return QInstance() } override suspend fun getProcessMetaData(processName: String): QProcessMetaData = error("Not used") override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used") override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used") diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt index 5b55917..4f11dfc 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled +import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.rememberProviderSignOutBinding import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key import androidx.compose.runtime.SideEffect @@ -405,6 +406,7 @@ private fun DeviceGate( viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit, content: @Composable () -> Unit ) { + val providerSignOut = rememberProviderSignOutBinding(viewModel) var attempt by remember { mutableIntStateOf(0) } var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") } var error by remember { mutableStateOf("") } @@ -444,6 +446,6 @@ private fun DeviceGate( Text("Your administrator can edit this device’s name, short name and description in QQQ before approval.") Button(enabled = status != "CHECKING", onClick = { attempt++ }) { Text("Check approval") } if (enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") } - Button(onClick = { viewModel.qNavigator.requestSignOut() }) { Text("Sign out") } + Button(onClick = { viewModel.qNavigator.requestSignOut(providerSignOut) }) { Text("Sign out") } } } -- 2.54.0