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:
1 parent
7e0cbee4fe
commit
df532ff514
19 files changed
+868
-264
No files matched your search
+23
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
+16
-11
@@ -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) }
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
|
||||
+69
@@ -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"))
|
||||
} }
|
||||
}
|
||||
}
|
||||
+1
@@ -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()
|
||||
|
||||
+27
@@ -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) }
|
||||
}
|
||||
}
|
||||
+48
-123
@@ -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…")
|
||||
}
|
||||
+24
-62
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+159
-53
@@ -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 {
|
||||
|
||||
+362
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
@@ -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>
|
||||
+15
-3
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user