From da91df080b72f650c6df1c2ce39a71be25c87844 Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Thu, 27 Aug 2026 11:18:16 +0000 Subject: [PATCH 1/4] feat(auth): add session/device list with remote sign-out (#208) Adds GET /auth/sessions plus DELETE /auth/sessions/{id} and DELETE /auth/sessions to the API contract, and surfaces them as an "Active Sessions" screen on both platforms showing device/platform/last-active time with biometric-gated "Sign out this device" / "Sign out all other devices" actions. --- .../java/com/ethosprotocol/api/ApiClient.kt | 9 ++ .../java/com/ethosprotocol/models/Models.kt | 15 +++ .../java/com/ethosprotocol/ui/MainActivity.kt | 9 +- .../java/com/ethosprotocol/ui/ViewModels.kt | 44 +++++++ .../com/ethosprotocol/ui/screens/Screens.kt | 114 ++++++++++++++++++ .../ethosprotocol/SessionsViewModelTest.kt | 112 +++++++++++++++++ ios/EthosProtocol/Sources/Models/Models.swift | 11 ++ .../Sources/Services/APIClient.swift | 26 ++++ .../Sources/ViewModels/Stores.swift | 50 ++++++++ .../Sources/Views/SettingsView.swift | 95 +++++++++++++++ ios/EthosProtocol/Tests/APIClientTests.swift | 35 ++++++ .../Tests/SessionsStoreTests.swift | 82 +++++++++++++ shared/api-contract.md | 47 ++++++++ 13 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 android/app/src/test/java/com/ethosprotocol/SessionsViewModelTest.kt create mode 100644 ios/EthosProtocol/Tests/SessionsStoreTests.swift 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..f2c20f6 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -120,6 +120,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") 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/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..519ffb8 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -160,6 +160,50 @@ class AuthViewModel @Inject constructor( } } +// --- 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") } + } + } +} + // --- TwoFactor ViewModel --- data class TwoFactorUiState( 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..34a9d02 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 @@ -199,6 +201,7 @@ private fun RecoverySheet( @Composable fun VaultListScreen( onVaultClick: (String) -> Unit, + onSessionsClick: () -> Unit = {}, vm: VaultViewModel = hiltViewModel() ) { val state by vm.state.collectAsStateWithLifecycle() @@ -261,6 +264,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 +1499,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/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..28b1b0a 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" 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..a60bec1 100644 --- a/ios/EthosProtocol/Tests/APIClientTests.swift +++ b/ios/EthosProtocol/Tests/APIClientTests.swift @@ -348,4 +348,39 @@ final class APIClientAuthTests: XCTestCase { XCTAssertEqual(message, "backup code did not match") } } + + // 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/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..0fb160b 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.** @@ -372,3 +407,15 @@ 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 +} +``` From aa2d791b27e3d2c4172f42bea4d35809965a29f3 Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Thu, 27 Aug 2026 11:21:15 +0000 Subject: [PATCH 2/4] fix(android): schedule proactive token refresh independent of requests (#209) iOS's AuthStore already scheduled a refresh timer ahead of token expiry; Android's ApiClient only refreshed lazily right before the next request, so a foregrounded-but-idle app could sit on a near-expired token indefinitely. AuthViewModel now polls every 30s while signed in and refreshes once within the existing 60s isNearExpiry margin, matching iOS's lead time. Documents the shared 60s refresh margin in the API contract. --- .../java/com/ethosprotocol/ui/ViewModels.kt | 43 ++++++++++- .../com/ethosprotocol/AuthViewModelTest.kt | 73 +++++++++++++++++++ shared/api-contract.md | 7 ++ 3 files changed, 122 insertions(+), 1 deletion(-) 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 519ffb8..5e9f2bf 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -72,6 +72,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 +130,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 +156,16 @@ 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) } } 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 +195,9 @@ 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 } } diff --git a/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt index 0c64bee..94d7b5a 100644 --- a/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/AuthViewModelTest.kt @@ -230,6 +230,79 @@ class AuthViewModelTest { assertEquals(0, vm.state.value.cooldownRemainingSeconds) } + // 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/shared/api-contract.md b/shared/api-contract.md index 0fb160b..d35ba72 100644 --- a/shared/api-contract.md +++ b/shared/api-contract.md @@ -377,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 { From 4821fcdc9e5d2c824b6e8d85ed62aa338225785d Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Thu, 27 Aug 2026 11:23:27 +0000 Subject: [PATCH 3/4] fix(auth): add passkey registration fallback copy for missing biometrics (#210) Registration on both platforms only handled the happy path where the device has a biometric enrolled. A device with none enrolled (or an MDM policy disabling it, or no passcode set) surfaced a generic CredentialManager/ASAuthorization failure instead of guidance. Android's register() now runs through the same CreateCredentialException mapping already used by the recovery path; iOS detects the wrapped LAError (biometryNotEnrolled/biometryNotAvailable/passcodeNotSet) and returns a dedicated PasskeyError.biometricUnavailable case. Both point the user at enrolling a biometric or falling back to a device PIN/passcode. --- .../ethosprotocol/services/PasskeyService.kt | 17 ++++++++-- .../com/ethosprotocol/PasskeyServiceTest.kt | 19 ++++++++++++ .../Sources/Services/PasskeyService.swift | 15 +++++++++ .../Tests/PasskeyServiceTests.swift | 31 +++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) 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..9411e39 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." diff --git a/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt b/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt index fed29ef..b1e16ef 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 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/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 } From e857d90fc84525d05a7f4c60d251a33990d3ac5c Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Thu, 27 Aug 2026 11:27:52 +0000 Subject: [PATCH 4/4] test(auth): add expired recovery token coverage and surface clear errors (#211) Neither platform had a test for a recovery token/proof expiring between initiate and complete, and a 401 on that call fell back to the generic "Authentication required" message with no path forward. Both clients' 401 handling now surfaces the server's human-readable error body when present (falling back to the existing generic message when it's empty, as on an ordinary rejected session token). Android's recovery flow (models, ApiClient, PasskeyService already existed but were never wired up) is connected end-to-end through AuthViewModel and the existing RecoverySheet, resetting to the "send a new code" step on a rejected token instead of leaving the user on a dead end. --- .../java/com/ethosprotocol/api/ApiClient.kt | 23 ++++++- .../com/ethosprotocol/api/ApiErrorMapper.kt | 7 +- .../ethosprotocol/services/PasskeyService.kt | 2 +- .../java/com/ethosprotocol/ui/ViewModels.kt | 41 ++++++++++++ .../com/ethosprotocol/ui/screens/Screens.kt | 16 ++++- .../java/com/ethosprotocol/ApiClientTest.kt | 45 +++++++++++++ .../com/ethosprotocol/AuthViewModelTest.kt | 66 +++++++++++++++++++ .../com/ethosprotocol/PasskeyServiceTest.kt | 50 ++++++++++++++ .../Sources/Services/APIClient.swift | 8 +++ ios/EthosProtocol/Tests/APIClientTests.swift | 43 ++++++++++++ ios/EthosProtocol/Tests/AuthStoreTests.swift | 35 ++++++++++ shared/api-contract.md | 11 ++++ 12 files changed, 339 insertions(+), 8 deletions(-) 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 f2c20f6..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 @@ -198,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) } @@ -221,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) } } @@ -242,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) } } @@ -263,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/services/PasskeyService.kt b/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt index 9411e39..f10373c 100644 --- a/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt +++ b/android/app/src/main/java/com/ethosprotocol/services/PasskeyService.kt @@ -156,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/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index 5e9f2bf..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 @@ -163,6 +165,45 @@ class AuthViewModel @Inject constructor( .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 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 34a9d02..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 @@ -52,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 } ) } @@ -71,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), @@ -109,6 +120,7 @@ fun AuthScreenContent( } Spacer(Modifier.height(8.dp)) TextButton(onClick = onRegister) { Text("Create account") } + TextButton(onClick = onRecover) { Text("Lost your device?") } } } 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 94d7b5a..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,72 @@ 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 diff --git a/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt b/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt index b1e16ef..152ba46 100644 --- a/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/PasskeyServiceTest.kt @@ -276,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/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index 28b1b0a..73b204d 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -421,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/Tests/APIClientTests.swift b/ios/EthosProtocol/Tests/APIClientTests.swift index a60bec1..bbd2036 100644 --- a/ios/EthosProtocol/Tests/APIClientTests.swift +++ b/ios/EthosProtocol/Tests/APIClientTests.swift @@ -349,6 +349,49 @@ final class APIClientAuthTests: XCTestCase { } } + // #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 { 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/shared/api-contract.md b/shared/api-contract.md index d35ba72..feb6973 100644 --- a/shared/api-contract.md +++ b/shared/api-contract.md @@ -400,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" } @@ -426,3 +434,6 @@ See §WebSocket Message Schema above for the full discriminated-union schema. "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.