fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s

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 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Sonnet 5.5 committed 2026-11-09 14:10:00 -06:00
1 parent 3b15183f52
commit b87171cafc
7 files changed
+91 -15

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
## 1.0.4 (versionCode 6) ## 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 <server>/qqq/v1/metaData". - 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 <server>/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) - 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 ## 1.0.2 (versionCode 4), candidate
+1 -1
View File
@@ -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) ## 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":"<registered callback>"}}` 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. 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":"<registered callback>"}}` 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.
@@ -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. ** 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. ** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out.
@@ -43,6 +43,13 @@ import kotlin.coroutines.resumeWithException
*******************************************************************************/ *******************************************************************************/
val LocalOAuth2Discovery = staticCompositionLocalOf<suspend (OAuth2LoginBinding) -> AuthorizationServiceConfiguration> { ::discoverOAuth2 } val LocalOAuth2Discovery = staticCompositionLocalOf<suspend (OAuth2LoginBinding) -> 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<String, String> = mapOf("max_age" to "0")
/******************************************************************************* /*******************************************************************************
** Browser authorization is standard AppAuth; API1.10 exchanges the code and validates the retained original nonce. ** 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( val request = AuthorizationRequest.Builder(
configuration, binding.clientId, ResponseTypeValues.CODE, configuration, binding.clientId, ResponseTypeValues.CODE,
Uri.parse(binding.redirectUri) 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") check(request.codeVerifierChallengeMethod == "S256")
if (viewModel.beginOAuth2Browser( if (viewModel.beginOAuth2Browser(
generation, binding, requireNotNull(request.state), generation, binding, requireNotNull(request.state),
@@ -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.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData 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.LocalCompanySignOutEnabled
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
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 } 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 @Composable
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) { fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? {
val companyLogout = if (LocalCompanySignOutEnabled.current) val companyLogout = if (LocalCompanySignOutEnabled.current)
((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let { ((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let {
oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName) oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName)
@@ -53,6 +64,12 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
supportsCompanyLogout = false supportsCompanyLogout = false
} }
} }
return providerSignOutBinding(companyLogout, supportsCompanyLogout)
}
@Composable
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
val providerSignOut = rememberProviderSignOutBinding(model)
AlertDialog( AlertDialog(
onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) }, onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) },
text = { 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") Text(if (model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in")
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = { TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
dismiss() dismiss()
model.qNavigator.requestSignOut() model.qNavigator.requestSignOut(providerSignOut)
}) { Text("Sign out") } }) { 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") } }) }, confirmButton = { TextButton(onClick = dismiss) { Text("Close") } })
} }
@@ -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.core.model.processes.ProcessStepResult
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer 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.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.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.OAuth2LogoutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -145,12 +147,60 @@ class QViewModelLogoutTest {
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName) assertNull(json.decodeFromString<ManageSessionResponse>("""{"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. ** 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)) QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
val model = QViewModel(repository, MockPreferencesDataStore()) val model = QViewModel(repository, store)
advanceUntilIdle() advanceUntilIdle()
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding)) val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER)) assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
@@ -182,6 +232,7 @@ class QViewModelLogoutTest {
private class LogoutRepository : QQQRepository { private class LogoutRepository : QQQRepository {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()
var activeSession = "" var activeSession = ""
var metaDataCalls = 0
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData( override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
"oauth", values = OAuth2AuthenticationMetaData.Values( "oauth", values = OAuth2AuthenticationMetaData.Values(
@@ -214,7 +265,7 @@ class QViewModelLogoutTest {
override fun setEnvironment(environment: Environment) {} override fun setEnvironment(environment: Environment) {}
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used") 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 getProcessMetaData(processName: String): QProcessMetaData = error("Not used")
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used") override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used") override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
@@ -30,6 +30,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled 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.LaunchedEffect
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
@@ -405,6 +406,7 @@ private fun DeviceGate(
viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit, viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val providerSignOut = rememberProviderSignOutBinding(viewModel)
var attempt by remember { mutableIntStateOf(0) } var attempt by remember { mutableIntStateOf(0) }
var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") } var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") }
var error by remember { mutableStateOf("") } 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.") 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") } Button(enabled = status != "CHECKING", onClick = { attempt++ }) { Text("Check approval") }
if (enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") } 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") }
} }
} }