feat(desktop-auth): RFC 8252 native-app loopback login for gated gateways

Let the desktop app log into a gated (OAuth) gateway/dashboard via the
user's SYSTEM browser instead of the embedded BrowserWindow webview +
HttpOnly session-cookie jar, and hold the resulting tokens itself
(Authorization: Bearer) instead of relying on cookies. The desktop probes
the gateway and falls back to the existing embedded-webview cookie flow
when the gateway doesn't advertise the new capability.

Server (hermes_cli/dashboard_auth/):
- native_auth.py: in-memory loopback broker (two-phase, ws_tickets-style).
  Validates RFC 8252 loopback redirect URIs (127.0.0.0/8, ::1, localhost;
  http only), verifies desktop-side PKCE (S256), single-use one-time code.
- routes.py: POST /auth/native/start (register broker, return upstream
  authorize URL), a native branch in the existing /auth/callback (recognised
  by upstream state matching a live broker record -> 302 to the desktop's
  loopback with a one-time code, NO session cookie), POST /auth/native/token
  (redeem code+verifier -> JSON tokens), and cookieless POST /api/auth/refresh.
- middleware.py: gated_auth_middleware now accepts Authorization: Bearer,
  verified through the SAME verify_session provider stack the cookie path
  uses, so /api/auth/me, /api/auth/ws-ticket, etc. work identically whether
  the caller authenticated by cookie or bearer. New /auth/native/* +
  /api/auth/refresh added to the public allowlist.
- web_server.py: /api/status advertises native_loopback_auth (true only when
  the gate is engaged AND a non-password OAuth provider is registered).

Desktop (apps/desktop/electron/):
- connection-config.ts: pure, unit-tested helpers — nativeLoopbackSupported
  (capability detect), buildLoopbackRedirectUri, buildNativeStartBody,
  parseLoopbackCallback, resolveLoopbackCallback (state/CSRF check first).
- main.ts: runNativeLoopbackLogin drives an ephemeral 127.0.0.1 listener +
  PKCE + shell.openExternal (system browser, no BrowserWindow) + code
  redemption; the oauth-login IPC handler probes /api/status and picks the
  native flow when advertised, else falls back to openOauthLoginWindow. Native
  tokens are stored via safeStorage (in-memory only when encryption is
  unavailable, never plaintext on disk) and sent as bearers; ws-ticket mint
  prefers the bearer and refreshes via /api/auth/refresh on 401.

Tests: tests/hermes_cli/test_native_loopback_auth.py (27 tests — broker units,
full start->callback->token round trip returning JSON with no Set-Cookie,
bearer unlocks /api/auth/me + mints a ws-ticket, cookieless refresh, capability
flag) and 24 new connection-config vitest cases. Portal/upstream IDP unchanged.
This commit is contained in:
Ben Barclay 2026-07-21 07:52:31 +10:00
parent c1af3772fc
commit 27b0b7c5a0
9 changed files with 1795 additions and 12 deletions

View file

@ -19,16 +19,21 @@ import {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
buildLoopbackRedirectUri,
buildNativeStartBody,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
nativeLoopbackSupported,
normalizeRemoteBaseUrl,
normAuthMode,
parseLoopbackCallback,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveLoopbackCallback,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
@ -458,3 +463,125 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
/mintTicket function is required/
)
})
// --- nativeLoopbackSupported (RFC 8252 capability detect) ---
test('nativeLoopbackSupported reads the /api/status flag', () => {
assert.equal(nativeLoopbackSupported({ native_loopback_auth: true }), true)
assert.equal(nativeLoopbackSupported({ native_loopback_auth: false }), false)
})
test('nativeLoopbackSupported is false when the field is absent (older gateway)', () => {
// Capability detection, not version sniffing: an older gateway that predates
// the feature simply omits the field, and the desktop falls back to the
// embedded-webview cookie flow.
assert.equal(nativeLoopbackSupported({ auth_required: true }), false)
assert.equal(nativeLoopbackSupported({}), false)
assert.equal(nativeLoopbackSupported(null), false)
assert.equal(nativeLoopbackSupported(undefined), false)
})
// --- buildLoopbackRedirectUri ---
test('buildLoopbackRedirectUri binds 127.0.0.1 with the ephemeral port', () => {
assert.equal(buildLoopbackRedirectUri(54123), 'http://127.0.0.1:54123/callback')
})
test('buildLoopbackRedirectUri rejects invalid ports', () => {
assert.throws(() => buildLoopbackRedirectUri(0), /Invalid loopback port/)
assert.throws(() => buildLoopbackRedirectUri(70000), /Invalid loopback port/)
assert.throws(() => buildLoopbackRedirectUri('nope'), /Invalid loopback port/)
})
// --- buildNativeStartBody ---
test('buildNativeStartBody assembles the /auth/native/start payload', () => {
const body = buildNativeStartBody({
provider: 'nous',
port: 54123,
codeChallenge: 'chal-abc',
state: 'st-xyz'
})
assert.deepEqual(body, {
provider: 'nous',
redirect_uri: 'http://127.0.0.1:54123/callback',
code_challenge: 'chal-abc',
code_challenge_method: 'S256',
state: 'st-xyz'
})
})
// --- parseLoopbackCallback ---
test('parseLoopbackCallback extracts code + state on success', () => {
const parsed = parseLoopbackCallback('/callback?code=abc123&state=st-1')
assert.equal(parsed.code, 'abc123')
assert.equal(parsed.state, 'st-1')
assert.equal(parsed.error, '')
})
test('parseLoopbackCallback extracts error + description on failure', () => {
const parsed = parseLoopbackCallback(
'/callback?error=access_denied&error_description=nope&state=st-1'
)
assert.equal(parsed.error, 'access_denied')
assert.equal(parsed.errorDescription, 'nope')
assert.equal(parsed.state, 'st-1')
assert.equal(parsed.code, '')
})
test('parseLoopbackCallback never throws on garbage', () => {
const parsed = parseLoopbackCallback('%%%not a url%%%')
assert.equal(typeof parsed.code, 'string')
assert.equal(typeof parsed.state, 'string')
})
// --- resolveLoopbackCallback (state check + outcome) ---
test('resolveLoopbackCallback returns the code when state matches', () => {
const out = resolveLoopbackCallback(
{ code: 'abc', state: 'st-1', error: '', errorDescription: '' },
'st-1'
)
assert.deepEqual(out, { ok: true, code: 'abc' })
})
test('resolveLoopbackCallback rejects a mismatched state FIRST (CSRF)', () => {
// Even a "successful"-looking callback with a code is dropped if the state
// doesn't match what we generated — the state check must precede everything.
const out = resolveLoopbackCallback(
{ code: 'attacker-code', state: 'forged', error: '', errorDescription: '' },
'st-1'
)
assert.deepEqual(out, { ok: false, reason: 'state_mismatch' })
})
test('resolveLoopbackCallback rejects a spoofed error under a mismatched state', () => {
const out = resolveLoopbackCallback(
{ code: '', state: 'forged', error: 'access_denied', errorDescription: '' },
'st-1'
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'state_mismatch')
})
test('resolveLoopbackCallback surfaces an IDP error when state matches', () => {
const out = resolveLoopbackCallback(
{ code: '', state: 'st-1', error: 'access_denied', errorDescription: 'user said no' },
'st-1'
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'idp_error')
assert.equal(out.error, 'access_denied')
assert.equal(out.errorDescription, 'user said no')
})
test('resolveLoopbackCallback flags a state-valid but code-less callback', () => {
const out = resolveLoopbackCallback(
{ code: '', state: 'st-1', error: '', errorDescription: '' },
'st-1'
)
assert.deepEqual(out, { ok: false, reason: 'no_code' })
})

View file

@ -251,6 +251,111 @@ function authModeFromStatus(statusBody) {
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
}
/**
* True when the gateway advertises RFC 8252 native-app (system-browser +
* loopback) login on its public /api/status. The desktop uses this to choose
* the system-browser flow over the legacy embedded-webview cookie flow.
*
* Capability detection, not version sniffing: an older gateway simply omits the
* field (=> false) and the caller falls back to openOauthLoginWindow. The flag
* only lights up when the gate is engaged AND a non-password OAuth provider is
* registered (see web_server.py), so a true here means "the three /auth/native
* seams are live and there is a provider to broker."
*/
function nativeLoopbackSupported(statusBody) {
return Boolean(statusBody && statusBody.native_loopback_auth)
}
/**
* Build the loopback redirect URI the desktop's transient listener serves and
* registers with the broker. RFC 8252 §7.3: http + a loopback IP literal +
* an ephemeral port. We use 127.0.0.1 (not `localhost`) so the value never
* depends on the host's name resolution, and a fixed `/callback` path.
*/
function buildLoopbackRedirectUri(port) {
const p = Number(port)
if (!Number.isInteger(p) || p <= 0 || p > 65535) {
throw new Error(`Invalid loopback port: ${port}`)
}
return `http://127.0.0.1:${p}/callback`
}
/**
* Build the POST /auth/native/start request body from a PKCE pair + loopback
* port + provider. Pure so the flow logic is unit-testable without a live
* listener; main.ts supplies the real PKCE + port.
*/
function buildNativeStartBody({ provider, port, codeChallenge, state }) {
return {
provider: String(provider || ''),
redirect_uri: buildLoopbackRedirectUri(port),
code_challenge: String(codeChallenge || ''),
code_challenge_method: 'S256',
state: String(state || '')
}
}
/**
* Parse the query the broker's 302 lands on the loopback listener with. The
* IDP round trip returns EITHER `?code=…&state=…` (success) or
* `?error=…&state=…` (failure). Returns a normalized
* `{ code, state, error, errorDescription }` never throws.
*/
function parseLoopbackCallback(rawUrl) {
let parsed
try {
// The listener sees a path+query (no authority); give URL a base to parse.
parsed = new URL(String(rawUrl || ''), 'http://127.0.0.1')
} catch {
return { code: '', state: '', error: 'invalid_request', errorDescription: '' }
}
const q = parsed.searchParams
return {
code: q.get('code') || '',
state: q.get('state') || '',
error: q.get('error') || '',
errorDescription: q.get('error_description') || ''
}
}
/**
* Validate the loopback callback against the state we generated (RFC 8252 CSRF
* defense) and surface a normalized outcome. Returns:
* - { ok: true, code } redeem this code
* - { ok: false, reason: 'state_mismatch' } drop (possible CSRF)
* - { ok: false, reason: 'idp_error', error, errorDescription }
* - { ok: false, reason: 'no_code' } malformed callback
*
* The state check is FIRST and unconditional: an attacker who reaches the
* loopback listener must not be able to inject a foreign code or a spoofed
* error, so a mismatched state is rejected before anything else is read.
*/
function resolveLoopbackCallback(parsed, expectedState) {
if (!parsed || parsed.state !== expectedState) {
return { ok: false, reason: 'state_mismatch' }
}
if (parsed.error) {
return {
ok: false,
reason: 'idp_error',
error: parsed.error,
errorDescription: parsed.errorDescription || ''
}
}
if (!parsed.code) {
return { ok: false, reason: 'no_code' }
}
return { ok: true, code: parsed.code }
}
/**
* Resolve the effective auth mode for a coerce/save operation.
* Explicit input wins; otherwise inherit the saved value; default 'token'.
@ -333,17 +438,22 @@ export {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
buildLoopbackRedirectUri,
buildNativeStartBody,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
nativeLoopbackSupported,
normalizeRemoteBaseUrl,
normAuthMode,
parseLoopbackCallback,
pathWithGlobalRemoteProfile,
PRIVY_SESSION_COOKIE_VARIANTS,
profileRemoteOverride,
resolveAuthMode,
resolveLoopbackCallback,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview

View file

@ -42,16 +42,20 @@ import {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
buildNativeStartBody,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
nativeLoopbackSupported,
normalizeRemoteBaseUrl,
normAuthMode,
parseLoopbackCallback,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveLoopbackCallback,
resolveTestWsUrl,
tokenPreview
} from './connection-config'
@ -5630,14 +5634,76 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
})
}
// JSON request authenticated with a native (RFC 8252) bearer access token
// instead of the OAuth session cookie. Attaches ``Authorization: Bearer`` and,
// on a 401, transparently rotates the stored refresh token via
// ``POST /api/auth/refresh`` (cookieless) and retries ONCE — mirroring the
// gateway middleware's server-side cookie refresh, but desktop-driven because
// the desktop, not a cookie jar, holds the tokens. Throws with statusCode 401
// only when the refresh also fails, which callers treat as "needs re-login".
async function fetchJsonViaNativeBearer(baseUrl, path, session, options: any = {}) {
const url = `${baseUrl}${path}`
const attempt = accessToken =>
fetchPublicJson(url, {
...options,
headers: { ...(options.headers || {}), Authorization: `Bearer ${accessToken}` }
})
try {
return await attempt(session.accessToken)
} catch (error: any) {
const status = parseInt(String(error?.message || '').split(':')[0], 10)
if (status !== 401 || !session.refreshToken) {
throw error
}
}
// Access token rejected — rotate via the cookieless refresh endpoint.
let rotated: any
try {
rotated = await fetchPublicJson(`${baseUrl}/api/auth/refresh`, {
method: 'POST',
timeoutMs: 8_000,
body: { refresh_token: session.refreshToken, provider: session.provider || '' }
})
} catch (refreshErr: any) {
const err = new Error(
'Your session has expired. Open Settings → Gateway and sign in again.'
) as any
err.statusCode = 401
err.needsOauthLogin = true
err.cause = refreshErr
throw err
}
// Persist the rotated tokens (RT rotation is mandatory — a stale RT would be
// reuse-detected and revoke the whole session on the next refresh).
persistNativeSession(baseUrl, rotated)
return attempt(rotated.access_token)
}
// Mint a single-use WS ticket for a gated gateway. Returns the ticket string.
// Throws (with statusCode 401) if the session cookie is missing/expired —
// callers treat that as "needs re-login".
// Throws (with statusCode 401) if the session is missing/expired — callers
// treat that as "needs re-login".
//
// Prefers a native (RFC 8252) bearer session when one exists for this gateway
// (the desktop holds the tokens itself); otherwise falls back to the OAuth
// session-cookie partition. Both mint the SAME ticket server-side — the gateway
// accepts either a verified cookie session or a verified bearer.
async function mintGatewayWsTicket(baseUrl) {
const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
method: 'POST',
timeoutMs: 8_000
})) as any
const native = getNativeSession(baseUrl)
const body = native
? ((await fetchJsonViaNativeBearer(baseUrl, '/api/auth/ws-ticket', native, {
method: 'POST',
timeoutMs: 8_000
})) as any)
: ((await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
method: 'POST',
timeoutMs: 8_000
})) as any)
const ticket = body?.ticket
@ -5953,6 +6019,175 @@ function trimCloudAgents(body) {
}))
}
// ---------------------------------------------------------------------------
// RFC 8252 native-app (system browser + loopback) gateway login.
//
// The system-browser flow that REPLACES the embedded-webview cookie flow when
// the gateway advertises it (``/api/status`` ``native_loopback_auth: true``).
// Instead of driving the OAuth round trip inside a BrowserWindow and scraping
// the HttpOnly cookie out of a session partition, we:
//
// 1. bind a transient loopback listener on 127.0.0.1:<ephemeral>,
// 2. generate a desktop-side PKCE pair + state,
// 3. POST /auth/native/start (public) to register the broker + get the
// upstream authorize URL,
// 4. open that URL in the user's REAL browser (shell.openExternal),
// 5. capture the broker's 302 back to the loopback listener (?code&state),
// 6. POST /auth/native/token to redeem the code for JSON tokens.
//
// The returned tokens are held by the desktop (encrypted via safeStorage by the
// caller) and sent as ``Authorization: Bearer`` — no cookie jar, no webview.
// Pure URL/PKCE/callback logic lives in connection-config.ts (unit-tested);
// this function owns only the electron-coupled I/O (listener, openExternal).
// ---------------------------------------------------------------------------
// How long to wait for the user to complete sign-in in their browser before
// giving up and tearing down the listener. Matches the broker record TTL
// (native_auth.BROKER_TTL_SECONDS = 10 min) so the two sides expire together.
const NATIVE_LOGIN_TIMEOUT_MS = 10 * 60 * 1000
function _b64urlNoPad(buf) {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// Generate an RFC 7636 S256 PKCE pair + a CSRF state, all from crypto rng.
function generateNativePkce() {
const verifier = _b64urlNoPad(crypto.randomBytes(64))
const challenge = _b64urlNoPad(crypto.createHash('sha256').update(verifier).digest())
const state = _b64urlNoPad(crypto.randomBytes(24))
return { verifier, challenge, state }
}
// Run the full native loopback login against ``baseUrl`` for ``provider``.
// Resolves with the token JSON /auth/native/token returned; rejects on user
// cancel (browser closed / timeout), state mismatch, or an IDP error.
//
// ``deps`` is injected so this is exercisable without real electron/network in
// integration tests: { fetchPublicJson, openExternal, createServer }.
function runNativeLoopbackLogin(baseUrl, provider, deps: any = {}) {
const doFetch = deps.fetchPublicJson || fetchPublicJson
const openUrl = deps.openExternal || (url => shell.openExternal(url))
const makeServer = deps.createServer || http.createServer
return new Promise((resolve, reject) => {
const { verifier, challenge, state } = generateNativePkce()
let settled = false
let server: any = null
let timeoutTimer: any = null
let redirectUri = ''
const cleanup = () => {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
try {
if (server) {
server.close()
}
} catch {
// listener already torn down
}
}
const finish = (err, value?) => {
if (settled) {
return
}
settled = true
cleanup()
if (err) {
reject(err)
} else {
resolve(value)
}
}
// A tiny loopback listener. It answers exactly one meaningful request — the
// broker's 302 to /callback?code&state — then hands control back. Any other
// path gets a 404 so a stray probe can't be mistaken for the callback.
server = makeServer((req, res) => {
const parsed = parseLoopbackCallback(req.url || '')
const outcome = resolveLoopbackCallback(parsed, state)
const reply = (status, message) => {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(
`<!doctype html><meta charset="utf-8"><title>Hermes</title>` +
`<body style="font:14px system-ui;padding:2rem;color:#111">${message}</body>`
)
}
if (!outcome.ok && outcome.reason === 'state_mismatch') {
// Could be a stray request that isn't our callback at all — don't tear
// the flow down, just 404 and keep waiting for the real one.
reply(404, 'Not found.')
return
}
if (!outcome.ok) {
reply(400, 'Sign-in failed. You can close this tab and return to Hermes.')
const detail =
outcome.reason === 'idp_error'
? `${outcome.error}${outcome.errorDescription ? `: ${outcome.errorDescription}` : ''}`
: 'no authorization code returned'
finish(new Error(`Native login failed (${detail})`))
return
}
reply(200, 'Signed in. You can close this tab and return to Hermes.')
// Redeem the single-use code + our PKCE verifier for JSON tokens.
doFetch(`${baseUrl}/auth/native/token`, {
method: 'POST',
timeoutMs: 30_000,
body: { code: outcome.code, code_verifier: verifier, redirect_uri: redirectUri }
})
.then(tokens => finish(null, tokens))
.catch(err => finish(err instanceof Error ? err : new Error(String(err))))
})
server.on('error', err => finish(err instanceof Error ? err : new Error(String(err))))
// Bind an ephemeral port on the loopback interface ONLY (never 0.0.0.0).
server.listen(0, '127.0.0.1', async () => {
try {
const port = server.address().port
redirectUri = `http://127.0.0.1:${port}/callback`
const startBody = buildNativeStartBody({ provider, port, codeChallenge: challenge, state })
const started: any = await doFetch(`${baseUrl}/auth/native/start`, {
method: 'POST',
timeoutMs: 30_000,
body: startBody
})
const authorizationUrl = started && started.authorization_url
if (!authorizationUrl) {
finish(new Error('Gateway did not return an authorization URL for native login.'))
return
}
// Arm the overall timeout only once we're actually waiting on the user.
timeoutTimer = setTimeout(
() => finish(new Error('Native login timed out. Please try signing in again.')),
NATIVE_LOGIN_TIMEOUT_MS
)
await openUrl(authorizationUrl)
} catch (err) {
finish(err instanceof Error ? err : new Error(String(err)))
}
})
})
}
// Silent per-agent sign-in: open the selected agent dashboard's /login in the
// SAME OAuth partition. Because the user already holds a live portal session
// there, the agent's /oauth/authorize auto-approves (org member) and 302s back,
@ -6005,6 +6240,116 @@ function decryptDesktopSecret(secret) {
return value
}
// ---------------------------------------------------------------------------
// Native-app (RFC 8252) session token store.
//
// Tokens obtained via runNativeLoopbackLogin are held BY THE DESKTOP (not in a
// cookie jar) and sent as ``Authorization: Bearer``. We persist them encrypted
// via safeStorage (the OS keychain-backed encryption already used for the
// remote session token) keyed by the gateway base URL, so a restart doesn't
// force a re-login. Kept in a dedicated file so it never entangles with the
// connection.json config schema.
//
// Security note: safeStorage encryption is only real when the OS provides a
// backend (Keychain on macOS, DPAPI on Windows, gnome-keyring/kwallet on
// Linux). When it isn't available we DO NOT persist the tokens to disk in
// plaintext — they live only in memory for the session and the user re-logs in
// next launch. A long-lived bearer token in a plaintext file would be a
// downgrade from the HttpOnly-cookie flow it replaces.
const DESKTOP_NATIVE_SESSION_PATH = path.join(app.getPath('userData'), 'native-session.json')
// In-memory cache of decrypted native sessions, keyed by normalized base URL.
const _nativeSessions = new Map<string, any>()
let _nativeSessionsLoaded = false
function _loadNativeSessions() {
if (_nativeSessionsLoaded) {
return
}
_nativeSessionsLoaded = true
try {
const raw = fs.readFileSync(DESKTOP_NATIVE_SESSION_PATH, 'utf8')
const parsed = JSON.parse(raw)
for (const [key, secret] of Object.entries(parsed?.sessions || {})) {
const json = decryptDesktopSecret(secret)
if (json) {
try {
_nativeSessions.set(key, JSON.parse(json))
} catch {
// corrupt entry — skip
}
}
}
} catch {
// No file yet / unreadable — start empty.
}
}
function _writeNativeSessions() {
// Only persist when encryption is genuinely available (see security note).
if (!safeStorage.isEncryptionAvailable()) {
return
}
const sessions: Record<string, any> = {}
for (const [key, value] of _nativeSessions.entries()) {
sessions[key] = encryptDesktopSecret(JSON.stringify(value))
}
try {
fs.mkdirSync(path.dirname(DESKTOP_NATIVE_SESSION_PATH), { recursive: true })
fs.writeFileSync(
DESKTOP_NATIVE_SESSION_PATH,
JSON.stringify({ version: 1, sessions }, null, 2),
{ mode: 0o600 }
)
} catch {
// Best-effort persistence; the in-memory copy still authenticates this run.
}
}
// Store the tokens the broker returned for ``baseUrl``. Persists to the
// encrypted store when safeStorage is available; always caches in memory.
function persistNativeSession(baseUrl, tokens) {
if (!tokens || !tokens.access_token) {
return
}
_loadNativeSessions()
_nativeSessions.set(baseUrl, {
accessToken: String(tokens.access_token),
refreshToken: String(tokens.refresh_token || ''),
expiresAt: Number(tokens.expires_at || 0),
provider: String(tokens.provider || ''),
userId: String(tokens.user_id || '')
})
_writeNativeSessions()
}
// Return the stored native session for ``baseUrl``, or null.
function getNativeSession(baseUrl) {
_loadNativeSessions()
return _nativeSessions.get(baseUrl) || null
}
// True when we hold ANY native session credential for ``baseUrl`` (access or
// refresh token). Like cookiesHaveLiveSession, this answers "is the user signed
// in at all?" — an expired access token with a live refresh token is still a
// connectable session (the desktop rotates via POST /api/auth/refresh).
function hasNativeSession(baseUrl) {
const s = getNativeSession(baseUrl)
return Boolean(s && (s.accessToken || s.refreshToken))
}
// Drop the stored native session for ``baseUrl`` (logout).
function clearNativeSession(baseUrl) {
_loadNativeSessions()
if (baseUrl) {
_nativeSessions.delete(baseUrl)
} else {
_nativeSessions.clear()
}
_writeNativeSessions()
}
// Validate + normalize the per-profile remote overrides map read from disk.
// Drops malformed names/entries and keeps only the recognized fields so a
// hand-edited or stale connection.json can't inject junk into resolution.
@ -8017,11 +8362,51 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
// Open the gateway's OAuth login window and wait for the session cookie to
// land in the OAuth partition. The caller (settings UI) typically saves the
// remote config with authMode='oauth' first, then calls this. We normalize
// the URL defensively so a login can be driven from a raw URL too.
// Open the gateway's OAuth login and wait until we hold a live session. The
// caller (settings UI) typically saves the remote config with
// authMode='oauth' first, then calls this. We normalize the URL defensively
// so a login can be driven from a raw URL too.
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
// Capability-gated flow selection (RFC 8252). Probe the gateway's public
// /api/status: a gateway that advertises ``native_loopback_auth`` supports
// the system-browser + loopback flow, which needs no embedded webview and no
// cookie jar. An older gateway omits the flag, so we fall back to the
// embedded-webview cookie flow unchanged. Capability detection, not version
// sniffing — see connection-config.nativeLoopbackSupported.
let status: any = null
try {
status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 })
} catch {
// Unreachable/parse failure → treat as no native support and let the
// webview flow surface the real connection error interactively.
status = null
}
if (nativeLoopbackSupported(status)) {
// Pick the interactive OAuth provider to broker. The status probe already
// knows the gate is engaged with ≥1 non-password provider; read the name
// from /api/auth/providers (first OAuth-redirect provider).
let provider = ''
try {
const body: any = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })
const first = (Array.isArray(body?.providers) ? body.providers : []).find(
p => p && typeof p === 'object' && p.name && !p.supports_password
)
provider = first ? String(first.name) : ''
} catch {
provider = ''
}
if (provider) {
const tokens: any = await runNativeLoopbackLogin(baseUrl, provider)
persistNativeSession(baseUrl, tokens)
return { ok: true, baseUrl, connected: hasNativeSession(baseUrl) }
}
// No brokerable provider name resolved — fall through to the webview flow.
}
await openOauthLoginWindow(baseUrl)
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
@ -8029,11 +8414,15 @@ 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)
// Also drop any native (RFC 8252) bearer session we hold for this gateway,
// so a logout signs the user out of BOTH transports regardless of which flow
// established the session.
clearNativeSession(baseUrl || undefined)
// 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 }
return { ok: true, connected: baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false }
})
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---

View file

@ -49,6 +49,10 @@ class AuditEvent(enum.Enum):
WS_TICKET_REJECTED = "ws_ticket_rejected"
TOKEN_AUTH_SUCCESS = "token_auth_success"
TOKEN_AUTH_FAILURE = "token_auth_failure"
# RFC 8252 native-app loopback login (desktop system-browser flow).
NATIVE_LOGIN_START = "native_login_start"
NATIVE_LOGIN_SUCCESS = "native_login_success"
NATIVE_LOGIN_FAILURE = "native_login_failure"
def _resolve_log_path() -> Path:

View file

@ -51,6 +51,9 @@ _GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
"/auth/callback",
"/auth/password-login",
"/auth/logout",
"/auth/native/start",
"/auth/native/token",
"/api/auth/refresh",
"/login",
"/api/auth/providers",
"/api/mcp/oauth/callback/",
@ -275,6 +278,54 @@ def _safe_next_target(request: Request) -> str:
return quote(target, safe="")
def _extract_bearer(request: Request) -> str:
"""Return the ``Authorization: Bearer`` token, or "" when absent/malformed.
Accepts ``<scheme> <token>`` where scheme is "bearer" (case-insensitive).
Mirrors ``token_auth.extract_bearer_token`` kept local so the gate has no
import dependency on the token-auth seam (a different, service-caller path).
"""
auth = request.headers.get("authorization", "")
parts = auth.split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def _verify_bearer(
access_token: str, *, provider_hint: str | None = None
):
"""Verify a native-app bearer access token through the session-provider stack.
Returns ``(session, unreachable_provider_name)``:
* ``(Session, None)`` a provider recognised and accepted the token.
* ``(None, None)`` no provider recognised it (reject 401).
* ``(None, name)`` no provider accepted it AND at least one provider's
IDP/JWKS was unreachable (the caller surfaces 503, not 401, so a
transient outage doesn't read as "expired session").
This is the bearer analogue of the cookie path's verify loop: it runs the
IDENTICAL ``verify_session`` stack with the identical stacking/unreachable
semantics, so a token minted for the cookie flow verifies here unchanged.
Never raises a provider ``ProviderError`` is caught and remembered.
"""
unreachable_provider: str | None = None
for provider in _ordered_session_providers(provider_hint):
try:
session = provider.verify_session(access_token=access_token)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during bearer verify: %s",
provider.name, e,
)
if unreachable_provider is None:
unreachable_provider = provider.name
continue
if session is not None:
return session, None
return None, unreachable_provider
async def gated_auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
@ -298,6 +349,35 @@ async def gated_auth_middleware(
if _path_is_public(path):
return await call_next(request)
# RFC 8252 native-app bearer auth. The desktop app holds its access token
# itself (obtained via the loopback broker) and presents it as
# ``Authorization: Bearer`` instead of a session cookie. Verify it
# through the SAME provider ``verify_session`` stack the cookie path uses,
# then attach the session and pass through — so every gated route
# (``/api/auth/me``, ``/api/auth/ws-ticket``, …) works identically whether
# the caller authenticated by cookie or by bearer. Only consulted when a
# bearer header is actually present, so the cookie flow is untouched. A
# present-but-invalid bearer yields 401 (no cookie fallback for an explicit
# bearer caller); the desktop rotates via ``/api/auth/refresh`` and retries.
bearer = _extract_bearer(request)
if bearer:
bearer_session, unreachable = _verify_bearer(
bearer, provider_hint=read_session_provider(request)
)
if bearer_session is not None:
request.state.session = bearer_session
return await call_next(request)
if unreachable is not None:
return JSONResponse(
{"detail": f"Auth provider {unreachable!r} unreachable"},
status_code=503,
)
return JSONResponse(
{"error": "session_expired", "detail": "Unauthorized",
"reason": "invalid_or_expired_bearer"},
status_code=401,
)
at, _rt = read_session_cookies(request)
provider_hint = read_session_provider(request)
if not at and not _rt:

View file

@ -0,0 +1,277 @@
"""Native-app (RFC 8252) loopback+PKCE login broker for the desktop app.
The desktop app authenticates to a **gated** gateway using the user's *system
browser* instead of an embedded webview, and holds the resulting tokens itself
(sending them as ``Authorization: Bearer``) instead of relying on the HttpOnly
session-cookie jar. This module is the gateway-side broker that makes that
possible without changing the gateway's own upstream IDP contract.
Two PKCE contexts are in play keep them distinct:
* **gateway IDP** the ordinary upstream PKCE the provider's ``start_login``
generates. In the cookie flow this verifier rides in the ``hermes_session_pkce``
browser cookie. In the native flow the *system browser* has no such cookie
(the desktop called ``/auth/native/start`` over its OWN HTTP client), so the
broker stores the upstream verifier **server-side**, keyed by the upstream
``state`` value the IDP echoes back on the callback.
* **desktop gateway** a SECOND PKCE pair the desktop generates and keeps to
itself. Its challenge is registered at ``start`` and verified at ``token``
redemption, exactly as RFC 8252 prescribes for the loopback code exchange.
Flow (see ``.hermes/plans/2026-07-20-desktop-rfc8252-loopback-auth.md``):
1. **start** (``POST /auth/native/start``, desktop gateway) desktop sends its
loopback ``redirect_uri``, its PKCE ``code_challenge``, and its ``state``. The
gateway runs the provider's ordinary ``start_login`` against the gateway's own
``/auth/callback``, extracts the upstream ``state`` + ``verifier`` from the
returned cookie payload, and stores a broker record keyed by the upstream
``state``. Returns the upstream ``authorization_url``; the desktop opens it in
the system browser.
2. **callback** the IDP redirects the *system browser* to the gateway's existing
``/auth/callback`` (no cookie present). The callback looks up a broker record
by the echoed ``state``; if found it completes the upstream login with the
server-stored verifier, attaches the :class:`Session` via
:func:`complete_broker`, and 302s the browser to the desktop's loopback
``redirect_uri`` carrying a single-use ``code`` + the desktop's ``state``.
3. **token** (``POST /auth/native/token``, desktop gateway) desktop redeems the
single-use ``code`` + its PKCE ``code_verifier``; the broker verifies the
verifier against the challenge from leg 1 and returns the session tokens as
JSON. No cookies set.
The returned tokens are the provider-issued ``access_token`` / ``refresh_token``
the cookie flow would have stored, so a subsequent
``Authorization: Bearer <access_token>`` request verifies through the same
``verify_session`` provider stack (see ``middleware.gated_auth_middleware``).
In-memory single-process store, same shape as ``ws_tickets.py``. Time is read via
``time.time`` so tests can monkeypatch it.
"""
from __future__ import annotations
import base64
import hashlib
import ipaddress
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Dict, Optional
from urllib.parse import urlparse
from hermes_cli.dashboard_auth.base import Session
# The broker record lives for the whole interactive login window: the user may
# take a while at the IDP consent screen. 10 minutes matches the PKCE cookie TTL.
BROKER_TTL_SECONDS = 10 * 60
# The redeemable one-time code is short-lived and single-use: the desktop
# redeems it the instant its loopback listener fires.
CODE_TTL_SECONDS = 60
class BrokerError(Exception):
"""A broker record / code was missing, expired, already used, or mismatched.
Carries an OAuth-style ``error`` code so the route can surface a stable
machine-readable envelope (``invalid_grant`` / ``invalid_request`` / ...).
"""
def __init__(self, message: str, *, error: str = "invalid_request") -> None:
super().__init__(message)
self.error = error
@dataclass
class _BrokerRecord:
# ---- gateway ↔ IDP (server-held upstream PKCE) ----
provider: str
upstream_verifier: str
# ---- desktop ↔ gateway (RFC 8252 loopback exchange) ----
code_challenge: str # desktop's PKCE challenge, verified at redeem
code_challenge_method: str
redirect_uri: str # desktop's loopback URL the callback 302s to
desktop_state: str # echoed back to the desktop on that 302
# ---- lifecycle ----
expires_at: int
session: Optional[Session] = None # set by complete_broker
code: Optional[str] = None # single-use redemption code
code_expires_at: int = 0
_lock = threading.Lock()
# Keyed by the UPSTREAM ``state`` (what the IDP echoes on the callback).
_records: Dict[str, _BrokerRecord] = {}
# Reverse index: one-time redemption code -> upstream_state.
_code_index: Dict[str, str] = {}
def _b64url_no_pad(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def is_loopback_redirect_uri(redirect_uri: str) -> bool:
"""True iff ``redirect_uri`` is an ``http`` loopback URL (RFC 8252 §7.3).
Only ``127.0.0.0/8``, ``::1``, and the literal ``localhost`` are accepted,
only over ``http``, and a path must be present. Anything else a public
host, ``https``, a custom scheme, a bare authority is rejected so the
broker can never be aimed at an attacker-controlled redirect.
"""
try:
parsed = urlparse(redirect_uri)
except (ValueError, TypeError):
return False
if parsed.scheme != "http":
return False
host = (parsed.hostname or "").strip()
if not host or not parsed.path:
return False
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _gc_expired_locked(now: int) -> None:
"""Drop expired broker records + their code-index entries. Holds ``_lock``."""
dead = [state for state, rec in _records.items() if rec.expires_at < now]
for state in dead:
rec = _records.pop(state, None)
if rec and rec.code:
_code_index.pop(rec.code, None)
def start_broker(
*,
upstream_state: str,
upstream_verifier: str,
provider: str,
code_challenge: str,
code_challenge_method: str,
redirect_uri: str,
desktop_state: str,
) -> None:
"""Register a pending native login, keyed by the upstream ``state``.
``upstream_state`` / ``upstream_verifier`` come from the provider's
``start_login`` (the gatewayIDP PKCE). ``code_challenge`` is the desktop's
own PKCE challenge (S256 only), verified at :func:`redeem_code`.
"""
if code_challenge_method != "S256":
raise BrokerError(
f"unsupported code_challenge_method: {code_challenge_method!r}",
error="invalid_request",
)
if not code_challenge:
raise BrokerError("code_challenge required", error="invalid_request")
if not is_loopback_redirect_uri(redirect_uri):
raise BrokerError(
"redirect_uri must be an http loopback URL", error="invalid_request"
)
if not desktop_state:
raise BrokerError("state required", error="invalid_request")
if not upstream_state:
raise BrokerError("internal: missing upstream state", error="server_error")
now = int(time.time())
with _lock:
_gc_expired_locked(now)
_records[upstream_state] = _BrokerRecord(
provider=provider,
upstream_verifier=upstream_verifier,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
redirect_uri=redirect_uri,
desktop_state=desktop_state,
expires_at=now + BROKER_TTL_SECONDS,
)
def get_broker(upstream_state: str) -> Optional[_BrokerRecord]:
"""Return a live broker record for an upstream ``state``, or None.
The ``/auth/callback`` handler calls this to decide whether an incoming
callback belongs to a native (loopback) login vs. the ordinary cookie flow.
Expired records are evicted on read.
"""
if not upstream_state:
return None
now = int(time.time())
with _lock:
rec = _records.get(upstream_state)
if rec is None:
return None
if rec.expires_at < now:
_records.pop(upstream_state, None)
if rec.code:
_code_index.pop(rec.code, None)
return None
return rec
def complete_broker(upstream_state: str, session: Session) -> str:
"""Attach the minted ``session`` to a broker record; return a one-time code.
Called by ``/auth/callback`` when the echoed ``state`` matches a broker
record. The returned single-use ``code`` is what the browser carries to the
desktop's loopback listener.
"""
now = int(time.time())
code = secrets.token_urlsafe(32)
with _lock:
rec = _records.get(upstream_state)
if rec is None or rec.expires_at < now:
raise BrokerError("broker session expired", error="invalid_grant")
if rec.code: # invalidate any prior code (defensive)
_code_index.pop(rec.code, None)
rec.session = session
rec.code = code
rec.code_expires_at = now + CODE_TTL_SECONDS
_code_index[code] = upstream_state
return code
def redeem_code(*, code: str, code_verifier: str, redirect_uri: str) -> Session:
"""Verify the desktop's PKCE + redirect_uri and return the session tokens.
Single-use: the code (and its broker record) are removed on success AND on a
PKCE/redirect mismatch, so a stolen code can't be brute-forced. Raises
:class:`BrokerError` (``invalid_grant``) on any failure.
"""
now = int(time.time())
with _lock:
upstream_state = _code_index.pop(code, None)
rec = _records.get(upstream_state) if upstream_state else None
# Whatever happens next, this code is spent — pop the record too so a
# failed attempt can't be retried with a guessed verifier.
if upstream_state is not None:
_records.pop(upstream_state, None)
if rec is None or rec.code != code:
raise BrokerError("unknown or used code", error="invalid_grant")
if rec.code_expires_at < now:
raise BrokerError("code expired", error="invalid_grant")
if rec.session is None:
raise BrokerError("login not completed", error="invalid_grant")
if redirect_uri != rec.redirect_uri:
raise BrokerError("redirect_uri mismatch", error="invalid_grant")
if not code_verifier:
raise BrokerError("code_verifier required", error="invalid_grant")
# PKCE S256: BASE64URL(SHA256(verifier)) must equal the stored challenge.
computed = _b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest())
if not secrets.compare_digest(computed, rec.code_challenge):
raise BrokerError("PKCE verification failed", error="invalid_grant")
return rec.session
def _reset_for_tests() -> None:
"""Test-only: drop all broker records + code index."""
with _lock:
_records.clear()
_code_index.clear()

View file

@ -35,6 +35,7 @@ from hermes_cli.dashboard_auth.base import (
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
)
from hermes_cli.dashboard_auth.cookies import (
clear_pkce_cookie,
@ -253,6 +254,21 @@ async def auth_callback(
error: str = "",
error_description: str = "",
):
# RFC 8252 native-app (desktop loopback) branch. The system browser reaches
# this callback with NO PKCE cookie (the desktop drove /auth/native/start
# over its own HTTP client), so a native login is recognised purely by the
# echoed upstream ``state`` matching a live broker record. When it does, the
# broker owns the exchange: complete the upstream login with the
# server-stored verifier and 302 the browser to the desktop's loopback
# listener carrying a single-use code — never setting a session cookie.
if state:
native_resp = await _maybe_handle_native_callback(
request, code=code, state=state, error=error,
error_description=error_description,
)
if native_resp is not None:
return native_resp
pkce_raw = read_pkce_cookie(request)
if not pkce_raw:
audit_log(
@ -374,6 +390,310 @@ async def auth_callback(
return resp
# ---------------------------------------------------------------------------
# Public: RFC 8252 native-app (desktop loopback + PKCE) login broker
# ---------------------------------------------------------------------------
#
# These three seams let the desktop app authenticate via the user's SYSTEM
# browser and hold the resulting tokens itself (Authorization: Bearer) instead
# of the embedded-webview + HttpOnly-cookie flow. The gateway brokers to its
# existing upstream IDP unchanged; see ``native_auth`` for the full contract.
def _parse_pkce_payload(payload: str) -> Dict[str, str]:
"""Parse a provider ``hermes_session_pkce`` payload into a flat dict.
Shape is ``key=value;key=value`` (e.g. ``state=;verifier=``). Mirrors the
callback's own parse so the native ``start`` extracts the SAME upstream
``state`` + ``verifier`` the cookie flow would have stashed.
"""
return dict(
seg.split("=", 1) for seg in payload.split(";") if "=" in seg
)
class _NativeStartBody(BaseModel):
provider: str
redirect_uri: str
code_challenge: str
code_challenge_method: str = "S256"
state: str
class _NativeTokenBody(BaseModel):
code: str
code_verifier: str
redirect_uri: str
class _RefreshBody(BaseModel):
refresh_token: str
provider: str = ""
def _native_error(error: str, detail: str, status_code: int = 400) -> JSONResponse:
"""OAuth-shaped error envelope for the native endpoints."""
return JSONResponse({"error": error, "detail": detail}, status_code=status_code)
@router.post("/auth/native/start", name="auth_native_start")
async def auth_native_start(request: Request, body: _NativeStartBody):
"""Begin an RFC 8252 loopback login; return the upstream authorize URL.
The desktop supplies its loopback ``redirect_uri`` + its OWN PKCE
``code_challenge`` + ``state``. We run the provider's ordinary
``start_login`` (gatewayIDP PKCE) against the gateway's own
``/auth/callback``, register a broker record keyed by the upstream
``state``, and hand back the upstream ``authorization_url`` for the desktop
to open in the system browser.
"""
from hermes_cli.dashboard_auth import native_auth
p = get_provider(body.provider)
if p is None or not getattr(p, "supports_session", True):
return _native_error(
"invalid_request", f"Unknown provider: {body.provider!r}", 404
)
if getattr(p, "supports_password", False):
# Password providers have no browser redirect to broker.
return _native_error(
"invalid_request",
f"Provider does not support browser login: {body.provider!r}",
400,
)
if not native_auth.is_loopback_redirect_uri(body.redirect_uri):
return _native_error(
"invalid_request", "redirect_uri must be an http loopback URL"
)
try:
ls = p.start_login(redirect_uri=_redirect_uri(request))
except ProviderError as e:
audit_log(
AuditEvent.NATIVE_LOGIN_FAILURE,
provider=body.provider,
reason="provider_unreachable",
ip=_client_ip(request),
)
return _native_error("server_error", f"Provider unreachable: {e}", 503)
pkce = _parse_pkce_payload(
ls.cookie_payload.get("hermes_session_pkce", "")
)
upstream_state = pkce.get("state", "")
upstream_verifier = pkce.get("verifier", "")
if not upstream_state:
# A provider that doesn't use the state/verifier cookie shape can't be
# brokered by state-matching; fail loudly rather than silently.
return _native_error(
"server_error",
"Provider is not compatible with native loopback login",
400,
)
try:
native_auth.start_broker(
upstream_state=upstream_state,
upstream_verifier=upstream_verifier,
provider=body.provider,
code_challenge=body.code_challenge,
code_challenge_method=body.code_challenge_method,
redirect_uri=body.redirect_uri,
desktop_state=body.state,
)
except native_auth.BrokerError as e:
return _native_error(e.error, str(e), 400)
audit_log(
AuditEvent.NATIVE_LOGIN_START,
provider=body.provider,
ip=_client_ip(request),
)
return JSONResponse({"authorization_url": ls.redirect_url})
async def _maybe_handle_native_callback(
request: Request,
*,
code: str,
state: str,
error: str,
error_description: str,
):
"""Handle ``/auth/callback`` for a native login, or return None.
Returns None when ``state`` matches no live broker record (the ordinary
cookie flow then proceeds). Otherwise completes the upstream login with the
server-stored verifier and 302s the browser to the desktop's loopback
``redirect_uri`` with a single-use ``code``.
"""
from urllib.parse import urlencode
from hermes_cli.dashboard_auth import native_auth
rec = native_auth.get_broker(state)
if rec is None:
return None
def _to_desktop(params: dict) -> RedirectResponse:
sep = "&" if "?" in rec.redirect_uri else "?"
return RedirectResponse(
url=f"{rec.redirect_uri}{sep}{urlencode(params)}", status_code=302
)
if error:
audit_log(
AuditEvent.NATIVE_LOGIN_FAILURE,
provider=rec.provider,
reason="idp_error",
error=error,
ip=_client_ip(request),
)
return _to_desktop(
{"error": error, "error_description": error_description,
"state": rec.desktop_state}
)
p = get_provider(rec.provider)
if p is None:
return _to_desktop(
{"error": "invalid_request", "state": rec.desktop_state}
)
try:
session = p.complete_login(
code=code,
state=state,
code_verifier=rec.upstream_verifier,
redirect_uri=_redirect_uri(request),
)
except InvalidCodeError:
audit_log(
AuditEvent.NATIVE_LOGIN_FAILURE,
provider=rec.provider,
reason="invalid_code",
ip=_client_ip(request),
)
return _to_desktop(
{"error": "invalid_grant", "state": rec.desktop_state}
)
except ProviderError:
audit_log(
AuditEvent.NATIVE_LOGIN_FAILURE,
provider=rec.provider,
reason="provider_unreachable",
ip=_client_ip(request),
)
return _to_desktop(
{"error": "server_error", "state": rec.desktop_state}
)
one_time_code = native_auth.complete_broker(state, session)
audit_log(
AuditEvent.NATIVE_LOGIN_SUCCESS,
provider=rec.provider,
user_id=session.user_id,
email=session.email,
org_id=session.org_id,
ip=_client_ip(request),
)
return _to_desktop({"code": one_time_code, "state": rec.desktop_state})
@router.post("/auth/native/token", name="auth_native_token")
async def auth_native_token(request: Request, body: _NativeTokenBody):
"""Redeem a single-use loopback ``code`` + PKCE verifier for session tokens.
Returns the provider-issued tokens as JSON (NO cookies). The desktop stores
them and sends ``Authorization: Bearer`` on subsequent
requests; ``expires_at`` lets it schedule a refresh.
"""
from hermes_cli.dashboard_auth import native_auth
try:
session = native_auth.redeem_code(
code=body.code,
code_verifier=body.code_verifier,
redirect_uri=body.redirect_uri,
)
except native_auth.BrokerError as e:
audit_log(
AuditEvent.NATIVE_LOGIN_FAILURE,
reason="token_redeem_failed",
error=e.error,
ip=_client_ip(request),
)
return _native_error(e.error, str(e), 400)
return JSONResponse(
{
"access_token": session.access_token,
"refresh_token": session.refresh_token,
"token_type": "Bearer",
"expires_at": session.expires_at,
"user_id": session.user_id,
"email": session.email,
"display_name": session.display_name,
"org_id": session.org_id,
"provider": session.provider,
}
)
@router.post("/api/auth/refresh", name="auth_native_refresh")
async def auth_native_refresh(request: Request, body: _RefreshBody):
"""Rotate a native session's tokens without a cookie.
Runs the same ``refresh_session`` provider stack the cookie middleware uses.
The ``provider`` hint only reorders candidates (an opaque foreign refresh
token is indistinguishable from an expired one), mirroring
``middleware._attempt_refresh``. Returns rotated tokens as JSON, or 401 when
every provider rejects the token.
"""
providers = list_session_providers()
if body.provider:
providers = sorted(providers, key=lambda pr: pr.name != body.provider)
unavailable = None
for provider in providers:
try:
session = provider.refresh_session(refresh_token=body.refresh_token)
except RefreshExpiredError:
continue
except ProviderError as e:
unavailable = str(e)
continue
audit_log(
AuditEvent.REFRESH_SUCCESS,
provider=session.provider,
user_id=session.user_id,
ip=_client_ip(request),
)
return JSONResponse(
{
"access_token": session.access_token,
"refresh_token": session.refresh_token,
"token_type": "Bearer",
"expires_at": session.expires_at,
"user_id": session.user_id,
"email": session.email,
"display_name": session.display_name,
"org_id": session.org_id,
"provider": session.provider,
}
)
if unavailable is not None:
return _native_error(
"server_error", f"Auth provider unreachable: {unavailable}", 503
)
audit_log(
AuditEvent.REFRESH_FAILURE, reason="no_provider_recognises",
ip=_client_ip(request),
)
return _native_error("invalid_grant", "Refresh token rejected", 401)
def _validate_post_login_target(raw: str) -> str:
"""Return ``raw`` if it's a safe same-origin path, else empty string.

View file

@ -2900,9 +2900,25 @@ async def get_status(profile: Optional[str] = None):
# "loopback only — no auth gate" with no extra round trips.
auth_required = bool(getattr(app.state, "auth_required", False))
auth_providers: list[str] = []
native_loopback_auth = False
try:
from hermes_cli.dashboard_auth import list_providers as _list_providers
from hermes_cli.dashboard_auth import (
list_providers as _list_providers,
list_session_providers as _list_session_providers,
)
auth_providers = [p.name for p in _list_providers()]
# RFC 8252 native-app loopback login is offered only when the gate
# is engaged AND at least one interactive OAuth (non-password)
# session provider is registered — the broker drives that
# provider's ``start_login`` redirect. The desktop reads this flag
# to choose the system-browser flow vs. the embedded-webview
# fallback. Password-only providers have no browser redirect to
# broker, so they don't light this up.
if auth_required:
native_loopback_auth = any(
not getattr(p, "supports_password", False)
for p in _list_session_providers()
)
except Exception:
# Module not importable yet (early startup) — leave as [].
pass
@ -2944,6 +2960,7 @@ async def get_status(profile: Optional[str] = None):
"active_sessions": active_sessions,
"auth_required": auth_required,
"auth_providers": auth_providers,
"native_loopback_auth": native_loopback_auth,
"nous_session_valid": nous_session_valid,
}

View file

@ -0,0 +1,459 @@
"""End-to-end + unit tests for the RFC 8252 native-app (desktop loopback) login.
Covers the gateway-side broker that lets the desktop app authenticate via the
user's SYSTEM browser and hold the tokens itself (``Authorization: Bearer``)
instead of the embedded-webview + HttpOnly-cookie flow:
* ``native_auth`` broker unit behaviour (loopback validation, PKCE verify,
single-use code, TTL expiry).
* Full ``/auth/native/start`` ``/auth/callback`` ``/auth/native/token``
round trip against the in-process ``StubAuthProvider``.
* The desktop-held bearer authenticates a gated REST route (``/api/auth/me``)
and mints a ws-ticket (``/api/auth/ws-ticket``) identically to a cookie
session while an invalid bearer is rejected 401.
* The cookieless ``/api/auth/refresh`` rotates tokens.
* ``/api/status`` advertises ``native_loopback_auth`` so the desktop can
choose the flow vs. the embedded-webview fallback.
Uses ``StubAuthProvider`` so the OAuth round trip completes in-process with no
external IDP. The stub's ``start_login`` bounces straight back to the callback
with ``code=stub_code``, which is exactly what the broker needs.
"""
from __future__ import annotations
import base64
import hashlib
import time
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import (
clear_providers,
register_provider,
)
from hermes_cli.dashboard_auth import native_auth
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# PKCE helper (desktop side)
# ---------------------------------------------------------------------------
def _pkce_pair() -> tuple[str, str]:
"""Return ``(verifier, challenge)`` — S256, matching native_auth's verify."""
verifier = base64.urlsafe_b64encode(b"x" * 48).rstrip(b"=").decode()
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
return verifier, challenge
# ===========================================================================
# native_auth broker — unit
# ===========================================================================
@pytest.fixture(autouse=True)
def _reset_broker():
native_auth._reset_for_tests()
yield
native_auth._reset_for_tests()
@pytest.mark.parametrize(
"uri,ok",
[
("http://127.0.0.1:8765/callback", True),
("http://localhost:8765/callback", True),
("http://[::1]:8765/callback", True),
("https://127.0.0.1:8765/callback", False), # https not allowed for loopback
("http://evil.example.com/callback", False), # public host
("http://127.0.0.1:8765", False), # no path
("hermes://callback", False), # custom scheme
("http://10.0.0.5/callback", False), # private but not loopback
("not a url", False),
],
)
def test_is_loopback_redirect_uri(uri, ok):
assert native_auth.is_loopback_redirect_uri(uri) is ok
def test_start_broker_rejects_non_s256():
_, challenge = _pkce_pair()
with pytest.raises(native_auth.BrokerError):
native_auth.start_broker(
upstream_state="us",
upstream_verifier="uv",
provider="stub",
code_challenge=challenge,
code_challenge_method="plain",
redirect_uri="http://127.0.0.1:9/cb",
desktop_state="ds",
)
def test_start_broker_rejects_non_loopback_redirect():
_, challenge = _pkce_pair()
with pytest.raises(native_auth.BrokerError):
native_auth.start_broker(
upstream_state="us",
upstream_verifier="uv",
provider="stub",
code_challenge=challenge,
code_challenge_method="S256",
redirect_uri="https://evil.example.com/cb",
desktop_state="ds",
)
def _mk_session():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://gw/auth/callback")
pkce = dict(
item.split("=", 1)
for item in ls.cookie_payload["hermes_session_pkce"].split(";")
)
return p.complete_login(
code="stub_code",
state=pkce["state"],
code_verifier=pkce["verifier"],
redirect_uri="https://gw/auth/callback",
)
def test_redeem_happy_path():
verifier, challenge = _pkce_pair()
native_auth.start_broker(
upstream_state="us1",
upstream_verifier="uv",
provider="stub",
code_challenge=challenge,
code_challenge_method="S256",
redirect_uri="http://127.0.0.1:9/cb",
desktop_state="ds",
)
session = _mk_session()
code = native_auth.complete_broker("us1", session)
got = native_auth.redeem_code(
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
)
assert got.user_id == session.user_id
assert got.access_token == session.access_token
def test_redeem_pkce_mismatch_rejected_and_code_burned():
_, challenge = _pkce_pair()
native_auth.start_broker(
upstream_state="us2",
upstream_verifier="uv",
provider="stub",
code_challenge=challenge,
code_challenge_method="S256",
redirect_uri="http://127.0.0.1:9/cb",
desktop_state="ds",
)
code = native_auth.complete_broker("us2", _mk_session())
# Wrong verifier → invalid_grant.
with pytest.raises(native_auth.BrokerError):
native_auth.redeem_code(
code=code,
code_verifier="wrong-verifier",
redirect_uri="http://127.0.0.1:9/cb",
)
# And the code is burned — a subsequent correct attempt also fails.
verifier, _ = _pkce_pair()
with pytest.raises(native_auth.BrokerError):
native_auth.redeem_code(
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
)
def test_redeem_is_single_use():
verifier, challenge = _pkce_pair()
native_auth.start_broker(
upstream_state="us3",
upstream_verifier="uv",
provider="stub",
code_challenge=challenge,
code_challenge_method="S256",
redirect_uri="http://127.0.0.1:9/cb",
desktop_state="ds",
)
code = native_auth.complete_broker("us3", _mk_session())
native_auth.redeem_code(
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
)
with pytest.raises(native_auth.BrokerError):
native_auth.redeem_code(
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
)
def test_redeem_redirect_uri_mismatch_rejected():
verifier, challenge = _pkce_pair()
native_auth.start_broker(
upstream_state="us4",
upstream_verifier="uv",
provider="stub",
code_challenge=challenge,
code_challenge_method="S256",
redirect_uri="http://127.0.0.1:9/cb",
desktop_state="ds",
)
code = native_auth.complete_broker("us4", _mk_session())
with pytest.raises(native_auth.BrokerError):
native_auth.redeem_code(
code=code,
code_verifier=verifier,
redirect_uri="http://127.0.0.1:9999/cb", # different port
)
def test_broker_record_expiry(monkeypatch):
_, challenge = _pkce_pair()
native_auth.start_broker(
upstream_state="us5",
upstream_verifier="uv",
provider="stub",
code_challenge=challenge,
code_challenge_method="S256",
redirect_uri="http://127.0.0.1:9/cb",
desktop_state="ds",
)
# Jump past the broker TTL — get_broker evicts on read.
real_time = time.time
monkeypatch.setattr(
native_auth.time, "time",
lambda: real_time() + native_auth.BROKER_TTL_SECONDS + 5,
)
assert native_auth.get_broker("us5") is None
# ===========================================================================
# HTTP round trip against the gated app
# ===========================================================================
@pytest.fixture
def gated_app():
"""web_server.app in gated mode with the stub OAuth provider registered."""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "gw.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
native_auth._reset_for_tests()
client = TestClient(web_server.app, base_url="https://gw.fly.dev")
yield client
clear_providers()
native_auth._reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _run_native_flow(client) -> dict:
"""Drive start → callback → token; return the token JSON the desktop gets."""
verifier, challenge = _pkce_pair()
redirect_uri = "http://127.0.0.1:8765/callback"
desktop_state = "desktop-state-xyz"
# 1) Desktop POSTs /auth/native/start (its own HTTP client, no cookie).
r_start = client.post(
"/auth/native/start",
json={
"provider": "stub",
"redirect_uri": redirect_uri,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": desktop_state,
},
)
assert r_start.status_code == 200, r_start.text
authorization_url = r_start.json()["authorization_url"]
# The stub's authorize URL bounces straight back to the gateway callback
# with ?code=stub_code&state=<upstream_state>. Extract those params and
# hit /auth/callback the way the system browser would (NO pkce cookie).
q = parse_qs(urlparse(authorization_url).query)
upstream_code = q["code"][0]
upstream_state = q["state"][0]
# 2) System browser → /auth/callback. The broker recognises the login by
# upstream state and 302s to the desktop's loopback with a one-time code.
r_cb = client.get(
f"/auth/callback?code={upstream_code}&state={upstream_state}",
follow_redirects=False,
)
assert r_cb.status_code == 302, r_cb.text
loc = r_cb.headers["location"]
assert loc.startswith(redirect_uri), loc
# No session cookie is set on the native callback (cookieless flow).
assert "set-cookie" not in {k.lower() for k in r_cb.headers.keys()}
cb_q = parse_qs(urlparse(loc).query)
assert cb_q["state"][0] == desktop_state # desktop CSRF check
one_time_code = cb_q["code"][0]
# 3) Desktop redeems the code + its PKCE verifier for JSON tokens.
r_tok = client.post(
"/auth/native/token",
json={
"code": one_time_code,
"code_verifier": verifier,
"redirect_uri": redirect_uri,
},
)
assert r_tok.status_code == 200, r_tok.text
return r_tok.json()
def test_full_native_round_trip_returns_json_tokens(gated_app):
tokens = _run_native_flow(gated_app)
assert tokens["token_type"] == "Bearer"
assert tokens["access_token"]
assert tokens["refresh_token"]
assert tokens["user_id"] == "stub-user-1"
assert tokens["provider"] == "stub"
assert isinstance(tokens["expires_at"], int)
def test_bearer_unlocks_gated_api_me(gated_app):
"""The desktop-held bearer authenticates a gated route with NO cookie."""
tokens = _run_native_flow(gated_app)
r = gated_app.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert r.status_code == 200, r.text
me = r.json()
assert me["user_id"] == "stub-user-1"
assert me["provider"] == "stub"
def test_bearer_mints_ws_ticket(gated_app):
"""POST /api/auth/ws-ticket works under a bearer exactly like under a cookie."""
tokens = _run_native_flow(gated_app)
r = gated_app.post(
"/api/auth/ws-ticket",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert r.status_code == 200, r.text
assert r.json().get("ticket")
def test_invalid_bearer_is_401(gated_app):
r = gated_app.get(
"/api/auth/me",
headers={"Authorization": "Bearer not-a-real-token"},
)
assert r.status_code == 401
assert r.json().get("error") == "session_expired"
def test_native_start_rejects_non_loopback_redirect(gated_app):
_, challenge = _pkce_pair()
r = gated_app.post(
"/auth/native/start",
json={
"provider": "stub",
"redirect_uri": "https://evil.example.com/cb",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": "s",
},
)
assert r.status_code == 400
assert r.json()["error"] == "invalid_request"
def test_native_start_unknown_provider_404(gated_app):
_, challenge = _pkce_pair()
r = gated_app.post(
"/auth/native/start",
json={
"provider": "nope",
"redirect_uri": "http://127.0.0.1:9/cb",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": "s",
},
)
assert r.status_code == 404
def test_native_token_rejects_bad_code(gated_app):
verifier, _ = _pkce_pair()
r = gated_app.post(
"/auth/native/token",
json={
"code": "never-issued",
"code_verifier": verifier,
"redirect_uri": "http://127.0.0.1:9/cb",
},
)
assert r.status_code == 400
assert r.json()["error"] == "invalid_grant"
def test_cookieless_refresh_rotates_tokens(gated_app):
tokens = _run_native_flow(gated_app)
r = gated_app.post(
"/api/auth/refresh",
json={"refresh_token": tokens["refresh_token"], "provider": "stub"},
)
assert r.status_code == 200, r.text
rotated = r.json()
assert rotated["access_token"]
assert rotated["user_id"] == "stub-user-1"
# The rotated access token verifies against a gated route.
r2 = gated_app.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {rotated['access_token']}"},
)
assert r2.status_code == 200
def test_refresh_bad_token_401(gated_app):
r = gated_app.post(
"/api/auth/refresh",
json={"refresh_token": "garbage", "provider": "stub"},
)
assert r.status_code == 401
assert r.json()["error"] == "invalid_grant"
def test_status_advertises_native_loopback_capability(gated_app):
r = gated_app.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["auth_required"] is True
assert body["native_loopback_auth"] is True
def test_status_native_capability_false_in_loopback_mode():
"""Loopback (no gate) → native_loopback_auth is False."""
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
web_server.app.state.auth_required = False
try:
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
body = client.get("/api/status").json()
assert body["auth_required"] is False
assert body["native_loopback_auth"] is False
finally:
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required