From 174c0d0a372903b6f7cb5521ddf494f4fdca7979 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Thu, 17 Sep 2026 16:19:02 +0000 Subject: [PATCH] fix: gate login methods by server availability --- .../android/core/ArcaneClientManager.kt | 150 +++++------ .../core/AuthenticationMethodAvailability.kt | 195 +++++++++++++++ .../getarcane/android/ui/auth/LoginScreen.kt | 42 ++-- .../screens/settings/PasskeySecurityScreen.kt | 10 +- app/src/main/res/values/strings.xml | 1 + .../AuthenticationMethodAvailabilityTest.kt | 235 ++++++++++++++++++ docs/ios-parity-task-list.md | 24 ++ docs/release-readiness.md | 43 ++++ 8 files changed, 597 insertions(+), 103 deletions(-) create mode 100644 app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt create mode 100644 app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt diff --git a/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt b/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt index 4dd4fdc..33f5914 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -43,7 +44,6 @@ import okhttp3.CookieJar import okhttp3.HttpUrl enum class AuthStatus { SETUP, AUTHENTICATING, LOGIN, AUTHENTICATED } -enum class PasskeyLoginState { LOADING, AVAILABLE, UNAVAILABLE, ERROR } internal sealed interface PasskeySecurityResult { data class Registration(val passkey: PasskeySummary) : PasskeySecurityResult @@ -126,9 +126,11 @@ class ArcaneClientManager(context: Context) { var supportsContainerReliabilityActions by mutableStateOf(false); private set var isLoading by mutableStateOf(false); private set var errorMessage by mutableStateOf(null); private set - var oidc by mutableStateOf(null); private set - var passkeyLoginState by mutableStateOf(PasskeyLoginState.LOADING); private set - var passkeyBridgeState by mutableStateOf(PasskeyLoginState.LOADING); private set + private var authenticationMethods by mutableStateOf(AuthenticationMethodAvailability()) + val oidc: OidcStatusInfo? get() = authenticationMethods.oidcStatus + val oidcLoginState: AuthenticationMethodState get() = authenticationMethods.oidcState + val passkeyLoginState: AuthenticationMethodState get() = authenticationMethods.passkeyLoginState + val passkeyBridgeState: AuthenticationMethodState get() = authenticationMethods.passkeyBridgeState var pendingMfa by mutableStateOf(null); private set var passkeyBrowserInProgress by mutableStateOf(false); private set internal var passkeySecurityEvent by mutableStateOf(null); private set @@ -160,8 +162,9 @@ class ArcaneClientManager(context: Context) { const val PASSKEY_REDIRECT_HOST = "passkey-callback" } - val isOidcAvailable: Boolean get() = - oidc?.let { it.envConfigured || it.envForced || it.providerName?.isNotBlank() == true } ?: false + val isOidcAvailable: Boolean get() = oidcLoginState == AuthenticationMethodState.AVAILABLE + internal fun authenticationMethodAvailability(): AuthenticationMethodAvailability = + authenticationMethods val isDemoActive: Boolean get() = demoEndsAt != null val serverSessionIdentity: String get() = ServerIdentities.from(serverUrl)?.canonicalOrigin.orEmpty() @@ -526,10 +529,8 @@ class ArcaneClientManager(context: Context) { supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false - oidc = null pendingMfa = null - passkeyLoginState = PasskeyLoginState.LOADING - passkeyBridgeState = PasskeyLoginState.LOADING + authenticationMethods = authenticationMethods.beginAll() cookieJar.clear() serverUrl = nextIdentity.normalizedUrl client = makeClient(nextIdentity.normalizedUrl) @@ -822,6 +823,8 @@ class ArcaneClientManager(context: Context) { fun logout() { val c = client ?: return val generation = clientGeneration + // Invalidate optional-method results synchronously; remote logout can be slow or fail. + authenticationMethods = authenticationMethods.beginAll() scope.launch { operationStore?.onSessionEnding() endResilientSession() @@ -842,10 +845,7 @@ class ArcaneClientManager(context: Context) { supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false - oidc = null pendingMfa = null - passkeyLoginState = PasskeyLoginState.LOADING - passkeyBridgeState = PasskeyLoginState.LOADING refreshLoginMethods() } } @@ -916,10 +916,8 @@ class ArcaneClientManager(context: Context) { supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false - oidc = null pendingMfa = null - passkeyLoginState = PasskeyLoginState.LOADING - passkeyBridgeState = PasskeyLoginState.LOADING + authenticationMethods = authenticationMethods.beginAll() isLoading = false isStartingDemo = false demoEndsAt = null @@ -986,6 +984,7 @@ class ArcaneClientManager(context: Context) { serverUrl = identity.normalizedUrl prefs.setServerUrl(identity.normalizedUrl) resetEnvironment() + authenticationMethods = authenticationMethods.beginAll() client?.close() // The demo router uses the session-id cookie to route API calls to the provisioned // instance; iOS gets this via shared cookie storage, so inject it on every request. @@ -1052,7 +1051,8 @@ class ArcaneClientManager(context: Context) { supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false - oidc = null + pendingMfa = null + authenticationMethods = authenticationMethods.beginAll() demoEndsAt = null serverUrl = "" client = null @@ -1110,76 +1110,44 @@ class ArcaneClientManager(context: Context) { val containerReliabilityActions: Boolean = false, ) - private suspend fun refreshOidc() { - val c = client ?: return - val generation = clientGeneration - val settings = try { - c.settings.getPublicSettings() - } catch (e: CancellationException) { - throw e - } catch (_: Throwable) { - null - } - val status = try { - c.auth.oidcStatus() - } catch (e: CancellationException) { - throw e - } catch (_: Throwable) { - null - } - if (!isCurrentClient(generation, c)) return - if (settings == null) { - oidc = status - return - } - - val public = settings.associate { it.key to it.value } - val oidcEnabled = public["oidcEnabled"]?.equals("true", ignoreCase = true) == true - val providerName = public["oidcProviderName"] - val providerLogoUrl = public["oidcProviderLogoUrl"] - val mergeAccounts = public["oidcMergeAccounts"]?.equals("true", ignoreCase = true) == true - - if (!isCurrentClient(generation, c)) return - oidc = OidcStatusInfo( - envConfigured = status?.envConfigured ?: oidcEnabled, - envForced = status?.envForced ?: false, - mergeAccounts = status?.mergeAccounts ?: mergeAccounts, - providerName = status?.providerName ?: providerName, - providerLogoUrl = status?.providerLogoUrl ?: providerLogoUrl, - ) - } - - private suspend fun refreshPasskeyAvailability() { + private suspend fun refreshLoginMethods() { val c = client ?: return - val generation = clientGeneration - if (isCurrentClient(generation, c)) { - passkeyLoginState = PasskeyLoginState.LOADING - passkeyBridgeState = PasskeyLoginState.LOADING - } - val bridgeState = try { - if (browserBridge(c).isBridgeAvailable()) { - PasskeyLoginState.AVAILABLE - } else { - PasskeyLoginState.UNAVAILABLE + val clientGeneration = clientGeneration + if (!isCurrentClient(clientGeneration, c)) return + authenticationMethods = authenticationMethods.beginAll() + val oidcProbeGeneration = authenticationMethods.oidcProbeGeneration + val passkeyProbeGeneration = authenticationMethods.passkeyProbeGeneration + + // These probes publish independently. A failure in either optional method cannot hide the + // other method or the password fallback. + coroutineScope { + launch { + val result = probeOidcAvailability( + loadPublicSettings = { + c.settings.getPublicSettings().associate { it.key to it.value } + }, + loadStatus = { c.auth.oidcStatus() }, + ) + if (isCurrentClient(clientGeneration, c)) { + authenticationMethods = authenticationMethods.applyOidc( + oidcProbeGeneration, + result, + ) + } + } + launch { + val result = probePasskeyAvailability( + loadLegacyAvailability = { c.passkeys.loginAvailability().available }, + loadBridgeAvailability = { browserBridge(c).isBridgeAvailable() }, + ) + if (isCurrentClient(clientGeneration, c)) { + authenticationMethods = authenticationMethods.applyPasskey( + passkeyProbeGeneration, + result, + ) + } } - } catch (e: CancellationException) { - throw e - } catch (_: Throwable) { - PasskeyLoginState.ERROR } - if (!isCurrentClient(generation, c)) return - passkeyBridgeState = bridgeState - // Current iOS and Arcane gate mobile sign-in on the versioned same-origin bridge manifest. - // The older public availability endpoint is absent on current Arcane and cannot be a - // prerequisite, though the SDK keeps it for legacy callers. - passkeyLoginState = bridgeState - } - - private suspend fun refreshLoginMethods() { - // These checks are intentionally isolated: a legacy/failed passkey endpoint must not hide - // an otherwise usable OIDC provider, and vice versa. - refreshOidc() - refreshPasskeyAvailability() } private fun isExpectedOidcCallback(uri: Uri): Boolean { @@ -1238,7 +1206,21 @@ class ArcaneClientManager(context: Context) { fun refreshPasskeySupport() { if (authStatus == AuthStatus.SETUP || serverUrl.isBlank() || client == null) return - scope.launch { refreshPasskeyAvailability() } + val c = client ?: return + val clientGeneration = clientGeneration + authenticationMethods = authenticationMethods.beginPasskeyBridgeProbe() + val probeGeneration = authenticationMethods.passkeyProbeGeneration + scope.launch { + val bridgeState = probePasskeyBridgeAvailability { + browserBridge(c).isBridgeAvailable() + } + if (isCurrentClient(clientGeneration, c)) { + authenticationMethods = authenticationMethods.applyPasskeyBridge( + probeGeneration, + bridgeState, + ) + } + } } } diff --git a/app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt b/app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt new file mode 100644 index 0000000..da197da --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt @@ -0,0 +1,195 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.errors.ArcaneError +import app.getarcane.sdk.models.auth.OidcStatusInfo +import kotlinx.coroutines.CancellationException + +enum class AuthenticationMethodState { LOADING, AVAILABLE, UNAVAILABLE, ERROR } + +internal data class OidcAvailabilityResult( + val state: AuthenticationMethodState, + val status: OidcStatusInfo? = null, +) + +internal data class PasskeyAvailabilityResult( + val loginState: AuthenticationMethodState, + val bridgeState: AuthenticationMethodState, +) + +/** + * Observable login-method state is replaced as one value so a new server can synchronously clear + * every result. Per-method generations also reject a late result from an earlier refresh of the + * same client, including a probe that was in flight while logout started. + */ +internal data class AuthenticationMethodAvailability( + val oidcProbeGeneration: Long = 0, + val passkeyProbeGeneration: Long = 0, + val oidcState: AuthenticationMethodState = AuthenticationMethodState.LOADING, + val oidcStatus: OidcStatusInfo? = null, + val passkeyLoginState: AuthenticationMethodState = AuthenticationMethodState.LOADING, + val passkeyBridgeState: AuthenticationMethodState = AuthenticationMethodState.LOADING, +) { + fun beginAll(): AuthenticationMethodAvailability = copy( + oidcProbeGeneration = oidcProbeGeneration + 1, + passkeyProbeGeneration = passkeyProbeGeneration + 1, + oidcState = AuthenticationMethodState.LOADING, + oidcStatus = null, + passkeyLoginState = AuthenticationMethodState.LOADING, + passkeyBridgeState = AuthenticationMethodState.LOADING, + ) + + fun beginPasskeyBridgeProbe(): AuthenticationMethodAvailability = copy( + passkeyProbeGeneration = passkeyProbeGeneration + 1, + passkeyLoginState = AuthenticationMethodState.LOADING, + passkeyBridgeState = AuthenticationMethodState.LOADING, + ) + + fun applyOidc( + probeGeneration: Long, + result: OidcAvailabilityResult, + ): AuthenticationMethodAvailability = if (probeGeneration == oidcProbeGeneration) { + copy(oidcState = result.state, oidcStatus = result.status) + } else { + this + } + + fun applyPasskey( + probeGeneration: Long, + result: PasskeyAvailabilityResult, + ): AuthenticationMethodAvailability = if (probeGeneration == passkeyProbeGeneration) { + copy( + passkeyLoginState = result.loginState, + passkeyBridgeState = result.bridgeState, + ) + } else { + this + } + + fun applyPasskeyBridge( + probeGeneration: Long, + bridgeState: AuthenticationMethodState, + ): AuthenticationMethodAvailability = if (probeGeneration == passkeyProbeGeneration) { + copy(passkeyBridgeState = bridgeState) + } else { + this + } +} + +internal data class LoginActionVisibility( + val showPasskey: Boolean, + val showOidc: Boolean, + val showOidcDisclosure: Boolean, + val showPassword: Boolean, +) + +internal fun loginActionVisibility( + availability: AuthenticationMethodAvailability, + showPasswordForm: Boolean, +): LoginActionVisibility { + val oidcAvailable = availability.oidcState == AuthenticationMethodState.AVAILABLE + return LoginActionVisibility( + showPasskey = availability.passkeyLoginState == AuthenticationMethodState.AVAILABLE, + showOidc = oidcAvailable && !showPasswordForm, + showOidcDisclosure = oidcAvailable, + showPassword = !oidcAvailable || showPasswordForm, + ) +} + +/** + * OIDC is offered only when the public setting explicitly enables it. For environment-managed + * OIDC, the status endpoint must additionally confirm that the required environment configuration + * is present. Database-managed OIDC reports its configuration through the enabled public setting; + * `envConfigured` intentionally describes only the environment-managed variant in Arcane. + */ +internal suspend fun probeOidcAvailability( + loadPublicSettings: suspend () -> Map, + loadStatus: suspend () -> OidcStatusInfo, +): OidcAvailabilityResult = try { + val settings = loadPublicSettings() + val enabled = settings["oidcEnabled"]?.trim()?.equals("true", ignoreCase = true) == true + if (!enabled) { + // Status still carries the environment-management flag used by authenticated settings. + // Its failure cannot turn an explicitly disabled login method into an error or affect the + // always-available password fallback. + val status = try { + loadStatus() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + OidcAvailabilityResult(AuthenticationMethodState.UNAVAILABLE, status) + } else { + val status = loadStatus() + if (status.envForced && !status.envConfigured) { + OidcAvailabilityResult(AuthenticationMethodState.UNAVAILABLE) + } else { + OidcAvailabilityResult( + state = AuthenticationMethodState.AVAILABLE, + status = status.copy( + providerName = status.providerName?.takeIf(String::isNotBlank) + ?: settings["oidcProviderName"], + providerLogoUrl = status.providerLogoUrl?.takeIf(String::isNotBlank) + ?: settings["oidcProviderLogoUrl"], + mergeAccounts = status.mergeAccounts || + settings["oidcMergeAccounts"]?.equals("true", ignoreCase = true) == true, + ), + ) + } + } +} catch (e: CancellationException) { + throw e +} catch (_: Throwable) { + OidcAvailabilityResult(AuthenticationMethodState.ERROR) +} + +private enum class LegacyPasskeyAvailability { AVAILABLE, UNAVAILABLE, ENDPOINT_MISSING, ERROR } + +/** + * Combines the 2.11 availability contract with the versioned browser manifest. Arcane 2.12 + * removed the legacy endpoint, so only a typed 404 falls back to the validated bridge result. + * Bridge availability remains separate because signed-in users may enroll their first passkey + * even when the legacy login result is false. + */ +internal suspend fun probePasskeyAvailability( + loadLegacyAvailability: suspend () -> Boolean, + loadBridgeAvailability: suspend () -> Boolean, +): PasskeyAvailabilityResult { + val legacy = try { + if (loadLegacyAvailability()) { + LegacyPasskeyAvailability.AVAILABLE + } else { + LegacyPasskeyAvailability.UNAVAILABLE + } + } catch (e: CancellationException) { + throw e + } catch (_: ArcaneError.NotFound) { + LegacyPasskeyAvailability.ENDPOINT_MISSING + } catch (_: Throwable) { + LegacyPasskeyAvailability.ERROR + } + + val bridgeState = probePasskeyBridgeAvailability(loadBridgeAvailability) + val loginState = when (legacy) { + LegacyPasskeyAvailability.UNAVAILABLE -> AuthenticationMethodState.UNAVAILABLE + LegacyPasskeyAvailability.ERROR -> AuthenticationMethodState.ERROR + LegacyPasskeyAvailability.AVAILABLE, + LegacyPasskeyAvailability.ENDPOINT_MISSING, + -> bridgeState + } + return PasskeyAvailabilityResult(loginState, bridgeState) +} + +internal suspend fun probePasskeyBridgeAvailability( + loadBridgeAvailability: suspend () -> Boolean, +): AuthenticationMethodState = try { + if (loadBridgeAvailability()) { + AuthenticationMethodState.AVAILABLE + } else { + AuthenticationMethodState.UNAVAILABLE + } +} catch (e: CancellationException) { + throw e +} catch (_: Throwable) { + AuthenticationMethodState.ERROR +} diff --git a/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt index 4a506b5..48d46fc 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt @@ -94,9 +94,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.getarcane.android.R import app.getarcane.android.core.ArcaneClientManager +import app.getarcane.android.core.LoginActionVisibility +import app.getarcane.android.core.AuthenticationMethodState import app.getarcane.android.core.AuthStatus import app.getarcane.android.core.LocalArcaneManager -import app.getarcane.android.core.PasskeyLoginState +import app.getarcane.android.core.loginActionVisibility import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.components.ClearSensitiveStateOnStop import app.getarcane.android.ui.components.ProtectSensitiveWindow @@ -128,7 +130,11 @@ fun LoginScreen() { // When OIDC is available the password form is hidden behind a disclosure so the provider button // is the primary action; the user can still reveal local sign-in (admin fallback). - val shouldShowPassword = !manager.isOidcAvailable || showPasswordForm + val actionVisibility = loginActionVisibility( + availability = manager.authenticationMethodAvailability(), + showPasswordForm = showPasswordForm, + ) + val shouldShowPassword = actionVisibility.showPassword // Re-fetch OIDC status whenever we enter login (or the server changes) so the provider button // shows correctly. Keyed on auth status + server URL; the manager no-ops in setup mode. @@ -203,6 +209,7 @@ fun LoginScreen() { if (manager.pendingMfa == null) Actions( manager = manager, + actionVisibility = actionVisibility, isSetup = isSetup, brand = brand, connectEnabled = url.isNotBlank(), @@ -377,6 +384,7 @@ private fun CredentialsFields( @Composable private fun Actions( manager: ArcaneClientManager, + actionVisibility: LoginActionVisibility, isSetup: Boolean, brand: Color, connectEnabled: Boolean, @@ -400,7 +408,7 @@ private fun Actions( onClick = onConnect, ) } else { - if (manager.passkeyLoginState == PasskeyLoginState.AVAILABLE) { + if (actionVisibility.showPasskey) { PrimaryButton( text = stringResource(R.string.auth_sign_in_passkey_action), icon = Icons.Filled.VpnKey, @@ -409,7 +417,7 @@ private fun Actions( onClick = onPasskeySignIn, ) } - if (manager.isOidcAvailable && !showPasswordForm) { + if (actionVisibility.showOidc) { PrimaryButton( text = stringResource( R.string.auth_continue_provider_action, @@ -422,7 +430,7 @@ private fun Actions( onClick = onOidcSignIn, ) } - if (manager.isOidcAvailable) { + if (actionVisibility.showOidcDisclosure) { OutlinedButton( onClick = { onTogglePasswordForm(!showPasswordForm) }, enabled = !manager.isLoading, @@ -480,15 +488,21 @@ private fun MfaChallengeContent(manager: ArcaneClientManager) { textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - PrimaryButton( - text = stringResource(R.string.auth_mfa_continue_passkey_action), - icon = Icons.Filled.VpnKey, - enabled = manager.passkeyBridgeState == PasskeyLoginState.AVAILABLE && !manager.isLoading, - loading = manager.isLoading && recoveryCode.isEmpty(), - onClick = { manager.completeMfaWithPasskey(context) }, - ) - if (manager.passkeyBridgeState != PasskeyLoginState.AVAILABLE) { - Text( + when (manager.passkeyBridgeState) { + AuthenticationMethodState.AVAILABLE -> PrimaryButton( + text = stringResource(R.string.auth_mfa_continue_passkey_action), + icon = Icons.Filled.VpnKey, + enabled = !manager.isLoading, + loading = manager.isLoading && recoveryCode.isEmpty(), + onClick = { manager.completeMfaWithPasskey(context) }, + ) + AuthenticationMethodState.LOADING -> Text( + stringResource(R.string.auth_checking_passkey_support), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + AuthenticationMethodState.UNAVAILABLE, AuthenticationMethodState.ERROR -> Text( stringResource(R.string.auth_mfa_passkey_unavailable), color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PasskeySecurityScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PasskeySecurityScreen.kt index c4727fd..f9c27f3 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PasskeySecurityScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PasskeySecurityScreen.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp -import app.getarcane.android.core.PasskeyLoginState +import app.getarcane.android.core.AuthenticationMethodState import app.getarcane.android.core.PasskeySecurityOwner import app.getarcane.android.core.PasskeySecurityResult import app.getarcane.android.ui.components.ClearSensitiveStateOnStop @@ -83,7 +83,7 @@ fun PasskeySecurityScreen(onBack: () -> Unit) { var recoveryCodes by remember(session) { mutableStateOf>(emptyList()) } val validGrant = validStepUpGrant(stepUpGrant, Clock.System.now()) val anyBusy = busy || manager.passkeyBrowserInProgress - val bridgeAvailable = manager.passkeyBridgeState == PasskeyLoginState.AVAILABLE + val bridgeAvailable = manager.passkeyBridgeState == AuthenticationMethodState.AVAILABLE fun invalidateSensitiveOperations() { operationGeneration.invalidate() @@ -252,17 +252,17 @@ fun PasskeySecurityScreen(onBack: () -> Unit) { SettingsSectionHeader("Passkeys") when (manager.passkeyBridgeState) { - PasskeyLoginState.LOADING -> Text( + AuthenticationMethodState.LOADING -> Text( "Checking passkey support…", Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) - PasskeyLoginState.UNAVAILABLE, PasskeyLoginState.ERROR -> Text( + AuthenticationMethodState.UNAVAILABLE, AuthenticationMethodState.ERROR -> Text( "Passkey ceremonies are unavailable for this server connection. Password recovery remains available.", Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) - PasskeyLoginState.AVAILABLE -> Unit + AuthenticationMethodState.AVAILABLE -> Unit } if (passkeys.isEmpty()) { Text("No passkeys enrolled", Modifier.padding(horizontal = 16.dp)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d845c08..be166ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ Use a registered passkey or one of your recovery codes. Continue with passkey Passkey verification is unavailable for this server connection. Use a recovery code. + Checking passkey support… Recovery code Use recovery code Back to sign in diff --git a/app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt b/app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt new file mode 100644 index 0000000..1e62a38 --- /dev/null +++ b/app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt @@ -0,0 +1,235 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.errors.ArcaneError +import app.getarcane.sdk.models.auth.OidcStatusInfo +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthenticationMethodAvailabilityTest { + @Test + fun `bridge available and legacy availability true shows passkey`() = runBlocking { + val result = probePasskeyAvailability( + loadLegacyAvailability = { true }, + loadBridgeAvailability = { true }, + ) + + assertEquals(AuthenticationMethodState.AVAILABLE, result.loginState) + assertEquals(AuthenticationMethodState.AVAILABLE, result.bridgeState) + } + + @Test + fun `bridge available and legacy availability false hides login without disabling enrollment`() = runBlocking { + val result = probePasskeyAvailability( + loadLegacyAvailability = { false }, + loadBridgeAvailability = { true }, + ) + + assertEquals(AuthenticationMethodState.UNAVAILABLE, result.loginState) + assertEquals(AuthenticationMethodState.AVAILABLE, result.bridgeState) + } + + @Test + fun `bridge unavailable hides passkey`() = runBlocking { + val result = probePasskeyAvailability( + loadLegacyAvailability = { true }, + loadBridgeAvailability = { false }, + ) + + assertEquals(AuthenticationMethodState.UNAVAILABLE, result.loginState) + assertEquals(AuthenticationMethodState.UNAVAILABLE, result.bridgeState) + } + + @Test + fun `legacy endpoint 404 falls back to available bridge`() = runBlocking { + val result = probePasskeyAvailability( + loadLegacyAvailability = { throw ArcaneError.NotFound }, + loadBridgeAvailability = { true }, + ) + + assertEquals(AuthenticationMethodState.AVAILABLE, result.loginState) + assertEquals(AuthenticationMethodState.AVAILABLE, result.bridgeState) + } + + @Test + fun `non-404 legacy failure hides passkey with error while preserving bridge support`() = runBlocking { + val result = probePasskeyAvailability( + loadLegacyAvailability = { throw ArcaneError.Transport("offline") }, + loadBridgeAvailability = { true }, + ) + + assertEquals(AuthenticationMethodState.ERROR, result.loginState) + assertEquals(AuthenticationMethodState.AVAILABLE, result.bridgeState) + } + + @Test + fun `passkey availability cancellation is rethrown`() { + val cancellation = CancellationException("server changed") + + val thrown = assertThrows(CancellationException::class.java) { + runBlocking { + probePasskeyAvailability( + loadLegacyAvailability = { throw cancellation }, + loadBridgeAvailability = { true }, + ) + } + } + + assertSame(cancellation, thrown) + } + + @Test + fun `OIDC requires explicit enabled setting and remains independent from passkey`() = runBlocking { + var statusRequested = false + val disabled = probeOidcAvailability( + loadPublicSettings = { + mapOf("oidcEnabled" to "false", "oidcProviderName" to "Configured Provider") + }, + loadStatus = { + statusRequested = true + oidcStatus() + }, + ) + val enabled = probeOidcAvailability( + loadPublicSettings = { mapOf("oidcEnabled" to "true") }, + loadStatus = { oidcStatus(providerName = "Configured Provider") }, + ) + + var availability = AuthenticationMethodAvailability().beginAll() + availability = availability.applyPasskey( + availability.passkeyProbeGeneration, + PasskeyAvailabilityResult( + AuthenticationMethodState.AVAILABLE, + AuthenticationMethodState.AVAILABLE, + ), + ) + availability = availability.applyOidc(availability.oidcProbeGeneration, disabled) + + assertTrue(statusRequested) + assertEquals(AuthenticationMethodState.UNAVAILABLE, disabled.state) + assertEquals(AuthenticationMethodState.AVAILABLE, enabled.state) + assertEquals(AuthenticationMethodState.AVAILABLE, availability.passkeyLoginState) + assertEquals(AuthenticationMethodState.UNAVAILABLE, availability.oidcState) + } + + @Test + fun `environment-managed OIDC must also report complete configuration`() = runBlocking { + val result = probeOidcAvailability( + loadPublicSettings = { mapOf("oidcEnabled" to "true") }, + loadStatus = { oidcStatus(envForced = true, envConfigured = false) }, + ) + + assertEquals(AuthenticationMethodState.UNAVAILABLE, result.state) + assertNull(result.status) + } + + @Test + fun `OIDC failure leaves passkey and password available`() = runBlocking { + val oidcResult = probeOidcAvailability( + loadPublicSettings = { throw ArcaneError.Transport("offline") }, + loadStatus = { error("must not run") }, + ) + var availability = AuthenticationMethodAvailability().beginAll() + availability = availability.applyPasskey( + availability.passkeyProbeGeneration, + PasskeyAvailabilityResult( + AuthenticationMethodState.AVAILABLE, + AuthenticationMethodState.AVAILABLE, + ), + ) + availability = availability.applyOidc(availability.oidcProbeGeneration, oidcResult) + val visibility = loginActionVisibility(availability, showPasswordForm = false) + + assertEquals(AuthenticationMethodState.ERROR, availability.oidcState) + assertTrue(visibility.showPasskey) + assertTrue(visibility.showPassword) + assertFalse(visibility.showOidc) + assertFalse(visibility.showOidcDisclosure) + } + + @Test + fun `server switch clears methods and rejects results from prior server`() { + var availability = AuthenticationMethodAvailability().beginAll() + val oldOidcGeneration = availability.oidcProbeGeneration + val oldPasskeyGeneration = availability.passkeyProbeGeneration + availability = availability.applyOidc( + oldOidcGeneration, + OidcAvailabilityResult(AuthenticationMethodState.AVAILABLE, oidcStatus()), + ) + availability = availability.applyPasskey( + oldPasskeyGeneration, + PasskeyAvailabilityResult( + AuthenticationMethodState.AVAILABLE, + AuthenticationMethodState.AVAILABLE, + ), + ) + + availability = availability.beginAll() + availability = availability.applyOidc( + oldOidcGeneration, + OidcAvailabilityResult(AuthenticationMethodState.AVAILABLE, oidcStatus()), + ) + availability = availability.applyPasskey( + oldPasskeyGeneration, + PasskeyAvailabilityResult( + AuthenticationMethodState.AVAILABLE, + AuthenticationMethodState.AVAILABLE, + ), + ) + + assertEquals(AuthenticationMethodState.LOADING, availability.oidcState) + assertEquals(AuthenticationMethodState.LOADING, availability.passkeyLoginState) + assertEquals(AuthenticationMethodState.LOADING, availability.passkeyBridgeState) + assertNull(availability.oidcStatus) + } + + @Test + fun `loading never exposes optional authentication actions`() { + val visibility = loginActionVisibility( + AuthenticationMethodAvailability(), + showPasswordForm = false, + ) + + assertFalse(visibility.showPasskey) + assertFalse(visibility.showOidc) + assertFalse(visibility.showOidcDisclosure) + assertTrue(visibility.showPassword) + } + + @Test + fun `OIDC availability preserves password fallback disclosure`() { + var availability = AuthenticationMethodAvailability().beginAll() + availability = availability.applyOidc( + availability.oidcProbeGeneration, + OidcAvailabilityResult(AuthenticationMethodState.AVAILABLE, oidcStatus()), + ) + + val providerPrimary = loginActionVisibility(availability, showPasswordForm = false) + val passwordFallback = loginActionVisibility(availability, showPasswordForm = true) + + assertTrue(providerPrimary.showOidc) + assertFalse(providerPrimary.showPassword) + assertTrue(providerPrimary.showOidcDisclosure) + assertFalse(passwordFallback.showOidc) + assertTrue(passwordFallback.showPassword) + assertTrue(passwordFallback.showOidcDisclosure) + } + + private fun oidcStatus( + envForced: Boolean = false, + envConfigured: Boolean = false, + providerName: String? = null, + ) = OidcStatusInfo( + envForced = envForced, + envConfigured = envConfigured, + mergeAccounts = false, + providerName = providerName, + providerLogoUrl = null, + ) +} diff --git a/docs/ios-parity-task-list.md b/docs/ios-parity-task-list.md index bd98c88..060c593 100644 --- a/docs/ios-parity-task-list.md +++ b/docs/ios-parity-task-list.md @@ -720,6 +720,30 @@ The standard checks are: complete a ceremony, so no credential was fabricated and that provider-dependent boundary is recorded separately from the deterministic Credential Manager coverage. + **Authentication-method availability hardening (2026-09-17):** Revalidation compared Android + `5d07cd0d3ba2925647687dafdc7b47e548ae2180`, iOS + `8d13fdb5cd61a62b1d666e9e982a2670d86086c3`, libarcane-kotlin + `b29695d547b78389ed7230b35cd133f7046b4b52`, and Arcane + `5ac6d89756f80d22cf4f057865497cb9a8cf061d`. The approved Android correctness deviation hides the + passkey action unless both server login availability and the version-2 browser bridge support it; + current iOS still presents Passkey without that server-side availability check. Arcane 2.11.1 + (`46e71e3da78bf3eb2eaba7bf5e54bd99de293322`) provides the public typed availability result, while + the 2.12.0 tag (`3089c2ec4a56da5ca934c37778e6aeb2f539862d`) omits that route and current Arcane source has + reintroduced it. Android therefore treats only SDK `ArcaneError.NotFound` as the bridge-manifest + compatibility fallback; `available:false` and every other error hide login without disabling the + authenticated bridge used to enroll a first passkey. OIDC requires the explicit enabled setting + and complete environment-managed configuration, and its probe cannot alter passkey or password + availability. The public read-only 2.11.1 target returned a valid version-2 manifest and + `available:false`; no mutation was made. On API 30 AVD `arcane_test_api30`, that target exposed + password login only, including after force-stop/reopen. An isolated exact v2.12.0 image exposed + its configured OIDC provider plus the password fallback and no Passkey action; notably, the + published image returned `available:false` even though the pinned tag source omits that route, so + the typed 404 compatibility branch remains covered deterministically rather than claimed as live + image evidence. Switching from that OIDC-enabled target to the OIDC-disabled Arcane 2.10.2 target + cleared both optional actions, password login succeeded, and authenticated force-stop/reopen plus + back/reopen restored Dashboard without a login/content flash. The Android baseline passed 392 + tests in 67 suites plus debug assembly, and lint reported no new issues. + - [x] **PAR-111 — Add scoped global-variable management** - **Status:** Complete diff --git a/docs/release-readiness.md b/docs/release-readiness.md index d313260..ecd21db 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -110,6 +110,49 @@ or committed. Eventual Play/other store accounts, signing custody, staged rollou listing, and reviewer responses remain maintainer responsibilities and are not delegated to an automated test or PR author. +## Authentication-method availability follow-up (2026-09-17) + +This focused correction compared Android `5d07cd0d3ba2925647687dafdc7b47e548ae2180`, iOS +`8d13fdb5cd61a62b1d666e9e982a2670d86086c3`, libarcane-kotlin +`b29695d547b78389ed7230b35cd133f7046b4b52`, and Arcane current source +`5ac6d89756f80d22cf4f057865497cb9a8cf061d`. The existing SDK contract is sufficient; no SDK source +change is required. + +- **Compatibility decision:** Android intentionally differs from current iOS, which still exposes an + unconditional Passkey login action. Android requires the validated version-2 mobile bridge plus + Arcane 2.11.1's typed `available:true` response. The v2.12.0 tag omits that route, so typed SDK + `ArcaneError.NotFound` falls back to the manifest; every other endpoint failure hides Passkey. + Current Arcane source has reintroduced the route, and remains compatible with the typed probe. + Bridge support is retained separately for authenticated Passkeys & MFA management and first-passkey + enrollment. OIDC requires explicit server enablement and complete environment-managed + configuration; failed optional probes never hide password login or each other. +- **Automated lane:** `:app:testDebugUnitTest :app:assembleDebug` passed 392 tests in 67 suites with + zero failures, errors, or skips. `:app:lintDebug` passed with no new findings; the checked-in + baseline filters 45 existing errors and one hint. Deterministic tests cover both legacy boolean + results, bridge absence, the 2.12 404 fallback, non-404 failure, cancellation, OIDC isolation, + server changes, loading visibility, and password fallback. +- **Server evidence:** Read-only probes against the public Arcane 2.11.1 target returned a valid + version-2 bridge manifest and `available:false`; the server was not mutated. The existing isolated + local target at `https://127.0.0.1:43553` independently returned the same manifest and false result + on Arcane 2.10.2. A temporary exact `ghcr.io/getarcaneapp/arcane:v2.12.0` image was also exercised. + Contrary to the pinned tag source, that published image still served the availability endpoint and + returned `available:false`; therefore the SDK-typed 404 branch is deterministic compatibility + coverage, not a claimed live v2.12 image result. Source inspection pins Arcane 2.11.1 to + `46e71e3da78bf3eb2eaba7bf5e54bd99de293322` and v2.12.0 to + `3089c2ec4a56da5ca934c37778e6aeb2f539862d`. +- **Device lane:** API 30 AVD `arcane_test_api30` + (`Android/sdk_phone_x86_64/generic_x86_64:11/RSR1.210722.013.A2/10067904:userdebug/test-keys`) + ran through the disposable LXD/KVM harness. The public 2.11.1 target showed password login only, + with neither Passkey nor OIDC before or after force-stop/reopen. The isolated v2.12.0 target showed + `Continue with Disposable OIDC` only after its checks, retained `Sign in with username and + password`, and hid Passkey for its false availability result. Switching to the OIDC-disabled + Arcane 2.10.2 target immediately removed the provider disclosure and Passkey action; password + login succeeded. Force-stop/reopen restored Dashboard, and back followed by reopen returned to + Dashboard without exposing login or stale optional actions. Deterministic tests additionally hold + optional actions hidden for the complete loading state, including probes faster than UIAutomator + can capture. The temporary v2.12 container, emulator copy, local port proxy, certificate trust + overlay, and LXD mounts were removed after validation. + ## PAR-401–406 validation record (2026-09-16) The batch compared Android `90b67366638c21c30b2c748347a57bd8f184d491`, iOS