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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) ────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <token>`.
|
||||
// - 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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user