The desktop OAuth remote-gateway path gated connectivity on hasOauthSessionCookie(), which checks only the access-token cookie (hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend() and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing a full IDP re-login every ~15 min — even though a valid 24h refresh-token cookie (hermes_session_rt) was sitting in the same jar. The desktop OAuth code (2026-06-04) was written against the obsolete "contract v1 issues no refresh token" model, two days after #37247 re-introduced server-side transparent refresh: Portal now issues a 24h rotating, reuse-detected refresh token, and the gateway middleware (_attempt_refresh) rotates a fresh AT from the RT on the next authenticated request. So an expired-AT/live-RT session is fully connectable — the desktop just never let the request through. Fix: - connection-config.cjs: add RT_COOKIE_VARIANTS + cookiesHaveLiveSession() (true when EITHER a live AT or RT cookie is present). Keep cookiesHaveSession() AT-only for callers that need that specific signal. - main.cjs: add hasLiveOauthSession(); resolveRemoteBackend()'s oauth branch now early-outs only when NEITHER cookie is present, otherwise uses the ws-ticket mint as the authoritative liveness probe (that POST carries the RT cookie and triggers the server-side AT rotation). A real 401 still surfaces as needsOauthLogin. Settings indicator + oauth-logout report against the same AT-or-RT notion. - Remove the stale "contract v1 / NO refresh token" docstrings in cookies.py and the verify_session comments in the Nous provider that contradicted #37247. Tests: +57 lines in connection-config.test.cjs covering the RT-only "still connectable" case. node --test: 32/32. dashboard-auth + nous-provider Python suites: 223/223. Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/) are comment/docstring-only here, but this touches outside apps/desktop/ so it needs Teknium review.
217 lines
8.2 KiB
JavaScript
217 lines
8.2 KiB
JavaScript
/**
|
|
* connection-config.cjs
|
|
*
|
|
* Pure, electron-free helpers for the desktop's remote-gateway connection
|
|
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
|
|
* auth-mode classification, and the auth-mode coercion rules.
|
|
*
|
|
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
|
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
|
|
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
|
*
|
|
* Background on the two auth models a remote gateway can use:
|
|
* - 'token': legacy static dashboard session token. REST uses an
|
|
* `X-Hermes-Session-Token` header; WS uses `?token=`.
|
|
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
|
|
* by an HttpOnly session cookie; WS upgrades require a single-use
|
|
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
|
|
* this via the public `/api/status` field `auth_required: true`.
|
|
*/
|
|
|
|
// Bare + prefixed variants of the session cookies the gateway may set,
|
|
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
|
|
// → __Secure-, loopback HTTP → bare). Mirrors
|
|
// hermes_cli/dashboard_auth/cookies.py.
|
|
//
|
|
// Two cookies are in play (see that module):
|
|
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
|
|
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
|
|
// instant the AT expires.
|
|
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
|
|
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
|
|
// has lapsed but the RT cookie is still present, the gateway middleware
|
|
// transparently rotates a fresh AT on the next authenticated request
|
|
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
|
|
// AT cookie. A liveness check that looked only at the AT cookie would
|
|
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
|
|
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
|
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
|
|
|
function normalizeRemoteBaseUrl(rawUrl) {
|
|
const value = String(rawUrl || '').trim()
|
|
|
|
if (!value) {
|
|
throw new Error('Remote gateway URL is required.')
|
|
}
|
|
|
|
let parsed
|
|
try {
|
|
parsed = new URL(value)
|
|
} catch (error) {
|
|
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
|
|
}
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
|
|
}
|
|
|
|
parsed.hash = ''
|
|
parsed.search = ''
|
|
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
|
|
|
|
return parsed.toString().replace(/\/+$/, '')
|
|
}
|
|
|
|
function buildGatewayWsUrl(baseUrl, token) {
|
|
const parsed = new URL(baseUrl)
|
|
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
|
const prefix = parsed.pathname.replace(/\/+$/, '')
|
|
|
|
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
|
|
}
|
|
|
|
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|
const parsed = new URL(baseUrl)
|
|
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
|
const prefix = parsed.pathname.replace(/\/+$/, '')
|
|
|
|
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
|
}
|
|
|
|
/**
|
|
* Build the WS URL the renderer would connect with, so the connection test can
|
|
* exercise the same transport the app actually uses.
|
|
*
|
|
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
|
|
* so this stays electron-free and unit-testable; main.cjs passes the real
|
|
* `mintGatewayWsTicket`.
|
|
*
|
|
* Return semantics:
|
|
* - token mode + token → ws(s)://…/api/ws?token=…
|
|
* - token mode, no token → null (genuine skip; nothing to authenticate with)
|
|
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
|
|
* - oauth, mint fails → THROWS (NOT a skip)
|
|
*
|
|
* The oauth-mint-failure throw is the important case: the real boot path
|
|
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
|
|
* "session expired" auth error and refuses to connect. Swallowing it here
|
|
* would re-introduce the exact false-positive this test exists to catch —
|
|
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
|
* can't authenticate /api/ws and boot dies with "Could not connect".
|
|
*
|
|
* @param {string} baseUrl
|
|
* @param {'token'|'oauth'} authMode
|
|
* @param {string|null} token
|
|
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
|
|
* @returns {Promise<string|null>}
|
|
*/
|
|
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
|
if (authMode === 'oauth') {
|
|
const mintTicket = deps.mintTicket
|
|
if (typeof mintTicket !== 'function') {
|
|
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
|
|
}
|
|
let ticket
|
|
try {
|
|
ticket = await mintTicket(baseUrl)
|
|
} catch (error) {
|
|
const err = new Error(
|
|
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
|
'(it may have expired). Open Settings → Gateway and sign in again.'
|
|
)
|
|
err.needsOauthLogin = true
|
|
err.cause = error
|
|
throw err
|
|
}
|
|
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
|
}
|
|
if (!token) {
|
|
return null
|
|
}
|
|
return buildGatewayWsUrl(baseUrl, token)
|
|
}
|
|
|
|
function tokenPreview(value) {
|
|
const raw = String(value || '')
|
|
|
|
if (!raw) {
|
|
return null
|
|
}
|
|
|
|
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
|
|
}
|
|
|
|
/**
|
|
* Classify a gateway's auth mode from its public /api/status body.
|
|
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
|
|
* Returns 'oauth' | 'token'.
|
|
*/
|
|
function authModeFromStatus(statusBody) {
|
|
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective auth mode for a coerce/save operation.
|
|
* Explicit input wins; otherwise inherit the saved value; default 'token'.
|
|
* Returns 'oauth' | 'token'.
|
|
*/
|
|
function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
|
if (inputAuthMode === 'oauth') return 'oauth'
|
|
if (inputAuthMode === 'token') return 'token'
|
|
if (existingAuthMode === 'oauth') return 'oauth'
|
|
return 'token'
|
|
}
|
|
|
|
/**
|
|
* True if any cookie in `cookies` is a hermes session ACCESS-token cookie
|
|
* with a non-empty value. `cookies` is an array of {name, value} (the shape
|
|
* Electron's session.cookies.get returns).
|
|
*
|
|
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
|
|
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
|
|
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
|
|
* check. `cookiesHaveSession` remains exported for callers that specifically
|
|
* need to know whether an unexpired access token is present right now.
|
|
*/
|
|
function cookiesHaveSession(cookies) {
|
|
if (!Array.isArray(cookies)) return false
|
|
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
|
}
|
|
|
|
/**
|
|
* True if the cookie jar holds a credential that can yield an authenticated
|
|
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
|
|
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
|
|
* transparently rotates a fresh AT from the RT on the next authenticated
|
|
* request. Gating connectivity on the AT alone would force a full IDP
|
|
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
|
|
*
|
|
* This answers "should we even attempt to connect / show as signed in?", not
|
|
* "is the access token unexpired?". The authoritative liveness check is still
|
|
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
|
|
* the RT is also dead/revoked).
|
|
*/
|
|
function cookiesHaveLiveSession(cookies) {
|
|
if (!Array.isArray(cookies)) return false
|
|
return cookies.some(
|
|
c =>
|
|
c &&
|
|
c.value &&
|
|
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
AT_COOKIE_VARIANTS,
|
|
RT_COOKIE_VARIANTS,
|
|
authModeFromStatus,
|
|
buildGatewayWsUrl,
|
|
buildGatewayWsUrlWithTicket,
|
|
cookiesHaveSession,
|
|
cookiesHaveLiveSession,
|
|
normalizeRemoteBaseUrl,
|
|
resolveAuthMode,
|
|
resolveTestWsUrl,
|
|
tokenPreview
|
|
}
|