From 27b0b7c5a02ab4b6b08ae158b283180787bd7197 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 21 Jul 2026 07:52:31 +1000 Subject: [PATCH] feat(desktop-auth): RFC 8252 native-app loopback login for gated gateways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../electron/connection-config.test.ts | 127 +++++ apps/desktop/electron/connection-config.ts | 110 +++++ apps/desktop/electron/main.ts | 411 +++++++++++++++- hermes_cli/dashboard_auth/audit.py | 4 + hermes_cli/dashboard_auth/middleware.py | 80 +++ hermes_cli/dashboard_auth/native_auth.py | 277 +++++++++++ hermes_cli/dashboard_auth/routes.py | 320 ++++++++++++ hermes_cli/web_server.py | 19 +- tests/hermes_cli/test_native_loopback_auth.py | 459 ++++++++++++++++++ 9 files changed, 1795 insertions(+), 12 deletions(-) create mode 100644 hermes_cli/dashboard_auth/native_auth.py create mode 100644 tests/hermes_cli/test_native_loopback_auth.py diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 425e63b15f2..8cd964e9347 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -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' }) +}) + diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 569f9cc0726..1a744407459 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -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 diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e0dd2c4fb54..bb314c1e85c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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:, +// 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( + `Hermes` + + `${message}` + ) + } + + 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() +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 = {} + 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) --- diff --git a/hermes_cli/dashboard_auth/audit.py b/hermes_cli/dashboard_auth/audit.py index cde23bf40b2..557904492d4 100644 --- a/hermes_cli/dashboard_auth/audit.py +++ b/hermes_cli/dashboard_auth/audit.py @@ -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: diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 5c029cac62d..de939863c78 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -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 `` `` 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: diff --git a/hermes_cli/dashboard_auth/native_auth.py b/hermes_cli/dashboard_auth/native_auth.py new file mode 100644 index 00000000000..d3587d5b8b9 --- /dev/null +++ b/hermes_cli/dashboard_auth/native_auth.py @@ -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 `` 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 gateway↔IDP 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() diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 5b833e5df79..a538258af9f 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -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`` (gateway↔IDP 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. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 93bd2447ecd..b9ab7c4cb4d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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, } diff --git a/tests/hermes_cli/test_native_loopback_auth.py b/tests/hermes_cli/test_native_loopback_auth.py new file mode 100644 index 00000000000..bd232af9710 --- /dev/null +++ b/tests/hermes_cli/test_native_loopback_auth.py @@ -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=. 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