feat(auth): automatic logoff after user inactivity (UI API 1.12.0)
Every request of a signed-in session states the age of the operator's last touch, key or scan in X-QQQ-User-Activity-Age, so the session heartbeat alone no longer keeps a session alive on a server with a user inactivity timeout. When the server publishes userInactivityTimeoutSeconds the scanner warns a minute before and signs out when it runs out; the server ends the session at the next request regardless. Servers without the value are unaffected. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
9bae976138
commit
d3258bf52a
6 files changed
+229
-5
No files matched your search
+3
-1
@@ -20,7 +20,9 @@ data class OAuth2AuthenticationMetaData(
|
|||||||
val clientId: String = "", val baseUrl: String = "", val scopes: String = "",
|
val clientId: String = "", val baseUrl: String = "", val scopes: String = "",
|
||||||
val nativeClientId: String? = null, val nativeClientBaseUrl: String? = null,
|
val nativeClientId: String? = null, val nativeClientBaseUrl: String? = null,
|
||||||
val bearerTokensAccepted: Boolean = false, val nativeSessionLogin: NativeSessionLogin? = null,
|
val bearerTokensAccepted: Boolean = false, val nativeSessionLogin: NativeSessionLogin? = null,
|
||||||
val nativeSessionLogout: NativeSessionLogout? = null
|
val nativeSessionLogout: NativeSessionLogout? = null,
|
||||||
|
// Automatic logoff (UI API 1.12.0): seconds without a user action after which the server ends the session.
|
||||||
|
val userInactivityTimeoutSeconds: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
|
|||||||
+2
@@ -39,6 +39,8 @@ class NativeSessionLoginTest {
|
|||||||
assertEquals("login", metadata.values.nativeSessionLogin!!.prompt)
|
assertEquals("login", metadata.values.nativeSessionLogin!!.prompt)
|
||||||
assertEquals("REQUIRED", metadata.values.nativeSessionLogin!!.nonceValidation)
|
assertEquals("REQUIRED", metadata.values.nativeSessionLogin!!.nonceValidation)
|
||||||
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.nativeSessionLogin)
|
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.nativeSessionLogin)
|
||||||
|
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
|
||||||
|
assertEquals(900, (Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"logoff","values":{"clientId":"server-client","userInactivityTimeoutSeconds":900}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
|
||||||
val wire = Json.encodeToString(request)
|
val wire = Json.encodeToString(request)
|
||||||
assertTrue(wire.contains("\"authenticationFlow\":\"AUTHORIZATION_CODE_PKCE_SESSION\""))
|
assertTrue(wire.contains("\"authenticationFlow\":\"AUTHORIZATION_CODE_PKCE_SESSION\""))
|
||||||
assertTrue(wire.contains("\"expectedNonce\":\"original-nonce-canary-1234\""))
|
assertTrue(wire.contains("\"expectedNonce\":\"original-nonce-canary-1234\""))
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ class ScannerActivity : ComponentActivity() {
|
|||||||
watchingManagedConfiguration = false
|
watchingManagedConfiguration = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Every touch and key dispatched to this activity is a user action for the server's inactivity timeout.
|
||||||
|
*******************************************************************************/
|
||||||
|
override fun onUserInteraction() {
|
||||||
|
super.onUserInteraction(); UserActivity.record()
|
||||||
|
}
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
** Reflect focus changes so obscured windows cannot keep accepting scanner input.
|
** Reflect focus changes so obscured windows cannot keep accepting scanner input.
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@@ -231,7 +238,7 @@ class ScannerActivity : ComponentActivity() {
|
|||||||
val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) }
|
val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||||
val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } }
|
val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } }
|
||||||
val repository = NetworkQQQRepository(server, requestObserver = diagnostics::nativeRequest, compatibility = compatibility) { requestedServer ->
|
val repository = NetworkQQQRepository(server, requestObserver = diagnostics::nativeRequest, compatibility = compatibility) { requestedServer ->
|
||||||
if (requestedServer == server) credentials.headers() else emptyMap()
|
if (requestedServer == server) credentials.headers() + UserActivity.header() else emptyMap()
|
||||||
}
|
}
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T
|
return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T
|
||||||
@@ -251,7 +258,9 @@ class ScannerActivity : ComponentActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest, compatibility) }
|
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest, compatibility) }
|
||||||
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, credentials::headers, compatibility, diagnostics::nativeRequest) }
|
// Every request of the session states the age of the operator's last action; heartbeats alone keep nothing alive.
|
||||||
|
val sessionHeaders = remember(server) { { credentials.headers() + UserActivity.header() } }
|
||||||
|
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, sessionHeaders, compatibility, diagnostics::nativeRequest) }
|
||||||
var approved by remember(qViewModel.sessionUUID) { mutableStateOf(false) }
|
var approved by remember(qViewModel.sessionUUID) { mutableStateOf(false) }
|
||||||
val location = rememberForegroundLocation(resumed && approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested)
|
val location = rememberForegroundLocation(resumed && approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested)
|
||||||
val currentScreen = sessionScreen(qViewModel, resumed, focused, approved, captureBounds != null)
|
val currentScreen = sessionScreen(qViewModel, resumed, focused, approved, captureBounds != null)
|
||||||
@@ -273,7 +282,7 @@ class ScannerActivity : ComponentActivity() {
|
|||||||
else WallpaperStatus.store.signedOutIfSignedIn()
|
else WallpaperStatus.store.signedOutIfSignedIn()
|
||||||
}
|
}
|
||||||
DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } }
|
DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } }
|
||||||
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, credentials::headers, compatibility, diagnostics::sessionRequest) }
|
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, sessionHeaders, compatibility, diagnostics::sessionRequest) }
|
||||||
val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) }
|
val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) }
|
||||||
val sessions = remember(sessionApi, qViewModel.sessionUUID)
|
val sessions = remember(sessionApi, qViewModel.sessionUUID)
|
||||||
{
|
{
|
||||||
@@ -310,6 +319,10 @@ class ScannerActivity : ComponentActivity() {
|
|||||||
{
|
{
|
||||||
if (approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run()
|
if (approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run()
|
||||||
}
|
}
|
||||||
|
InactivityLogoff(
|
||||||
|
((authentication as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.values?.userInactivityTimeoutSeconds,
|
||||||
|
qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
|
||||||
|
) { qViewModel.logOut() }
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalCompanySignOutEnabled provides true,
|
LocalCompanySignOutEnabled provides true,
|
||||||
LocalBusinessNavigationEnabled provides approved,
|
LocalBusinessNavigationEnabled provides approved,
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ internal fun ScannerWidget(
|
|||||||
{
|
{
|
||||||
ScannerInputSession(
|
ScannerInputSession(
|
||||||
{ driverFactory(context) },
|
{ driverFactory(context) },
|
||||||
{ capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
|
// A hardware scan is a user action even when no key event reaches the activity.
|
||||||
|
{ capture -> UserActivity.record(); currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
|
||||||
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
|
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
|
||||||
canAcceptInput = { currentInputReady() })
|
canAcceptInput = { currentInputReady() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
package io.qrun.mobile.scanner
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Automatic logoff (UI API 1.12.0). A request is not a user action: this app sends heartbeats. When the
|
||||||
|
** server has a user inactivity timeout, only what the operator does (a touch, a key, a scan) keeps the
|
||||||
|
** session alive. This object remembers when that last happened and states its age on every session request.
|
||||||
|
**
|
||||||
|
** The time base counts device sleep, so a handheld left in a cradle ages while its screen is off. Until the
|
||||||
|
** first user action of this process the age is the device's uptime: nothing is assumed in the user's favour.
|
||||||
|
*******************************************************************************/
|
||||||
|
object UserActivity {
|
||||||
|
const val HEADER = "X-QQQ-User-Activity-Age"
|
||||||
|
const val WARNING_SECONDS = 60
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
internal var clock: () -> Long = { SystemClock.elapsedRealtime() }
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastActionAt = 0L
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Record a user action. Call it only from a real touch, key or scan, never from a timer or a response.
|
||||||
|
*******************************************************************************/
|
||||||
|
fun record() {
|
||||||
|
lastActionAt = clock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** The current time on this object's time base, for a caller that needs a starting point.
|
||||||
|
*******************************************************************************/
|
||||||
|
fun now(): Long = clock()
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Whole seconds since the last user action, never negative. A caller that only started watching at
|
||||||
|
** notBefore (the on-screen timer) counts from there when no action has been seen since.
|
||||||
|
*******************************************************************************/
|
||||||
|
fun ageSeconds(notBefore: Long = 0L): Long = ((clock() - maxOf(lastActionAt, notBefore)) / 1000).coerceAtLeast(0)
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** The header that states the user's activity to the server; sent with every request of a session.
|
||||||
|
*******************************************************************************/
|
||||||
|
fun header(): Map<String, String> = mapOf(HEADER to ageSeconds().toString())
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Seconds left before the server's timeout runs out; zero or less means the session is over.
|
||||||
|
*******************************************************************************/
|
||||||
|
fun remainingSeconds(timeoutSeconds: Int, notBefore: Long = 0L): Long = timeoutSeconds - ageSeconds(notBefore)
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Forget the last user action; for tests.
|
||||||
|
*******************************************************************************/
|
||||||
|
internal fun reset() {
|
||||||
|
lastActionAt = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Sign the operator out when the server's user inactivity timeout runs out, and warn a minute before. The
|
||||||
|
** server enforces the same timeout at the next request; this is what the operator sees. Shows nothing
|
||||||
|
** unless the server has such a timeout and somebody is signed in.
|
||||||
|
*******************************************************************************/
|
||||||
|
@Composable
|
||||||
|
fun InactivityLogoff(timeoutSeconds: Int?, signedIn: Boolean, onLogoff: () -> Unit) {
|
||||||
|
if (timeoutSeconds == null || timeoutSeconds <= 0 || !signedIn) return
|
||||||
|
var remaining by remember(timeoutSeconds) { mutableStateOf<Long?>(null) }
|
||||||
|
// A session resumed by a fresh process has seen no action yet: the on-screen timer starts now, while the
|
||||||
|
// header still states the full age and the server decides from what it recorded.
|
||||||
|
val shownAt = remember(timeoutSeconds) { UserActivity.now() }
|
||||||
|
LaunchedEffect(timeoutSeconds)
|
||||||
|
{
|
||||||
|
while (true) {
|
||||||
|
val left = UserActivity.remainingSeconds(timeoutSeconds, shownAt)
|
||||||
|
if (left <= 0) {
|
||||||
|
remaining = null
|
||||||
|
onLogoff()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
remaining = left.takeIf { it <= UserActivity.WARNING_SECONDS }
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val left = remaining ?: return
|
||||||
|
// A dialog has its own window: touches in it do not reach the activity, so they are recorded here.
|
||||||
|
val stay = { UserActivity.record(); remaining = null }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = stay,
|
||||||
|
confirmButton = { TextButton(onClick = stay) { Text("Stay signed in") } },
|
||||||
|
title = { Text("Are you still there?") },
|
||||||
|
text = { Text("You will be signed out in $left ${if (left == 1L) "second" else "seconds"} because of inactivity.") }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||||
|
package io.qrun.mobile.scanner
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
class UserActivityTest {
|
||||||
|
private var now = 0L
|
||||||
|
private val systemClock = UserActivity.clock
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Before
|
||||||
|
fun useAFakeClock() {
|
||||||
|
now = 7_200_000L
|
||||||
|
UserActivity.clock = { now }
|
||||||
|
UserActivity.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@After
|
||||||
|
fun restoreTheClock() {
|
||||||
|
UserActivity.clock = systemClock
|
||||||
|
UserActivity.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun beforeAnyUserActionNothingIsAssumedInTheUsersFavour() {
|
||||||
|
assertEquals(7200L, UserActivity.ageSeconds())
|
||||||
|
assertTrue(UserActivity.remainingSeconds(900) <= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun ageIsWholeSecondsSinceTheLastUserActionAndNeverNegative() {
|
||||||
|
UserActivity.record()
|
||||||
|
assertEquals(0L, UserActivity.ageSeconds())
|
||||||
|
now += 999
|
||||||
|
assertEquals(0L, UserActivity.ageSeconds())
|
||||||
|
now += 60_501
|
||||||
|
assertEquals(61L, UserActivity.ageSeconds())
|
||||||
|
now -= 120_000
|
||||||
|
assertEquals(0L, UserActivity.ageSeconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun timePassingAloneNeverCountsOnlyARecordedActionDoes() {
|
||||||
|
UserActivity.record()
|
||||||
|
now += 899_000
|
||||||
|
assertEquals(1L, UserActivity.remainingSeconds(900))
|
||||||
|
now += 1_000
|
||||||
|
assertEquals(0L, UserActivity.remainingSeconds(900))
|
||||||
|
now += 600_000
|
||||||
|
assertEquals(-600L, UserActivity.remainingSeconds(900))
|
||||||
|
UserActivity.record()
|
||||||
|
assertEquals(900L, UserActivity.remainingSeconds(900))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun theOnScreenTimerOfAResumedSessionStartsWhenItIsShownTheHeaderDoesNot() {
|
||||||
|
val shownAt = UserActivity.now()
|
||||||
|
now += 30_000
|
||||||
|
assertEquals(870L, UserActivity.remainingSeconds(900, shownAt))
|
||||||
|
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "7230"), UserActivity.header())
|
||||||
|
UserActivity.record()
|
||||||
|
now += 10_000
|
||||||
|
assertEquals(890L, UserActivity.remainingSeconds(900, shownAt))
|
||||||
|
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "10"), UserActivity.header())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun everySessionRequestStatesTheAgeInTheUiApiHeader() {
|
||||||
|
UserActivity.record()
|
||||||
|
now += 42_000
|
||||||
|
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "42"), UserActivity.header())
|
||||||
|
assertEquals("X-QQQ-User-Activity-Age", UserActivity.HEADER)
|
||||||
|
assertEquals(60, UserActivity.WARNING_SECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user