diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index e063aaf..f4e71e0 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -17,6 +17,8 @@ import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.* +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json @@ -120,6 +122,15 @@ class ApiClient( suspend fun completeRecovery(req: RecoveryCompleteRequest): ApiResult = post("/auth/recovery/complete", req) + // Sessions (#208) + suspend fun listSessions(): ApiResult> = get("/auth/sessions") + + // "Sign out this device" for a specific session (may be the caller's own current session). + suspend fun revokeSession(id: String): ApiResult = delete("/auth/sessions/$id", Unit) + + // "Sign out all other devices" — revokes every session except the one making this call. + suspend fun revokeOtherSessions(): ApiResult = delete("/auth/sessions", Unit) + // Vaults suspend fun listVaults(): ApiResult> = get("/vaults") @@ -189,7 +200,10 @@ class ApiClient( } // The token the server rejected is no longer valid — clear it locally so it // isn't kept being sent, and so the UI correctly routes back to AuthScreen. - 401 -> { tokenProvider.clear(); ApiResult.Error("Unauthorized", 401) } + // #211: a 401 can carry a human-readable reason in its body (e.g. an expired + // recovery token/proof on completeRecovery) — surface it instead of the + // generic "Unauthorized" so the caller isn't left with a dead-end message. + 401 -> { tokenProvider.clear(); ApiResult.Error(response.unauthorizedMessage(), 401) } 404 -> ApiResult.Error("Not found", 404) else -> ApiResult.Error("Server error ${response.status.value}", response.status.value) } @@ -212,7 +226,10 @@ class ApiClient( } when (response.status.value) { in 200..299 -> ApiResult.Success(if (T::class == Unit::class) Unit as T else response.body()) - 401 -> { tokenProvider.clear(); ApiResult.Error("Unauthorized", 401) } + // #211: a 401 can carry a human-readable reason in its body (e.g. an expired + // recovery token/proof on completeRecovery) — surface it instead of the + // generic "Unauthorized" so the caller isn't left with a dead-end message. + 401 -> { tokenProvider.clear(); ApiResult.Error(response.unauthorizedMessage(), 401) } else -> ApiResult.Error("Server error ${response.status.value}", response.status.value) } }.getOrElse { e -> ApiErrorMapper.toApiResult(e) { if (BuildConfig.DEBUG) Log.w(TAG, "$path failed", it) } } @@ -233,7 +250,10 @@ class ApiClient( // deletion (401/500/etc.) is silently reported back to callers as success. when (response.status.value) { in 200..299 -> ApiResult.Success(if (T::class == Unit::class) Unit as T else response.body()) - 401 -> { tokenProvider.clear(); ApiResult.Error("Unauthorized", 401) } + // #211: a 401 can carry a human-readable reason in its body (e.g. an expired + // recovery token/proof on completeRecovery) — surface it instead of the + // generic "Unauthorized" so the caller isn't left with a dead-end message. + 401 -> { tokenProvider.clear(); ApiResult.Error(response.unauthorizedMessage(), 401) } else -> ApiResult.Error("Server error ${response.status.value}", response.status.value) } }.getOrElse { e -> ApiErrorMapper.toApiResult(e) { if (BuildConfig.DEBUG) Log.w(TAG, "$path failed", it) } } @@ -254,6 +274,12 @@ class ApiClient( // HttpRequestTimeoutException is checked explicitly because it subclasses // CancellationException (so HttpTimeout can cooperate with coroutine // cancellation) rather than IOException. + // #211: reads the `{"error": ""}` body a 401 response may carry, falling back to + // "Unauthorized" when there's no body (the normal case for a rejected session token). + private suspend fun HttpResponse.unauthorizedMessage(): String = + runCatching { Json.decodeFromString>(bodyAsText())["error"] } + .getOrNull() ?: "Unauthorized" + private fun isRetryableNetworkError(e: Throwable): Boolean = e is HttpRequestTimeoutException || e is IOException diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiErrorMapper.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiErrorMapper.kt index 334a05a..6955c77 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiErrorMapper.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiErrorMapper.kt @@ -11,8 +11,11 @@ import javax.net.ssl.SSLException // Marks a message that is already user-presentable (server status text, "No network // connection") so ApiErrorMapper passes it through instead of replacing it with the -// generic fallback. -class ApiCallFailedException(message: String) : Exception(message) +// generic fallback. `code` carries the originating ApiResult.Error's HTTP status (0 when +// there wasn't one, e.g. "No network connection") so callers like PasskeyService's recovery +// flow (#211) can distinguish "the token/proof was rejected" (401) from other failures +// without parsing the message text. +class ApiCallFailedException(message: String, val code: Int = 0) : Exception(message) object ApiErrorMapper { diff --git a/android/app/src/main/java/com/ethosprotocol/models/Models.kt b/android/app/src/main/java/com/ethosprotocol/models/Models.kt index f04701f..a0e60f0 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/Models.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/Models.kt @@ -98,6 +98,21 @@ data class RecoveryCompleteRequest( @SerialName("client_data_json") val clientDataJson: String ) +// MARK: - Sessions (#208) +// +// A device currently holding a valid JWT for this account. Backs SessionsScreen's device +// list and its "Sign out this device" / "Sign out all other devices" actions. + +@Serializable +data class Session( + val id: String, + @SerialName("device_name") val deviceName: String, + val platform: String, + @SerialName("created_at") val createdAt: String, + @SerialName("last_active_at") val lastActiveAt: String, + @SerialName("is_current") val isCurrent: Boolean +) + // MARK: - 2FA Models @Serializable diff --git a/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt b/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt index e7c47f8..f10373c 100644 --- a/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt +++ b/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt @@ -56,8 +56,15 @@ class PasskeyService @Inject constructor( val requestJson = PasskeyRequestBuilder.registrationRequestJson(challenge, normalizedUsername) val credManager = credentialManagerFactory.create(activity) - val resp = credManager.createCredential(activity, CreatePublicKeyCredentialRequest(requestJson)) - as CreatePublicKeyCredentialResponse + // #210: devices with no biometric enrolled (or biometrics disabled by policy) throw + // here — map to tailored copy instead of letting a generic CredentialManager error + // reach the user with no indication of what to actually do about it. + val resp = try { + credManager.createCredential(activity, CreatePublicKeyCredentialRequest(requestJson)) + as CreatePublicKeyCredentialResponse + } catch (e: CreateCredentialException) { + throw PasskeyException(mapCreateCredentialError(e)) + } val json = JSONObject(resp.registrationResponseJson) val regReq = PasskeyRegisterRequest( credentialId = json.getString("id"), @@ -124,8 +131,12 @@ class PasskeyService @Inject constructor( // subtypes that carry actionable meaning; anything else falls back to a generic retry prompt. private fun mapCreateCredentialError(e: CreateCredentialException): String = when (e) { is CreateCredentialCancellationException -> "Sign-up canceled." + // #210: this fires on devices with no biometric enrolled (or an unlockable screen + // lock at all) — e.g. older/budget hardware below this app's usual assumptions, or + // biometrics disabled by MDM policy. Direct the user to a fix instead of a dead end. is CreateCredentialNoCreateOptionException -> - "This device can't create a passkey. Add a screen lock in Settings and try again." + "This device has no biometric or screen lock set up, so it can't create a passkey. " + + "Enroll a fingerprint or face unlock, or set a device PIN/passcode, in Settings and try again." is CreateCredentialProviderConfigurationException -> "No passkey provider is set up on this device." is CreateCredentialInterruptedException -> "Setup was interrupted — please try again." @@ -145,7 +156,7 @@ class PasskeyService @Inject constructor( internal fun requireSuccess(result: ApiResult): T { return when (result) { is ApiResult.Success -> result.data - is ApiResult.Error -> throw ApiCallFailedException(result.message) + is ApiResult.Error -> throw ApiCallFailedException(result.message, result.code) ApiResult.NetworkUnavailable -> throw ApiCallFailedException("No network connection") } } diff --git a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt index e806dda..6ddcb19 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt @@ -35,6 +35,7 @@ import com.ethosprotocol.ui.screens.DepositScreen import com.ethosprotocol.ui.screens.VaultDeepLinkScreen import com.ethosprotocol.ui.screens.VaultListScreen import com.ethosprotocol.ui.screens.WithdrawScreen +import com.ethosprotocol.ui.screens.SessionsScreen import com.ethosprotocol.ui.theme.EthosProtocolTheme import dagger.hilt.android.AndroidEntryPoint @@ -220,7 +221,13 @@ private fun AppNavigation( NavHost(navController, startDestination = if (authState.isAuthenticated) "vaults" else "auth") { composable("auth") { AuthScreen(vm = authVm) } composable("vaults") { - VaultListScreen(onVaultClick = { /* navigate to detail */ }) + VaultListScreen( + onVaultClick = { /* navigate to detail */ }, + onSessionsClick = { navController.navigate("sessions") } + ) + } + composable("sessions") { + SessionsScreen(onBack = { navController.popBackStack() }) } composable("accept/{vaultId}/{token}") { backStack -> val vaultId = backStack.arguments?.getString("vaultId") ?: return@composable diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index 9ef735f..c5f58b3 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ethosprotocol.BuildConfig +import com.ethosprotocol.api.ApiCallFailedException import com.ethosprotocol.api.ApiClient import com.ethosprotocol.api.ApiErrorMapper import com.ethosprotocol.api.ApiResult @@ -21,6 +22,7 @@ import com.ethosprotocol.models.Enable2FARequest import com.ethosprotocol.models.Enable2FAResponse import com.ethosprotocol.models.Verify2FARequest import com.ethosprotocol.services.NotificationHelper +import com.ethosprotocol.services.PasskeyException import com.ethosprotocol.services.PasskeyService import com.ethosprotocol.services.PendingAction import com.ethosprotocol.services.PendingActionDao @@ -72,6 +74,38 @@ class AuthViewModel @Inject constructor( private var consecutiveFailures = 0 private var cooldownJob: Job? = null + // #209: proactive token refresh, scheduled independently of request activity — ApiClient's + // ensureFreshToken() already covers "refresh before the next API call", but a foregrounded, + // idle app makes no calls and would otherwise sit on a token until it's rejected. Polling + // (rather than a one-shot timer at the exact expiry, as AuthStore.swift does) is used because + // TokenProvider only exposes isNearExpiry(), not the raw expiry instant. + private var refreshJob: Job? = null + + init { + if (tokenProvider.token != null) startScheduledRefresh() + } + + private fun startScheduledRefresh() { + refreshJob?.cancel() + refreshJob = viewModelScope.launch { + while (true) { + delay(REFRESH_POLL_INTERVAL_MILLIS) + if (tokenProvider.token == null) return@launch + if (!tokenProvider.isNearExpiry()) continue + when (val result = apiClient.refreshToken()) { + is ApiResult.Success -> tokenProvider.setSession(result.data) + is ApiResult.Error -> if (result.code == 401) { + // ApiClient already cleared the stored token on the 401; reflect that + // here so the UI falls back to full re-authentication. + _state.update { it.copy(isAuthenticated = false) } + return@launch + } + ApiResult.NetworkUnavailable -> Unit // transient — retry on the next tick + } + } + } + } + /** Called from MainActivity.onStop — records when the app left the foreground. */ fun onAppBackgrounded(now: Long = System.currentTimeMillis()) { if (_state.value.isAuthenticated) backgroundedAtMillis = now @@ -98,6 +132,7 @@ class AuthViewModel @Inject constructor( consecutiveFailures = 0 cooldownJob?.cancel() _state.update { it.copy(isAuthenticated = true, isLoading = false, cooldownRemainingSeconds = 0) } + startScheduledRefresh() } .onFailure { e -> handleAuthFailure(e) } } @@ -123,11 +158,55 @@ class AuthViewModel @Inject constructor( // PasskeyService.register already stores the session token returned by the // backend, so there's no need to run a second sign-in ceremony here. passkeyService.register(activity, username) - .onSuccess { _state.update { it.copy(isAuthenticated = true, isLoading = false) } } + .onSuccess { + _state.update { it.copy(isAuthenticated = true, isLoading = false) } + startScheduledRefresh() + } .onFailure { e -> handleAuthFailure(e) } } + // ── Account recovery ("lost your device?") — mirrors iOS AuthStore.recoverAccess (#211) ── + + fun sendRecoveryCode(username: String) = viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + when (val result = apiClient.initiateRecovery(RecoveryInitiateRequest(username))) { + is ApiResult.Success -> _state.update { it.copy(isLoading = false, recoveryToken = result.data.recoveryToken) } + is ApiResult.Error -> _state.update { it.copy(isLoading = false, error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(isLoading = false, error = "No network") } + } + } + + fun finishRecovery(activity: Activity, username: String) = viewModelScope.launch { + val recoveryToken = _state.value.recoveryToken ?: return@launch + _state.update { it.copy(isLoading = true, error = null) } + passkeyService.recoverAccount(activity, username, recoveryToken) + .onSuccess { + _state.update { it.copy(isAuthenticated = true, isLoading = false, recoveryToken = null) } + startScheduledRefresh() + } + .onFailure { e -> + // A rejected recovery token/proof (401) can't be retried as-is — drop back to + // the "send code" step instead of leaving the user stuck on a dead-end error. + val recoveryRejected = (e as? ApiCallFailedException)?.code == 401 + val message = if (e is PasskeyException) e.message ?: "Recovery failed. Please try again." + else ApiErrorMapper.friendlyMessage(e) + _state.update { + it.copy( + isLoading = false, + error = message, + recoveryToken = if (recoveryRejected) null else it.recoveryToken + ) + } + } + } + + fun clearRecovery() { + _state.update { it.copy(recoveryToken = null, error = null) } + } + fun signOut() = viewModelScope.launch { + refreshJob?.cancel() + refreshJob = null // Unregister before clearing the auth token — ApiClient.bearerAuth() reads // tokenProvider.token when building the request, so clearing first would send // the delete unauthenticated. Best-effort: sign-out proceeds locally either way. @@ -157,6 +236,53 @@ class AuthViewModel @Inject constructor( private const val COOLDOWN_FAILURE_THRESHOLD = 3 private const val COOLDOWN_BASE_SECONDS = 2 private const val COOLDOWN_MAX_SECONDS = 60 + // #209: matches TokenProvider.isNearExpiry's default 60s threshold — checking every + // 30s guarantees at least one check lands inside that window before expiry. + private const val REFRESH_POLL_INTERVAL_MILLIS = 30_000L + } +} + +// --- Sessions ViewModel (#208) --- + +data class SessionsUiState( + val sessions: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) + +@HiltViewModel +class SessionsViewModel @Inject constructor( + private val apiClient: ApiClient +) : ViewModel() { + + private val _state = MutableStateFlow(SessionsUiState()) + val state = _state.asStateFlow() + + fun load() = viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + when (val result = apiClient.listSessions()) { + is ApiResult.Success -> _state.update { it.copy(sessions = result.data, isLoading = false) } + is ApiResult.Error -> _state.update { it.copy(isLoading = false, error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(isLoading = false, error = "No network") } + } + } + + // Caller (SessionsScreen) is responsible for the biometric prompt before invoking this — + // mirrors VaultListScreen's check-in confirmation pattern. + fun revoke(session: Session) = viewModelScope.launch { + when (val result = apiClient.revokeSession(session.id)) { + is ApiResult.Success -> _state.update { it.copy(sessions = it.sessions.filter { s -> s.id != session.id }) } + is ApiResult.Error -> _state.update { it.copy(error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } + } + } + + fun revokeAllOthers() = viewModelScope.launch { + when (val result = apiClient.revokeOtherSessions()) { + is ApiResult.Success -> load() + is ApiResult.Error -> _state.update { it.copy(error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } + } } } diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index d9cba64..5028688 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -25,6 +25,7 @@ import com.ethosprotocol.models.TwoFactorStatus import com.ethosprotocol.models.Enable2FARequest import com.ethosprotocol.models.Verify2FARequest import com.ethosprotocol.models.StellarAddress +import com.ethosprotocol.models.Session import com.ethosprotocol.services.BiometricHelper import com.ethosprotocol.services.UsernameValidator import com.ethosprotocol.services.VaultDeepLinkAction @@ -33,6 +34,7 @@ import com.ethosprotocol.ui.AuthUiState import com.ethosprotocol.ui.AuthViewModel import com.ethosprotocol.ui.VaultViewModel import com.ethosprotocol.ui.TwoFactorViewModel +import com.ethosprotocol.ui.SessionsViewModel // MARK: - Auth Screen @@ -50,12 +52,22 @@ fun AuthScreen(vm: AuthViewModel = hiltViewModel()) { ) } + if (showRecovery) { + RecoverySheet( + state = state, + onSendCode = { username -> vm.sendRecoveryCode(username) }, + onFinish = { username -> vm.finishRecovery(activity, username) }, + onDismiss = { showRecovery = false; vm.clearRecovery() } + ) + } + AuthScreenContent( isLoading = state.isLoading, error = state.error, cooldownRemainingSeconds = state.cooldownRemainingSeconds, onSignIn = { vm.signIn(activity) }, - onRegister = { showRegister = true } + onRegister = { showRegister = true }, + onRecover = { showRecovery = true } ) } @@ -69,7 +81,8 @@ fun AuthScreenContent( error: String?, cooldownRemainingSeconds: Int = 0, onSignIn: () -> Unit, - onRegister: () -> Unit + onRegister: () -> Unit, + onRecover: () -> Unit = {} ) { Column( modifier = Modifier.fillMaxSize().padding(32.dp), @@ -107,6 +120,7 @@ fun AuthScreenContent( } Spacer(Modifier.height(8.dp)) TextButton(onClick = onRegister) { Text("Create account") } + TextButton(onClick = onRecover) { Text("Lost your device?") } } } @@ -199,6 +213,7 @@ private fun RecoverySheet( @Composable fun VaultListScreen( onVaultClick: (String) -> Unit, + onSessionsClick: () -> Unit = {}, vm: VaultViewModel = hiltViewModel() ) { val state by vm.state.collectAsStateWithLifecycle() @@ -261,6 +276,7 @@ fun VaultListScreen( Scaffold( topBar = { TopAppBar(title = { Text("My Vaults") }, actions = { + IconButton(onClick = onSessionsClick) { Icon(Icons.Default.Devices, "Active sessions") } IconButton(onClick = { showCreate = true }) { Icon(Icons.Default.Add, "Create vault") } }) } @@ -1495,3 +1511,113 @@ fun VaultDetailScreen( } } } + +// MARK: - Sessions Screen (#208) + +@Composable +fun SessionsScreen( + onBack: () -> Unit, + vm: SessionsViewModel = hiltViewModel() +) { + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + var pendingRevocation by remember { mutableStateOf(null) } + var showRevokeAllConfirmation by remember { mutableStateOf(false) } + var biometricError by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { vm.load() } + + pendingRevocation?.let { session -> + AlertDialog( + onDismissRequest = { pendingRevocation = null }, + title = { Text("Sign out this device?") }, + text = { Text("${session.deviceName} will be signed out immediately.") }, + confirmButton = { + TextButton(onClick = { + pendingRevocation = null + BiometricHelper(context as androidx.fragment.app.FragmentActivity).authenticate( + title = "Sign Out Device", + subtitle = "Confirm signing out ${session.deviceName}", + onSuccess = { vm.revoke(session) }, + onError = { err -> biometricError = err }, + ) + }) { Text("Sign Out") } + }, + dismissButton = { TextButton(onClick = { pendingRevocation = null }) { Text("Cancel") } } + ) + } + + if (showRevokeAllConfirmation) { + AlertDialog( + onDismissRequest = { showRevokeAllConfirmation = false }, + title = { Text("Sign out every other device?") }, + text = { Text("All other devices signed in to this account will be signed out immediately.") }, + confirmButton = { + TextButton(onClick = { + showRevokeAllConfirmation = false + BiometricHelper(context as androidx.fragment.app.FragmentActivity).authenticate( + title = "Sign Out All Other Devices", + subtitle = "Confirm signing out every other device", + onSuccess = { vm.revokeAllOthers() }, + onError = { err -> biometricError = err }, + ) + }) { Text("Sign Out All") } + }, + dismissButton = { TextButton(onClick = { showRevokeAllConfirmation = false }) { Text("Cancel") } } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Active Sessions") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } + } + ) + } + ) { padding -> + Box(Modifier.padding(padding).fillMaxSize()) { + when { + state.isLoading && state.sessions.isEmpty() -> + CircularProgressIndicator(Modifier.align(Alignment.Center)) + else -> LazyColumn { + val errorMsg = biometricError ?: state.error + errorMsg?.let { err -> + item { + Text(err, color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp)) + } + } + items(state.sessions, key = { it.id }) { session -> + ListItem( + headlineContent = { + Row { + Text(session.deviceName) + if (session.isCurrent) { + Spacer(Modifier.width(8.dp)) + Text("(This Device)", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary) + } + } + }, + supportingContent = { Text("Last active: ${session.lastActiveAt}") }, + trailingContent = { + TextButton(onClick = { pendingRevocation = session }) { Text("Sign Out") } + } + ) + } + if (state.sessions.any { !it.isCurrent }) { + item { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + OutlinedButton(onClick = { showRevokeAllConfirmation = true }) { + Text("Sign Out All Other Devices") + } + } + } + } + } + } + } + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt b/android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt index 6ac35bd..087941c 100644 --- a/android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt @@ -15,6 +15,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -102,4 +103,48 @@ class ApiClientTest { assertTrue(result is ApiResult.Error) verify { tokenProvider.clear() } } + + @Test + fun `401 with no body falls back to a generic Unauthorized message`() = runTest { + every { tokenProvider.token } returns null + every { tokenProvider.isNearExpiry() } returns false + + val engine = MockEngine { respond(content = "", status = HttpStatusCode.Unauthorized) } + val apiClient = ApiClient(tokenProvider, networkMonitor, offlineCache, "https://test", engine) + + val result = apiClient.listVaults() + + assertEquals("Unauthorized", (result as ApiResult.Error).message) + } + + // #211: an expired recovery token/proof on completeRecovery must surface a clear, + // actionable message — not the generic "Unauthorized" shown for a rejected session. + @Test + fun `401 with an error body on completeRecovery surfaces the server message`() = runTest { + every { tokenProvider.token } returns null + every { tokenProvider.isNearExpiry() } returns false + + val engine = MockEngine { + respond( + content = """{"error":"Your recovery code has expired. Please request a new one."}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + val apiClient = ApiClient(tokenProvider, networkMonitor, offlineCache, "https://test", engine) + + val result = apiClient.completeRecovery( + com.ethosprotocol.models.RecoveryCompleteRequest( + recoveryToken = "expired-token", + credentialId = "cred", + publicKey = "pk", + clientDataJson = "cdj" + ) + ) + + assertEquals( + "Your recovery code has expired. Please request a new one.", + (result as ApiResult.Error).message + ) + } } diff --git a/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt index 0c64bee..b7db371 100644 --- a/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt @@ -230,6 +230,145 @@ class AuthViewModelTest { assertEquals(0, vm.state.value.cooldownRemainingSeconds) } + // MARK: - #211 Account recovery — expired recovery token + + @Test + fun `sendRecoveryCode success stores the recovery token`() = runTest { + val response = com.ethosprotocol.models.RecoveryInitiateResponse( + recoveryToken = "recovery-token-123", expiresAt = "2099-01-01T00:00:00Z" + ) + coEvery { apiClient.initiateRecovery(any()) } returns ApiResult.Success(response) + + vm.sendRecoveryCode("alice") + + assertEquals("recovery-token-123", vm.state.value.recoveryToken) + assertNull(vm.state.value.error) + } + + @Test + fun `finishRecovery success authenticates and clears the recovery token`() = runTest { + val response = com.ethosprotocol.models.RecoveryInitiateResponse( + recoveryToken = "recovery-token-123", expiresAt = "2099-01-01T00:00:00Z" + ) + coEvery { apiClient.initiateRecovery(any()) } returns ApiResult.Success(response) + coEvery { passkeyService.recoverAccount(activity, "alice", "recovery-token-123") } returns Result.success(Unit) + vm.sendRecoveryCode("alice") + + vm.finishRecovery(activity, "alice") + + assertTrue(vm.state.value.isAuthenticated) + assertNull(vm.state.value.recoveryToken) + } + + @Test + fun `finishRecovery with expired token surfaces a clear error and resets to the send-code step`() = runTest { + val response = com.ethosprotocol.models.RecoveryInitiateResponse( + recoveryToken = "recovery-token-123", expiresAt = "2099-01-01T00:00:00Z" + ) + coEvery { apiClient.initiateRecovery(any()) } returns ApiResult.Success(response) + coEvery { passkeyService.recoverAccount(activity, "alice", "recovery-token-123") } returns + Result.failure(com.ethosprotocol.api.ApiCallFailedException( + "Your recovery code has expired. Please request a new one.", 401 + )) + vm.sendRecoveryCode("alice") + + vm.finishRecovery(activity, "alice") + + assertFalse(vm.state.value.isAuthenticated) + assertEquals("Your recovery code has expired. Please request a new one.", vm.state.value.error) + assertNull("An expired recovery token must not leave the user on the same dead-end step", + vm.state.value.recoveryToken) + } + + @Test + fun `finishRecovery with a non-expiry failure keeps the recovery token so the user can retry`() = runTest { + val response = com.ethosprotocol.models.RecoveryInitiateResponse( + recoveryToken = "recovery-token-123", expiresAt = "2099-01-01T00:00:00Z" + ) + coEvery { apiClient.initiateRecovery(any()) } returns ApiResult.Success(response) + coEvery { passkeyService.recoverAccount(activity, "alice", "recovery-token-123") } returns + Result.failure(RuntimeException("device error")) + vm.sendRecoveryCode("alice") + + vm.finishRecovery(activity, "alice") + + assertFalse(vm.state.value.isAuthenticated) + assertEquals("recovery-token-123", vm.state.value.recoveryToken) + } + + // MARK: - #209 Scheduled proactive token refresh + + @Test + fun `scheduled refresh fires while signed in and near expiry, keeping the user authenticated`() = runTest { + every { tokenProvider.token } returns "token-value" + every { tokenProvider.isNearExpiry() } returns true + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + val refreshedToken = com.ethosprotocol.models.AuthToken(token = "new-token", expiresAt = "2099-01-01T00:00:00Z") + coEvery { apiClient.refreshToken() } returns ApiResult.Success(refreshedToken) + + vm.signIn(activity) + testDispatcher.scheduler.advanceTimeBy(31_000) + + coVerify(atLeast = 1) { apiClient.refreshToken() } + verify { tokenProvider.setSession(refreshedToken) } + assertTrue(vm.state.value.isAuthenticated) + } + + @Test + fun `scheduled refresh rejected with 401 signs the user out`() = runTest { + every { tokenProvider.token } returns "token-value" + every { tokenProvider.isNearExpiry() } returns true + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + coEvery { apiClient.refreshToken() } returns ApiResult.Error("Unauthorized", 401) + + vm.signIn(activity) + testDispatcher.scheduler.advanceTimeBy(31_000) + + assertFalse(vm.state.value.isAuthenticated) + } + + @Test + fun `scheduled refresh transient failure does not sign the user out`() = runTest { + every { tokenProvider.token } returns "token-value" + every { tokenProvider.isNearExpiry() } returns true + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + coEvery { apiClient.refreshToken() } returns ApiResult.NetworkUnavailable + + vm.signIn(activity) + testDispatcher.scheduler.advanceTimeBy(31_000) + + assertTrue(vm.state.value.isAuthenticated) + } + + @Test + fun `scheduled refresh does not fire when token is not near expiry`() = runTest { + every { tokenProvider.token } returns "token-value" + every { tokenProvider.isNearExpiry() } returns false + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + + vm.signIn(activity) + testDispatcher.scheduler.advanceTimeBy(31_000) + + coVerify(exactly = 0) { apiClient.refreshToken() } + } + + @Test + fun `signOut cancels the scheduled refresh loop`() = runTest { + every { tokenProvider.token } returns "token-value" + every { tokenProvider.isNearExpiry() } returns true + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + coEvery { apiClient.refreshToken() } returns ApiResult.Success( + com.ethosprotocol.models.AuthToken(token = "new-token", expiresAt = "2099-01-01T00:00:00Z") + ) + + vm.signIn(activity) + vm.signOut() + every { tokenProvider.token } returns null + testDispatcher.scheduler.advanceTimeBy(31_000) + + coVerify(exactly = 0) { apiClient.refreshToken() } + } + @Test fun `signOut clears cooldown state`() = runTest { coEvery { passkeyService.authenticate(activity) } returns Result.failure(RuntimeException("bad")) diff --git a/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt b/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt index fed29ef..152ba46 100644 --- a/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt @@ -165,6 +165,25 @@ class PasskeyServiceTest { assertEquals("Forbidden", result.exceptionOrNull()?.message) } + // ── register: no biometric hardware available (#210) ─────────────────────── + + @Test + fun register_noCreateOptionAvailable_returnsFailureWithBiometricFallbackCopy() = runTest { + coEvery { mockApiClient.getChallenge() } returns ApiResult.Success(fakeChallenge) + coEvery { mockCredentialManager.createCredential(mockActivity, any()) } throws + androidx.credentials.exceptions.CreateCredentialNoCreateOptionException() + + val result = service.register(mockActivity, "alice") + + assertTrue("Expected failure", result.isFailure) + val message = result.exceptionOrNull()?.message.orEmpty() + assertTrue("Expected guidance to enroll a biometric: $message", + message.contains("biometric", ignoreCase = true)) + assertTrue("Expected guidance about a device PIN/passcode fallback: $message", + message.contains("PIN", ignoreCase = true) || message.contains("passcode", ignoreCase = true)) + coVerify(exactly = 0) { mockApiClient.registerPasskey(any()) } + } + // ── authenticate ───────────────────────────────────────────────────────── @Test @@ -257,6 +276,56 @@ class PasskeyServiceTest { assertEquals("old-token", tokenProvider.token) } + // ── recoverAccount (#211 — expired recovery token) ────────────────────────── + + @Test + fun recoverAccount_success_callsCompleteRecoveryWithCorrectFields() = runTest { + val fakeRegResponse = mockk { + every { registrationResponseJson } returns fakeRegistrationJson + } + coEvery { mockApiClient.getChallenge() } returns ApiResult.Success(fakeChallenge) + coEvery { mockCredentialManager.createCredential(mockActivity, any()) } returns fakeRegResponse + coEvery { mockApiClient.completeRecovery(any()) } returns ApiResult.Success(Unit) + + val result = service.recoverAccount(mockActivity, "alice", "recovery-token-123") + + assertTrue("Expected success", result.isSuccess) + val slot = slot() + coVerify { mockApiClient.completeRecovery(capture(slot)) } + assertEquals("recovery-token-123", slot.captured.recoveryToken) + assertEquals("credential-id-reg", slot.captured.credentialId) + } + + @Test + fun recoverAccount_expiredRecoveryToken_returnsFailureWithServerMessage_notGenericFailure() = runTest { + val fakeRegResponse = mockk { + every { registrationResponseJson } returns fakeRegistrationJson + } + coEvery { mockApiClient.getChallenge() } returns ApiResult.Success(fakeChallenge) + coEvery { mockCredentialManager.createCredential(mockActivity, any()) } returns fakeRegResponse + coEvery { mockApiClient.completeRecovery(any()) } returns + ApiResult.Error("Your recovery code has expired. Please request a new one.", 401) + + val result = service.recoverAccount(mockActivity, "alice", "expired-token") + + assertTrue("Expected failure", result.isFailure) + assertEquals( + "Your recovery code has expired. Please request a new one.", + result.exceptionOrNull()?.message + ) + assertEquals(401, (result.exceptionOrNull() as? ApiCallFailedException)?.code) + } + + @Test + fun recoverAccount_getChallengeNetworkError_returnsFailure_completeRecoveryNotCalled() = runTest { + coEvery { mockApiClient.getChallenge() } returns ApiResult.NetworkUnavailable + + val result = service.recoverAccount(mockActivity, "alice", "recovery-token-123") + + assertTrue("Expected failure", result.isFailure) + coVerify(exactly = 0) { mockApiClient.completeRecovery(any()) } + } + // ── requireSuccess error mapping ────────────────────────────────────────── @Test diff --git a/android/app/src/test/java/com/ethosprotocol/SessionsViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/SessionsViewModelTest.kt new file mode 100644 index 0000000..eeac7cb --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/SessionsViewModelTest.kt @@ -0,0 +1,112 @@ +package com.ethosprotocol + +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.models.Session +import com.ethosprotocol.ui.SessionsViewModel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Unit tests for [SessionsViewModel] (#208 — session/device list with remote sign-out). */ +@OptIn(ExperimentalCoroutinesApi::class) +class SessionsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val apiClient: ApiClient = mockk() + private lateinit var vm: SessionsViewModel + + private val current = Session( + id = "s1", deviceName = "Pixel 8", platform = "android", + createdAt = "2026-01-01T00:00:00Z", lastActiveAt = "2026-01-02T00:00:00Z", isCurrent = true + ) + private val other = Session( + id = "s2", deviceName = "iPhone 15 Pro", platform = "ios", + createdAt = "2026-01-01T00:00:00Z", lastActiveAt = "2026-01-01T12:00:00Z", isCurrent = false + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + vm = SessionsViewModel(apiClient) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `load populates sessions on success`() = runTest { + coEvery { apiClient.listSessions() } returns ApiResult.Success(listOf(current, other)) + + vm.load() + + assertEquals(listOf(current, other), vm.state.value.sessions) + assertNull(vm.state.value.error) + } + + @Test + fun `load sets error on failure`() = runTest { + coEvery { apiClient.listSessions() } returns ApiResult.Error("Server error", 500) + + vm.load() + + assertTrue(vm.state.value.sessions.isEmpty()) + assertEquals("Server error", vm.state.value.error) + } + + @Test + fun `revoke removes session from state on success`() = runTest { + coEvery { apiClient.listSessions() } returns ApiResult.Success(listOf(current, other)) + coEvery { apiClient.revokeSession("s2") } returns ApiResult.Success(Unit) + vm.load() + + vm.revoke(other) + + assertEquals(listOf(current), vm.state.value.sessions) + } + + @Test + fun `revoke keeps session and sets error on failure`() = runTest { + coEvery { apiClient.listSessions() } returns ApiResult.Success(listOf(current, other)) + coEvery { apiClient.revokeSession("s2") } returns ApiResult.Error("Not found", 404) + vm.load() + + vm.revoke(other) + + assertEquals(listOf(current, other), vm.state.value.sessions) + assertEquals("Not found", vm.state.value.error) + } + + @Test + fun `revokeAllOthers reloads the session list on success`() = runTest { + coEvery { apiClient.revokeOtherSessions() } returns ApiResult.Success(Unit) + coEvery { apiClient.listSessions() } returns ApiResult.Success(listOf(current)) + + vm.revokeAllOthers() + + coVerify { apiClient.listSessions() } + assertEquals(listOf(current), vm.state.value.sessions) + } + + @Test + fun `revokeAllOthers sets error on failure without reloading`() = runTest { + coEvery { apiClient.revokeOtherSessions() } returns ApiResult.Error("Server error", 500) + + vm.revokeAllOthers() + + coVerify(exactly = 0) { apiClient.listSessions() } + assertEquals("Server error", vm.state.value.error) + } +} diff --git a/ios/EthosProtocol/Sources/Models/Models.swift b/ios/EthosProtocol/Sources/Models/Models.swift index 31db604..88aea40 100644 --- a/ios/EthosProtocol/Sources/Models/Models.swift +++ b/ios/EthosProtocol/Sources/Models/Models.swift @@ -167,6 +167,17 @@ struct PushRegistration: Codable { let platform: String // "ios" | "android" } +/// A device currently holding a valid JWT for this account (#208). Drives SessionsView's +/// device list and its "Sign out this device" / "Sign out all other devices" actions. +struct Session: Codable, Identifiable, Equatable { + let id: String + let deviceName: String + let platform: String + let createdAt: Date + let lastActiveAt: Date + let isCurrent: Bool +} + // MARK: - 2FA Models enum TwoFactorMethod: String, Codable, CaseIterable { diff --git a/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index 24282f0..73b204d 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -149,6 +149,32 @@ public final class APIClient { let _: EmptyBody = try await post(path: "/auth/recover/link", body: body) } + // MARK: - Sessions (#208) + + func listSessions() async throws -> [Session] { + try await get(path: "/auth/sessions") + } + + /// "Sign out this device" for a specific session (may be the caller's own current session). + func revokeSession(id: String) async throws { + var req = request(path: "/auth/sessions/\(id)") + req.httpMethod = "DELETE" + for (field, value) in Self.makeAntiReplayHeaders() { + req.setValue(value, forHTTPHeaderField: field) + } + _ = try await execute(req) + } + + /// "Sign out all other devices" — revokes every session except the one making this call. + func revokeOtherSessions() async throws { + var req = request(path: "/auth/sessions") + req.httpMethod = "DELETE" + for (field, value) in Self.makeAntiReplayHeaders() { + req.setValue(value, forHTTPHeaderField: field) + } + _ = try await execute(req) + } + // MARK: - Vaults /// One page of `GET /vaults`. See shared/api-contract.md's "List Pagination" @@ -395,6 +421,14 @@ public final class APIClient { // The token the server rejected is no longer valid — drop it locally so we // don't keep sending it, and so a relaunch correctly shows the sign-in screen. KeychainService.shared.deleteToken() + // #211: a 401 can carry a human-readable reason in its body (e.g. an expired + // recovery token/proof on /auth/recover/link) — surface it instead of the + // generic "Authentication required" so the caller isn't left on a dead end. + // Ordinary session-token 401s have an empty body, so this falls through to the + // existing .unauthorized behavior unchanged. + if let message = (try? JSONDecoder().decode([String: String].self, from: data))?["error"] { + throw APIError.serverError(message) + } throw APIError.unauthorized case 404: throw APIError.notFound default: diff --git a/ios/EthosProtocol/Sources/Services/PasskeyService.swift b/ios/EthosProtocol/Sources/Services/PasskeyService.swift index 988e9e0..ff25bd5 100644 --- a/ios/EthosProtocol/Sources/Services/PasskeyService.swift +++ b/ios/EthosProtocol/Sources/Services/PasskeyService.swift @@ -1,5 +1,6 @@ import AuthenticationServices import Foundation +import LocalAuthentication final class PasskeyService: NSObject { static let shared = PasskeyService() @@ -195,6 +196,7 @@ enum PasskeyError: LocalizedError, Equatable { case userCancelled case notInteractive case credentialAlreadyExists + case biometricUnavailable var errorDescription: String? { switch self { @@ -203,6 +205,9 @@ enum PasskeyError: LocalizedError, Equatable { case .userCancelled: return "Passkey request was cancelled." case .notInteractive: return "Bring the app to the foreground to use your passkey." case .credentialAlreadyExists: return "A passkey for this account already exists on this device." + case .biometricUnavailable: + return "This device has no biometric or passcode set up, so it can't create a passkey. " + + "Enroll Face ID/Touch ID, or set a device passcode, in Settings and try again." } } @@ -210,6 +215,16 @@ enum PasskeyError: LocalizedError, Equatable { /// best describes it, so the UI can show distinct guidance instead of one generic /// failure message for cancellation, backgrounding, and duplicate-credential cases. static func map(_ error: Error, fallback: PasskeyError) -> PasskeyError { + // #210: a device with no biometric enrolled (or an MDM policy disabling it, or no + // passcode set) surfaces as an ASAuthorizationError wrapping the real LAError in + // NSUnderlyingErrorKey — check that before falling through to the ASAuthorizationError + // code switch below, which has no case that distinguishes this from any other failure. + if let underlying = (error as NSError).userInfo[NSUnderlyingErrorKey] as? NSError, + underlying.domain == LAErrorDomain, + let laCode = LAError.Code(rawValue: underlying.code), + [.biometryNotEnrolled, .biometryNotAvailable, .passcodeNotSet].contains(laCode) { + return .biometricUnavailable + } guard let authError = error as? ASAuthorizationError else { return fallback } switch authError.code { case .canceled: diff --git a/ios/EthosProtocol/Sources/ViewModels/Stores.swift b/ios/EthosProtocol/Sources/ViewModels/Stores.swift index 75dbb53..b0c4638 100644 --- a/ios/EthosProtocol/Sources/ViewModels/Stores.swift +++ b/ios/EthosProtocol/Sources/ViewModels/Stores.swift @@ -234,6 +234,56 @@ enum ReLockTimeoutOption: Int, CaseIterable, Identifiable { } } +/// Backs SessionsView (#208): the list of devices currently holding a valid JWT for this +/// account, plus the "Sign out this device" / "Sign out all other devices" actions. Both +/// mutating actions are expected to be gated behind a biometric prompt by the caller (the +/// view), same as VaultStore.withdraw — this store just performs the already-authorized action. +@MainActor +final class SessionsStore: ObservableObject { + @Published var sessions: [Session] = [] + @Published var isLoading = false + @Published var error: ErrorPresentation? + + // Injected for testing; defaults to the real APIClient calls. + var listSessions: () async throws -> [Session] = { try await APIClient.shared.listSessions() } + var revokeSession: (String) async throws -> Void = { try await APIClient.shared.revokeSession(id: $0) } + var revokeOtherSessions: () async throws -> Void = { try await APIClient.shared.revokeOtherSessions() } + + func load() async { + isLoading = true; error = nil + do { + sessions = try await listSessions() + } catch { + self.error = ErrorPresentation(error) + } + isLoading = false + } + + /// Signs out the device behind `session`. Removes it from the local list immediately on + /// success rather than requiring a full reload. + func revoke(_ session: Session) async { + error = nil + do { + try await revokeSession(session.id) + sessions.removeAll { $0.id == session.id } + } catch { + self.error = ErrorPresentation(error) + } + } + + /// Signs out every device except the current one, then reloads so the list reflects the + /// server's view (rather than assuming every non-current session was in `sessions`). + func revokeAllOthers() async { + error = nil + do { + try await revokeOtherSessions() + await load() + } catch { + self.error = ErrorPresentation(error) + } + } +} + @MainActor final class VaultStore: ObservableObject { @Published var vaults: [Vault] = [] diff --git a/ios/EthosProtocol/Sources/Views/SettingsView.swift b/ios/EthosProtocol/Sources/Views/SettingsView.swift index b4e32c4..cae2b61 100644 --- a/ios/EthosProtocol/Sources/Views/SettingsView.swift +++ b/ios/EthosProtocol/Sources/Views/SettingsView.swift @@ -33,7 +33,102 @@ struct SettingsView: View { } header: { Text("Privacy") } + + Section { + NavigationLink("Active Sessions") { SessionsView() } + } header: { + Text("Security") + } } .navigationTitle("Settings") } } + +/// Shows every device currently holding a valid JWT for this account (#208), with +/// biometric-gated "Sign out this device" / "Sign out all other devices" actions. +struct SessionsView: View { + @StateObject private var store = SessionsStore() + @State private var pendingRevocation: Session? + @State private var showRevokeAllConfirmation = false + + var body: some View { + List { + if let error = store.error { + Section { Text(error.message).foregroundStyle(.red).font(.caption) } + } + Section { + ForEach(store.sessions) { session in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(session.deviceName).font(.headline) + if session.isCurrent { + Text("This Device") + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(.blue.opacity(0.15)) + .foregroundStyle(.blue) + .clipShape(Capsule()) + } + } + Text("Last active \(session.lastActiveAt.formatted(.relative(presentation: .named)))") + .font(.caption) + .foregroundStyle(.secondary) + } + .swipeActions { + Button("Sign Out", role: .destructive) { pendingRevocation = session } + } + } + } header: { + Text("Signed-In Devices") + } + + if store.sessions.contains(where: { !$0.isCurrent }) { + Section { + Button("Sign Out All Other Devices", role: .destructive) { + showRevokeAllConfirmation = true + } + } + } + } + .overlay { if store.isLoading && store.sessions.isEmpty { ProgressView() } } + .navigationTitle("Active Sessions") + .task { await store.load() } + .refreshable { await store.load() } + .confirmationDialog( + "Sign out this device?", + isPresented: Binding(get: { pendingRevocation != nil }, set: { if !$0 { pendingRevocation = nil } }), + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + guard let session = pendingRevocation else { return } + pendingRevocation = nil + Task { + do { + try await BiometricService.shared.authenticate(reason: "Sign out \(session.deviceName)") + await store.revoke(session) + } catch { + store.error = ErrorPresentation(error) + } + } + } + Button("Cancel", role: .cancel) { pendingRevocation = nil } + } + .confirmationDialog( + "Sign out every other device?", + isPresented: $showRevokeAllConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out All Other Devices", role: .destructive) { + Task { + do { + try await BiometricService.shared.authenticate(reason: "Sign out all other devices") + await store.revokeAllOthers() + } catch { + store.error = ErrorPresentation(error) + } + } + } + Button("Cancel", role: .cancel) {} + } + } +} diff --git a/ios/EthosProtocol/Tests/APIClientTests.swift b/ios/EthosProtocol/Tests/APIClientTests.swift index 03acde4..bbd2036 100644 --- a/ios/EthosProtocol/Tests/APIClientTests.swift +++ b/ios/EthosProtocol/Tests/APIClientTests.swift @@ -348,4 +348,82 @@ final class APIClientAuthTests: XCTestCase { XCTAssertEqual(message, "backup code did not match") } } + + // #211: an expired recovery proof must surface its own clear message, not the generic + // "Authentication required" shown for a rejected session token. + func test_linkAdditionalPasskey_expiredRecoveryProof_surfacesServerMessage_notGenericUnauthorized() async throws { + let errorBody = """ + {"error": "Your recovery code has expired. Please request a new one."} + """.data(using: .utf8)! + mockResponse(for: linkURL, status: 401, body: errorBody) + + let proof = AccountRecoveryProof(email: "user@example.com", backupCode: "123456") + + do { + try await client.linkAdditionalPasskey( + existingAccountProof: proof, + credentialID: "cred-1", + publicKey: "pubkey-1", + clientDataJSON: "client-data-1" + ) + XCTFail("Expected linkAdditionalPasskey to throw for an expired recovery proof") + } catch APIError.serverError(let message) { + XCTAssertEqual(message, "Your recovery code has expired. Please request a new one.") + } + } + + // A 401 with no body (the normal rejected-session-token case) must keep the generic, + // "sign in again" message — this behavior must not regress from the fix above. + func test_plain401WithNoBody_stillThrowsGenericUnauthorized() async throws { + mockResponse(for: linkURL, status: 401, body: Data()) + + let proof = AccountRecoveryProof(email: "user@example.com", backupCode: "123456") + + do { + try await client.linkAdditionalPasskey( + existingAccountProof: proof, + credentialID: "cred-1", + publicKey: "pubkey-1", + clientDataJSON: "client-data-1" + ) + XCTFail("Expected linkAdditionalPasskey to throw") + } catch APIError.unauthorized { + // expected + } + } + + // MARK: Sessions (#208) + + func test_listSessions_decodesSessionList() async throws { + let sessionsURL = "https://api.ethos-protocol.app/v1/auth/sessions" + let json = """ + [{"id": "s1", "device_name": "iPhone 15 Pro", "platform": "ios", + "created_at": "2026-01-01T00:00:00Z", "last_active_at": "2026-01-02T00:00:00Z", "is_current": true}] + """.data(using: .utf8)! + mockResponse(for: sessionsURL, body: json) + + let sessions = try await client.listSessions() + + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions[0].id, "s1") + XCTAssertTrue(sessions[0].isCurrent) + } + + func test_revokeSession_deletesToSessionEndpoint() async throws { + let revokeURL = "https://api.ethos-protocol.app/v1/auth/sessions/s2" + mockResponse(for: revokeURL, body: Data()) + + try await client.revokeSession(id: "s2") + + XCTAssertTrue(MockURLProtocol.requestedURLs.contains { $0.absoluteString == revokeURL }) + } + + func test_revokeOtherSessions_deletesToSessionsCollectionEndpoint() async throws { + let sessionsURL = "https://api.ethos-protocol.app/v1/auth/sessions" + mockResponse(for: sessionsURL, body: Data()) + + try await client.revokeOtherSessions() + + XCTAssertTrue(MockURLProtocol.requestedURLs.contains { $0.absoluteString == sessionsURL }) + } } diff --git a/ios/EthosProtocol/Tests/AuthStoreTests.swift b/ios/EthosProtocol/Tests/AuthStoreTests.swift index 7c1becf..90edebd 100644 --- a/ios/EthosProtocol/Tests/AuthStoreTests.swift +++ b/ios/EthosProtocol/Tests/AuthStoreTests.swift @@ -100,3 +100,38 @@ final class AuthStoreTokenRefreshTests: XCTestCase { await store.signOut() } } + +// MARK: - #211 Account Recovery Token Expiry Tests + +@MainActor +final class AuthStoreRecoveryExpiryTests: XCTestCase { + + func test_recoverAccess_expiredRecoveryProof_surfacesClearError_notDeadEnd() async { + let store = AuthStore() + store.linkAdditionalPasskey = { _, _ in + throw APIError.serverError("Your recovery code has expired. Please request a new one.") + } + var authenticateCallCount = 0 + store.passkeyAuthenticate = { authenticateCallCount += 1; return AuthToken(token: "t", expiresAt: Date()) } + + await store.recoverAccess(email: "user@example.com", backupCode: "123456", username: "alice") + + XCTAssertFalse(store.isAuthenticated) + XCTAssertEqual(store.error?.message, "Your recovery code has expired. Please request a new one.", + "The user must see the specific expiry reason, not a generic failure (#211)") + XCTAssertEqual(authenticateCallCount, 0, + "A failed recovery link must not proceed to authenticate with the never-linked passkey") + } + + func test_recoverAccess_success_authenticatesAfterLinking() async { + let store = AuthStore() + store.linkAdditionalPasskey = { _, _ in "new-credential-id" } + store.passkeyAuthenticate = { AuthToken(token: "t", expiresAt: Date().addingTimeInterval(3_600)) } + + await store.recoverAccess(email: "user@example.com", backupCode: "123456", username: "alice") + + XCTAssertTrue(store.isAuthenticated) + XCTAssertNil(store.error) + await store.signOut() + } +} diff --git a/ios/EthosProtocol/Tests/PasskeyServiceTests.swift b/ios/EthosProtocol/Tests/PasskeyServiceTests.swift index 4ebd2c6..91394d3 100644 --- a/ios/EthosProtocol/Tests/PasskeyServiceTests.swift +++ b/ios/EthosProtocol/Tests/PasskeyServiceTests.swift @@ -177,6 +177,37 @@ final class PasskeyErrorMappingTests: XCTestCase { XCTAssertEqual(mapped, .registrationFailed) } + // MARK: - #210 No biometric hardware / not enrolled + + private func makeUnderlyingLAError(_ code: LAError.Code) -> NSError { + let underlying = NSError(domain: LAErrorDomain, code: code.rawValue) + return NSError(domain: ASAuthorizationErrorDomain, + code: ASAuthorizationError.failed.rawValue, + userInfo: [NSUnderlyingErrorKey: underlying]) + } + + func test_biometryNotEnrolled_mapsToBiometricUnavailable() { + let mapped = PasskeyError.map(makeUnderlyingLAError(.biometryNotEnrolled), fallback: .registrationFailed) + XCTAssertEqual(mapped, .biometricUnavailable) + } + + func test_biometryNotAvailable_mapsToBiometricUnavailable() { + let mapped = PasskeyError.map(makeUnderlyingLAError(.biometryNotAvailable), fallback: .registrationFailed) + XCTAssertEqual(mapped, .biometricUnavailable) + } + + func test_passcodeNotSet_mapsToBiometricUnavailable() { + let mapped = PasskeyError.map(makeUnderlyingLAError(.passcodeNotSet), fallback: .registrationFailed) + XCTAssertEqual(mapped, .biometricUnavailable) + } + + func test_biometricUnavailable_hasActionableCopy() { + let message = PasskeyError.biometricUnavailable.errorDescription ?? "" + XCTAssertTrue(message.localizedCaseInsensitiveContains("passcode") || + message.localizedCaseInsensitiveContains("Face ID") || + message.localizedCaseInsensitiveContains("Touch ID")) + } + func test_allCases_haveDistinctNonEmptyDescriptions() { let cases: [PasskeyError] = [.registrationFailed, .authenticationFailed, .userCancelled, .notInteractive, .credentialAlreadyExists] let descriptions = cases.compactMap { $0.errorDescription } diff --git a/ios/EthosProtocol/Tests/SessionsStoreTests.swift b/ios/EthosProtocol/Tests/SessionsStoreTests.swift new file mode 100644 index 0000000..1c378fd --- /dev/null +++ b/ios/EthosProtocol/Tests/SessionsStoreTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import EthosProtocol + +// MARK: - #208 Session/Device List Tests + +@MainActor +final class SessionsStoreTests: XCTestCase { + + private func makeSession(id: String, isCurrent: Bool = false) -> Session { + Session(id: id, deviceName: "Test Device \(id)", platform: "ios", + createdAt: Date(), lastActiveAt: Date(), isCurrent: isCurrent) + } + + func test_load_populatesSessions() async { + let store = SessionsStore() + let sessions = [makeSession(id: "1", isCurrent: true), makeSession(id: "2")] + store.listSessions = { sessions } + + await store.load() + + XCTAssertEqual(store.sessions, sessions) + XCTAssertNil(store.error) + } + + func test_load_failure_setsError() async { + let store = SessionsStore() + store.listSessions = { throw APIError.networkUnavailable } + + await store.load() + + XCTAssertTrue(store.sessions.isEmpty) + XCTAssertNotNil(store.error) + } + + func test_revoke_removesSessionFromLocalListOnSuccess() async { + let store = SessionsStore() + let current = makeSession(id: "1", isCurrent: true) + let other = makeSession(id: "2") + store.sessions = [current, other] + var revokedID: String? + store.revokeSession = { id in revokedID = id } + + await store.revoke(other) + + XCTAssertEqual(revokedID, "2") + XCTAssertEqual(store.sessions, [current]) + } + + func test_revoke_failure_keepsSessionAndSetsError() async { + let store = SessionsStore() + let session = makeSession(id: "2") + store.sessions = [session] + store.revokeSession = { _ in throw APIError.serverError("Not found") } + + await store.revoke(session) + + XCTAssertEqual(store.sessions, [session]) + XCTAssertNotNil(store.error) + } + + func test_revokeAllOthers_reloadsListOnSuccess() async { + let store = SessionsStore() + var revokeOthersCalled = false + store.revokeOtherSessions = { revokeOthersCalled = true } + let reloaded = [makeSession(id: "1", isCurrent: true)] + store.listSessions = { reloaded } + + await store.revokeAllOthers() + + XCTAssertTrue(revokeOthersCalled) + XCTAssertEqual(store.sessions, reloaded) + } + + func test_revokeAllOthers_failure_setsError() async { + let store = SessionsStore() + store.revokeOtherSessions = { throw APIError.networkUnavailable } + + await store.revokeAllOthers() + + XCTAssertNotNil(store.error) + } +} diff --git a/shared/api-contract.md b/shared/api-contract.md index d438ce6..feb6973 100644 --- a/shared/api-contract.md +++ b/shared/api-contract.md @@ -52,6 +52,9 @@ The server must: | POST | `/auth/register` | Register new passkey credential, returns `AuthToken` directly (#2) — no separate `/auth/verify` call is needed right after registering | | POST | `/auth/refresh` | Proactively refresh the current session before it expires, returns a new `AuthToken` (#3) | | POST | `/auth/recover/link` | Link a new passkey to an existing account, once identity is proven via email/backup code ("lost your device" recovery) | +| GET | `/auth/sessions` | List active sessions (devices) holding a valid JWT for the calling account — see §Session/Device List (#208) | +| DELETE | `/auth/sessions/{id}` | Revoke a single session by id ("Sign out this device") — see §Session/Device List (#208) | +| DELETE | `/auth/sessions` | Revoke every session except the caller's current one ("Sign out all other devices") — see §Session/Device List (#208) | ### Vaults | Method | Path | Description | @@ -180,6 +183,38 @@ deposit, withdrawal, beneficiary change, status transition). --- +## Session/Device List (#208) + +Visibility and control over which devices currently hold a valid JWT for the account, +addressing the gap where token storage was entirely server-side and invisible to the user +(e.g. no way to remotely sign out a lost phone). + +### `GET /auth/sessions` + +Response: `200` with a JSON array of `Session` (see §Models). The session for the device +making the request has `is_current: true`. Ordered most-recently-active first. + +### `DELETE /auth/sessions/{id}` + +Revokes the session identified by `id` — the corresponding JWT is invalidated server-side. +Revoking a session other than the caller's own signs that device out ("Sign out this +device"); revoking the caller's own current session is equivalent to a normal sign-out. +Response: `204 No Content` on success; `404` if `id` does not belong to the caller's account. + +### `DELETE /auth/sessions` + +Revokes every session for the account **except** the one making the request ("Sign out all +other devices"). Response: `204 No Content`. + +### Platform requirement: biometric gate + +Both `DELETE /auth/sessions/{id}` and `DELETE /auth/sessions` are destructive, remote-facing +actions and must be gated behind a biometric (or device passcode) prompt client-side before +the request is sent — the same `BiometricService`/`BiometricHelper` used for withdrawals and +2FA disable. + +--- + ## Beneficiary Acceptance (#109) **Decision: token is required.** @@ -342,6 +377,13 @@ No request body. Requires the current (possibly near-expiry, not-yet-expired) `A Bearer ` header. Response: `AuthToken`. `401` if the current token is no longer valid — the client falls back to its normal delete-and-reauth behavior in that case. +**Refresh margin (#209):** both clients treat a token as due for proactive refresh once it is +within **60 seconds** of `expires_at`. iOS (`AuthStore.refreshLeadTime`) schedules a one-shot +timer for `expires_at - 60s` the moment a token is stored, independent of whether a request is +in flight. Android (`TokenProvider.isNearExpiry`, default `threshold = 60s`) additionally runs a +periodic check (`AuthViewModel`'s scheduled-refresh loop, every 30s while signed in) so a token +is refreshed even if the app is foregrounded but idle, not just before the next API call. + ### RecoverAccessLinkRequest ```json { @@ -358,6 +400,14 @@ normal WebAuthn registration ceremony against a `/auth/challenge` obtained for t account) rather than issuing a session directly. Clients call `POST /auth/verify` afterwards to authenticate with the newly linked passkey. +**Expiry (#211):** the same applies to Android's `RecoveryCompleteRequest`/`recovery_token`, +issued by `POST /auth/recovery/initiate` with a limited lifetime. If the recovery token/proof +has expired by the time the client completes the ceremony, the server responds `401` with a +human-readable message in the body: `{"error": ""}` (e.g. `"Your recovery code has +expired. Please request a new one."`). Clients must surface this message as-is rather than the +generic "sign in again" copy normally shown for a `401`, and let the user request a fresh code +instead of leaving them on a dead-end error. + ### BeneficiaryUpdateRequest ```json { "beneficiary": "string" } @@ -372,3 +422,18 @@ Response: `204 No Content`. ### WebSocketMessage (#110) See §WebSocket Message Schema above for the full discriminated-union schema. + +### Session (#208) +```json +{ + "id": "string", + "device_name": "string", + "platform": "ios|android", + "created_at": "ISO8601", + "last_active_at": "ISO8601", + "is_current": true +} +``` +`device_name` is a human-readable label (e.g. "iPhone 15 Pro", "Pixel 8") the server derives +from the device that registered the session. `is_current` marks the session belonging to the +device making the `GET /auth/sessions` request.