diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index efc01323..c773d149 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -160,6 +160,23 @@ object AuthManager { serverStore.update { it.copy(wsAuthParam = param) } } + /** + * True when the active connection profile authenticated against a gated + * dashboard (non-loopback bind → session-cookie auth) instead of loopback + * token mode. Derived from [ServerStoreState.wsAuthParam], which + * [com.m57.hermescontrol.ui.authlogin.AuthLoginViewModel.connect] sets to + * "ticket" on gated (basic-auth) login and "token" on loopback. + * + * Critical: gated dashboards 401 any request carrying an + * `Authorization: Bearer` header — even alongside a valid session cookie + * (verified live 2026-07-23). So REST requests in gated mode MUST rely on + * the session cookie in the shared [CookieManager] jar and MUST NOT stamp a + * Bearer header. This is the fix for the "token expired" error that broke + * every REST tab (skills/cron/config/...) while the WS chat (ticket auth) + * kept working. + */ + fun isGatedMode(): Boolean = serverStore.getLatestState().wsAuthParam == "ticket" + // ── Session Cookie (for gated/dashboard REST API) ──────────────────── /** diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index 79f21598..94d32674 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -90,12 +90,22 @@ object ApiClient { } } - // Loopback mode: authenticate via Bearer token (the session cookie used - // in gated mode is now handled automatically by the shared CookieJar — - // see issue #470). + // REST auth header strategy: + // - Loopback/token mode: stamp `Authorization: Bearer `. + // - Gated (basic-auth cookie) mode: the shared CookieManager jar + // attaches the session cookie automatically (issue #470). The + // dashboard 401s any request that ALSO carries an + // `Authorization: Bearer` header (verified live 2026-07-23), so we + // must NOT stamp one here — otherwise every REST tab (skills, cron, + // config, ...) fails with "token expired" while the WS chat, which + // authenticates via ?ticket=, keeps working. val authInterceptor = Interceptor { chain -> val request = chain.request() + if (AuthManager.isGatedMode()) { + // Cookie in the shared jar is the only valid REST credential. + return@Interceptor chain.proceed(request) + } val token = AuthManager.getToken() if (!token.isNullOrBlank()) { chain.proceed( diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt index c7bdfa9d..c4f6c43d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt @@ -14,9 +14,12 @@ object TokenRefreshAuthenticator : Authenticator { if (response.priorResponse != null) { return null // Only retry once } - // Gated mode: the session cookie is now managed automatically by the - // shared CookieJar (issue #470), so OkHttp re-attaches it on retry - // without any manual header manipulation here. + // Gated mode (session-cookie auth): a REST 401 here means the session + // cookie is genuinely expired (the shared CookieJar re-attaches it on + // retry, issue #470). Stamping a Bearer header would 401 again, and the + // SPA token refresher only works in loopback mode — so bail out and let + // safeApiCall() route to AuthSessionState.requireSignIn(). + if (AuthManager.isGatedMode()) return null // Loopback mode: a dashboard restart invalidates the saved ephemeral // token. First reuse a token another request may already have refreshed; diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt index 0f84728e..41e05b66 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt @@ -49,6 +49,11 @@ class ApiClientTest { // which calls getSelectedProfileId(). Stub it to null so the interceptor // short-circuits (no profile scope) and these auth tests stay focused. every { AuthManager.getSelectedProfileId() } returns null + + // Default to loopback/token mode; the gated-mode test overrides this. + // (mockkObject runs the real isGatedMode() body, which reads serverStore + // — null in unit tests — so it must be stubbed.) + every { AuthManager.isGatedMode() } returns false } @AfterEach @@ -140,6 +145,50 @@ class ApiClientTest { assertEquals("Bearer $rawToken", request.getHeader("Authorization")) } + @Test + fun testAuthInterceptor_omitsBearerInGatedModeEvenWithToken() = + runTest { + // Gated (basic-auth cookie) mode: wsAuthParam == "ticket". The mobile + // still stores the WS ticket as the token, but stamping it as a Bearer + // header 401s on the live dashboard (verified 2026-07-23) — the session + // cookie in the shared jar is the only valid REST credential. + every { AuthManager.getToken() } returns "ws-ticket-abc123" + every { AuthManager.isGatedMode() } returns true + + ApiClient.rebuild() + + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setBody("""{"gateway_running":true}"""), + ) + + val response = ApiClient.hermesApi.getStatus() + assertTrue(response.isSuccessful) + + val request = mockWebServer.takeRequest() + // No Bearer header must be sent in gated mode. + assertNull(request.getHeader("Authorization")) + } + + @Test + fun testAuthInterceptor_stampsBearerInLoopbackMode() = + runTest { + // Loopback token mode: wsAuthParam != "ticket" → Bearer header required. + every { AuthManager.getToken() } returns "loopback-token-xyz" + every { AuthManager.isGatedMode() } returns false + + ApiClient.rebuild() + + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setBody("""{"gateway_running":true}"""), + ) + + val response = ApiClient.hermesApi.getStatus() + assertTrue(response.isSuccessful) + + val request = mockWebServer.takeRequest() + assertEquals("Bearer loopback-token-xyz", request.getHeader("Authorization")) + } + @Test fun testAuthInterceptor_refreshesExpiredDashboardTokenAndRetries() = runTest {