diff --git a/android/app/src/main/java/com/ethosprotocol/services/VaultDeepLinkParser.kt b/android/app/src/main/java/com/ethosprotocol/services/VaultDeepLinkParser.kt index 6d33133..fac3aa9 100644 --- a/android/app/src/main/java/com/ethosprotocol/services/VaultDeepLinkParser.kt +++ b/android/app/src/main/java/com/ethosprotocol/services/VaultDeepLinkParser.kt @@ -27,6 +27,20 @@ data class VaultDeepLink(val vaultId: String, val action: VaultDeepLinkAction) */ data class BeneficiaryAcceptLink(val vaultId: String, val token: String) +/** + * #258: A web-initiated account-recovery link + * (https://ethos-protocol.app/auth/recover/link?token={token}). + * + * The link is emailed to the user's registered address and carries a one-time + * [token] that pre-fills the recovery step in the app so the user does not have + * to retype the value shown in the browser. This corresponds to + * POST /auth/recover/link in shared/api-contract.md. + * + * [token] follows the same allowlist as vault IDs and acceptance tokens: + * [A-Za-z0-9_-]{1,128}. + */ +data class RecoveryDeepLink(val token: String) + object VaultDeepLinkParser { /** * Vault IDs are only ever used to build API request paths (e.g. "/vaults/$vaultId/checkin") @@ -69,11 +83,36 @@ object VaultDeepLinkParser { @Volatile var eventLogger: EventLogger = defaultEventLogger + /** + * #259: The set of vault IDs owned by the currently signed-in user. + * + * When non-null, deep links referencing a vault not in this set are rejected + * before any API call is made. The error is intentionally generic — it must + * not reveal whether the vault exists — to avoid leaking vault-existence + * information via deep-link probing. + * + * Set to `null` (the default) when the vault list has not been loaded yet; + * ownership is then treated as unknown and the check is skipped (the server + * will return 403 or 404 if the vault doesn't belong to the caller). + * + * VaultViewModel or MainActivity should populate this after a successful + * listVaults() response and clear it on sign-out. + */ + @Volatile + var ownerVaultIds: Set? = null + + /** Returns true if ownership validation should pass for [vaultId]. */ + private fun isOwnedVault(vaultId: String): Boolean { + val owned = ownerVaultIds ?: return true // unknown — skip check + return vaultId in owned + } + /** Parses ethosprotocol://vault/{vault_id}/{action} from a URL string or returns null if unrecognised. */ fun parseUrl(url: String): VaultDeepLink? { val match = URL_PATTERN.matchEntire(url.trim()) ?: return null val vaultId = match.groupValues[1] if (!isValidVaultId(vaultId)) return null + if (!isOwnedVault(vaultId)) return null val action = VaultDeepLinkAction.fromPathSegment(match.groupValues[2]) ?: return null eventLogger.onDeepLinkParsed(action) return VaultDeepLink(vaultId = vaultId, action = action) @@ -86,6 +125,7 @@ object VaultDeepLinkParser { if (segments.size != 2) return null val vaultId = segments[0] if (!isValidVaultId(vaultId)) return null + if (!isOwnedVault(vaultId)) return null val action = VaultDeepLinkAction.fromPathSegment(segments[1]) ?: return null eventLogger.onDeepLinkParsed(action) return VaultDeepLink(vaultId = vaultId, action = action) @@ -109,5 +149,26 @@ object VaultDeepLinkParser { return BeneficiaryAcceptLink(vaultId = vaultId, token = token) } + /** + * #258: Parses https://ethos-protocol.app/auth/recover/link?token={token}. + * + * Returns a [RecoveryDeepLink] with the pre-filled recovery token so the user lands + * directly in the "finish recovery" step rather than having to retype the value. + * + * Returns null when: + * - scheme is not https (rejects any custom-scheme forgery) + * - host is not ethos-protocol.app + * - path is not exactly /auth/recover/link + * - the token query parameter is missing or fails the allowlist check + */ + fun parseRecoveryLink(uri: Uri): RecoveryDeepLink? { + if (uri.scheme != "https" || uri.host != "ethos-protocol.app") return null + val segments = uri.pathSegments + // Expect /auth/recover/link — exactly three segments. + if (segments.size != 3 || segments[0] != "auth" || segments[1] != "recover" || segments[2] != "link") return null + val token = uri.getQueryParameter("token")?.takeIf { isValidVaultId(it) } ?: return null + return RecoveryDeepLink(token = token) + } + private val URL_PATTERN = Regex("^ethosprotocol://vault/([^/]+)/([^/]+)$") } diff --git a/android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt b/android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt index 5af56bc..8be06fb 100644 --- a/android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt +++ b/android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt @@ -1,14 +1,18 @@ package com.ethosprotocol.services import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult import com.ethosprotocol.api.TokenProvider import com.ethosprotocol.models.VaultEvent import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.client.request.header import io.ktor.client.request.url import io.ktor.http.HttpHeaders +import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.closeReason import io.ktor.websocket.readText import javax.inject.Inject import javax.inject.Singleton @@ -49,11 +53,24 @@ data class ReconnectBackoff( } } +// Sentinel used by events() to signal that a 4401 close was received and the +// silent-refresh path should be entered instead of the normal backoff reconnect. +private class Auth4401Exception : Exception("WebSocket closed with code 4401 (auth failure)") + // Client for the `wss://.../ws?vault_id={id}` endpoint (shared/api-contract.md). // Reuses ApiClient's HttpClient/WebSockets plugin rather than a second client. // A dropped or failed connection reconnects with exponential backoff // ([ReconnectBackoff]) until the collecting coroutine is cancelled; the backoff // resets once a new connection is established. +// +// #257 — 4401 handling: +// When the server closes the socket with code 4401 (authentication failure), the +// client distinguishes two cases: +// • Expired token (refreshable): attempt one silent refresh via ApiClient.refreshToken() +// and reconnect if it succeeds. This covers the normal JWT-expiry-mid-connection case. +// • Invalid / revoked token: if the refresh call itself fails (e.g. the server returns +// 401 on the refresh endpoint), give up and emit the special `authFailure` event so +// the UI can route the user back to the sign-in screen. @Singleton class VaultEventSocket( private val apiClient: ApiClient, @@ -75,6 +92,12 @@ class VaultEventSocket( } } + // Injectable for tests so they can simulate a successful or failing refresh + // without hitting a real server. + internal var refreshToken: suspend () -> ApiResult = { + apiClient.refreshToken() + } + fun events(vaultId: String): Flow = flow { var attempt = 0 while (currentCoroutineContext().isActive) { @@ -87,8 +110,35 @@ class VaultEventSocket( .onSuccess { emit(it) } } } + // Check close reason after the incoming channel drains. + val closeReason = session.closeReason.await() + if (closeReason?.code?.toInt() == 4401) { + throw Auth4401Exception() + } } catch (e: CancellationException) { throw e + } catch (e: Auth4401Exception) { + // #257: The server closed with 4401 (auth failure). Attempt one silent + // token refresh before deciding whether to reconnect or signal sign-out. + val refreshResult = runCatching { refreshToken() }.getOrElse { + if (it is CancellationException) throw it + ApiResult.Error("refresh call threw", 0) + } + when (refreshResult) { + is ApiResult.Success -> { + // Refresh succeeded — store the new token and reconnect. + tokenProvider.setSession(refreshResult.data) + attempt = 0 + // No backoff delay; reconnect immediately with the fresh token. + continue + } + else -> { + // Refresh failed — the token is invalid, not just expired. + // Emit an authFailure sentinel event so the UI can sign the user out. + emit(VaultEvent(type = "auth_failure", vault = null)) + return@flow + } + } } catch (e: Exception) { // Connection failed or dropped — fall through to backoff and reconnect. } diff --git a/android/app/src/test/java/com/ethosprotocol/VaultDeepLinkParserTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultDeepLinkParserTest.kt index 5e64886..82b8e3a 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultDeepLinkParserTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultDeepLinkParserTest.kt @@ -265,5 +265,122 @@ class VaultDeepLinkParserTest { VaultDeepLinkParser.parseUrl("ethosprotocol://vault/../../etc/passwd/check-in") ) } + + // ========================================================================= + // #258 — parseRecoveryLink + // ========================================================================= + + @Test + fun parseRecoveryLink_wellFormedUrl_returnsRecoveryDeepLink() { + val uri = android.net.Uri.parse("https://ethos-protocol.app/auth/recover/link?token=abc123-XYZ") + val result = VaultDeepLinkParser.parseRecoveryLink(uri) + assertEquals("abc123-XYZ", result?.token) + } + + @Test + fun parseRecoveryLink_missingToken_returnsNull() { + val uri = android.net.Uri.parse("https://ethos-protocol.app/auth/recover/link") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_emptyToken_returnsNull() { + val uri = android.net.Uri.parse("https://ethos-protocol.app/auth/recover/link?token=") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_wrongScheme_returnsNull() { + val uri = android.net.Uri.parse("http://ethos-protocol.app/auth/recover/link?token=abc123") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_customScheme_returnsNull() { + val uri = android.net.Uri.parse("ethosprotocol://ethos-protocol.app/auth/recover/link?token=abc123") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_wrongHost_returnsNull() { + val uri = android.net.Uri.parse("https://evil.com/auth/recover/link?token=abc123") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_wrongPath_returnsNull() { + val uri = android.net.Uri.parse("https://ethos-protocol.app/auth/reset/link?token=abc123") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_invalidToken_returnsNull() { + // Token with disallowed characters must be rejected. + val uri = android.net.Uri.parse("https://ethos-protocol.app/auth/recover/link?token=abc%40evil") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_overLengthToken_returnsNull() { + val longToken = "a".repeat(129) + val uri = android.net.Uri.parse("https://ethos-protocol.app/auth/recover/link?token=$longToken") + assertNull(VaultDeepLinkParser.parseRecoveryLink(uri)) + } + + @Test + fun parseRecoveryLink_maxLengthToken_accepted() { + val maxToken = "a".repeat(128) + val uri = android.net.Uri.parse("https://ethos-protocol.app/auth/recover/link?token=$maxToken") + val result = VaultDeepLinkParser.parseRecoveryLink(uri) + assertEquals(maxToken, result?.token) + } + + // ========================================================================= + // #259 — Client-side vault ID ownership validation + // ========================================================================= + + @After + fun resetOwnerVaultIds() { + VaultDeepLinkParser.ownerVaultIds = null + } + + @Test + fun parseUrl_vaultIdOwnedByUser_returnsDeepLink() { + VaultDeepLinkParser.ownerVaultIds = setOf("vault-mine") + val result = VaultDeepLinkParser.parseUrl("ethosprotocol://vault/vault-mine/check-in") + assertEquals("vault-mine", result?.vaultId) + } + + @Test + fun parseUrl_vaultIdNotOwnedByUser_returnsNull() { + VaultDeepLinkParser.ownerVaultIds = setOf("vault-mine") + // A vault the signed-in user does not own must be rejected client-side. + // The error must not reveal whether the vault exists at all. + val result = VaultDeepLinkParser.parseUrl("ethosprotocol://vault/vault-someone-elses/check-in") + assertNull(result) + } + + @Test + fun parseUrl_ownerVaultIdsNull_skipOwnershipCheck() { + // When the vault list has not been loaded yet (null = unknown), the ownership + // check is skipped so the deep link still routes — the server will 403 if needed. + VaultDeepLinkParser.ownerVaultIds = null + val result = VaultDeepLinkParser.parseUrl("ethosprotocol://vault/vault-abc-123/check-in") + assertEquals("vault-abc-123", result?.vaultId) + } + + @Test + fun parse_vaultIdNotOwnedByUser_returnsNull() { + VaultDeepLinkParser.ownerVaultIds = setOf("vault-mine") + val uri = android.net.Uri.parse("ethosprotocol://vault/vault-other/check-in") + assertNull(VaultDeepLinkParser.parse(uri)) + } + + @Test + fun parse_emptyOwnerSet_rejectsAllVaultIds() { + VaultDeepLinkParser.ownerVaultIds = emptySet() + val uri = android.net.Uri.parse("ethosprotocol://vault/vault-abc/check-in") + assertNull(VaultDeepLinkParser.parse(uri)) + } } diff --git a/android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt index dbc6f4a..88544e1 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt @@ -1,10 +1,13 @@ package com.ethosprotocol import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult import com.ethosprotocol.api.TokenProvider +import com.ethosprotocol.models.AuthToken import com.ethosprotocol.models.VaultEvent import com.ethosprotocol.services.ReconnectBackoff import com.ethosprotocol.services.VaultEventSocket +import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame import io.ktor.websocket.WebSocketSession import io.mockk.coEvery @@ -13,6 +16,7 @@ import io.mockk.mockk import java.io.IOException import java.util.concurrent.atomic.AtomicInteger import kotlin.random.Random +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList @@ -85,7 +89,10 @@ class VaultEventSocketTest { socket.openSession = { if (openAttempts.getAndIncrement() == 0) throw IOException("connection refused") - mockk { every { incoming } returns channel } + mockk { + every { incoming } returns channel + every { closeReason } returns CompletableDeferred(null) + } } val received = socket.events("vault-1").take(1).toList() @@ -95,6 +102,43 @@ class VaultEventSocketTest { assertEquals(listOf(999L), delays) } + // #256 — api-contract.md: "clients should ignore unrecognised values instead of erroring". + // Sending a made-up future type must not crash and must not surface a VaultEvent (the + // runCatching in events() silently drops frames it cannot decode, which is the desired + // behaviour — unknown types are handled at the VaultEvent decode level where the Json + // deserializer simply skips the unknown discriminator and returns the default null vault). + @Test + fun `events ignores unknown type discriminator without crashing`() = runTest { + val backoff = ReconnectBackoff( + baseDelayMillis = 1_000, + maxDelayMillis = 30_000, + sleep = {}, + random = maxJitterRandom() + ) + val socket = VaultEventSocket(apiClient, tokenProvider, backoff) + + // Two frames: first has an unrecognised type (must be silently dropped), second is a + // known type (must be emitted) — so take(1) collects exactly the known event and + // never blocks. + val channel = Channel(capacity = 2) + val unknownFrame = Frame.Text("""{"type":"future_event_type_v99","vault":null}""") + val knownEvent = VaultEvent(type = "check_in", vault = null) + val knownFrame = Frame.Text(Json.encodeToString(VaultEvent.serializer(), knownEvent)) + channel.trySend(unknownFrame) + channel.trySend(knownFrame) + + socket.openSession = { + mockk { + every { incoming } returns channel + every { closeReason } returns CompletableDeferred(null) + } + } + + // Collecting does not throw; the unknown frame is silently skipped. + val received = socket.events("vault-1").take(1).toList() + assertEquals(listOf(knownEvent), received) + } + @Test fun `events resets the backoff attempt counter after a successful connection`() = runTest { val delays = mutableListOf() @@ -112,12 +156,16 @@ class VaultEventSocketTest { socket.openSession = { when (openAttempts.getAndIncrement()) { 0 -> throw IOException("connection refused") - 1 -> mockk { every { incoming } returns Channel().apply { close() } } + 1 -> mockk { + every { incoming } returns Channel().apply { close() } + every { closeReason } returns CompletableDeferred(null) + } 2 -> throw IOException("connection refused") else -> mockk { every { incoming } returns Channel(capacity = 1).apply { trySend(Frame.Text(Json.encodeToString(VaultEvent.serializer(), event))) } + every { closeReason } returns CompletableDeferred(null) } } } @@ -135,4 +183,110 @@ class VaultEventSocketTest { // further delay is recorded. assertEquals(listOf(999L, 999L, 1_999L), delays) } + + // ========================================================================= + // #257 — WebSocket close code 4401 handling + // ========================================================================= + + /** + * Creates a mock [WebSocketSession] whose [incoming] channel closes immediately + * (simulating the server closing the socket after the handshake) and whose + * [closeReason] resolves to a [CloseReason] with the given [code]. + */ + private fun mockSessionWithClose(code: Short, emptyChannel: Channel = Channel().apply { close() }): WebSocketSession = + mockk { + every { incoming } returns emptyChannel + every { closeReason } returns CompletableDeferred(CloseReason(code, "")) + } + + @Test + fun `events on 4401 with successful refresh reconnects and emits events`() = runTest { + val socket = VaultEventSocket( + apiClient, tokenProvider, + ReconnectBackoff(1_000, 30_000, sleep = {}, random = maxJitterRandom()) + ) + + val refreshedToken = AuthToken(token = "new-token", expiresAt = "2099-01-01T00:00:00Z") + socket.refreshToken = { ApiResult.Success(refreshedToken) } + + val openAttempts = AtomicInteger(0) + val knownEvent = VaultEvent(type = "check_in", vault = null) + + socket.openSession = { + when (openAttempts.getAndIncrement()) { + // First connection: server immediately closes with 4401. + 0 -> mockSessionWithClose(4401) + // Second connection (after silent refresh): delivers a real event. + else -> mockk { + every { incoming } returns Channel(capacity = 1).apply { + trySend(Frame.Text(Json.encodeToString(VaultEvent.serializer(), knownEvent))) + } + every { closeReason } returns CompletableDeferred(null) + } + } + } + + val received = socket.events("vault-1").take(1).toList() + + assertEquals(listOf(knownEvent), received) + assertEquals(2, openAttempts.get()) + } + + @Test + fun `events on 4401 with failed refresh emits auth_failure and terminates`() = runTest { + val socket = VaultEventSocket( + apiClient, tokenProvider, + ReconnectBackoff(1_000, 30_000, sleep = {}, random = maxJitterRandom()) + ) + + // Refresh fails — token is invalid/revoked, not just expired. + socket.refreshToken = { ApiResult.Error("Unauthorized", 401) } + + socket.openSession = { + mockSessionWithClose(4401) + } + + // The flow should emit exactly one auth_failure event and then terminate. + val received = socket.events("vault-1").toList() + + assertEquals(1, received.size) + assertEquals("auth_failure", received[0].type) + } + + @Test + fun `events on non-4401 close reconnects with backoff as normal`() = runTest { + val delays = mutableListOf() + val socket = VaultEventSocket( + apiClient, tokenProvider, + ReconnectBackoff(1_000, 30_000, sleep = { delays.add(it) }, random = maxJitterRandom()) + ) + + // refreshToken must NOT be called for non-4401 closes. + var refreshCalled = false + socket.refreshToken = { refreshCalled = true; ApiResult.Error("should not be called", 0) } + + val openAttempts = AtomicInteger(0) + val knownEvent = VaultEvent(type = "check_in", vault = null) + + socket.openSession = { + when (openAttempts.getAndIncrement()) { + // First connection: server closes with a normal code (e.g. 1001 Going Away). + 0 -> mockSessionWithClose(1001) + // Second connection: delivers an event. + else -> mockk { + every { incoming } returns Channel(capacity = 1).apply { + trySend(Frame.Text(Json.encodeToString(VaultEvent.serializer(), knownEvent))) + } + every { closeReason } returns CompletableDeferred(null) + } + } + } + + val received = socket.events("vault-1").take(1).toList() + + assertEquals(listOf(knownEvent), received) + assertFalse("refreshToken must not be called for non-4401 closes", refreshCalled) + // A normal close drops through to the standard backoff path. + assertEquals(1, delays.size) + } } diff --git a/ios/EthosProtocol/Sources/Services/DeepLinkLogger.swift b/ios/EthosProtocol/Sources/Services/DeepLinkLogger.swift index ecbf8b9..3df8cab 100644 --- a/ios/EthosProtocol/Sources/Services/DeepLinkLogger.swift +++ b/ios/EthosProtocol/Sources/Services/DeepLinkLogger.swift @@ -13,6 +13,8 @@ final class DeepLinkLogger { case vaultActionWithdraw = "deep_link_vault_action_withdraw" case vaultActionViewDetails = "deep_link_vault_action_view_details" case vaultActionManageBeneficiary = "deep_link_vault_action_manage_beneficiary" + /// #258: Account recovery via emailed link. + case recoveryLink = "deep_link_recovery_link" } private var eventLog: [DeepLinkLogEntry] = [] diff --git a/ios/EthosProtocol/Sources/Services/UniversalLinkRouter.swift b/ios/EthosProtocol/Sources/Services/UniversalLinkRouter.swift index f483948..fa413f8 100644 --- a/ios/EthosProtocol/Sources/Services/UniversalLinkRouter.swift +++ b/ios/EthosProtocol/Sources/Services/UniversalLinkRouter.swift @@ -15,6 +15,9 @@ final class UniversalLinkRouter { case vaultInvitation(vaultID: String) case beneficiaryAcceptance(vaultID: String, token: String) case vaultAction(vaultID: String, action: VaultAction) + /// #258: Web-initiated account recovery — opened from an emailed link. + /// The `token` pre-fills the "finish recovery" screen so the user doesn't retype it. + case recoveryLink(token: String) } private static let validationRegex = try! NSRegularExpression(pattern: "^[A-Za-z0-9_-]{1,128}$") @@ -25,6 +28,23 @@ final class UniversalLinkRouter { return Self.validationRegex.firstMatch(in: value, range: range) != nil } + // #259: Vault IDs owned by the currently signed-in user. + // + // When non-nil, deep links referencing a vault not in this set are rejected + // client-side before any API call is made. The rejection is intentionally + // generic — it must not reveal whether the vault exists — to avoid leaking + // vault-existence information via deep-link probing. + // + // Set to nil (the default) when the vault list has not been loaded yet; + // ownership is treated as unknown and the check is skipped. The server + // will return 403/404 if the vault doesn't belong to the caller. + var ownerVaultIDs: Set? = nil + + private func isOwnedVault(_ vaultID: String) -> Bool { + guard let owned = ownerVaultIDs else { return true } // unknown — skip check + return owned.contains(vaultID) + } + /// Parses a universal link or custom-scheme URL into a typed DeepLink, or returns nil if unrecognised. func parse(url: URL) -> DeepLink? { // ethosprotocol://vault/{vault_id}/{action} @@ -32,16 +52,12 @@ final class UniversalLinkRouter { let parts = url.pathComponents.filter { $0 != "/" } guard parts.count == 2, let action = VaultAction(rawValue: parts[1]) else { return nil } guard isValidIdentifier(parts[0]) else { return nil } + guard isOwnedVault(parts[0]) else { return nil } let link = DeepLink.vaultAction(vaultID: parts[0], action: action) logDeepLink(link) return link } - // Require both scheme and host to match — host alone is insufficient because the - // custom ethosprotocol:// scheme can be invoked by any app with no domain-ownership - // verification, unlike https:// Universal Links whose routing is gated by iOS's - // AASA verification. Matching Android's extractBeneficiaryAccept check in - // MainActivity.kt (uri.scheme != "https" || uri.host != "ethos-protocol.app"). guard url.scheme == "https", url.host == "ethos-protocol.app" else { return nil } let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let parts = url.pathComponents.filter { $0 != "/" } @@ -49,6 +65,7 @@ final class UniversalLinkRouter { // /vaults/{vaultID}/invite if parts.count == 3, parts[0] == "vaults", parts[2] == "invite" { guard isValidIdentifier(parts[1]) else { return nil } + guard isOwnedVault(parts[1]) else { return nil } let link = DeepLink.vaultInvitation(vaultID: parts[1]) logDeepLink(link) return link @@ -57,17 +74,26 @@ final class UniversalLinkRouter { // /vaults/{vaultID}/accept?token={token} if parts.count == 3, parts[0] == "vaults", parts[2] == "accept" { guard isValidIdentifier(parts[1]) else { return nil } + guard isOwnedVault(parts[1]) else { return nil } let token = components?.queryItems?.first(where: { $0.name == "token" })?.value ?? "" - // A missing/empty token still routes to the acceptance screen (with an empty - // token) so the app can show an explicit "missing token" error there, rather - // than silently failing to open the link at all. Only a malformed non-empty - // token is rejected as an invalid link. guard token.isEmpty || isValidIdentifier(token) else { return nil } let link = DeepLink.beneficiaryAcceptance(vaultID: parts[1], token: token) logDeepLink(link) return link } + // #258: /auth/recover/link?token={token} + if parts.count == 3, parts[0] == "auth", parts[1] == "recover", parts[2] == "link" { + let token = components?.queryItems?.first(where: { $0.name == "token" })?.value ?? "" + // Recovery tokens must always be present and well-formed — a missing or + // malformed token cannot pre-fill the recovery step, so return nil to avoid + // routing into an unrecoverable broken state. + guard !token.isEmpty, isValidIdentifier(token) else { return nil } + let link = DeepLink.recoveryLink(token: token) + logDeepLink(link) + return link + } + return nil } @@ -78,6 +104,8 @@ final class UniversalLinkRouter { event = .vaultInvitation case .beneficiaryAcceptance: event = .beneficiaryAcceptance + case .recoveryLink: + event = .recoveryLink case .vaultAction(_, let action): switch action { case .checkIn: diff --git a/ios/EthosProtocol/Sources/Services/VaultEventSocket.swift b/ios/EthosProtocol/Sources/Services/VaultEventSocket.swift index 103318e..634ff1b 100644 --- a/ios/EthosProtocol/Sources/Services/VaultEventSocket.swift +++ b/ios/EthosProtocol/Sources/Services/VaultEventSocket.swift @@ -7,9 +7,15 @@ protocol WebSocketTasking: AnyObject { func resume() func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func receive(completionHandler: @escaping (Result) -> Void) + /// #257: The close code set by the server when it closes the socket. Returns `.invalid` + /// before the socket is closed. Exposed on the protocol so MockWebSocketTask can + /// simulate a 4401 close without a real URLSessionWebSocketTask. + var closeCode: URLSessionWebSocketTask.CloseCode { get } } -extension URLSessionWebSocketTask: WebSocketTasking {} +extension URLSessionWebSocketTask: WebSocketTasking { + // URLSessionWebSocketTask already has a `closeCode` property — no need to add it. +} /// Exponential backoff for WebSocket reconnect attempts, capped at `maxDelay`, /// with randomized jitter to reduce synchronized reconnect storms. @@ -62,6 +68,9 @@ final class VaultEventSocket { case connecting case connected case fallbackToPolling + /// #257: The server closed with 4401 and a token refresh could not recover the session. + /// The user must sign in again. + case authFailure } enum VaultEvent: Equatable { @@ -95,6 +104,11 @@ final class VaultEventSocket { var backoff: ReconnectBackoff let maxReconnectAttempts: Int + // #257: Injectable token-refresh closure. In production this calls APIClient to get + // a fresh JWT; in tests a fake is injected to simulate refresh success or failure. + // Throws on failure (e.g. the backend returned 401 — token is invalid, not just expired). + var tokenRefresh: () async throws -> String + private let baseURL: URL private let decoder: JSONDecoder private var task: WebSocketTasking? @@ -105,7 +119,8 @@ final class VaultEventSocket { init(baseURL: URL, maxReconnectAttempts: Int = 5, backoff: ReconnectBackoff = .socketDefault, - makeTask: ((URLRequest) -> WebSocketTasking)? = nil) { + makeTask: ((URLRequest) -> WebSocketTasking)? = nil, + tokenRefresh: ((() async throws -> String))? = nil) { self.baseURL = baseURL self.maxReconnectAttempts = maxReconnectAttempts self.backoff = backoff @@ -114,6 +129,11 @@ final class VaultEventSocket { decoder.dateDecodingStrategy = .iso8601 self.decoder = decoder self.makeTask = makeTask ?? { request in URLSession.shared.webSocketTask(with: request) } + self.tokenRefresh = tokenRefresh ?? { + // Production: call the shared APIClient to refresh the JWT and return the raw token string. + let authToken = try await APIClient.shared.refreshToken() + return authToken.token + } } /// Connects (or reconnects from scratch, resetting the backoff counter) to @@ -197,8 +217,43 @@ final class VaultEventSocket { reconnectAttempt = 0 handle(message: message) listen() - case .failure: - handleFailure() + case .failure(let error): + // #257: Inspect the URLSessionWebSocketTask close code when the receive + // call fails. URLSession surfaces a 4401 custom close code as an + // NSError with the close code accessible via the underlying + // URLSessionWebSocketTask. We detect it via the task's closeCode + // property — by the time the receive handler fires with a failure, the + // task has already transitioned to the closed state. + let closeCode = task?.closeCode ?? .invalid + if closeCode.rawValue == 4401 { + handleAuth4401() + } else { + handleFailure() + } + } + } + + // #257: The server closed with 4401 (authentication failure). Attempt one silent + // token refresh. If it succeeds, store the new token and reconnect immediately + // (no backoff — the token was just refreshed, so this should succeed). If it + // fails, the token is invalid/revoked and requires re-login: transition to + // .authFailure so the UI can sign the user out. + private func handleAuth4401() { + guard !isStopped else { return } + state = .disconnected + reconnectTask = Task { @MainActor [weak self] in + guard let self, !self.isStopped else { return } + do { + let newToken = try await self.tokenRefresh() + guard !Task.isCancelled, !self.isStopped else { return } + KeychainService.shared.saveToken(newToken) + self.reconnectAttempt = 0 + self.openSocket() + } catch { + guard !Task.isCancelled else { return } + // Refresh failed — signal permanent auth failure. + self.state = .authFailure + } } } diff --git a/ios/EthosProtocol/Tests/EthosProtocolTests.swift b/ios/EthosProtocol/Tests/EthosProtocolTests.swift index a6e8037..8bc06b2 100644 --- a/ios/EthosProtocol/Tests/EthosProtocolTests.swift +++ b/ios/EthosProtocol/Tests/EthosProtocolTests.swift @@ -587,6 +587,99 @@ final class UniversalLinkRouterTests: XCTestCase { XCTAssertEqual(events[1].event, .vaultActionCheckIn) XCTAssertEqual(events[2].event, .beneficiaryAcceptance) } + + // MARK: - #258 Recovery deep-link tests + + func test_parse_recoveryLink_wellFormed_returnsRecoveryLink() { + let url = URL(string: "https://ethos-protocol.app/auth/recover/link?token=rec-token-123")! + let result = router.parse(url: url) + XCTAssertEqual(result, .recoveryLink(token: "rec-token-123")) + } + + func test_parse_recoveryLink_missingToken_returnsNil() { + let url = URL(string: "https://ethos-protocol.app/auth/recover/link")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_recoveryLink_emptyToken_returnsNil() { + let url = URL(string: "https://ethos-protocol.app/auth/recover/link?token=")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_recoveryLink_invalidTokenChars_returnsNil() { + let url = URL(string: "https://ethos-protocol.app/auth/recover/link?token=bad@token")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_recoveryLink_oversizedToken_returnsNil() { + let big = String(repeating: "a", count: 129) + let url = URL(string: "https://ethos-protocol.app/auth/recover/link?token=\(big)")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_recoveryLink_wrongHost_returnsNil() { + let url = URL(string: "https://evil.com/auth/recover/link?token=rec-token-123")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_recoveryLink_customScheme_returnsNil() { + let url = URL(string: "ethosprotocol://ethos-protocol.app/auth/recover/link?token=rec-token-123")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_recoveryLink_logsExactlyOnce() { + DeepLinkLogger.shared.clearLog() + let url = URL(string: "https://ethos-protocol.app/auth/recover/link?token=rec-token-log")! + _ = router.parse(url: url) + XCTAssertEqual(DeepLinkLogger.shared.getEventCount(), 1) + XCTAssertEqual(DeepLinkLogger.shared.getLoggedEvents().first?.event, .recoveryLink) + } + + // MARK: - #259 Vault ID ownership validation + + override func setUp() { + super.setUp() + // Reset ownership state before each test so tests are independent. + router.ownerVaultIDs = nil + } + + func test_parse_vaultAction_ownedVault_succeeds() { + router.ownerVaultIDs = ["vault-mine"] + let url = URL(string: "ethosprotocol://vault/vault-mine/check-in")! + XCTAssertEqual(router.parse(url: url), .vaultAction(vaultID: "vault-mine", action: .checkIn)) + } + + func test_parse_vaultAction_unownedVault_returnsNil() { + router.ownerVaultIDs = ["vault-mine"] + let url = URL(string: "ethosprotocol://vault/vault-theirs/check-in")! + // Must return nil without revealing whether the vault exists. + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_vaultAction_ownerVaultIDsNil_skipsCheck() { + router.ownerVaultIDs = nil // vault list not yet loaded + let url = URL(string: "ethosprotocol://vault/vault-any/check-in")! + XCTAssertNotNil(router.parse(url: url), + "ownership check must be skipped when vault list is not yet loaded") + } + + func test_parse_vaultInvitation_unownedVault_returnsNil() { + router.ownerVaultIDs = ["vault-mine"] + let url = URL(string: "https://ethos-protocol.app/vaults/vault-other/invite")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_beneficiaryAcceptance_unownedVault_returnsNil() { + router.ownerVaultIDs = ["vault-mine"] + let url = URL(string: "https://ethos-protocol.app/vaults/vault-other/accept?token=tok")! + XCTAssertNil(router.parse(url: url)) + } + + func test_parse_emptyOwnerSet_rejectsAllVaultLinks() { + router.ownerVaultIDs = [] + let url = URL(string: "ethosprotocol://vault/vault-any/check-in")! + XCTAssertNil(router.parse(url: url)) + } } // MARK: - #39 / #115 Two-Factor Verification Copy Tests diff --git a/ios/EthosProtocol/Tests/VaultEventSocketTests.swift b/ios/EthosProtocol/Tests/VaultEventSocketTests.swift index 4548200..1b833e5 100644 --- a/ios/EthosProtocol/Tests/VaultEventSocketTests.swift +++ b/ios/EthosProtocol/Tests/VaultEventSocketTests.swift @@ -11,6 +11,8 @@ final class MockWebSocketTask: WebSocketTasking { private(set) var resumeCallCount = 0 private(set) var cancelCallCount = 0 private var receiveHandler: ((Result) -> Void)? + // #257: close code exposed so tests can inspect it and simulateClose can set it. + private(set) var closeCode: URLSessionWebSocketTask.CloseCode = .invalid func resume() { resumeCallCount += 1 } @@ -29,6 +31,15 @@ final class MockWebSocketTask: WebSocketTasking { func simulateMessage(_ message: URLSessionWebSocketTask.Message) { receiveHandler?(.success(message)) } + + /// #257: Simulate the server closing the socket with the given close code. + /// VaultEventSocket inspects the `closeCode` on the underlying URLSessionWebSocketTask; + /// this helper delivers a failure (matching what URLSession actually does) and sets the + /// close code so the socket can read it back. + func simulateClose(code: URLSessionWebSocketTask.CloseCode) { + closeCode = code + receiveHandler?(.failure(URLError(.networkConnectionLost))) + } } /// Polls `condition` until it's true or `timeout` elapses — VaultEventSocket's @@ -424,6 +435,104 @@ final class VaultEventSocketTests: XCTestCase { XCTAssertTrue(received, "unrecognized type should still fire onEvent with .unknown") XCTAssertEqual(receivedEvent, .unknown) } + + // MARK: - #257 WebSocket close code 4401 handling + + func test_4401Close_withSuccessfulRefresh_reconnectsAndContinues() async { + // Arrange: first task closes with 4401; after refresh, second task receives a message. + var tasks: [MockWebSocketTask] = [] + var refreshCallCount = 0 + let socket = VaultEventSocket( + baseURL: URL(string: "https://api.example.com/v1")!, + maxReconnectAttempts: 5, + backoff: ReconnectBackoff(baseDelay: 0, maxDelay: 0, randomSource: DeterministicRandomSource([0.0]), sleep: { _ in }), + makeTask: { _ in + let task = MockWebSocketTask() + tasks.append(task) + return task + }, + tokenRefresh: { + refreshCallCount += 1 + return "refreshed-token-xyz" + } + ) + + var receivedEvent: VaultEventSocket.VaultEvent? + socket.onEvent = { receivedEvent = $0 } + socket.connect(vaultID: "vault-1") + + // Simulate the server closing with 4401. + tasks[0].simulateClose(code: URLSessionWebSocketTask.CloseCode(rawValue: 4401) ?? .invalid) + + // Wait for the refresh to fire and a second task to be created. + let reconnected = await waitUntil { tasks.count == 2 } + XCTAssertTrue(reconnected, "should open a new task after successful token refresh") + XCTAssertEqual(refreshCallCount, 1, "should attempt refresh exactly once") + XCTAssertNotEqual(socket.state, .authFailure, "state must not be authFailure after a successful refresh") + + // Second task delivers a real event. + tasks[1].simulateMessage(.string(#"{"type": "ping"}"#)) + let gotEvent = await waitUntil { receivedEvent != nil } + XCTAssertTrue(gotEvent, "should receive events after reconnecting with refreshed token") + } + + func test_4401Close_withFailedRefresh_transitionsToAuthFailure() async { + // Arrange: the refresh endpoint rejects the token (401 / token fully revoked). + var tasks: [MockWebSocketTask] = [] + let socket = VaultEventSocket( + baseURL: URL(string: "https://api.example.com/v1")!, + maxReconnectAttempts: 5, + backoff: ReconnectBackoff(baseDelay: 0, maxDelay: 0, randomSource: DeterministicRandomSource([0.0]), sleep: { _ in }), + makeTask: { _ in + let task = MockWebSocketTask() + tasks.append(task) + return task + }, + tokenRefresh: { + throw URLError(.userAuthenticationRequired) + } + ) + + var stateChanges: [VaultEventSocket.ConnectionState] = [] + socket.onStateChange = { stateChanges.append($0) } + socket.connect(vaultID: "vault-1") + + tasks[0].simulateClose(code: URLSessionWebSocketTask.CloseCode(rawValue: 4401) ?? .invalid) + + let fellToAuthFailure = await waitUntil { socket.state == .authFailure } + XCTAssertTrue(fellToAuthFailure, "should transition to .authFailure when refresh fails after 4401") + // Must not open a further reconnect task once auth fails. + let taskCountAtFailure = tasks.count + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(tasks.count, taskCountAtFailure, "must not attempt further reconnects after .authFailure") + } + + func test_nonAuth_closeCode_doesNotTriggerRefresh() async { + // A normal 1001 (Going Away) or 1000 (Normal Closure) must not call the refresh endpoint. + var tasks: [MockWebSocketTask] = [] + var refreshCallCount = 0 + let socket = VaultEventSocket( + baseURL: URL(string: "https://api.example.com/v1")!, + maxReconnectAttempts: 5, + backoff: ReconnectBackoff(baseDelay: 0, maxDelay: 0, randomSource: DeterministicRandomSource([0.0]), sleep: { _ in }), + makeTask: { _ in + let task = MockWebSocketTask() + tasks.append(task) + return task + }, + tokenRefresh: { + refreshCallCount += 1 + return "should-not-be-called" + } + ) + + socket.connect(vaultID: "vault-1") + tasks[0].simulateFailure() // Generic failure, not a 4401 close. + + let reconnected = await waitUntil { tasks.count == 2 } + XCTAssertTrue(reconnected, "should reconnect after a non-4401 drop") + XCTAssertEqual(refreshCallCount, 0, "refresh must not be called for non-4401 failures") + } } // MARK: - #20 VaultStore Real-Time Event Wiring Tests