In gated mode (basic-auth cookie flow) the dashboard 401s any request that carries an Authorization: Bearer header alongside the valid session cookie (verified live 2026-07-23). ApiClient.authInterceptor stamped the WS ticket as a Bearer header on every REST call, so every non-chat tab (skills, cron, config, tools, mcp, ...) failed with "token expired" while the WS chat (?ticket=) kept working. - Add AuthManager.isGatedMode() (wsAuthParam == "ticket"). - authInterceptor skips the Bearer header in gated mode; loopback/token behavior unchanged. - TokenRefreshAuthenticator bails in gated mode instead of attempting the loopback-only SPA token refresh. - ApiClientTest: add gated/loopback regression tests. 7/7 pass, ktlint clean. Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
b2a7dabbfc
commit
152b7d5c0d
4 files changed
+85
-6
No files matched your search
@@ -160,6 +160,23 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(wsAuthParam = param) }
|
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) ────────────────────
|
// ── Session Cookie (for gated/dashboard REST API) ────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -90,12 +90,22 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loopback mode: authenticate via Bearer token (the session cookie used
|
// REST auth header strategy:
|
||||||
// in gated mode is now handled automatically by the shared CookieJar —
|
// - Loopback/token mode: stamp `Authorization: Bearer <token>`.
|
||||||
// see issue #470).
|
// - 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 =
|
val authInterceptor =
|
||||||
Interceptor { chain ->
|
Interceptor { chain ->
|
||||||
val request = chain.request()
|
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()
|
val token = AuthManager.getToken()
|
||||||
if (!token.isNullOrBlank()) {
|
if (!token.isNullOrBlank()) {
|
||||||
chain.proceed(
|
chain.proceed(
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ object TokenRefreshAuthenticator : Authenticator {
|
|||||||
if (response.priorResponse != null) {
|
if (response.priorResponse != null) {
|
||||||
return null // Only retry once
|
return null // Only retry once
|
||||||
}
|
}
|
||||||
// Gated mode: the session cookie is now managed automatically by the
|
// Gated mode (session-cookie auth): a REST 401 here means the session
|
||||||
// shared CookieJar (issue #470), so OkHttp re-attaches it on retry
|
// cookie is genuinely expired (the shared CookieJar re-attaches it on
|
||||||
// without any manual header manipulation here.
|
// 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
|
// Loopback mode: a dashboard restart invalidates the saved ephemeral
|
||||||
// token. First reuse a token another request may already have refreshed;
|
// token. First reuse a token another request may already have refreshed;
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ class ApiClientTest {
|
|||||||
// which calls getSelectedProfileId(). Stub it to null so the interceptor
|
// which calls getSelectedProfileId(). Stub it to null so the interceptor
|
||||||
// short-circuits (no profile scope) and these auth tests stay focused.
|
// short-circuits (no profile scope) and these auth tests stay focused.
|
||||||
every { AuthManager.getSelectedProfileId() } returns null
|
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
|
@AfterEach
|
||||||
@@ -140,6 +145,50 @@ class ApiClientTest {
|
|||||||
assertEquals("Bearer $rawToken", request.getHeader("Authorization"))
|
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
|
@Test
|
||||||
fun testAuthInterceptor_refreshesExpiredDashboardTokenAndRetries() =
|
fun testAuthInterceptor_refreshesExpiredDashboardTokenAndRetries() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in new issue
Block a user