Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<String>? = 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)
Expand All @@ -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)
Expand All @@ -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/([^/]+)/([^/]+)$")
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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<com.ethosprotocol.models.AuthToken> = {
apiClient.refreshToken()
}

fun events(vaultId: String): Flow<VaultEvent> = flow {
var attempt = 0
while (currentCoroutineContext().isActive) {
Expand All @@ -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.
}
Expand Down
117 changes: 117 additions & 0 deletions android/app/src/test/java/com/ethosprotocol/VaultDeepLinkParserTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Loading