Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions apps/desktop/electron/connection-config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,24 @@
* this via the public `/api/status` field `auth_required: true`.
*/

// Bare + prefixed variants of the access-token cookie the gateway may set,
// 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()
Expand Down Expand Up @@ -97,21 +110,52 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
}

/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* 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,
tokenPreview
Expand Down
57 changes: 56 additions & 1 deletion apps/desktop/electron/connection-config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ const assert = require('node:assert/strict')

const {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
cookiesHaveLiveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
Expand Down Expand Up @@ -130,7 +132,10 @@ test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})

test('cookiesHaveSession ignores unrelated cookies', () => {
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
// is NOT an access token, so this returns false. Connectivity callers must
// use cookiesHaveLiveSession instead (see below).
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
Expand All @@ -145,6 +150,56 @@ test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
})

test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
})

// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---

test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})

test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
// but the 24h RT cookie is still alive. The session is still connectable —
// the gateway rotates a fresh AT from the RT on the next request.
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
})

test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: 'a' },
{ name: 'hermes_session_rt', value: 'r' }
]),
true
)
})

test('cookiesHaveLiveSession is false for empty values', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: '' },
{ name: 'hermes_session_rt', value: '' }
]),
false
)
})

test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHaveLiveSession(null), false)
assert.equal(cookiesHaveLiveSession(undefined), false)
assert.equal(cookiesHaveLiveSession([]), false)
})

// --- tokenPreview ---

test('tokenPreview returns null for empty', () => {
Expand Down
78 changes: 67 additions & 11 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const {
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
cookiesHaveLiveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
Expand Down Expand Up @@ -3125,8 +3126,16 @@ function installMediaPermissions() {
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
// path is unconditionally rejected by gated gateways.
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
// a ~15-min TTL. On 401 we must re-run the login round trip.
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
// is still alive, the gateway middleware transparently rotates a fresh AT
// on the next authenticated request — so connectivity must NOT be gated on
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
// (which triggers that server-side refresh) and treat a real 401 as
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
// "is the user signed in at all?" gate / display signal.
// ---------------------------------------------------------------------------

const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
Expand All @@ -3137,8 +3146,9 @@ function getOauthSession() {
return oauthSession
}

// Bare + prefixed variants of the access-token cookie live in
// connection-config.cjs (cookiesHaveSession). See that module for details.
// Bare + prefixed variants of the session cookies live in
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
// that module for details.

async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
Expand All @@ -3159,6 +3169,30 @@ async function hasOauthSessionCookie(baseUrl) {
}
}

// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
// "is the user signed in at all?" check: an expired AT with a live RT is still
// a connectable session because the gateway rotates a fresh AT server-side on
// the next authenticated request. Gating on the AT alone forces a needless full
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
async function hasLiveOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return false
const parsed = new URL(baseUrl)
try {
const cookies = await sess.cookies.get({ url: baseUrl })
return cookiesHaveLiveSession(cookies)
} catch {
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHaveLiveSession(cookies)
} catch {
return false
}
}
}

async function clearOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return
Expand Down Expand Up @@ -3456,7 +3490,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
try {
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
// Display signal: treat a live RT cookie as "connected" even if the AT
// cookie has lapsed — the gateway refreshes the AT on the next request,
// so the session is still usable. The authoritative liveness check is
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
} catch {
remoteOauthConnected = false
}
Expand Down Expand Up @@ -3541,11 +3579,22 @@ async function resolveRemoteBackend() {
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'

if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
// Verify the cookie is present, then mint a single-use WS ticket (the
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
// user needs to (re-)log in via Settings → Gateway.
if (!(await hasOauthSessionCookie(baseUrl))) {
// OAuth gateway: auth comes from the session cookies in the OAuth
// partition. Liveness is NOT "is the access-token cookie present?" —
// Portal issues a 24h rotating refresh token (hermes #37247), and the
// gateway middleware transparently rotates a fresh ~15-min access token
// from it on the next authenticated request. So a session with an expired
// AT cookie but a live RT cookie is still perfectly connectable.
//
// We therefore:
// 1. cheap early-out ONLY when the jar holds NEITHER an AT nor an RT
// cookie — a genuinely signed-out user — to avoid a pointless network
// round-trip and give a clear "sign in" message.
// 2. otherwise probe liveness by actually minting a ws-ticket. That POST
// carries the cookie jar (incl. the RT cookie); the gateway refreshes
// the AT server-side and returns a ticket. A real 401 here means the
// RT is also dead/revoked → genuine re-login needed.
if (!(await hasLiveOauthSession(baseUrl))) {
const err = new Error(
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
Expand All @@ -3556,6 +3605,10 @@ async function resolveRemoteBackend() {

let ticket
try {
// This mint is the authoritative liveness check. If only the RT cookie
// is alive, the gateway rotates a fresh AT cookie back onto the partition
// via Set-Cookie (Electron's persistent session absorbs it), so the very
// next request is already re-authed — no user-visible re-login.
ticket = await mintGatewayWsTicket(baseUrl)
} catch (error) {
const err = new Error(
Expand Down Expand Up @@ -4065,7 +4118,10 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
// Report against the SAME liveness notion the Settings indicator uses
// (AT-or-RT) so a logout that left any session cookie behind is reflected
// as still-connected rather than silently signed-out.
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
Expand Down
51 changes: 32 additions & 19 deletions hermes_cli/dashboard_auth/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@

Three cookies in play:
- hermes_session_at: the OAuth access token
(HttpOnly, lifetime = token TTL)
(HttpOnly, lifetime = token TTL, ~15 min)
- hermes_session_rt: the OAuth refresh token
(HttpOnly, lifetime = 30 days)
**DEPRECATED in OAuth contract v1** — Nous Portal
does not issue refresh tokens; we keep the cookie
name and clear semantics for forward compatibility
and to flush stale cookies from old browsers.
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
Nous Portal issues a rotating refresh token for the
dashboard auth-code grant (Portal NAS #293 / hermes
#37247). ``set_session_cookies`` writes this cookie
whenever the provider returns a non-empty
``refresh_token``; the middleware uses it to rotate a
fresh access token transparently on AT expiry. A
provider that omits the refresh token (empty string)
degrades gracefully to access-token-only sessions —
the RT cookie is simply not written.
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
hint (HttpOnly, lifetime = 10 minutes)

Expand Down Expand Up @@ -39,13 +44,15 @@
cookie *name* changes — a reader that looked up the bare name when the
setter wrote ``__Secure-hermes_session_at`` would never find the value.

.. deprecated:: contract v1
``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1
default) and silently skips writing the RT cookie in that case.
``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT
cookie so users carrying a stale cookie from an earlier deployment get
it cleared on logout / session expiry. The full refresh-flow machinery
was rewritten as "401 → redirect to /login" in Phase 6.
Refresh-token handling:
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
it) and silently skips writing the RT cookie in that case, so a
refresh-token-less provider degrades to access-token-only sessions.
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
cookie on logout / session expiry so a stale cookie from an earlier
deployment gets cleared. The transparent rotation flow ("expired AT +
live RT → rotate server-side, else 401 → /login") lives in
``middleware._attempt_refresh``.
"""
from __future__ import annotations

Expand All @@ -66,7 +73,13 @@
# practice — a single request emits exactly one variant).
_NAME_VARIANTS = ("__Host-", "__Secure-", "")

# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
# real authority — once the RT itself expires/rotates out, a refresh attempt
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
# lifetime to a server-side TTL that can change independently; revisit if the
# stale-cookie refresh churn ever matters.)
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60

Expand Down Expand Up @@ -126,11 +139,11 @@ def set_session_cookies(
``access_token_expires_in`` is in seconds. Use the provider's reported
TTL for the access token.

``refresh_token`` is accepted for backward / forward compatibility but
SKIPPED when empty — Nous Portal contract v1 issues no refresh tokens
so a ``Session.refresh_token == ""`` from the provider means we don't
persist anything. If a future contract revision starts emitting refresh
tokens, this helper will write the RT cookie again with no other change.
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
issues a 24h rotating refresh token (hermes #37247); a provider that
omits it returns ``Session.refresh_token == ""`` and we simply don't
persist the RT cookie — the session then behaves as access-token-only
until the AT expires. No other branch changes between the two cases.

``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
or ``""`` for a direct deploy. It influences both the cookie name
Expand Down
Loading
Loading