fix: isolate operator login and logout lifecycles

Bind password and legacy Auth0 responses to their initiating origin,
metadata and generation. Defer cookie adoption, consume exchanges once,
and revoke known abandoned sessions at their original server. Make
local logout independent of provider callbacks and idempotent across
overlapping calls, delayed storage and screen remounts.

Exclude sample session storage from backup and transfer, and restrict
sample cleartext environments to debug builds. Document the remaining
trusted-scanner input finding and the real-provider acceptance boundary.

Validation:109 JVM tests and scanner lint pass;32 selected TC22 native
tests pass, including ten new authentication lifecycle regressions.
Packaged unsigned sample release denies cleartext and backup, with all
ten cloud/device-transfer exclusions. Operator installation is unchanged.

Co-Authored-By: OpenAI GPT-6.1 (Codex) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandOpenAI GPT-6.1 committed 2026-10-31 17:37:39 -05:00
1 parent 7e0cbee4fe
commit df532ff514
19 files changed
+868 -264

No files matched your search

+23 -1
View File
@@ -48,11 +48,33 @@ The pending attempt is kept in memory and bound to server, provider metadata, cl
Provider callback URLs, authorization codes, verifiers and raw errors are not shown in diagnostics. App sign-out ends the QQQ session. The browser's identity-provider SSO session may remain; global provider logout is not implied by this contract.
Password and legacy Auth0 flows also bind each attempt and provisional cookie to
its initiating server, metadata and generation. A response cannot restore a
signed-out operator or replace a later operator after a server change. The
repository returns provisional session results: custom callers must validate
their pending attempt before explicitly adopting the session. Repeated logout
effects share one logical sign-out; local credentials are cleared independently
of bounded server revocation and the browser provider's outcome.
The fresh-login prompt is a request to the provider, not cryptographic proof of a newly validated `auth_time`. Before shared-handheld deployment, test with the prior operator still signed into the browser: the next login must require authentication, allow the next operator to identify themselves, and attribute subsequent scans to that operator. Custom provider flows or upstream SSO may affect this behavior and require provider configuration; this client change does not modify them.
## Host storage and transport
Applications embedding the shared libraries must keep reusable session storage under
Android's `noBackupFilesDir` and exclude it from both cloud backup and device transfer.
Ordinary preference storage can carry session authority to a restored installation.
The scanner and sample hosts disable backup and define explicit transfer exclusions.
The sample now uses a no-backup store; it deliberately does not migrate its previous
backup-eligible settings, so an upgraded sample requires a fresh login.
The sample permits HTTP development environments and cleartext traffic only in debug
builds. Its release manifest rejects cleartext even for an old restored HTTP address.
Embedding applications must enforce the same release transport policy before allowing
authentication. The scanner's existing release HTTPS requirement remains in force.
## Verification status
The combined client passed 102 JVM tests, scanner lint and APK assembly. All 20 selected native navigation/input/account/OAuth cases passed on TC22, including seven synthetic AppAuth request/result scenarios; [the evidence](NATIVE-AUTH-ACCEPTANCE.md) states their boundaries. Generated AppAuth notices were verified in debug/E2E APKs. A real configured Authentik sign-in remains a separate acceptance gate: it requires a reachable HTTPS QQQ server advertising the descriptor, an explicitly registered callback and an authorized test account. No live provider configuration was changed as part of implementation.
The latest security qualification passes 109 JVM tests, scanner lint and APK assembly. All 32 selected native navigation/input/account/authentication/storage cases pass on TC22, including seven synthetic AppAuth request/result scenarios and ten additional authentication lifecycle regressions. See [the scoped security evidence](SECURITY-REVIEW-2026-10-31.md) and [native OAuth evidence](NATIVE-AUTH-ACCEPTANCE.md) for their boundaries. Earlier generated AppAuth notices were verified in debug/E2E APKs. A real configured Authentik sign-in remains a separate acceptance gate: it requires a reachable HTTPS QQQ server advertising the descriptor, an explicitly registered callback and an authorized test account. No live provider configuration was changed as part of implementation.
Primary references: [AppAuth Android 0.11.1](https://github.com/openid/AppAuth-Android/tree/0.11.1) documents external-browser authorization, PKCE and server-side exchange when a confidential secret is needed. [Authentik OAuth2 provider documentation](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/) documents PKCE, exact redirect registration and global issuer mode.
+36
View File
@@ -0,0 +1,36 @@
# October 31 client security review
The deep static review completed against published client revision `d1de0bf`.
It reported one medium and five low findings. Coverage is partial: external
server/provider/dependency implementations and some bundled binary implementation
review were outside the completed scope. This is not production release clearance.
| Finding | Severity | Current verification |
| --- | --- | --- |
| Delayed password completion after logout/server change | Medium | Fixed origin/generation binding and deferred cookie adoption; HTTP and native delayed-response cases pass. |
| Sample release cleartext traffic | Low | Debug-only HTTP environments/manifest override; packaged release denies cleartext. |
| Auth0 provider failure preserving local QQQ login | Low | Local sign-out is independent, bounded and idempotent; failed-provider, restart and remount cases pass. |
| Unauthenticated external scan broadcasts | Low | Open. Trusted scanner integration awaits the separate Zebra SDK license decision; no production ingress change installed. |
| Legacy Auth0 callback crossing server origins | Low | Origin-bound callback/exchange lifecycle, one-use exchange and discarded-session cleanup; HTTP/native cases pass. |
| Sample session backup | Low | No-backup storage, disabled backups and explicit cloud/transfer exclusions; native storage and packaged release checks pass. |
The qualification batch passes 109 JVM tests (30 core, 46 mobile, 33 scanner),
scanner lint with no errors or warnings, debug/native-test APK assembly, and
unsigned sample release assembly. A physical TC22 run passes all 32 selected
navigation, input, account, synthetic OAuth/Auth0 lifecycle and host-storage cases
in 117.325 seconds. Ten new lifecycle cases cover delayed completion, duplicate
callbacks, failed provider logout, overlapping logout and logout-screen remounting.
These use synthetic repositories/browser callbacks; they do not prove a real
configured identity-provider round trip.
Packaged release inspection confirms backup and cleartext disabled, plus all ten
cloud/device-transfer exclusions. The wrapper now matches the official Gradle 8.7
checksum; see [provenance](WRAPPER-PROVENANCE.md). The new scanner debug APK hash is
`8c4d3886b4e994759f909677849dae26a333f476a036457c9ea76fe4566c512a`.
It has not replaced the operator's installed scanner app in this checkpoint.
Separate live dependency review identified affected Guava in the server's tested
classpath. A patched source pin is undergoing isolated consumer qualification;
the client APK does not contain that Guava artifact. Actual Authentik, trusted
physical scanner input, immutable Record Files publication and final deployment
and signing gates remain open. See [TODO](TODO.md) and [release prerequisites](RELEASING.md).
@@ -136,7 +136,8 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
***************************************************************************/
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse
{
return resolveSession(qqqApiServiceMap[baseUrl]!!.manageSession(body))
val service = requireNotNull(qqqApiServiceMap[baseUrl])
return resolveSession(service.manageSession(body, DeferredSessionCookie()))
}
override suspend fun verifyNativeSessionLogin() = verifyNativeSessionLoginAt(baseUrl)
@@ -155,6 +156,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
val service = requireNotNull(qqqApiServiceMap[server])
verifyNativeSessionLoginAt(server)
val response = service.exchangeNativeSession(body, DeferredSessionCookie())
return resolveSession(response)
}
/** Login cookies remain provisional until the initiating ViewModel accepts the response. */
private fun resolveSession(response: retrofit2.Response<ManageSessionResponse>): ManageSessionResponse
{
if(!response.isSuccessful) throw retrofit2.HttpException(response)
val result = response.body() ?: throw IllegalStateException("Sign-in response did not establish a session")
val requestUrl = response.raw().request.url
val cookie = response.headers().values("Set-Cookie").mapNotNull { Cookie.parse(requestUrl, it) }
@@ -170,22 +178,19 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
}
/** Send credentials only to the session endpoint, never to subsequent requests. */
override suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse =
resolveSession(qqqApiServiceMap[baseUrl]!!.loginWithPassword(Credentials.basic(username, password, Charsets.UTF_8), emptyMap()))
/** Modern QQQ deliberately omits the UUID body field and returns an HttpOnly session cookie. */
private fun resolveSession(response: ManageSessionResponse): ManageSessionResponse
override suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse
{
val session = response.uuid?.takeIf { it.isNotBlank() } ?: sessionUUIDs[baseUrl]?.takeIf { it.isNotBlank() }
?: throw IllegalStateException("Sign-in response did not establish a session")
return response.copy(uuid = session)
val service = requireNotNull(qqqApiServiceMap[baseUrl])
return resolveSession(service.loginWithPassword(Credentials.basic(username, password, Charsets.UTF_8), emptyMap(), DeferredSessionCookie()))
}
/** Preserve the cookie until the server has received the revocation request. */
override suspend fun logout()
{
qqqApiServiceMap[baseUrl]!!.logout().close()
sessionUUIDs.remove(baseUrl)
val server = baseUrl
val session = sessionUUIDs[server]
try { if(!session.isNullOrBlank()) discardNativeSession(server, session) }
finally { if(session != null) sessionUUIDs.remove(server, session) }
}
/***************************************************************************
@@ -38,7 +38,7 @@ import retrofit2.http.Query
const val BASE_PATH = "/qqq/v1/"
/** A native login response is adopted only after its initiating ViewModel accepts it. */
/** A login response is adopted only after its initiating ViewModel accepts it. */
class DeferredSessionCookie
/***************************************************************************
@@ -54,14 +54,16 @@ interface QQQApiService
suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData
@POST(BASE_PATH + "manageSession")
suspend fun manageSession(@Body body: ManageSessionRequest): ManageSessionResponse
suspend fun manageSession(@Body body: ManageSessionRequest,
@retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): retrofit2.Response<ManageSessionResponse>
@POST(BASE_PATH + "manageSession")
suspend fun exchangeNativeSession(@Body body: ManageSessionRequest,
@retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): retrofit2.Response<ManageSessionResponse>
@POST(BASE_PATH + "manageSession")
suspend fun loginWithPassword(@Header("Authorization") authorization: String, @Body body: Map<String, String>): ManageSessionResponse
suspend fun loginWithPassword(@Header("Authorization") authorization: String, @Body body: Map<String, String>,
@retrofit2.http.Tag cookiePolicy: DeferredSessionCookie): retrofit2.Response<ManageSessionResponse>
@POST(BASE_PATH + "logout")
suspend fun logout(): ResponseBody
@@ -40,6 +40,7 @@ interface QQQRepository
suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData
/** Return a provisional session; the initiating login must accept it before calling setSessionUUID. */
suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse
/** Native browser sign-in requires explicit version/feature discovery before authorization. */
@@ -52,7 +53,7 @@ interface QQQRepository
/** Revoke an abandoned exchange at its original server without activating its cookie. */
suspend fun discardNativeSession(server: String, session: String) { }
/** Authenticate table-backed users without retaining their password. */
/** Authenticate without retaining the password or adopting the provisional response cookie. */
suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse =
throw UnsupportedOperationException("Password authentication is unavailable")
@@ -0,0 +1,69 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.core.controllers
import com.kingsrook.qqq.frontend.android.core.model.Environment
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.TimeUnit
class DeferredAuthenticationTest
{
@Test fun passwordCookieAfterSwitchIsNeverAdopted() = delayedResponse(password = true, cookie = true)
@Test fun passwordBodyAfterSwitchIsNeverAdopted() = delayedResponse(password = true, cookie = false)
@Test fun auth0CookieAfterSwitchIsNeverAdopted() = delayedResponse(password = false, cookie = true)
@Test fun auth0BodyAfterSwitchIsNeverAdopted() = delayedResponse(password = false, cookie = false)
private fun delayedResponse(password: Boolean, cookie: Boolean) = runBlocking()
{
MockWebServer().use { first -> MockWebServer().use { second ->
val origin = first.url("/").toString()
val otherOrigin = second.url("/").toString()
first.enqueue(MockResponse().setBody(VERSION_FIXTURE))
val response = MockResponse().setBody(if(cookie) "{}" else """{"uuid":"abandoned-session"}""")
.setBodyDelay(500, TimeUnit.MILLISECONDS)
if(cookie) response.addHeader("Set-Cookie", "sessionUUID=abandoned-session; HttpOnly; Path=/")
first.enqueue(response)
first.enqueue(MockResponse().setBody("{}").addHeader("Set-Cookie", "sessionUUID=; Path=/; Max-Age=0"))
first.enqueue(MockResponse().setBody("{}"))
second.enqueue(MockResponse().setBody(VERSION_FIXTURE))
second.enqueue(MockResponse().setBody("{}"))
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("first", origin))
val pending = async(Dispatchers.IO) {
if(password) repository.loginWithPassword("operator", "synthetic-password")
else repository.manageSession(ManageSessionRequest(accessToken = "synthetic-provider-token"))
}
withContext(Dispatchers.IO) {
assertEquals("/qqq/v1/version", first.takeRequest(5, TimeUnit.SECONDS)?.path)
val login = requireNotNull(first.takeRequest(5, TimeUnit.SECONDS))
assertEquals("/qqq/v1/manageSession", login.path)
assertNull(login.getHeader("Cookie"))
}
repository.setEnvironment(Environment("second", otherOrigin))
repository.setSessionUUID("current-second-session")
assertEquals("abandoned-session", pending.await().uuid)
repository.discardNativeSession(origin, "abandoned-session")
val discard = requireNotNull(first.takeRequest(5, TimeUnit.SECONDS))
assertEquals("/qqq/v1/logout", discard.path)
assertEquals("sessionUUID=abandoned-session", discard.getHeader("Cookie"))
assertNull(discard.getHeader("Authorization"))
repository.getMetaData("test", "1", "test", "1")
assertEquals("/qqq/v1/version", second.takeRequest(5, TimeUnit.SECONDS)?.path)
val current = requireNotNull(second.takeRequest(5, TimeUnit.SECONDS))
assertEquals("sessionUUID=current-second-session", current.getHeader("Cookie"))
assertNull(current.getHeader("Authorization"))
assertFalse(current.body.readUtf8().contains("synthetic-provider-token"))
repository.setEnvironment(Environment("first", origin))
repository.getMetaData("test", "1", "test", "1")
assertNull("Returning to the original server must not resurrect the abandoned cookie",
requireNotNull(first.takeRequest(5, TimeUnit.SECONDS)).getHeader("Cookie"))
} }
}
}
@@ -116,6 +116,7 @@ class NetworkQQQRepositoryTest
repository.setEnvironment(Environment("first", origin))
val login = repository.loginWithPassword("demo", "synthetic-password")
assertEquals("synthetic-session", login.uuid)
repository.setSessionUUID(requireNotNull(login.uuid))
repository.getMetaData("test", "1", "test", "1")
assertEquals("/qqq/v1/version", first.takeRequest().path)
val loginRequest = first.takeRequest()
@@ -0,0 +1,27 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication
import android.content.Context
import androidx.compose.runtime.staticCompositionLocalOf
import com.auth0.android.Auth0
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.provider.WebAuthProvider
import com.auth0.android.result.Credentials
/** Platform boundary: production always uses Auth0's external browser implementation. */
interface Auth0Browser
{
fun login(context: Context, account: Auth0, audience: String, scheme: String, callback: Callback<Credentials, AuthenticationException>)
fun logout(context: Context, account: Auth0, scheme: String, callback: Callback<Void?, AuthenticationException>)
}
val LocalAuth0Browser = staticCompositionLocalOf<Auth0Browser> {
object : Auth0Browser
{
override fun login(context: Context, account: Auth0, audience: String, scheme: String, callback: Callback<Credentials, AuthenticationException>)
{ WebAuthProvider.login(account).withAudience(audience).withScheme(scheme).start(context, callback) }
override fun logout(context: Context, account: Auth0, scheme: String, callback: Callback<Void?, AuthenticationException>)
{ WebAuthProvider.logout(account).withScheme(scheme).start(context, callback) }
}
}
@@ -21,152 +21,77 @@
package com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication
import android.util.Log
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.auth0.android.Auth0
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.provider.WebAuthProvider
import com.auth0.android.result.Credentials
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Auth0AuthenticationMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData
import com.kingsrook.qqq.frontend.android.core.utils.firstN
import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullScreenLoading
import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullSizedAllCenteredColumn
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TAG = "QAuth0Form"
/***************************************************************************
** form for logging in using auth0
***************************************************************************/
@Composable
fun QAuth0Login(qViewModel: QViewModel, modifier: Modifier = Modifier)
{
val authenticationMetaData = (qViewModel.authenticationMetaDataLoadState as LoadState.Success<BaseAuthenticationMetaData>).value as Auth0AuthenticationMetaData
val auth0Instance: Auth0 = QAuth0Service.makeAuth0Instance(qViewModel)
val auth0Success = remember { mutableStateOf(false) }
val auth0Failure = remember { mutableStateOf(false) }
val callback = object : Callback<Credentials, AuthenticationException>
val metadata = (qViewModel.authenticationMetaDataLoadState as? LoadState.Success)?.value as? Auth0AuthenticationMetaData ?: return
val context = LocalContext.current
val launcher = LocalAuth0Browser.current
val scope = rememberCoroutineScope()
var retry by remember { mutableIntStateOf(0) }
var failed by remember { mutableStateOf(false) }
var exchanging by remember { mutableStateOf(false) }
DisposableEffect(qViewModel, qViewModel.environment.baseUrl, metadata, retry)
{
/***************************************************************************
**
***************************************************************************/
override fun onFailure(error: AuthenticationException)
val attempt = qViewModel.prepareAuthentication()
if(attempt != null)
{
Log.w(TAG, "Auth0 Called back to failure", error)
error.printStackTrace()
qViewModel.logInFailed()
auth0Failure.value = true
}
/***************************************************************************
**
***************************************************************************/
override fun onSuccess(result: Credentials)
{
Log.d(TAG, "Auth0 sign-in succeeded")
auth0Success.value = true
qViewModel.doSetSessionUserFullName(result.user.nickname ?: "Unknown")
qViewModel.manageSession(result.accessToken)
}
}
/***************************************************************************
**
***************************************************************************/
fun tryAgain()
{
/////////////////////////////////////////////////////////////////////////
// just by resetting these flags, we should re-run the WebAuthProvider //
/////////////////////////////////////////////////////////////////////////
auth0Failure.value = false
auth0Success.value = false
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
// do a little count-down before going to auth0 - probably just for dev, so we can see it happening //
//////////////////////////////////////////////////////////////////////////////////////////////////////
val countdown = remember { mutableStateOf(0) }
LaunchedEffect(key1 = countdown.value)
{
if(countdown.value > 0)
{
delay(100)
countdown.value = countdown.value - 1
}
}
//////////////////////////////////////////////////////////////////////////////
// pick what we show based on state - later states at the top of the switch //
//////////////////////////////////////////////////////////////////////////////
if(qViewModel.isAuthenticated)
{
Log.d(TAG, "User is authenticated - should be seeing apps now")
FullScreenLoading("Login Successful. You should be taken to your apps now.")
}
else if(auth0Success.value)
{
Log.d(TAG, "Auth0Success is true - looking at manageSessionLoadState now...")
when (val state = qViewModel.manageSessionLoadState)
{
is LoadState.Loading ->
val callback = object : Callback<Credentials, AuthenticationException>
{
FullScreenLoading("Creating Application Session...")
}
is LoadState.Error -> Text("Error creating session: ${state.message}")
is LoadState.Success ->
{
FullScreenLoading("Login Successful. You should be taken to your apps now.")
override fun onFailure(error: AuthenticationException)
{
scope.launch {
if(qViewModel.isCurrentAuthentication(attempt))
{
qViewModel.cancelAuthentication(attempt)
failed = true
}
}
}
override fun onSuccess(result: Credentials)
{
scope.launch {
if(qViewModel.isCurrentAuthentication(attempt))
{
exchanging = true
qViewModel.completeAuth0Login(attempt, result.accessToken, result.user.nickname ?: "Unknown")
}
}
}
}
try { launcher.login(context, QAuth0Service.makeAuth0Instance(qViewModel), metadata.values.audience, QAuth0Service.scheme, callback) }
catch(exception: Exception) { qViewModel.cancelAuthentication(attempt); failed = true }
}
onDispose { if(attempt != null) qViewModel.cancelAuthentication(attempt) }
}
if(failed || (exchanging && qViewModel.manageSessionLoadState is LoadState.Error))
{
FullSizedAllCenteredColumn {
Text("Sign-in failed. Please try again.")
Button(onClick = { failed = false; exchanging = false; retry++ }) { Text("Try again") }
}
}
else if(auth0Failure.value)
{
Log.d(TAG, "Auth0Failure is true - Offering try-again button")
FullSizedAllCenteredColumn()
{
Text("Login failed... ", modifier = Modifier.padding(bottom = 16.dp))
Button(onClick = { tryAgain() })
{
Text("Try again")
}
}
}
else if(countdown.value > 0)
{
Log.d(TAG, "Sleeping before Auth0 login")
FullScreenLoading("Taking you to login in ${countdown.value} moment${if(countdown.value == 1) "" else "s"}...")
}
else
{
FullScreenLoading("Logging in...")
Log.d(TAG, "Going to Auth0 login (WebAuthProvider)")
WebAuthProvider
.login(auth0Instance)
.withAudience(authenticationMetaData.values.audience)
.withScheme(QAuth0Service.scheme)
.start(LocalContext.current, callback)
}
else FullScreenLoading(if(exchanging) "Creating Application Session…" else "Logging in…")
}
@@ -20,86 +20,48 @@
package com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication
import android.util.Log
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.provider.WebAuthProvider
import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullScreenLoading
import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullSizedAllCenteredColumn
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TAG = "QAuth0Logout"
/***************************************************************************
**
***************************************************************************/
@Composable
fun QAuth0Logout(qViewModel: QViewModel, modifier: Modifier = Modifier)
{
val auth0Instance = QAuth0Service.makeAuth0Instance(qViewModel);
val auth0Success = remember { mutableStateOf(false) }
val callback = object : Callback<Void?, AuthenticationException>
val context = LocalContext.current
val launcher = LocalAuth0Browser.current
val scope = rememberCoroutineScope()
var providerMessage by remember { mutableStateOf("Provider sign-out has not been confirmed.") }
LaunchedEffect(qViewModel)
{
/***************************************************************************
**
***************************************************************************/
override fun onFailure(error: AuthenticationException)
// Local QQQ sign-out must not depend on a browser callback (including cancellation or failure).
qViewModel.logOut()
if(!qViewModel.claimAuth0ProviderLogout()) return@LaunchedEffect
val callback = object : Callback<Void?, AuthenticationException>
{
Log.w(TAG, "Auth0 Logout Called back to failure", error)
}
/***************************************************************************
**
***************************************************************************/
override fun onSuccess(result: Void?)
{
Log.d(TAG, "Auth0 Logout Called back success")
auth0Success.value = true;
qViewModel.logOut()
override fun onFailure(error: AuthenticationException)
{ scope.launch { providerMessage = "Provider sign-out could not be confirmed. Your provider may still be signed in." } }
override fun onSuccess(result: Void?)
{ scope.launch { providerMessage = "Provider sign-out confirmed." } }
}
try { launcher.logout(context, QAuth0Service.makeAuth0Instance(qViewModel), QAuth0Service.scheme, callback) }
catch(exception: Exception) { providerMessage = "Provider sign-out could not be started." }
}
//////////////////////////////////////////////////////////////////////////////
// pick what we show based on state - later states at the top of the switch //
//////////////////////////////////////////////////////////////////////////////
if(!qViewModel.isAuthenticated)
{
Log.d(TAG, "User is not authenticated, so, idk, shouldn't be in here.")
FullSizedAllCenteredColumn()
{
Text("Logout Successful.", modifier = Modifier.padding(bottom = 16.dp))
Button(onClick = { qViewModel.requestLogin() })
{
Text("Log back in")
}
}
FullSizedAllCenteredColumn {
Text(qViewModel.logoutMessage.ifBlank { "Signing out…" })
Text(providerMessage)
Button(onClick = { qViewModel.requestLogin() }, enabled = !qViewModel.logoutInProgress) { Text("Log back in") }
}
else if(auth0Success.value)
{
Log.d(TAG, "Auth0Success is true - where do we go now?")
FullScreenLoading("Completing Logout...")
}
else
{
Log.d(TAG, "Going to Auth0 logout (WebAuthProvider)")
FullScreenLoading("Logging out...")
WebAuthProvider.logout(auth0Instance)
.withScheme(QAuth0Service.scheme)
.start(LocalContext.current, callback)
}
}
@@ -52,6 +52,8 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAu
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private const val TAG = "QViewModel"
@@ -140,16 +142,110 @@ open class QViewModel(
private var oauth2Metadata: BaseAuthenticationMetaData? = null
private var oauth2ExchangeJob: Job? = null
/** Opaque login identity: callbacks and responses belong to their initiating server and generation. */
class AuthenticationAttempt internal constructor(internal val generation: Long, internal val server: String,
internal val metadata: BaseAuthenticationMetaData?)
{
internal var exchangeStarted = false
}
private var authenticationGeneration = 0L
private var authenticationAttempt: AuthenticationAttempt? = null
private var authenticationJob: Job? = null
private var logoutJob: Job? = null
private var auth0ProviderLogoutJob: Job? = null
private val settingsMutex = Mutex()
fun prepareAuthentication(): AuthenticationAttempt?
{
if(isAuthenticated || isLogoutRequested || logoutInProgress) return null
val metadata = (authenticationMetaDataLoadState as? LoadState.Success)?.value ?: return null
cancelAuthentication()
cancelOAuth2Login()
return AuthenticationAttempt(authenticationGeneration, environment.baseUrl, metadata).also { authenticationAttempt = it }
}
fun isCurrentAuthentication(attempt: AuthenticationAttempt): Boolean =
authenticationAttempt === attempt && attempt.generation == authenticationGeneration &&
attempt.server == environment.baseUrl && attempt.metadata == (authenticationMetaDataLoadState as? LoadState.Success)?.value &&
!isAuthenticated && !isLogoutRequested && !logoutInProgress
fun cancelAuthentication(attempt: AuthenticationAttempt? = null)
{
if(attempt != null && authenticationAttempt !== attempt) return
authenticationGeneration++
authenticationAttempt = null
authenticationJob?.cancel()
authenticationJob = null
}
fun completeAuth0Login(attempt: AuthenticationAttempt, accessToken: String, userName: String)
{
viewModelScope.launch {
if(isCurrentAuthentication(attempt)) exchangeAuthentication(attempt, userName) {
qqqRepository.manageSession(ManageSessionRequest(accessToken = accessToken, uuid = null))
}
}
}
private fun exchangeAuthentication(attempt: AuthenticationAttempt, userName: String?, exchange: suspend () -> ManageSessionResponse)
{
if(!isCurrentAuthentication(attempt) || attempt.exchangeStarted) return
attempt.exchangeStarted = true
manageSessionLoadState = LoadState.Loading(Unit)
authenticationJob = viewModelScope.launch {
var newSession: String? = null
var adopted = false
try
{
val response = exchange()
newSession = response.uuid?.takeIf { it.isNotBlank() }
kotlinx.coroutines.currentCoroutineContext().ensureActive()
if(!isCurrentAuthentication(attempt)) return@launch
sessionUUID = requireNotNull(newSession)
qqqRepository.setSessionUUID(sessionUUID)
if(userName != null) doSetSessionUserFullName(userName)
manageSessionLoadState = LoadState.Success(response)
logInSuccessful()
adopted = true
authenticationAttempt = null
loadQInstance()
}
catch(exception: kotlinx.coroutines.CancellationException) { throw exception }
catch(exception: Exception)
{
if(isCurrentAuthentication(attempt)) manageSessionLoadState = LoadState.Error("Sign-in failed. Check your credentials and connection.")
}
finally
{
if(!adopted && newSession != null) kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
try { kotlinx.coroutines.withTimeout(3000) { qqqRepository.discardNativeSession(attempt.server, newSession!!) } }
catch(exception: Exception) { /* Never adopt an abandoned credential. */ }
}
}
}
}
////////////////////////////////////////////////////////////////////
// note - new properties - do you want to add them to clearAll()? //
////////////////////////////////////////////////////////////////////
init
{
val generation = authenticationGeneration
viewModelScope.launch()
{
doInitialLoadFromDataStore().join()
if(generation != authenticationGeneration)
{
initialLoadFromDataStoreLoadState = LoadState.Success(Unit)
return@launch
}
loadAuthenticationMetaData().join()
if(generation != authenticationGeneration)
{
initialLoadFromDataStoreLoadState = LoadState.Success(Unit)
return@launch
}
tryLoginFromDataStore().join()
}
}
@@ -193,21 +289,22 @@ open class QViewModel(
/***************************************************************************
**
***************************************************************************/
private fun clearAll()
private fun clearAll(preserveLogoutScreen: Boolean = false)
{
cancelAuthentication()
cancelOAuth2Login()
////////////////////////////////////////////
// reset all the things to initial values //
////////////////////////////////////////////
// storedSessionDance = LoadState.Loading(Unit) // well, not this one - it's only for our very first load.
isAuthenticated = false
isLogoutRequested = false
if(!preserveLogoutScreen) isLogoutRequested = false
sessionUUID = ""
storedSessionUUIDLoadState = LoadState.Loading(Unit)
sessionUserFullName = ""
storedSessionUserFullName = LoadState.Loading(Unit)
storedEnvironmentLoadState = LoadState.Loading(Unit)
authenticationMetaDataLoadState = LoadState.Loading(Unit)
if(!preserveLogoutScreen) authenticationMetaDataLoadState = LoadState.Loading(Unit)
manageSessionLoadState = LoadState.Loading(Unit)
qInstanceLoadState = LoadState.Loading(Unit)
topBarStatusText = ""
@@ -220,9 +317,11 @@ open class QViewModel(
***************************************************************************/
private fun loadAuthenticationMetaData(): Job
{
val generation = authenticationGeneration
val server = environment.baseUrl
return viewModelScope.launch()
{
authenticationMetaDataLoadState = try
val result: LoadState<BaseAuthenticationMetaData> = try
{
LoadState.Success(qqqRepository.getAuthenticationMetaData())
}
@@ -231,6 +330,8 @@ open class QViewModel(
Log.w(TAG, "Error loading Authentication MetaData")
LoadState.Error(e, "Error loading Authentication MetaData")
}
kotlinx.coroutines.currentCoroutineContext().ensureActive()
if(generation == authenticationGeneration && server == environment.baseUrl) authenticationMetaDataLoadState = result
}
}
@@ -239,24 +340,9 @@ open class QViewModel(
***************************************************************************/
open fun manageSession(accessToken: String)
{
val outerThis = this
viewModelScope.launch()
{
try
{
val manageSessionLoadState = LoadState.Success(qqqRepository.manageSession(ManageSessionRequest(accessToken = accessToken, uuid = null)))
sessionUUID = requireNotNull(manageSessionLoadState.value.uuid)
qqqRepository.setSessionUUID(sessionUUID)
loadQInstance()
logInSuccessful()
outerThis.manageSessionLoadState = manageSessionLoadState
}
catch(e: Exception)
{
Log.w(TAG, "Error managing session")
outerThis.manageSessionLoadState = LoadState.Error(e, "Error managing session")
}
val attempt = prepareAuthentication() ?: return
exchangeAuthentication(attempt, sessionUserFullName.takeIf { it.isNotBlank() }) {
qqqRepository.manageSession(ManageSessionRequest(accessToken = accessToken, uuid = null))
}
}
@@ -287,6 +373,7 @@ open class QViewModel(
***************************************************************************/
fun requestLogout()
{
cancelAuthentication()
cancelOAuth2Login()
isLogoutRequested = true
qNavigator.goHome(cancelActiveProcess = false)
@@ -329,6 +416,7 @@ open class QViewModel(
***************************************************************************/
private fun tryLoginFromDataStore(): Job
{
val generation = authenticationGeneration
return viewModelScope.launch()
{
when (storedSessionUUIDLoadState)
@@ -348,12 +436,14 @@ open class QViewModel(
Log.d(TAG, "loadStoredSessionUUID was set - trying to load instance now")
qqqRepository.setSessionUUID(sessionUUID)
loadQInstance().join()
if(generation != authenticationGeneration || isLogoutRequested) return@launch
when (qInstanceLoadState)
{
is LoadState.Success<QInstance> ->
{
loadStoredSessionUserFullName().join()
if(generation != authenticationGeneration || isLogoutRequested) return@launch
sessionUserFullName = when (val it = storedSessionUserFullName)
{
@@ -445,6 +535,11 @@ open class QViewModel(
{
return viewModelScope.launch()
{
if(this@QViewModel.environment.baseUrl != environment.baseUrl)
{
authenticationMetaDataLoadState = LoadState.Loading(Unit)
manageSessionLoadState = LoadState.Loading(Unit)
}
this@QViewModel.environment = environment
qqqRepository.setEnvironment(environment)
settingsRepository.setEnvironment(environment)
@@ -457,6 +552,8 @@ open class QViewModel(
***************************************************************************/
fun switchEnvironment(environment: Environment, postSwitchCallback: () -> Unit)
{
cancelAuthentication()
cancelOAuth2Login()
qNavigator.goHome(cancelActiveProcess = false)
viewModelScope.launch()
{
@@ -491,9 +588,12 @@ open class QViewModel(
***************************************************************************/
private fun storeSessionUUID(value: String)
{
val generation = authenticationGeneration
viewModelScope.launch()
{
settingsRepository.setSessionUUID(value)
settingsMutex.withLock {
if(generation == authenticationGeneration) settingsRepository.setSessionUUID(value)
}
}
}
@@ -503,9 +603,12 @@ open class QViewModel(
fun doSetSessionUserFullName(value: String)
{
sessionUserFullName = value
val generation = authenticationGeneration
viewModelScope.launch()
{
settingsRepository.setSessionUserFullName(value)
settingsMutex.withLock {
if(generation == authenticationGeneration) settingsRepository.setSessionUserFullName(value)
}
}
}
@@ -514,37 +617,52 @@ open class QViewModel(
***************************************************************************/
fun logOut(): Job
{
logoutJob?.takeIf { it.isActive || (!isAuthenticated && sessionUUID.isBlank() &&
authenticationAttempt == null && oauth2LoginPhase == OAuth2LoginPhase.READY) }?.let { return it }
cancelAuthentication()
cancelOAuth2Login()
isAuthenticated = false
logoutInProgress = true
return viewModelScope.launch()
{
val capturedIsLogoutRequested = isLogoutRequested
val capturedAuthLoadState = authenticationMetaDataLoadState
activeProcessViewModel?.reset()
logoutInProgress = true
logoutMessage = "Signing out…"
var serverConfirmed = false
try
{
try { beforeLogout() }
settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") }
try { kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() } }
catch(exception: kotlinx.coroutines.CancellationException) { throw exception }
catch(exception: Exception) { /* Core logout and local clearing still run. */ }
qqqRepository.logout()
serverConfirmed = true
serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) { qqqRepository.logout(); true } ?: false
}
catch(exception: kotlinx.coroutines.CancellationException) { throw exception }
catch(exception: Exception) { /* Local sign-out still completes when the network is unavailable. */ }
finally
{
clearAll()
clearAll(preserveLogoutScreen = true)
qqqRepository.setSessionUUID("")
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { settingsRepository.setSessionUUID("") }
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") }
}
isLogoutRequested = capturedIsLogoutRequested
authenticationMetaDataLoadState = capturedAuthLoadState
logoutInProgress = false
logoutMessage = if(serverConfirmed) "Signed out."
else "Signed out on this device. Server sign-out could not be confirmed."
}
}
}.also { logoutJob = it }
}
/** Activity recreation may render logout again; each logical sign-out opens the provider only once. */
fun claimAuth0ProviderLogout(): Boolean
{
val current = logoutJob ?: return false
if(auth0ProviderLogoutJob === current) return false
auth0ProviderLogoutJob = current
return true
}
/***************************************************************************
@@ -552,9 +670,11 @@ open class QViewModel(
***************************************************************************/
fun loadQInstance(): Job
{
val generation = authenticationGeneration
val server = environment.baseUrl
return viewModelScope.launch()
{
qInstanceLoadState = try
val result: LoadState<QInstance> = try
{
// todo - get frontend name & version from ... somewhere!
val value = qqqRepository.getMetaData("qqq-android", "1.0", applicationName, applicationVersion)
@@ -565,12 +685,15 @@ open class QViewModel(
Log.w(TAG, "Error loading QInstance")
LoadState.Error(e,"Error loading QInstance")
}
kotlinx.coroutines.currentCoroutineContext().ensureActive()
if(generation == authenticationGeneration && server == environment.baseUrl) qInstanceLoadState = result
}
}
fun prepareOAuth2Login(binding: OAuth2LoginBinding): Long?
{
if(oauth2LoginPhase != OAuth2LoginPhase.READY || isAuthenticated || isLogoutRequested || binding.server != environment.baseUrl) return null
if(oauth2LoginPhase != OAuth2LoginPhase.READY || isAuthenticated || isLogoutRequested || logoutInProgress || binding.server != environment.baseUrl) return null
cancelAuthentication()
oauth2Generation++
oauth2Binding = binding
oauth2Metadata = (authenticationMetaDataLoadState as? LoadState.Success)?.value
@@ -581,7 +704,7 @@ open class QViewModel(
fun isCurrentOAuth2Login(generation: Long, binding: OAuth2LoginBinding): Boolean =
generation == oauth2Generation && binding == oauth2Binding && binding.server == environment.baseUrl &&
oauth2Metadata == (authenticationMetaDataLoadState as? LoadState.Success)?.value && !isAuthenticated && !isLogoutRequested
oauth2Metadata == (authenticationMetaDataLoadState as? LoadState.Success)?.value && !isAuthenticated && !isLogoutRequested && !logoutInProgress
fun beginOAuth2Browser(generation: Long, binding: OAuth2LoginBinding, state: String, nonce: String, verifier: String): Boolean
{
@@ -660,25 +783,8 @@ open class QViewModel(
/** Password remains in this request only; the returned session drives the existing host. */
fun loginWithPassword(username: String, password: String)
{
manageSessionLoadState = LoadState.Loading(Unit)
viewModelScope.launch()
{
try
{
val response = qqqRepository.loginWithPassword(username, password)
sessionUUID = requireNotNull(response.uuid)
qqqRepository.setSessionUUID(sessionUUID)
doSetSessionUserFullName(username)
manageSessionLoadState = LoadState.Success(response)
logInSuccessful()
loadQInstance()
}
catch(e: kotlinx.coroutines.CancellationException) { throw e }
catch(e: Exception)
{
manageSessionLoadState = LoadState.Error("Sign-in failed. Check your credentials and connection.")
}
}
val attempt = prepareAuthentication() ?: return
exchangeAuthentication(attempt, username) { qqqRepository.loginWithPassword(username, password) }
}
/***************************************************************************
@@ -54,6 +54,7 @@ android {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
compose = true
}
composeOptions {
@@ -0,0 +1,362 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.sampleandroidmobileapp
import android.content.Context
import android.util.Base64
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.auth0.android.Auth0
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.result.Credentials
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
import com.kingsrook.qqq.frontend.android.core.model.Environment
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Auth0AuthenticationMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Auth0AuthenticationMetaDataValues
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse
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.SESSION_UUID_KEY
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.Auth0Browser
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalAuth0Browser
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.QAuth0Login
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.QAuth0Logout
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.QAuth0Service
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.QAuthenticationWrapper
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import java.util.Date
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.assertSame
import org.junit.Assert.assertNull
import org.junit.After
import org.junit.Rule
import org.junit.Test
/** Deliberately delayed, cancellation-resistant responses model a server finishing after local sign-out. */
class AuthenticationLifecycleTests : BaseTest()
{
@get:Rule val ui = createAndroidComposeRule<ComponentActivity>()
private val first = Environment("First", "https://first.example.test/")
private val second = Environment("Second", "https://second.example.test/")
private val fixtures = mutableListOf<Fixture>()
@After fun releaseHeldResponses()
{
fixtures.forEach { it.release.complete(Unit); it.releaseLogout.complete(Unit); it.store.releaseWrite.complete(Unit) }
}
private class HeldStore : DataStore<Preferences>
{
private val delegate = MockPreferencesDataStore()
val preferences get() = delegate.preferences
override val data get() = delegate.data
val holdNextWrite = AtomicBoolean()
val writeStarted = CompletableDeferred<Unit>()
val releaseWrite = CompletableDeferred<Unit>()
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = delegate.updateData {
val next = transform(it)
if(holdNextWrite.compareAndSet(true, false))
{
writeStarted.complete(Unit)
releaseWrite.await()
}
next
}
}
private inner class Fixture(holdFirst: Boolean, holdLogout: Boolean, holdFinalWrite: Boolean)
{
val store = HeldStore()
val calls = AtomicInteger()
val cookie = AtomicReference("")
val discarded = CopyOnWriteArrayList<Pair<String, String>>()
val release = CompletableDeferred<Unit>()
val releaseLogout = CompletableDeferred<Unit>()
val logoutCalls = AtomicInteger()
val browserCalls = AtomicInteger()
val providerLogoutCalls = AtomicInteger()
val callback = AtomicReference<Callback<Credentials, AuthenticationException>?>()
val logoutCallback = AtomicReference<Callback<Void?, AuthenticationException>?>()
val metadata = Auth0AuthenticationMetaData("Auth0", Auth0AuthenticationMetaDataValues(
"https://identity.example.test", "public-client", "https://api.example.test"))
val repository = object : QQQRepository by SampleAppMockQQQRepository()
{
override suspend fun getAuthenticationMetaData() = metadata
override fun setSessionUUID(sessionUUID: String) { cookie.set(sessionUUID) }
override suspend fun logout()
{
logoutCalls.incrementAndGet()
if(holdLogout) releaseLogout.await()
cookie.set("")
if(holdFinalWrite) store.holdNextWrite.set(true)
}
private suspend fun exchange(): ManageSessionResponse
{
val number = calls.incrementAndGet()
if(holdFirst && number == 1) withContext(NonCancellable) { release.await() }
return ManageSessionResponse("synthetic-session-$number")
}
override suspend fun loginWithPassword(username: String, password: String) = exchange()
override suspend fun manageSession(body: ManageSessionRequest) = exchange()
override suspend fun discardNativeSession(server: String, session: String) { discarded.add(server to session) }
}
lateinit var model: QViewModel
val browser = object : Auth0Browser
{
override fun login(context: Context, account: Auth0, audience: String, scheme: String,
callback: Callback<Credentials, AuthenticationException>)
{ browserCalls.incrementAndGet(); this@Fixture.callback.set(callback) }
override fun logout(context: Context, account: Auth0, scheme: String, callback: Callback<Void?, AuthenticationException>)
{ providerLogoutCalls.incrementAndGet(); logoutCallback.set(callback) }
}
}
private fun start(holdFirst: Boolean = true, holdLogout: Boolean = false, holdFinalWrite: Boolean = false): Fixture
{
QAppContainer.availableEnvironments = listOf(first, second)
QAuth0Service.scheme = "qqq-test"
val fixture = Fixture(holdFirst, holdLogout, holdFinalWrite).also { fixtures.add(it) }
ui.runOnIdle { fixture.model = QViewModel(fixture.repository, fixture.store) }
ui.waitUntil(5000) { fixture.model.initialLoadFromDataStoreLoadState is LoadState.Success }
return fixture
}
private fun assertCleared(fixture: Fixture)
{
ui.runOnIdle {
assertFalse(fixture.model.isAuthenticated)
assertEquals("", fixture.model.sessionUUID)
assertEquals("", fixture.cookie.get())
assertEquals("", fixture.store.preferences[SESSION_UUID_KEY])
}
}
@Test fun delayedPasswordAfterLogoutCannotRestoreRetainedModel()
{
val fixture = start()
ui.runOnIdle { fixture.model.loginWithPassword("operator", "synthetic-password") }
ui.waitUntil(5000) { fixture.calls.get() == 1 }
ui.runOnIdle { fixture.model.logOut() }
ui.waitUntil(5000) { !fixture.model.logoutInProgress }
fixture.release.complete(Unit)
ui.waitUntil(5000) { fixture.discarded.size == 1 }
assertCleared(fixture)
assertEquals(first.baseUrl to "synthetic-session-1", fixture.discarded.single())
}
@Test fun delayedPasswordFromPreviousServerCannotReplaceNewOperator()
{
val fixture = start()
ui.runOnIdle { fixture.model.loginWithPassword("old", "synthetic-password") }
ui.waitUntil(5000) { fixture.calls.get() == 1 }
ui.runOnIdle { fixture.model.switchEnvironment(second) {} }
ui.waitUntil(5000) { fixture.model.environment == second && fixture.model.authenticationMetaDataLoadState is LoadState.Success }
ui.runOnIdle { fixture.model.loginWithPassword("new", "synthetic-password") }
ui.waitUntil(5000) { fixture.model.isAuthenticated && fixture.store.preferences[SESSION_UUID_KEY] == "synthetic-session-2" }
fixture.release.complete(Unit)
ui.waitUntil(5000) { fixture.discarded.size == 1 }
ui.runOnIdle {
assertEquals("synthetic-session-2", fixture.model.sessionUUID)
assertEquals("synthetic-session-2", fixture.cookie.get())
assertEquals("new", fixture.model.sessionUserFullName)
assertEquals("synthetic-session-2", fixture.store.preferences[SESSION_UUID_KEY])
}
assertEquals(first.baseUrl to "synthetic-session-1", fixture.discarded.single())
}
private fun showAuth0(fixture: Fixture)
{
ui.setContent { CompositionLocalProvider(LocalAuth0Browser provides fixture.browser) { QAuth0Login(fixture.model) } }
ui.waitUntil(5000) { fixture.callback.get() != null }
}
private fun credentials(): Credentials
{
fun encoded(value: String) = Base64.encodeToString(value.toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
val token = encoded("""{"alg":"none"}""") + "." + encoded("""{"sub":"synthetic","nickname":"Operator"}""") + ".synthetic"
return Credentials(token, "synthetic-provider-token", "Bearer", null, Date(System.currentTimeMillis() + 60000), "openid")
}
@Test fun lateAuth0BrowserCallbackAfterLogoutSendsNoToken()
{
val fixture = start()
showAuth0(fixture)
val callback = requireNotNull(fixture.callback.get())
ui.runOnIdle { fixture.model.logOut() }
ui.waitUntil(5000) { !fixture.model.logoutInProgress }
callback.onSuccess(credentials())
ui.waitForIdle()
assertEquals(0, fixture.calls.get())
assertCleared(fixture)
}
@Test fun lateAuth0BrowserCallbackAfterServerSwitchSendsNoToken()
{
val fixture = start()
showAuth0(fixture)
val callback = requireNotNull(fixture.callback.get())
ui.runOnIdle { fixture.model.switchEnvironment(second) {} }
ui.waitUntil(5000) { fixture.model.environment == second }
callback.onSuccess(credentials())
ui.waitForIdle()
assertEquals(0, fixture.calls.get())
assertCleared(fixture)
}
@Test fun delayedAuth0ExchangeAfterLogoutIsRevokedAtOriginalServer()
{
val fixture = start()
showAuth0(fixture)
requireNotNull(fixture.callback.get()).onSuccess(credentials())
ui.waitUntil(5000) { fixture.calls.get() == 1 }
ui.runOnIdle { fixture.model.logOut() }
ui.waitUntil(5000) { !fixture.model.logoutInProgress }
fixture.release.complete(Unit)
ui.waitUntil(5000) { fixture.discarded.size == 1 }
assertCleared(fixture)
assertEquals(first.baseUrl to "synthetic-session-1", fixture.discarded.single())
}
@Test fun duplicateAuth0SuccessCallbackStartsOnlyOneExchange()
{
val fixture = start()
showAuth0(fixture)
val callback = requireNotNull(fixture.callback.get())
callback.onSuccess(credentials())
ui.waitUntil(5000) { fixture.calls.get() == 1 }
callback.onSuccess(credentials())
ui.waitForIdle()
assertEquals(1, fixture.calls.get())
fixture.release.complete(Unit)
ui.waitUntil(5000) { fixture.model.isAuthenticated && fixture.store.preferences[SESSION_UUID_KEY] == "synthetic-session-1" }
assertEquals(1, fixture.calls.get())
assertEquals("synthetic-session-1", fixture.cookie.get())
assertTrue(fixture.discarded.isEmpty())
}
@Test fun failedProviderLogoutStillClearsLocalSessionAndCannotRestoreOnRestart()
{
val fixture = start(holdFirst = false)
ui.runOnIdle { fixture.model.loginWithPassword("operator", "synthetic-password") }
ui.waitUntil(5000) { fixture.model.isAuthenticated && !fixture.store.preferences[SESSION_UUID_KEY].isNullOrBlank() }
ui.runOnIdle { fixture.model.requestLogout() }
ui.setContent { CompositionLocalProvider(LocalAuth0Browser provides fixture.browser) { QAuth0Logout(fixture.model) } }
ui.waitUntil(5000) { fixture.logoutCallback.get() != null }
requireNotNull(fixture.logoutCallback.get()).onFailure(AuthenticationException("synthetic_failure", "Synthetic provider cancellation"))
ui.waitUntil(5000) { !fixture.model.logoutInProgress }
assertCleared(fixture)
lateinit var recreated: QViewModel
ui.runOnIdle { recreated = QViewModel(fixture.repository, fixture.store) }
ui.waitUntil(5000) { recreated.initialLoadFromDataStoreLoadState is LoadState.Success }
ui.runOnIdle { assertFalse(recreated.isAuthenticated); assertEquals("", recreated.sessionUUID) }
ui.runOnIdle {
fixture.model.requestLogin()
fixture.model.loginWithPassword("next", "synthetic-password")
}
ui.waitUntil(5000) { fixture.model.isAuthenticated && fixture.store.preferences[SESSION_UUID_KEY] == "synthetic-session-2" }
requireNotNull(fixture.logoutCallback.get()).onSuccess(null)
ui.waitForIdle()
ui.runOnIdle {
assertTrue(fixture.model.isAuthenticated)
assertEquals("synthetic-session-2", fixture.model.sessionUUID)
assertEquals("synthetic-session-2", fixture.cookie.get())
}
}
@Test fun auth0EffectLaunchesOnceAndCurrentResponseIsAdopted()
{
val fixture = start(holdFirst = false)
val redraw = mutableStateOf(0)
ui.setContent {
CompositionLocalProvider(LocalAuth0Browser provides fixture.browser) {
Text("Redraw ${redraw.value}")
QAuth0Login(fixture.model)
}
}
ui.waitUntil(5000) { fixture.callback.get() != null }
ui.runOnIdle { redraw.value++ }
assertEquals(1, fixture.browserCalls.get())
requireNotNull(fixture.callback.get()).onSuccess(credentials())
ui.waitUntil(5000) { fixture.model.isAuthenticated && fixture.store.preferences[SESSION_UUID_KEY] == "synthetic-session-1" }
assertEquals(1, fixture.calls.get())
assertEquals("synthetic-session-1", fixture.cookie.get())
assertTrue(fixture.discarded.isEmpty())
}
@Test fun repeatedLogoutSharesOneRevocationAndCannotClearTheNextLogin()
{
val fixture = start(holdFirst = false, holdLogout = true)
ui.runOnIdle { fixture.model.loginWithPassword("first", "synthetic-password") }
ui.waitUntil(5000) { fixture.model.isAuthenticated }
ui.runOnIdle {
val firstLogout = fixture.model.logOut()
val repeatedLogout = fixture.model.logOut()
assertSame(firstLogout, repeatedLogout)
fixture.model.loginWithPassword("blocked", "synthetic-password")
assertTrue(fixture.model.logoutInProgress)
assertNull(fixture.model.prepareOAuth2Login(OAuth2LoginBinding(first.baseUrl, "provider",
"https://identity.example.test/", "https://identity.example.test/discovery", "public-client",
"openid", "qqq-test://callback", "login")))
}
ui.waitUntil(5000) { fixture.logoutCalls.get() == 1 }
assertEquals(1, fixture.calls.get())
fixture.releaseLogout.complete(Unit)
ui.waitUntil(5000) { !fixture.model.logoutInProgress }
assertCleared(fixture)
ui.runOnIdle { fixture.model.loginWithPassword("next", "synthetic-password") }
ui.waitUntil(5000) { fixture.model.isAuthenticated && fixture.store.preferences[SESSION_UUID_KEY] == "synthetic-session-2" }
ui.runOnIdle {
assertEquals(1, fixture.logoutCalls.get())
assertEquals("synthetic-session-2", fixture.cookie.get())
assertEquals("next", fixture.model.sessionUserFullName)
}
}
@Test fun fullAuthenticationWrapperKeepsLogoutStableWhileStorageFinishes()
{
val fixture = start(holdFirst = false, holdFinalWrite = true)
val visible = mutableStateOf(true)
ui.runOnIdle { fixture.model.loginWithPassword("operator", "synthetic-password") }
ui.waitUntil(5000) { fixture.model.isAuthenticated && !fixture.store.preferences[SESSION_UUID_KEY].isNullOrBlank() }
ui.runOnIdle { fixture.model.requestLogout() }
ui.setContent {
CompositionLocalProvider(LocalAuth0Browser provides fixture.browser) {
if(visible.value) QAuthenticationWrapper(fixture.model)
}
}
ui.waitUntil(5000) { fixture.store.writeStarted.isCompleted }
ui.runOnIdle {
assertTrue(fixture.model.isLogoutRequested)
assertTrue(fixture.model.authenticationMetaDataLoadState is LoadState.Success)
assertTrue(fixture.model.logoutInProgress)
}
fixture.store.releaseWrite.complete(Unit)
ui.waitUntil(5000) { !fixture.model.logoutInProgress }
assertEquals(1, fixture.logoutCalls.get())
assertEquals(1, fixture.providerLogoutCalls.get())
// The retained model's completed sign-out is still idempotent when the logout form remounts.
ui.runOnIdle { visible.value = false }
ui.runOnIdle { visible.value = true }
ui.waitForIdle()
assertEquals(1, fixture.logoutCalls.get())
assertEquals(1, fixture.providerLogoutCalls.get())
assertCleared(fixture)
}
}
@@ -0,0 +1,50 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2004-2026. Kingsrook, LLC
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qqq.sampleandroidmobileapp
import android.content.pm.ApplicationInfo
import android.security.NetworkSecurityPolicy
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.kingsrook.qqq.frontend.android.mobileapp.data.SettingsRepository
import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SampleHostSecurityTests
{
@Test
fun sessionStorageIsExcludedFromBackup() = runBlocking()
{
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertFalse(context.applicationInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP != 0)
assertEquals(BuildConfig.DEBUG, NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
val settings = SettingsRepository(context.dataStore)
try
{
settings.setSessionUUID("synthetic-backup-exclusion-test")
assertEquals("synthetic-backup-exclusion-test", settings.getSessionUUID())
assertTrue(File(context.noBackupFilesDir, "settings.preferences_pb").isFile)
}
finally
{
settings.setSessionUUID("")
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application android:usesCleartextTraffic="true" tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -4,15 +4,16 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Not clear if we want the usesCleartextTraffic below in the long term! -->
<application
android:name="com.kingsrook.qqq.frontend.android.mobileapp.QMobileApplication"
android:allowBackup="true"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="false"
android:theme="@style/Theme.AndroidDevProject">
<activity
@@ -47,4 +48,4 @@
</application>
</manifest>
</manifest>
@@ -31,7 +31,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.rememberNavController
import com.kingsrook.qqq.frontend.android.core.model.Environment
@@ -40,6 +40,7 @@ import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.QAuth0Service
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.sampleandroidmobileapp.ui.theme.AppTheme
import java.io.File
/***************************************************************************
**
@@ -66,9 +67,10 @@ class SampleAppMainActivity : ComponentActivity()
Environment("Production", "https://live.coldtrack.com/"),
Environment("Staging", "https://live.coldtrack-staging.com/"),
Environment("Development", "https://live.coldtrack-dev.com/"),
) + if(BuildConfig.DEBUG) listOf(
Environment("Local Dev", "http://192.168.4.122:8000/"),
Environment("Bad URL", "http://no.such.domain.dot.dot/"),
)
) else emptyList()
val splashResourceId: Int? = null // R.drawable.kingsrook_logo
///////////////////////////////////////////////////////////
@@ -98,4 +100,14 @@ class SampleAppMainActivity : ComponentActivity()
}
}
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
private var settingsStore: DataStore<Preferences>? = null
// Do not migrate the old backup-eligible store: upgraded/restored samples require fresh login.
val Context.dataStore: DataStore<Preferences>
get() = synchronized(SampleAppMainActivity::class.java)
{
settingsStore ?: run {
val file = File(applicationContext.noBackupFilesDir, "settings.preferences_pb")
PreferenceDataStoreFactory.create { file }.also { settingsStore = it }
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
+3 -3
View File
@@ -119,13 +119,13 @@ export async function runScannerE2e(options = {}) {
await adbRun('install', join(repository, 'qqqsampleandroidmobileapp/build/outputs/apk/androidTest/debug/qqqsampleandroidmobileapp-debug-androidTest.apk'));
temporary.push(sample + '.test');
const output = await adbRun('shell', 'am', 'instrument', '-w', '-r',
'-e', 'class', 'com.kingsrook.qqq.sampleandroidmobileapp.NavigationTests,com.kingsrook.qqq.sampleandroidmobileapp.SemanticInputTests,com.kingsrook.qqq.sampleandroidmobileapp.SessionTests,com.kingsrook.qqq.sampleandroidmobileapp.OAuth2LoginTests',
'-e', 'class', 'com.kingsrook.qqq.sampleandroidmobileapp.NavigationTests,com.kingsrook.qqq.sampleandroidmobileapp.SemanticInputTests,com.kingsrook.qqq.sampleandroidmobileapp.SessionTests,com.kingsrook.qqq.sampleandroidmobileapp.OAuth2LoginTests,com.kingsrook.qqq.sampleandroidmobileapp.SampleHostSecurityTests,com.kingsrook.qqq.sampleandroidmobileapp.AuthenticationLifecycleTests',
sample + '.test/androidx.test.runner.AndroidJUnitRunner');
const evidence = join(repository, 'scanner-host/build/native-device-e2e');
await mkdir(evidence, { recursive: true });
await writeFile(join(evidence, 'navigation-instrumentation.txt'), output);
if (!/OK \(21 tests\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) throw new Error('Physical native navigation/input/account/OAuth acceptance failed; inspect the isolated test evidence.');
console.log('Physical native navigation/input/account/OAuth acceptance passed (21 tests, synthetic repository and browser result).');
if (!/OK \(32 tests\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) throw new Error('Physical native navigation/input/account/OAuth acceptance failed; inspect the isolated test evidence.');
console.log('Physical native navigation/input/account/OAuth acceptance passed (32 tests, synthetic repository and browser result).');
} finally {
for (const installed of temporary.reverse()) await adbRun('uninstall', installed);
}