diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce63b9..a820ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### Security +- **Your login credentials only ever go to your own server now.** The app used to + attach your session token (and any Cloudflare Access secrets) to every network + request, including image loads. An avatar or an image embedded in a bio that was + hosted on some other server would therefore receive your live credentials. They + are now sent only to your configured instance and its image host, never to any + other server. The "remember this device" cookie is scoped the same way. + +- **Switching servers, or being signed out, fully clears the previous account.** + Changing the server address while signed in now ends the old session and clears + its cached data and pending offline actions instead of leaving them behind; + a registration that skips email verification, and a forced sign-out when your + session expires, now clear the same data. Previously some of these paths left a + prior account's cache or offline queue on the device. + - **Your data no longer goes into Android's cloud backup.** The phone and watch apps now opt out of backup and device-to-device transfer entirely. Previously the local cache (members, groups, front history, messages), the offline switch diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt index 2f1d837..dbadcef 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/AuthInterceptor.kt @@ -28,17 +28,33 @@ class AuthInterceptor @Inject constructor( private val clientHeader = "Sheaf Android/${BuildConfig.VERSION_NAME}" override fun intercept(chain: Interceptor.Chain): Response { - val token = pendingToken ?: runBlocking { prefs.accessToken.firstOrNull() } - val cfClientId = runBlocking { prefs.cfClientId.firstOrNull() } - val cfClientSecret = runBlocking { prefs.cfClientSecret.firstOrNull() } - val builder = chain.request().newBuilder() + val request = chain.request() + val builder = request.newBuilder() .addHeader("X-Sheaf-Client", clientHeader) - if (token != null) { - builder.addHeader("Authorization", "Bearer $token") + + // Credentials go only to the instance's own origins. The Coil image + // client shares this interceptor, and image URLs can point at an + // external host (a remote avatar, an image embedded in a bio), so an + // unconditional bearer / CF-Access header would hand the user's live + // session to whatever server that image lives on. + val trusted = runBlocking { + isTrustedCredentialOrigin( + request.url, + prefs.baseUrl.firstOrNull(), + prefs.fileCdnBase.firstOrNull(), + ) } - if (cfClientId != null && cfClientSecret != null) { - builder.addHeader("CF-Access-Client-Id", cfClientId) - builder.addHeader("CF-Access-Client-Secret", cfClientSecret) + if (trusted) { + val token = pendingToken ?: runBlocking { prefs.accessToken.firstOrNull() } + if (token != null) { + builder.addHeader("Authorization", "Bearer $token") + } + val cfClientId = runBlocking { prefs.cfClientId.firstOrNull() } + val cfClientSecret = runBlocking { prefs.cfClientSecret.firstOrNull() } + if (cfClientId != null && cfClientSecret != null) { + builder.addHeader("CF-Access-Client-Id", cfClientId) + builder.addHeader("CF-Access-Client-Secret", cfClientSecret) + } } return chain.proceed(builder.build()) } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/CredentialOrigin.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/CredentialOrigin.kt new file mode 100644 index 0000000..a99fcdd --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/CredentialOrigin.kt @@ -0,0 +1,54 @@ +package systems.lupine.sheaf.data.api + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * Origin matching for deciding where credentials may be sent. + * + * The API auth stack (bearer token, Cloudflare Access secrets, the + * trusted-device cookie) used to be attached host-blind, and the Coil image + * client is cloned from the API client, so a member avatar or a bio-embedded + * image hosted on an external server received the user's live credentials. The + * interceptors now gate on origin: credentials go only to the instance's own + * hosts, never to whatever host an image URL happens to point at. + */ + +private fun normalizedOrigin(configured: String?): HttpUrl? { + val raw = configured?.trim()?.trimEnd('/')?.ifBlank { null } ?: return null + // The CDN base can arrive scheme-less from the instance config; assume https + // so a bare host still parses to a comparable origin. + val withScheme = if (raw.contains("://")) raw else "https://$raw" + return withScheme.toHttpUrlOrNull() +} + +/** True when [url] has the same scheme, host and port as [configured]. Path is ignored. */ +internal fun originMatches(url: HttpUrl, configured: String?): Boolean { + val base = normalizedOrigin(configured) ?: return false + return url.scheme == base.scheme && + url.host.equals(base.host, ignoreCase = true) && + url.port == base.port +} + +/** + * Credentials may be sent to the API base origin and to the instance's + * configured file CDN origin (both are infrastructure the user configured or + * received from the instance's own config), and to nothing else. + */ +internal fun isTrustedCredentialOrigin(url: HttpUrl, baseUrl: String?, fileCdnBase: String?): Boolean = + originMatches(url, baseUrl) || originMatches(url, fileCdnBase) + +/** + * True when two configured base URLs point at the same origin. Used to decide + * whether changing the server URL is actually switching instances (and so must + * drop the old session) or just a cosmetic edit of the same one. Two blank/unset + * values count as the same. + */ +internal fun sameConfiguredOrigin(a: String?, b: String?): Boolean { + val ao = normalizedOrigin(a) + val bo = normalizedOrigin(b) + if (ao == null || bo == null) return ao == null && bo == null + return ao.scheme == bo.scheme && + ao.host.equals(bo.host, ignoreCase = true) && + ao.port == bo.port +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt index ac0d525..0a0b2a5 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TokenAuthenticator.kt @@ -13,6 +13,7 @@ import okhttp3.Response import okhttp3.Route import systems.lupine.sheaf.data.model.TokenRefresh import systems.lupine.sheaf.data.model.TokenResponse +import systems.lupine.sheaf.data.repository.AccountDataWiper import systems.lupine.sheaf.data.repository.PreferencesRepository import javax.inject.Inject import javax.inject.Singleton @@ -22,6 +23,9 @@ class TokenAuthenticator @Inject constructor( private val prefs: PreferencesRepository, private val moshi: Moshi, private val lazyClient: Lazy, + // Lazy so the OkHttp graph doesn't have to build the Room-backed wiper up + // front; it's only needed on the rare forced-logout path. + private val accountDataWiper: Lazy, ) : Authenticator { // Synchronized to prevent concurrent refresh races: if two 401s arrive at once, @@ -77,7 +81,13 @@ class TokenAuthenticator @Inject constructor( // Only a 401 means the refresh token is genuinely invalid/expired. // Any other failure (5xx, 503, etc.) is transient — don't destroy the session. if (refreshResponse.code == 401) { - runBlocking { prefs.clearTokens() } + // Forced logout: clear the tokens AND wipe the account's cache + // and offline queue, so the next account signed in on this device + // can't inherit them (clearTokens alone left both behind). + runBlocking { + prefs.clearTokens() + accountDataWiper.get().wipe() + } } return null } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt index b50cc97..3cbd951 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJar.kt @@ -1,5 +1,6 @@ package systems.lupine.sheaf.data.api +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import okhttp3.Cookie import okhttp3.CookieJar @@ -40,6 +41,9 @@ class TrustedDeviceCookieJar @Inject constructor( ) : CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: List) { + // Only store the cookie when it came from our own API origin, so a + // response from some other host can't seed it. + if (!isApiOrigin(url)) return val tdc = cookies.firstOrNull { it.name == COOKIE_NAME } ?: return runBlocking { prefs.saveTrustedDeviceCookie(tdc.value, tdc.expiresAt) @@ -47,7 +51,11 @@ class TrustedDeviceCookieJar @Inject constructor( } override fun loadForRequest(url: HttpUrl): List { + // Host-scope as well as path-scope: the trusted-device token is bound to + // the instance that issued it and must not ride along to another host + // (e.g. after the user points the app at a different server). if (!url.encodedPath.startsWith(COOKIE_PATH)) return emptyList() + if (!isApiOrigin(url)) return emptyList() val value = prefs.trustedDeviceCookieBlocking() ?: return emptyList() return listOf( Cookie.Builder() @@ -59,6 +67,9 @@ class TrustedDeviceCookieJar @Inject constructor( ) } + private fun isApiOrigin(url: HttpUrl): Boolean = + originMatches(url, runBlocking { prefs.baseUrl.firstOrNull() }) + private companion object { const val COOKIE_NAME = "sheaf_trusted_device" const val COOKIE_PATH = "/v1/auth/" diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt index 47b70d8..365f67e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/di/NetworkModule.kt @@ -126,11 +126,13 @@ object NetworkModule { @ApplicationContext context: Context, okHttpClient: OkHttpClient, relativeUrlInterceptor: RelativeUrlInterceptor, - userAgentInterceptor: UserAgentInterceptor, ): ImageLoader { - val imageClient = okHttpClient.newBuilder() - .addInterceptor(userAgentInterceptor) - .build() + // Cloned from the API client so it keeps the debug SSL-trust config for + // local dev servers. It also inherits AuthInterceptor, but that only + // attaches credentials to the instance's own origins now, so an image + // fetched from an external host carries none. (User-Agent and the + // cookie jar are inherited from the clone, so we don't re-add them.) + val imageClient = okHttpClient.newBuilder().build() return ImageLoader.Builder(context) .okHttpClient(imageClient) .components { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt index 1a27907..ac0cae7 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import systems.lupine.sheaf.data.api.AltchaSolver import systems.lupine.sheaf.data.api.AuthInterceptor import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.api.sameConfiguredOrigin +import kotlinx.coroutines.flow.firstOrNull import systems.lupine.sheaf.data.model.AuthConfig import systems.lupine.sheaf.data.model.TokenResponse import systems.lupine.sheaf.data.model.UserLogin @@ -129,7 +131,19 @@ class AuthViewModel @Inject constructor( private var pendingCaptcha: String? = null fun saveBaseUrl(url: String) { - viewModelScope.launch { prefs.saveBaseUrl(url) } + viewModelScope.launch { + val previous = prefs.baseUrl.firstOrNull() + prefs.saveBaseUrl(url) + // Switching to a different instance must drop any session bound to + // the old one: otherwise the previous instance's tokens, cache and + // offline queue survive against the new host (and its bearer would + // ride to the new server). + if (!sameConfiguredOrigin(previous, url)) { + authInterceptor.pendingToken = null + prefs.clearTokens() + accountDataWiper.wipe() + } + } } fun saveCfTokens(clientId: String, clientSecret: String) { @@ -246,6 +260,11 @@ class AuthViewModel @Inject constructor( pendingRefreshToken = tokens.refreshToken _uiState.value = AuthUiState.AwaitingEmailVerification() } else { + // This branch commits a session directly instead of via + // finishAuth(), so wipe here too: without it a prior + // account's cache and offline queue survive into the new + // account (finishAuth is the only other path that wipes). + accountDataWiper.wipe() prefs.saveTokens(tokens.accessToken, tokens.refreshToken) runCatching { PhoneDataLayerService.pushWatchCredentials( diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt index d6ba3f0..fe5207f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt @@ -83,6 +83,8 @@ class SettingsViewModel @Inject constructor( private val prefs: PreferencesRepository, private val notificationHelper: FrontNotificationHelper, private val watchSession: WatchSessionRepository, + private val accountDataWiper: systems.lupine.sheaf.data.repository.AccountDataWiper, + private val authInterceptor: systems.lupine.sheaf.data.api.AuthInterceptor, @dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context, ) : ViewModel() { @@ -164,7 +166,18 @@ class SettingsViewModel @Inject constructor( } fun saveBaseUrl(url: String) { - viewModelScope.launch { prefs.saveBaseUrl(url) } + viewModelScope.launch { + val previous = prefs.baseUrl.firstOrNull() + prefs.saveBaseUrl(url) + // Changing the server here means switching instances while signed in. + // Drop the old session so its tokens, cache and offline queue can't + // carry over to (or leak toward) the new host. + if (!systems.lupine.sheaf.data.api.sameConfiguredOrigin(previous, url)) { + authInterceptor.pendingToken = null + prefs.clearTokens() + accountDataWiper.wipe() + } + } } fun saveTheme(mode: String) { diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/AuthInterceptorTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/AuthInterceptorTest.kt new file mode 100644 index 0000000..ac600ce --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/AuthInterceptorTest.kt @@ -0,0 +1,81 @@ +package systems.lupine.sheaf.data.api + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import systems.lupine.sheaf.data.repository.PreferencesRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The end-to-end check on the credential leak: the bearer and Cloudflare Access + * headers must appear only on requests to the instance's own origins, never on a + * request to an external image host (the image client shares this interceptor). + */ +class AuthInterceptorTest { + + private val prefs = mockk().apply { + every { baseUrl } returns flowOf("https://app.sheaf.sh") + every { fileCdnBase } returns flowOf("https://cdn.sheaf.sh") + every { accessToken } returns flowOf("access-tok") + every { cfClientId } returns flowOf("cf-id") + every { cfClientSecret } returns flowOf("cf-secret") + } + private val interceptor = AuthInterceptor(prefs) + + /** Runs the interceptor against [urlString] and returns the outgoing request. */ + private fun send(urlString: String): Request { + val request = Request.Builder().url(urlString).build() + val sent = slot() + val chain = mockk() + every { chain.request() } returns request + every { chain.proceed(capture(sent)) } answers { + Response.Builder() + .request(sent.captured) + .protocol(Protocol.HTTP_1_1) + .code(200).message("OK") + .build() + } + interceptor.intercept(chain) + return sent.captured + } + + @Test fun `API requests carry the bearer and CF headers`() { + val sent = send("https://app.sheaf.sh/v1/members") + assertEquals("Bearer access-tok", sent.header("Authorization")) + assertEquals("cf-id", sent.header("CF-Access-Client-Id")) + assertEquals("cf-secret", sent.header("CF-Access-Client-Secret")) + } + + @Test fun `CDN image requests are still credentialed`() { + val sent = send("https://cdn.sheaf.sh/avatars/x.png") + assertEquals("Bearer access-tok", sent.header("Authorization")) + assertEquals("cf-id", sent.header("CF-Access-Client-Id")) + } + + @Test fun `an external image host receives no credentials`() { + val sent = send("https://images.example.com/remote-avatar.png") + assertNull(sent.header("Authorization")) + assertNull(sent.header("CF-Access-Client-Id")) + assertNull(sent.header("CF-Access-Client-Secret")) + } + + @Test fun `the client header is always sent, even to external hosts`() { + // Not a credential; safe (and useful) everywhere. + val sent = send("https://images.example.com/remote-avatar.png") + assertEquals("Sheaf Android/${systems.lupine.sheaf.BuildConfig.VERSION_NAME}", sent.header("X-Sheaf-Client")) + } + + @Test fun `the in-memory pending token is used during intermediate auth`() { + every { prefs.accessToken } returns flowOf(null) + interceptor.pendingToken = "pending-tok" + val sent = send("https://app.sheaf.sh/v1/users/me") + assertEquals("Bearer pending-tok", sent.header("Authorization")) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/CredentialOriginTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/CredentialOriginTest.kt new file mode 100644 index 0000000..fedc549 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/CredentialOriginTest.kt @@ -0,0 +1,95 @@ +package systems.lupine.sheaf.data.api + +import okhttp3.HttpUrl.Companion.toHttpUrl +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Credentials (bearer, CF-Access secrets, the trusted-device cookie) may reach + * only the instance's own origins. This is what stops the shared image client + * from handing the user's session to whatever host an avatar or bio-embedded + * image points at. + */ +class CredentialOriginTest { + + private fun url(u: String) = u.toHttpUrl() + + private val api = "https://app.sheaf.sh" + private val cdn = "https://cdn.sheaf.sh" + + @Test fun `a request to the API origin is trusted`() { + assertTrue(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), api, cdn)) + } + + @Test fun `a request to the configured CDN origin is trusted`() { + assertTrue(isTrustedCredentialOrigin(url("https://cdn.sheaf.sh/avatars/x.png"), api, cdn)) + } + + @Test fun `an external host is never trusted`() { + assertFalse(isTrustedCredentialOrigin(url("https://evil.example.com/x.png"), api, cdn)) + assertFalse(isTrustedCredentialOrigin(url("https://imgur.com/a.png"), api, cdn)) + } + + @Test fun `a lookalike host is not trusted`() { + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh.evil.com/x"), api, cdn)) + assertFalse(isTrustedCredentialOrigin(url("https://notapp.sheaf.sh/x"), api, cdn)) + } + + @Test fun `scheme must match`() { + // http vs https is a different origin; a downgrade must not carry creds. + assertFalse(isTrustedCredentialOrigin(url("http://app.sheaf.sh/v1/members"), api, cdn)) + } + + @Test fun `port must match`() { + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh:8443/v1/members"), api, cdn)) + } + + @Test fun `the path prefix on the base URL is ignored for origin matching`() { + val based = "https://example.org/sheaf" + assertTrue(isTrustedCredentialOrigin(url("https://example.org/v1/members"), based, null)) + assertTrue(isTrustedCredentialOrigin(url("https://example.org/sheaf/v1/members"), based, null)) + } + + @Test fun `host comparison is case-insensitive`() { + assertTrue(isTrustedCredentialOrigin(url("https://APP.sheaf.sh/v1/members"), api, cdn)) + } + + @Test fun `no CDN configured means only the API origin is trusted`() { + assertTrue(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), api, null)) + assertFalse(isTrustedCredentialOrigin(url("https://cdn.sheaf.sh/x"), api, null)) + } + + @Test fun `a scheme-less CDN base is assumed https`() { + assertTrue(isTrustedCredentialOrigin(url("https://cdn.sheaf.sh/x"), api, "cdn.sheaf.sh")) + } + + @Test fun `nothing is trusted when no base is configured`() { + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), null, null)) + assertFalse(isTrustedCredentialOrigin(url("https://app.sheaf.sh/v1/members"), "", null)) + } + + // ── Server-change detection ───────────────────────────────────────────── + + @Test fun `the same origin is recognised despite path or trailing slash`() { + assertTrue(sameConfiguredOrigin("https://app.sheaf.sh", "https://app.sheaf.sh/")) + assertTrue(sameConfiguredOrigin("https://example.org/sheaf", "https://example.org/other")) + assertTrue(sameConfiguredOrigin("app.sheaf.sh", "https://app.sheaf.sh")) + } + + @Test fun `a different host is a different origin`() { + assertFalse(sameConfiguredOrigin("https://app.sheaf.sh", "https://other.example.org")) + } + + @Test fun `first-time setup counts as a change`() { + // previous is unset, new is a real server: must be treated as a switch + // so the (empty) teardown runs and nothing stale is assumed. + assertFalse(sameConfiguredOrigin(null, "https://app.sheaf.sh")) + assertFalse(sameConfiguredOrigin("", "https://app.sheaf.sh")) + } + + @Test fun `two unset values are the same`() { + assertTrue(sameConfiguredOrigin(null, null)) + assertTrue(sameConfiguredOrigin("", " ")) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt index fdd56f3..c6c9b2c 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt @@ -4,6 +4,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl import systems.lupine.sheaf.data.repository.PreferencesRepository @@ -19,7 +20,10 @@ import kotlin.test.assertTrue */ class TrustedDeviceCookieJarTest { - private val prefs = mockk(relaxed = true) + private val prefs = mockk(relaxed = true).also { + // isApiOrigin reads the configured base URL to host-scope the cookie. + every { it.baseUrl } returns flowOf("https://app.sheaf.sh") + } private val jar = TrustedDeviceCookieJar(prefs) private val authUrl = "https://app.sheaf.sh/v1/auth/login".toHttpUrl() @@ -70,4 +74,21 @@ class TrustedDeviceCookieJarTest { assertTrue(jar.loadForRequest(authUrl).isEmpty()) } + + @Test fun `the cookie is not attached to a different host's auth path`() { + // Host-scoping: even a /v1/auth/ request to another instance (e.g. after + // the user changed servers) must not receive the old instance's token. + every { prefs.trustedDeviceCookieBlocking() } returns "tdc-1" + + assertTrue(jar.loadForRequest("https://other.example.org/v1/auth/login".toHttpUrl()).isEmpty()) + } + + @Test fun `a cookie from a different host is not stored`() { + jar.saveFromResponse( + "https://other.example.org/v1/auth/login".toHttpUrl(), + listOf(cookie("sheaf_trusted_device", "tdc-evil")), + ) + + coVerify(exactly = 0) { prefs.saveTrustedDeviceCookie(any(), any()) } + } }