From 7857d8737c2fb0fb8aca74cadae5e0685a58ce4a Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 20 Jul 2026 17:52:04 +1000 Subject: [PATCH] feat(dashboard-auth): RFC 8252 native desktop sign-in (system browser + PKCE, no webview/cookies) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Desktop app can now sign in to a gated gateway using the user's SYSTEM browser and OAuth 2.0 for Native Apps (RFC 8252) instead of an embedded Electron BrowserWindow, and authenticates with bearer tokens it holds itself instead of relying on HttpOnly browser session cookies. Why brokered: the upstream IDP (Nous Portal) binds client_id to the gateway instance and only permits redirect_uris on the gateway's own origin, so a desktop loopback redirect can't be a direct Portal client. The gateway therefore acts as the authorization server TO the desktop and an OAuth client TO the Portal, reusing the existing PKCE start_login/complete_login provider path unchanged. Server (Ben's dashboard-auth lane): - native_flow.py: in-memory broker — binds the desktop's PKCE challenge to a completed Session, mints a single-use, short-TTL, PKCE-verified gateway authorization code. Constant-time compare, single-use (consumed before the PKCE check so a wrong verifier can't be retried), capacity-bounded. - routes.py: GET /auth/native/authorize (starts the brokered PKCE login, loopback-only redirect_uri, S256-only), POST /auth/native/token (loopback code + verifier -> tokens in the JSON body, never Set-Cookie), POST /auth/native/refresh (desktop-held RT rotation). /auth/callback branches to mint a loopback code + 302 to 127.0.0.1 when a broker_state rides the PKCE cookie; the cookie/SPA path is untouched. - middleware.py: the gate accepts Authorization: Bearer , verified via the same verify_session provider stack (no cookie set/read), with the same "provider unreachable -> 503, not logout" semantics. - web_server.py /api/status: advertise auth_flows (["cookie","native_pkce"]) so clients can detect the capability; native_pkce only when a brokerable OAuth provider is registered. Desktop (Ben's lane): - native-oauth.ts: pure PKCE/capability/URL/callback/token helpers. - native-oauth-login.ts: loopback-listener orchestration (system browser via openExternal, ephemeral 127.0.0.1 listener, state/PKCE verification), all I/O injected for testability. - main.ts: capability-gated oauth-login IPC — native flow when advertised, automatic fallback to the existing embedded-webview cookie flow otherwise; tokens stored encrypted (safeStorage/OS keychain), REST + ws-ticket authenticated by bearer, transparent refresh, logout clears both shapes. Tests: 18 server pytest (broker unit + full authorize->callback->token E2E + cookieless bearer auth of a gated route + ws-ticket mint + capability advertisement + refresh); desktop node --test/vitest for both pure modules (PKCE, capability detection, callback CSRF, loopback round trip, timeout, browser-open failure). Electron project typechecks clean. Docs: website/docs/guides/desktop-native-signin.md. --- apps/desktop/electron/main.ts | 260 +++++++++- .../electron/native-oauth-login.test.ts | 169 +++++++ apps/desktop/electron/native-oauth-login.ts | 213 ++++++++ apps/desktop/electron/native-oauth.test.ts | 190 ++++++++ apps/desktop/electron/native-oauth.ts | 215 +++++++++ hermes_cli/dashboard_auth/audit.py | 5 + hermes_cli/dashboard_auth/middleware.py | 74 +++ hermes_cli/dashboard_auth/native_flow.py | 275 +++++++++++ hermes_cli/dashboard_auth/routes.py | 313 ++++++++++++ hermes_cli/web_server.py | 23 +- .../test_dashboard_auth_native_flow.py | 456 ++++++++++++++++++ web/src/lib/api.ts | 7 + website/docs/guides/desktop-native-signin.md | 119 +++++ 13 files changed, 2310 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/electron/native-oauth-login.test.ts create mode 100644 apps/desktop/electron/native-oauth-login.ts create mode 100644 apps/desktop/electron/native-oauth.test.ts create mode 100644 apps/desktop/electron/native-oauth.ts create mode 100644 hermes_cli/dashboard_auth/native_flow.py create mode 100644 tests/hermes_cli/test_dashboard_auth_native_flow.py create mode 100644 website/docs/guides/desktop-native-signin.md diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5ea4c9e16c..5e622bb859 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -80,6 +80,14 @@ import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' +import { runNativeLogin } from './native-oauth-login' +import { + nativeRefreshUrl, + parseTokenResponse, + resolveLoginStrategy, + tokenNeedsRefresh, + type NativeTokenSet +} from './native-oauth' import { scanGitRepos } from './git-repo-scan' import { fileDiffVsHead, @@ -3840,6 +3848,12 @@ function fetchJson(url, token, options: any = {}) { headers: { 'Content-Type': contentType, 'X-Hermes-Session-Token': token, + // RFC 8252 native flow authenticates the gated gateway with a bearer + // token instead of the loopback session-token header. When + // ``options.bearer`` is set we send Authorization: Bearer ; + // the gateway's OAuth gate verifies it via the provider stack with + // no cookie involved. + ...(options.bearer ? { Authorization: `Bearer ${options.bearer}` } : {}), ...(body ? { 'Content-Length': String(body.length) } : {}) } }, @@ -5693,10 +5707,181 @@ function fetchJsonViaOauthSession(url, options: any = {}) { }) } +// --------------------------------------------------------------------------- +// RFC 8252 native-app tokens (system-browser + loopback + PKCE). +// +// Unlike the cookie flow, the native flow hands the desktop opaque bearer +// tokens it holds itself: the access token authenticates REST via +// ``Authorization: Bearer`` (which the gateway gate now accepts) and mints WS +// tickets the same way, so NO browser session cookie or embedded webview is +// involved. Tokens are persisted encrypted at rest via Electron ``safeStorage`` +// (OS keychain) keyed by gateway base URL, and refreshed via +// ``/auth/native/refresh`` before expiry. This is the desktop half of the +// feature; the server half lives in hermes_cli/dashboard_auth/native_flow.py. +// --------------------------------------------------------------------------- + +// In-memory cache of decrypted native tokens, keyed by normalized base URL. +// Backed by the encrypted on-disk store so it survives restarts. +const _nativeTokens = new Map() + +function _nativeTokenStorePath() { + // Co-located with the connection config under userData; one JSON file mapping + // baseUrl → { encoding, value } safeStorage payloads. + return path.join(app.getPath('userData'), 'native-oauth-tokens.json') +} + +function _readNativeTokenStore(): Record { + try { + const raw = fs.readFileSync(_nativeTokenStorePath(), 'utf8') + const parsed = JSON.parse(raw) + + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function _persistNativeTokens(baseUrl: string, tokens: NativeTokenSet | null) { + const store = _readNativeTokenStore() + + if (tokens) { + // Encrypt the whole token set as one blob so the refresh token never + // lands in plaintext on disk. Reuse the hardened encrypt helper. + const secret = encryptDesktopSecret(JSON.stringify(tokens)) + store[baseUrl] = secret + } else { + delete store[baseUrl] + } + + try { + fs.mkdirSync(path.dirname(_nativeTokenStorePath()), { recursive: true }) + fs.writeFileSync(_nativeTokenStorePath(), JSON.stringify(store), { mode: 0o600 }) + } catch (error) { + rememberLog(`[native-oauth] failed to persist tokens: ${(error as Error).message}`) + } +} + +function _loadNativeTokens(baseUrl: string): NativeTokenSet | null { + const cached = _nativeTokens.get(baseUrl) + + if (cached) { + return cached + } + + const store = _readNativeTokenStore() + const secret = store[baseUrl] + + if (!secret) { + return null + } + + try { + const plaintext = decryptDesktopSecret(secret) + + if (!plaintext) { + return null + } + + const tokens = parseTokenResponse(JSON.parse(plaintext)) + _nativeTokens.set(baseUrl, tokens) + + return tokens + } catch { + return null + } +} + +function _storeNativeTokens(baseUrl: string, tokens: NativeTokenSet) { + _nativeTokens.set(baseUrl, tokens) + _persistNativeTokens(baseUrl, tokens) +} + +function _clearNativeTokens(baseUrl: string) { + _nativeTokens.delete(baseUrl) + _persistNativeTokens(baseUrl, null) +} + +// True when we hold native bearer tokens for this gateway (the native-flow +// analogue of hasLiveOauthSession's cookie check). +function hasNativeSession(baseUrl: string): boolean { + return _loadNativeTokens(baseUrl) !== null +} + +// POST JSON WITHOUT the OAuth cookie partition — used for the native token + +// refresh exchanges, which are cookieless by design. Thin wrapper over +// fetchJson (no token) so it shares timeout/JSON handling. +function postJsonNoAuth(url: string, body: unknown, opts: any = {}) { + return fetchJson(url, null, { method: 'POST', body: JSON.stringify(body), ...opts }) +} + +// Return a valid native access token for baseUrl, refreshing via +// /auth/native/refresh if the stored one is at/near expiry. Returns null when +// there are no tokens or the refresh is terminally rejected (caller re-logins). +async function ensureNativeAccessToken(baseUrl: string): Promise { + const tokens = _loadNativeTokens(baseUrl) + + if (!tokens) { + return null + } + + if (!tokenNeedsRefresh(tokens, Math.floor(Date.now() / 1000))) { + return tokens.accessToken + } + + if (!tokens.refreshToken) { + // Access token expired and no RT to rotate — force re-login. + _clearNativeTokens(baseUrl) + + return null + } + + try { + const body = await postJsonNoAuth( + nativeRefreshUrl(baseUrl), + { refresh_token: tokens.refreshToken, provider: tokens.provider }, + { timeoutMs: 10_000 } + ) + const rotated = parseTokenResponse(body) + _storeNativeTokens(baseUrl, rotated) + + return rotated.accessToken + } catch (error: any) { + // A 401 means the RT is dead (session_expired) — drop tokens so the UI + // prompts a fresh native login. A 503/transient keeps them for a retry. + if (error && error.statusCode === 401) { + _clearNativeTokens(baseUrl) + + return null + } + + throw error + } +} + // Mint a single-use WS ticket for a gated gateway. Returns the ticket string. +// Prefers a native bearer token (cookieless RFC 8252 flow) when present, +// falling back to the OAuth cookie partition otherwise. // Throws (with statusCode 401) if the session cookie is missing/expired — // callers treat that as "needs re-login". async function mintGatewayWsTicket(baseUrl) { + // Native flow: mint the ticket with the bearer token, no cookie involved. + const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null) + + if (nativeAt) { + const body = (await fetchJson(`${baseUrl}/api/auth/ws-ticket`, null, { + method: 'POST', + timeoutMs: 8_000, + bearer: nativeAt + })) as any + const ticket = body?.ticket + + if (!ticket || typeof ticket !== 'string') { + throw new Error('Gateway did not return a WS ticket.') + } + + return ticket + } + const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000 @@ -6923,7 +7108,19 @@ async function requestJsonForProfile(profile: string, path: string, method: stri const url = `${conn.baseUrl}${path}` const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } - return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts) + if (conn.authMode === 'oauth') { + // Native RFC 8252 flow: authenticate with the bearer token (cookieless) + // when we hold one for this gateway; otherwise use the cookie partition. + const nativeAt = await ensureNativeAccessToken(conn.baseUrl).catch(() => null) + + if (nativeAt) { + return fetchJson(url, null, { ...opts, bearer: nativeAt }) + } + + return fetchJsonViaOauthSession(url, opts) + } + + return fetchJson(url, conn.token, opts) } async function probeRemoteAuthMode(rawUrl) { @@ -8708,11 +8905,49 @@ ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => { 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. + // Capability-gated login (RFC 8252). Probe the gateway's public /api/status: + // - advertises "native_pkce" in auth_flows → run the system-browser + + // loopback + PKCE flow. No embedded webview, tokens held by the app + // (encrypted keychain), REST/WS authenticated by bearer — no cookies. + // - older gateway without native_pkce → fall back to the legacy embedded + // BrowserWindow cookie flow, preserving compatibility. + // This is the "observable ladder + compatibility fallback tied to an + // identified older runtime" the desktop guide requires. const baseUrl = normalizeRemoteBaseUrl(rawUrl) + + let statusBody: any = null + + try { + statusBody = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 }) + } catch { + // Can't read status — fall through to the embedded flow, which has its + // own error handling and works against any gated gateway. + } + + const strategy = resolveLoginStrategy(statusBody) + + if (strategy === 'native') { + try { + const tokens = await runNativeLogin(baseUrl, { + openExternal: url => shell.openExternal(url), + postJson: (url, body, opts) => postJsonNoAuth(url, body, opts), + rememberLog + }) + _storeNativeTokens(baseUrl, tokens) + + return { ok: true, baseUrl, connected: true } + } catch (error) { + rememberLog( + `[native-oauth] native login failed (${ + error instanceof Error ? error.message : String(error) + }); falling back to embedded flow` + ) + // Fall through to the embedded flow so a native-flow hiccup (blocked + // loopback, user closed the browser) still lets the user sign in. + } + } + + // Legacy embedded-webview cookie flow. await openOauthLoginWindow(baseUrl) return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) } @@ -8720,11 +8955,20 @@ 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 tokens for this gateway so a + // logout clears BOTH auth shapes. + if (baseUrl) { + _clearNativeTokens(baseUrl) + } // 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 } + // (AT-or-RT cookie, or a native token) so a logout that left any session + // behind is reflected as still-connected rather than silently signed-out. + const connected = baseUrl + ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) + : false + + return { ok: true, connected } }) // --- Hermes Cloud (cloud-auto-discovery Phase 3) --- diff --git a/apps/desktop/electron/native-oauth-login.test.ts b/apps/desktop/electron/native-oauth-login.test.ts new file mode 100644 index 0000000000..23b7e9c727 --- /dev/null +++ b/apps/desktop/electron/native-oauth-login.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for electron/native-oauth-login.ts — the loopback-listener + * orchestration of the RFC 8252 native login, with all I/O injected (fake + * http server, fake openExternal, fake token POST) so no real socket or + * browser is needed. + * + * Run with: node --test electron/native-oauth-login.test.ts + */ + +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' + +import { test } from 'vitest' + +import { runNativeLogin } from './native-oauth-login' + +// A fake http.Server: captures the request handler, lets the test drive a +// synthetic browser callback, and records listen/close lifecycle. +function makeFakeServerFactory(port = 51234) { + const state: any = { handler: null, listening: false, closed: false, openedUrl: null } + + const createServer: any = (handler: any) => { + state.handler = handler + const server: any = new EventEmitter() + server.listen = (_port: number, _host: string, cb: () => void) => { + state.listening = true + cb() + } + server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port }) + server.close = () => { + state.closed = true + } + state.server = server + + return server + } + + // Drive a synthetic browser hit to the loopback callback. + state.hitCallback = (query: string) => { + const res: any = { writeHead: () => undefined, end: () => undefined } + state.handler({ url: `/callback?${query}` }, res) + } + + return { createServer, state } +} + +test('runNativeLogin completes the loopback round trip and returns tokens', async () => { + const { createServer, state } = makeFakeServerFactory() + let capturedAuthorizeUrl = '' + let tokenPostBody: any = null + + const promise = runNativeLogin( + 'https://gw.example.com', + { + openExternal: async url => { + capturedAuthorizeUrl = url + }, + postJson: async (_url, body) => { + tokenPostBody = body + + return { + access_token: 'AT-native', + refresh_token: 'RT-native', + token_type: 'Bearer', + expires_at: 1893456000, + provider: 'nous', + user_id: 'u-9' + } + }, + createServer, + timeoutMs: 5_000 + }, + { provider: 'nous' } + ) + + // Give the listen callback a tick to open the browser + capture the URL. + await new Promise(r => setTimeout(r, 5)) + + // The authorize URL must carry OUR challenge + loopback redirect + state. + const authorize = new URL(capturedAuthorizeUrl) + assert.equal(authorize.pathname, '/auth/native/authorize') + const challenge = authorize.searchParams.get('code_challenge') + const stateParam = authorize.searchParams.get('state') + assert.ok(challenge && challenge.length > 0) + assert.match(authorize.searchParams.get('redirect_uri') || '', /^http:\/\/127\.0\.0\.1:\d+\/callback$/) + + // Synthetic browser redirect back with the matching state + a code. + state.hitCallback(`code=gw-code-1&state=${encodeURIComponent(stateParam!)}`) + + const tokens = await promise + assert.equal(tokens.accessToken, 'AT-native') + assert.equal(tokens.refreshToken, 'RT-native') + assert.equal(tokens.userId, 'u-9') + // The token POST carried the code + a verifier whose hash is the challenge. + assert.equal(tokenPostBody.code, 'gw-code-1') + assert.ok(tokenPostBody.code_verifier && tokenPostBody.code_verifier.length >= 43) + // Listener was cleaned up. + assert.equal(state.closed, true) +}) + +test('runNativeLogin rejects on a state mismatch (CSRF) without redeeming', async () => { + const { createServer, state } = makeFakeServerFactory() + let tokenPostCalled = false + + const promise = runNativeLogin('https://gw.example.com', { + openExternal: async () => undefined, + postJson: async () => { + tokenPostCalled = true + + return {} + }, + createServer, + timeoutMs: 5_000 + }) + + await new Promise(r => setTimeout(r, 5)) + // Wrong state — must not redeem the code. + state.hitCallback('code=evil&state=not-the-real-state') + + await assert.rejects(promise, /state mismatch/i) + assert.equal(tokenPostCalled, false) + assert.equal(state.closed, true) +}) + +test('runNativeLogin surfaces a gateway error param', async () => { + const { createServer, state } = makeFakeServerFactory() + + const promise = runNativeLogin('https://gw.example.com', { + openExternal: async () => undefined, + postJson: async () => ({}), + createServer, + timeoutMs: 5_000 + }) + + await new Promise(r => setTimeout(r, 5)) + state.hitCallback('error=access_denied&error_description=user_declined') + + await assert.rejects(promise, /access_denied/i) +}) + +test('runNativeLogin times out when no callback arrives', async () => { + const { createServer } = makeFakeServerFactory() + + await assert.rejects( + runNativeLogin('https://gw.example.com', { + openExternal: async () => undefined, + postJson: async () => ({}), + createServer, + timeoutMs: 20 + }), + /timed out/i + ) +}) + +test('runNativeLogin fails if the browser cannot be opened', async () => { + const { createServer } = makeFakeServerFactory() + + await assert.rejects( + runNativeLogin('https://gw.example.com', { + openExternal: async () => { + throw new Error('no browser') + }, + postJson: async () => ({}), + createServer, + timeoutMs: 5_000 + }), + /could not open the system browser/i + ) +}) diff --git a/apps/desktop/electron/native-oauth-login.ts b/apps/desktop/electron/native-oauth-login.ts new file mode 100644 index 0000000000..20d53d10ab --- /dev/null +++ b/apps/desktop/electron/native-oauth-login.ts @@ -0,0 +1,213 @@ +/** + * native-oauth-login.ts + * + * Electron-coupled driver for the RFC 8252 native-app login: it runs the + * loopback HTTP listener that catches the gateway's browser redirect, opens + * the system browser, redeems the one-time code for tokens, and hands them + * back. The PURE logic (PKCE, URL building, callback parsing, token-response + * normalization) lives in native-oauth.ts and is unit-tested separately; this + * module is the thin I/O shell around it. + * + * Dependencies are INJECTED (openExternal, a JSON-POST fn, an http-server + * factory, a clock) so the orchestration is testable without booting Electron + * or opening real sockets — mirroring how connection-config.ts injects + * `mintTicket`. main.ts supplies the real electron shell.openExternal, + * electron.net POST, and node:http server. + * + * Security posture (see native-oauth.ts for the flow-level rationale): + * - the loopback server binds 127.0.0.1 on an EPHEMERAL port and shuts down + * the instant it receives the callback (or times out) — no long-lived + * local listener; + * - the `state` is verified before the code is redeemed (CSRF); + * - the PKCE verifier never leaves this process until the token POST, and + * the gateway enforces SHA256(verifier)==challenge server-side; + * - the browser sees only a minimal "you can close this window" HTML page, + * never the tokens. + */ + +import http from 'node:http' +import type { AddressInfo } from 'node:net' + +import { + buildNativeAuthorizeUrl, + generatePkcePair, + generateState, + nativeTokenUrl, + parseLoopbackCallback, + parseTokenResponse, + type NativeTokenSet +} from './native-oauth' + +// Loopback login must complete inside this window (user opens browser, +// authenticates, gets redirected back). Matches the server-side pending TTL. +const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000 + +// The minimal page the browser lands on after the gateway redirect. No tokens, +// no secrets — just a close affordance. Served for any loopback request so a +// favicon probe doesn't look like a failure. +const DONE_HTML = + 'Signed in' + + '' + + '

✓ Signed in to Hermes

' + + '

You can close this window and return to the app.

' + + '' + +export interface NativeLoginDeps { + /** Open a URL in the user's system browser (shell.openExternal). */ + openExternal: (url: string) => Promise + /** POST JSON and resolve the parsed body (electron.net-backed in prod). */ + postJson: (url: string, body: unknown, opts?: { timeoutMs?: number }) => Promise + /** http.createServer, injectable for tests. */ + createServer?: typeof http.createServer + /** Clock + timeout, injectable for tests. */ + now?: () => number + timeoutMs?: number + /** Optional logger for boot diagnostics. */ + rememberLog?: (line: string) => void +} + +/** + * Drive a full native login against `baseUrl` and return the token set. + * + * Steps: bind a loopback listener → open the system browser at the gateway's + * /auth/native/authorize with our PKCE challenge + loopback redirect_uri → + * await the ?code= redirect → verify state → POST /auth/native/token with the + * verifier → return tokens. Rejects on timeout, state mismatch, a gateway + * error param, or a token-exchange failure. Always tears the listener down. + */ +export async function runNativeLogin( + baseUrl: string, + deps: NativeLoginDeps, + opts: { provider?: string } = {} +): Promise { + const createServer = deps.createServer || http.createServer + const timeoutMs = deps.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS + const log = deps.rememberLog || (() => undefined) + + const { verifier, challenge } = generatePkcePair() + const state = generateState() + + return new Promise((resolve, reject) => { + let settled = false + let timer: NodeJS.Timeout | null = null + const server = createServer((req, res) => { + // Only the callback path carries the code; any other path (favicon, + // etc.) still gets the friendly page so the browser tab looks sane. + const url = req.url || '/' + + // Always answer the browser with the close page — we never surface the + // outcome to the browser, only to the app. + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(DONE_HTML) + + if (settled) { + return + } + + // Ignore non-callback noise (e.g. /favicon.ico) — wait for the ?code=. + if (!/[?&](code|error)=/.test(url)) { + return + } + + try { + const { code } = parseLoopbackCallback(url, state) + finishWith(async () => { + const tokenBody = await deps.postJson( + nativeTokenUrl(baseUrl), + { code, code_verifier: verifier }, + { timeoutMs: 15_000 } + ) + + return parseTokenResponse(tokenBody) + }) + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))) + } + }) + + const cleanup = () => { + if (timer) { + clearTimeout(timer) + } + + try { + server.close() + } catch { + // already closed + } + } + + const fail = (error: Error) => { + if (settled) { + return + } + + settled = true + cleanup() + reject(error) + } + + const finishWith = (produce: () => Promise) => { + if (settled) { + return + } + + settled = true + // Keep the listener up just long enough to have answered the browser, + // then redeem the code out-of-band. + produce() + .then(tokens => { + cleanup() + resolve(tokens) + }) + .catch(error => { + cleanup() + reject(error instanceof Error ? error : new Error(String(error))) + }) + } + + server.on('error', err => fail(err instanceof Error ? err : new Error(String(err)))) + + // Bind an ephemeral loopback port, then open the browser. + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo | null + + if (!addr || typeof addr === 'string') { + fail(new Error('Failed to bind loopback listener for native login')) + + return + } + + const redirectUri = `http://127.0.0.1:${addr.port}/callback` + const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, { + challenge, + redirectUri, + state, + provider: opts.provider + }) + + timer = setTimeout(() => { + fail( + new Error( + 'Native sign-in timed out. The browser window may not have completed ' + + 'sign-in; open Settings → Gateway and try again.' + ) + ) + }, timeoutMs) + + log(`[native-oauth] loopback listening on 127.0.0.1:${addr.port}; opening system browser`) + + deps.openExternal(authorizeUrl).catch(error => { + fail( + new Error( + `Could not open the system browser for native sign-in: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ) + }) + }) + }) +} + +export { DEFAULT_LOGIN_TIMEOUT_MS } diff --git a/apps/desktop/electron/native-oauth.test.ts b/apps/desktop/electron/native-oauth.test.ts new file mode 100644 index 0000000000..2f3da633cc --- /dev/null +++ b/apps/desktop/electron/native-oauth.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for electron/native-oauth.ts — the pure RFC 8252 native-app login + * helpers (PKCE, capability detection, URL building, loopback callback + * parsing, token-response normalization, refresh-timing). + * + * Run with: node --test electron/native-oauth.test.ts + * (Wired into the vitest `electron` project via electron/**\/*.test.ts.) + */ + +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' + +import { test } from 'vitest' + +import { + buildNativeAuthorizeUrl, + generatePkcePair, + generateState, + NATIVE_FLOW_ID, + nativeRefreshUrl, + nativeTokenUrl, + parseLoopbackCallback, + parseTokenResponse, + resolveLoginStrategy, + statusSupportsNativeFlow, + tokenNeedsRefresh +} from './native-oauth' + +// --- PKCE --- + +test('generatePkcePair produces a valid S256 verifier/challenge', () => { + const pair = generatePkcePair() + + assert.equal(pair.method, 'S256') + // Verifier length within RFC 7636 range (43–128). + assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128) + // Challenge must be the base64url SHA-256 of the verifier. + const expected = createHash('sha256') + .update(pair.verifier, 'ascii') + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + assert.equal(pair.challenge, expected) + // No padding / URL-unsafe chars. + assert.doesNotMatch(pair.verifier, /[+/=]/) + assert.doesNotMatch(pair.challenge, /[+/=]/) +}) + +test('generatePkcePair is unique per call', () => { + assert.notEqual(generatePkcePair().verifier, generatePkcePair().verifier) +}) + +test('generateState is non-empty and URL-safe', () => { + const s = generateState() + + assert.ok(s.length > 0) + assert.doesNotMatch(s, /[+/=]/) +}) + +// --- capability detection --- + +test('statusSupportsNativeFlow reads the auth_flows array', () => { + assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie', NATIVE_FLOW_ID] }), true) + assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie'] }), false) + // Older gateway: no auth_flows field at all ⇒ not supported. + assert.equal(statusSupportsNativeFlow({ auth_required: true }), false) + assert.equal(statusSupportsNativeFlow({}), false) + assert.equal(statusSupportsNativeFlow(null), false) + // Malformed field shapes never throw. + assert.equal(statusSupportsNativeFlow({ auth_flows: 'native_pkce' }), false) +}) + +test('resolveLoginStrategy picks native only when advertised and not forced', () => { + const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] } + const legacy = { auth_required: true, auth_flows: ['cookie'] } + + assert.equal(resolveLoginStrategy(gated), 'native') + // Compatibility fallback: an older gateway lacking native_pkce ⇒ embedded. + assert.equal(resolveLoginStrategy(legacy), 'embedded') + // A user/env override can pin the legacy flow even on a capable gateway. + assert.equal(resolveLoginStrategy(gated, { forceEmbedded: true }), 'embedded') +}) + +// --- URL building --- + +test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => { + const url = buildNativeAuthorizeUrl('https://gw.example.com', { + challenge: 'CHAL', + redirectUri: 'http://127.0.0.1:51000/callback', + state: 'STATE', + provider: 'nous' + }) + const parsed = new URL(url) + + assert.equal(parsed.origin, 'https://gw.example.com') + assert.equal(parsed.pathname, '/auth/native/authorize') + assert.equal(parsed.searchParams.get('code_challenge'), 'CHAL') + assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256') + assert.equal(parsed.searchParams.get('redirect_uri'), 'http://127.0.0.1:51000/callback') + assert.equal(parsed.searchParams.get('state'), 'STATE') + assert.equal(parsed.searchParams.get('provider'), 'nous') +}) + +test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix', () => { + const url = buildNativeAuthorizeUrl('https://gw.example.com/hermes', { + challenge: 'C', + redirectUri: 'http://127.0.0.1:1/cb', + state: 'S' + }) + const parsed = new URL(url) + + assert.equal(parsed.pathname, '/hermes/auth/native/authorize') + assert.equal(parsed.searchParams.get('provider'), null) +}) + +test('nativeTokenUrl / nativeRefreshUrl build the right endpoints', () => { + assert.equal(nativeTokenUrl('https://gw.example.com'), 'https://gw.example.com/auth/native/token') + assert.equal(nativeRefreshUrl('https://gw.example.com/hermes'), 'https://gw.example.com/hermes/auth/native/refresh') +}) + +// --- loopback callback parsing --- + +test('parseLoopbackCallback returns the code on a state match', () => { + const { code } = parseLoopbackCallback('/callback?code=abc123&state=xyz', 'xyz') + + assert.equal(code, 'abc123') +}) + +test('parseLoopbackCallback throws on state mismatch (CSRF)', () => { + assert.throws( + () => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), + /state mismatch/i + ) +}) + +test('parseLoopbackCallback surfaces a gateway error param', () => { + assert.throws( + () => parseLoopbackCallback('/callback?error=access_denied&error_description=nope', 'xyz'), + /access_denied.*nope/i + ) +}) + +test('parseLoopbackCallback throws when the code is absent', () => { + assert.throws(() => parseLoopbackCallback('/callback?state=xyz', 'xyz'), /missing authorization code/i) +}) + +// --- token response normalization --- + +test('parseTokenResponse maps a well-formed body', () => { + const t = parseTokenResponse({ + access_token: 'AT', + refresh_token: 'RT', + token_type: 'Bearer', + expires_at: 1893456000, + provider: 'nous', + user_id: 'u-1' + }) + + assert.equal(t.accessToken, 'AT') + assert.equal(t.refreshToken, 'RT') + assert.equal(t.expiresAt, 1893456000) + assert.equal(t.provider, 'nous') + assert.equal(t.userId, 'u-1') +}) + +test('parseTokenResponse throws on a missing access token', () => { + assert.throws(() => parseTokenResponse({ refresh_token: 'RT' }), /missing access_token/i) +}) + +test('parseTokenResponse tolerates an absent refresh token / expiry', () => { + const t = parseTokenResponse({ access_token: 'AT' }) + + assert.equal(t.refreshToken, '') + assert.equal(t.expiresAt, 0) +}) + +// --- refresh timing --- + +test('tokenNeedsRefresh respects the skew window', () => { + const now = 1_000_000 + // Expires comfortably in the future ⇒ no refresh. + assert.equal(tokenNeedsRefresh({ expiresAt: now + 3600 }, now), false) + // Within the 60s skew ⇒ refresh early. + assert.equal(tokenNeedsRefresh({ expiresAt: now + 30 }, now), true) + // Already expired ⇒ refresh. + assert.equal(tokenNeedsRefresh({ expiresAt: now - 10 }, now), true) + // Unknown expiry ⇒ refresh (validate before use). + assert.equal(tokenNeedsRefresh({ expiresAt: 0 }, now), true) +}) diff --git a/apps/desktop/electron/native-oauth.ts b/apps/desktop/electron/native-oauth.ts new file mode 100644 index 0000000000..9e8bc6fdbd --- /dev/null +++ b/apps/desktop/electron/native-oauth.ts @@ -0,0 +1,215 @@ +/** + * native-oauth.ts + * + * Pure, electron-free helpers for the desktop's RFC 8252 (OAuth 2.0 for Native + * Apps) login to a gated Hermes gateway: system-browser + loopback redirect + + * PKCE, with tokens returned to the app (never browser session cookies). + * + * Kept standalone (no `import 'electron'`) so it unit-tests with `node --test` + * — same pattern as connection-config.ts. main.ts owns the electron-coupled + * parts (the actual http.Server loopback listener, shell.openExternal, and + * safeStorage keychain writes) and calls these helpers for the pure logic. + * + * Why the gateway brokers the flow (not a direct desktop→IDP client): the + * upstream IDP (Nous Portal) issues a per-gateway-instance client_id and only + * accepts a redirect_uri on the gateway's own origin, so a desktop loopback + * redirect can't be a direct Portal client. Instead the gateway exposes + * /auth/native/{authorize,token,refresh}: it is the authorization server to + * the desktop and an OAuth client to Portal. The desktop still gets the full + * RFC 8252 experience — its own PKCE pair, its own loopback redirect, tokens + * it stores itself. + * + * Capability detection: the gateway advertises supported flows on the public + * /api/status `auth_flows` array. `native_pkce` present ⇒ use this flow; + * absent (older gateway) ⇒ the caller falls back to the embedded-webview + * cookie flow. This is the "observable ladder / compatibility fallback tied to + * an identified older runtime" the desktop guide requires. + */ + +import { createHash, randomBytes } from 'node:crypto' + +// The gateway status field that lists supported auth flows. See +// hermes_cli/web_server.py status handler. +const NATIVE_FLOW_ID = 'native_pkce' + +export interface NativePkcePair { + verifier: string + challenge: string + method: 'S256' +} + +export interface NativeTokenSet { + accessToken: string + refreshToken: string + expiresAt: number + provider: string + userId: string +} + +/** base64url without `=` padding (RFC 7636 §4). */ +function b64url(raw: Buffer): string { + return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Generate a PKCE verifier/challenge pair (S256). The verifier is 32 random + * bytes base64url-encoded (43 chars, within RFC 7636's 43–128 range). + */ +export function generatePkcePair(randomImpl: (n: number) => Buffer = randomBytes): NativePkcePair { + const verifier = b64url(randomImpl(32)) + const challenge = b64url(createHash('sha256').update(verifier, 'ascii').digest()) + + return { verifier, challenge, method: 'S256' } +} + +/** A high-entropy CSRF `state` value for the loopback round trip. */ +export function generateState(randomImpl: (n: number) => Buffer = randomBytes): string { + return b64url(randomImpl(24)) +} + +/** + * True if a gateway `/api/status` body advertises the native PKCE flow. + * Tolerant of the field being absent (older gateway) or malformed. + */ +export function statusSupportsNativeFlow(statusBody: any): boolean { + const flows = statusBody && statusBody.auth_flows + + return Array.isArray(flows) && flows.includes(NATIVE_FLOW_ID) +} + +/** + * Decide the login strategy for a gated gateway from its status body. + * Returns 'native' when the gateway can do RFC 8252 AND we're not forced to + * the legacy path; 'embedded' otherwise (older gateway ⇒ webview fallback). + * + * `forceEmbedded` lets a user/setting or an env override pin the legacy flow + * (e.g. a corporate proxy that blocks loopback). Precedence written down here, + * in one place, as a pure function — per the desktop "observable ladder" rule. + */ +export function resolveLoginStrategy( + statusBody: any, + opts: { forceEmbedded?: boolean } = {} +): 'native' | 'embedded' { + if (opts.forceEmbedded) { + return 'embedded' + } + + return statusSupportsNativeFlow(statusBody) ? 'native' : 'embedded' +} + +/** + * Build the gateway `/auth/native/authorize` URL the system browser opens. + * `redirectUri` is the desktop's loopback callback (127.0.0.1:/...). + * `provider` is optional — omitted lets the gateway pick when it has exactly + * one session provider (the common hosted case). + */ +export function buildNativeAuthorizeUrl( + baseUrl: string, + params: { challenge: string; redirectUri: string; state: string; provider?: string } +): string { + const parsed = new URL(baseUrl) + const prefix = parsed.pathname.replace(/\/+$/, '') + const q = new URLSearchParams({ + code_challenge: params.challenge, + code_challenge_method: 'S256', + redirect_uri: params.redirectUri, + state: params.state + }) + + if (params.provider) { + q.set('provider', params.provider) + } + + return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/authorize?${q.toString()}` +} + +/** The `/auth/native/token` endpoint URL for a gateway base URL. */ +export function nativeTokenUrl(baseUrl: string): string { + const parsed = new URL(baseUrl) + const prefix = parsed.pathname.replace(/\/+$/, '') + + return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/token` +} + +/** The `/auth/native/refresh` endpoint URL for a gateway base URL. */ +export function nativeRefreshUrl(baseUrl: string): string { + const parsed = new URL(baseUrl) + const prefix = parsed.pathname.replace(/\/+$/, '') + + return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/refresh` +} + +/** + * Parse the loopback redirect the gateway sends the browser to. Returns the + * `code` + `state`, or throws with the gateway's `error` if the flow failed. + * `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch + * throws rather than proceeding. + */ +export function parseLoopbackCallback( + requestUrl: string, + expectedState: string +): { code: string } { + // requestUrl is the path+query the loopback server received, e.g. + // "/callback?code=...&state=...". Resolve against a dummy origin to parse. + const parsed = new URL(requestUrl, 'http://127.0.0.1') + const error = parsed.searchParams.get('error') + + if (error) { + const desc = parsed.searchParams.get('error_description') || '' + throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`) + } + + const code = parsed.searchParams.get('code') || '' + const state = parsed.searchParams.get('state') || '' + + if (!code) { + throw new Error('Loopback callback missing authorization code') + } + + if (!expectedState || state !== expectedState) { + // Never redeem a code that arrived with a mismatched state — it may be a + // forged callback trying to inject an attacker's code. + throw new Error('Loopback callback state mismatch (possible CSRF)') + } + + return { code } +} + +/** + * Normalize a `/auth/native/token` (or refresh) JSON response into a + * NativeTokenSet, validating the shape. Throws on a missing/short access + * token so a malformed response fails loudly rather than storing junk. + */ +export function parseTokenResponse(body: any): NativeTokenSet { + const accessToken = String(body?.access_token || '') + + if (!accessToken) { + throw new Error('Gateway token response missing access_token') + } + + const expiresAt = Number(body?.expires_at) + + return { + accessToken, + refreshToken: String(body?.refresh_token || ''), + expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0, + provider: String(body?.provider || ''), + userId: String(body?.user_id || '') + } +} + +/** + * True when a stored token set is at/near expiry and should be refreshed + * before use. `skewSeconds` refreshes slightly early to avoid a race where + * the token expires in flight (mirrors the server's 60s cookie floor). + */ +export function tokenNeedsRefresh(tokens: Pick, nowSeconds: number, skewSeconds = 60): boolean { + if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) { + // Unknown expiry ⇒ treat as needing refresh so we validate before use. + return true + } + + return nowSeconds >= tokens.expiresAt - skewSeconds +} + +export { NATIVE_FLOW_ID } diff --git a/hermes_cli/dashboard_auth/audit.py b/hermes_cli/dashboard_auth/audit.py index cde23bf40b..4c05b42ada 100644 --- a/hermes_cli/dashboard_auth/audit.py +++ b/hermes_cli/dashboard_auth/audit.py @@ -49,6 +49,11 @@ 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 (system-browser + loopback + PKCE) flow. + NATIVE_AUTHORIZE_START = "native_authorize_start" + NATIVE_CODE_ISSUED = "native_code_issued" + NATIVE_TOKEN_SUCCESS = "native_token_success" + NATIVE_TOKEN_FAILURE = "native_token_failure" def _resolve_log_path() -> Path: diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 5c029cac62..5b11e98cf2 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -49,6 +49,9 @@ _log = logging.getLogger(__name__) _GATE_PUBLIC_PREFIXES: tuple[str, ...] = ( "/auth/login", "/auth/callback", + "/auth/native/authorize", + "/auth/native/token", + "/auth/native/refresh", "/auth/password-login", "/auth/logout", "/login", @@ -275,6 +278,48 @@ def _safe_next_target(request: Request) -> str: return quote(target, safe="") +def _extract_bearer(request: Request) -> str: + """Return the ``Authorization: Bearer `` value, or "".""" + 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(request: Request, *, access_token: str): + """Verify a native-app bearer access token via the session-provider stack. + + Returns the :class:`Session` on success, or ``None`` if no provider + recognises the token (expired/invalid/unknown). Mirrors the cookie path's + verify loop, including the "one provider unreachable ⇒ don't force + re-login" semantics: a transient IDP outage returns a 503 rather than a + 401, so the desktop retries instead of dropping the user to full re-login. + Unlike the cookie path there is no server-side refresh — the desktop owns + its refresh token and rotates via ``/auth/native/refresh``. + """ + unreachable_provider: str | None = None + for provider in list_session_providers(): + 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 + if unreachable_provider is not None: + # Signal transient outage to the caller via a sentinel exception the + # middleware turns into 503. Raising keeps the "don't logout on a + # flaky IDP" contract identical to the cookie path. + raise ProviderError(unreachable_provider) + return None + + async def gated_auth_middleware( request: Request, call_next: Callable[[Request], Awaitable[Response]], @@ -298,6 +343,35 @@ async def gated_auth_middleware( if _path_is_public(path): return await call_next(request) + # RFC 8252 native-app bearer path (goal: no session cookies). The desktop + # authenticates REST with ``Authorization: Bearer `` — the + # SAME provider-minted access token the cookie flow stores in + # ``hermes_session_at``. Verify it with the identical ``verify_session`` + # provider stack and attach the Session; on success we're done, with no + # cookie set or read. A missing/expired/invalid bearer falls through to + # the cookie path (a request may legitimately carry neither). Token + # rotation for this path is the desktop's job via /auth/native/refresh — + # the gate never sets a cookie here, so the transparent cookie-rotation + # below must not run for a bearer caller. + bearer = _extract_bearer(request) + if bearer: + try: + bearer_session = _verify_bearer(request, access_token=bearer) + except ProviderError as e: + # At least one provider's IDP/JWKS was unreachable and none + # verified the token — transient outage, not bad credentials. + return JSONResponse( + {"detail": f"Auth provider {str(e)!r} unreachable"}, + status_code=503, + ) + if bearer_session is not None: + request.state.session = bearer_session + return await call_next(request) + # A bearer was presented but didn't verify (expired/invalid/unknown). + # Return the structured 401 so the desktop knows to refresh or + # re-login, rather than falling through to the cookie/login redirect. + return _unauth_response(request, reason="invalid_or_expired_session") + 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_flow.py b/hermes_cli/dashboard_auth/native_flow.py new file mode 100644 index 0000000000..2f464a3866 --- /dev/null +++ b/hermes_cli/dashboard_auth/native_flow.py @@ -0,0 +1,275 @@ +"""Gateway-brokered RFC 8252 (OAuth 2.0 for Native Apps) authorization store. + +The desktop app is a *native* OAuth client that wants to sign in to a gated +gateway **without an embedded webview and without relying on browser session +cookies**. It cannot be a direct OAuth client of the upstream IDP (Nous +Portal): the Portal ``client_id`` is per-gateway-instance +(``agent:{instance_id}``) and the Portal validates that the ``redirect_uri`` +ends in ``/auth/callback`` on the gateway's own public origin — a desktop +loopback ``127.0.0.1`` redirect is rejected. So the **gateway brokers** the +flow: it is the authorization server *to the desktop*, and an OAuth client *to +the Portal*. This is still a textbook RFC 8252 deployment — system browser, +loopback redirect, PKCE, tokens returned to the app (never cookies). + +Wire shape (all gateway-side state lives in this module): + + 1. Desktop generates its OWN PKCE pair ``(cv_d, cc_d)`` and a ``state``, opens + a loopback listener on ``127.0.0.1:``, and opens the system browser + to the gateway's ``GET /auth/native/authorize?...`` carrying ``cc_d``, + ``state``, and its loopback ``redirect_uri``. + 2. The gateway ``authorize`` route stashes a **pending authorization** + (``register_pending``) keyed by an opaque ``broker_state`` and runs the + EXISTING upstream PKCE flow (``provider.start_login`` → Portal + ``/oauth/authorize`` → gateway ``/auth/callback``). The desktop's + ``cc_d`` / ``state`` / loopback ``redirect_uri`` ride through the upstream + round trip inside the gateway's own PKCE cookie, so no desktop secret is + ever exposed to the Portal. + 3. On the upstream callback the gateway holds a verified :class:`Session`. It + **mints a one-time gateway authorization code** (``complete_pending``) + bound to the desktop's ``cc_d``, and 302s the browser to the desktop's + ``redirect_uri?code=&state=``. + 4. The desktop's loopback listener catches ``gw_code``, then POSTs + ``/auth/native/token`` with ``gw_code`` + its ``cv_d``. The gateway + verifies ``SHA256(cv_d) == cc_d`` (``redeem_code``), consumes the code + (single use), and returns the upstream ``access_token`` / + ``refresh_token`` / ``expires_at`` **in the JSON body**. + 5. The desktop stores those in the OS keychain and authenticates REST with + ``Authorization: Bearer `` (via the existing ``token_auth`` + seam) and mints ws-tickets the same way — no cookies anywhere. + +Security properties this module guarantees: + + * **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client + that presented the matching ``code_challenge``. An attacker who intercepts + the loopback ``gw_code`` (e.g. a hostile process racing the redirect) cannot + exchange it without ``cv_d``, which never leaves the desktop. + * **Single use.** ``redeem_code`` pops the entry; a replay finds nothing. + * **Short TTLs.** A pending authorization lives ``_PENDING_TTL`` seconds (the + interactive login window); a minted code lives ``_CODE_TTL`` seconds (the + loopback round trip is sub-second). Expired entries are refused and GC'd. + * **Opaque, high-entropy handles.** ``broker_state`` and ``gw_code`` are + 256-bit ``secrets.token_urlsafe`` values; comparison is constant-time. + * **No secret logging.** The module stores tokens transiently in memory only + between callback and redemption; nothing here writes them to disk (the + audit log strips token fields). + +In-memory and process-local: the dashboard is a single process, so no +distributed coordination is needed (mirrors ``ws_tickets``). A functional API +(not a class) keeps ``time.time`` patchable in tests. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import threading +import time +from dataclasses import dataclass +from typing import Dict, Optional + +from hermes_cli.dashboard_auth.base import Session + +# TTL for a pending authorization (step 2→3): the whole interactive login, +# including the user typing Portal credentials / approving in the browser. +_PENDING_TTL_SECONDS = 600 # 10 minutes — mirrors the PKCE cookie lifetime. + +# TTL for a minted gateway code (step 3→4): only the loopback redirect + the +# desktop's immediate token POST, which is sub-second in practice. +_CODE_TTL_SECONDS = 120 # 2 minutes — generous for a slow local hop. + +# Cap the number of concurrent pending/issued entries so a misbehaving or +# malicious client cannot grow the store unbounded. Well above any legitimate +# concurrent-login count for a single desktop user. +_MAX_ENTRIES = 256 + +_lock = threading.Lock() + + +@dataclass +class _Pending: + """An in-flight native authorization awaiting the upstream callback. + + Created when the desktop hits ``/auth/native/authorize`` and consumed when + the upstream ``/auth/callback`` completes and mints the gateway code. + """ + + code_challenge: str # the DESKTOP's S256 challenge (cc_d), base64url no-pad + redirect_uri: str # the desktop's loopback redirect (127.0.0.1:/...) + client_state: str # the desktop's own ``state`` (echoed back on redirect) + expires_at: int + + +@dataclass +class _IssuedCode: + """A minted one-time gateway authorization code bound to a Session.""" + + code_challenge: str # cc_d — verified against cv_d at redemption + session: Session + expires_at: int + + +# broker_state -> _Pending +_pending: Dict[str, _Pending] = {} +# gw_code -> _IssuedCode +_issued: Dict[str, _IssuedCode] = {} + + +class NativeFlowError(Exception): + """Base for native-flow failures (bad/expired/replayed handle, PKCE fail).""" + + +class PendingNotFound(NativeFlowError): + """The broker_state is unknown or expired (login window lapsed).""" + + +class CodeInvalid(NativeFlowError): + """The gateway code is unknown, expired, already redeemed, or PKCE-mismatched.""" + + +def _b64url_no_pad(raw: bytes) -> str: + """Base64url without ``=`` padding (RFC 7636 §4).""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _s256(verifier: str) -> str: + """RFC 7636 S256 transform: base64url(sha256(ascii(verifier))).""" + return _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest()) + + +def _gc_locked(now: int) -> None: + """Drop expired pending + issued entries. Caller holds ``_lock``.""" + expired_p = [k for k, v in _pending.items() if v.expires_at < now] + for k in expired_p: + _pending.pop(k, None) + expired_c = [k for k, v in _issued.items() if v.expires_at < now] + for k in expired_c: + _issued.pop(k, None) + + +def _capacity_ok_locked() -> bool: + return (len(_pending) + len(_issued)) < _MAX_ENTRIES + + +def register_pending( + *, + code_challenge: str, + redirect_uri: str, + client_state: str, + now: Optional[int] = None, +) -> str: + """Stash a pending native authorization; return an opaque ``broker_state``. + + Called by ``/auth/native/authorize``. ``code_challenge`` is the DESKTOP's + S256 challenge (``cc_d``) — we never see the verifier until redemption. + ``redirect_uri`` is the desktop's loopback callback and ``client_state`` is + the desktop's own CSRF ``state`` (echoed verbatim on the final redirect). + + The returned ``broker_state`` is what the gateway threads through its OWN + upstream PKCE round trip (inside the ``hermes_session_pkce`` cookie), so the + callback can find this entry again via :func:`complete_pending`. + + Raises ``NativeFlowError`` if the store is at capacity (fail closed). + """ + now = int(time.time()) if now is None else now + broker_state = secrets.token_urlsafe(32) + with _lock: + _gc_locked(now) + if not _capacity_ok_locked(): + raise NativeFlowError("native-flow authorization store at capacity") + _pending[broker_state] = _Pending( + code_challenge=code_challenge, + redirect_uri=redirect_uri, + client_state=client_state, + expires_at=now + _PENDING_TTL_SECONDS, + ) + return broker_state + + +def get_pending(broker_state: str, *, now: Optional[int] = None) -> _Pending: + """Return the pending authorization for ``broker_state`` without consuming it. + + Read-only peek used by the callback to learn the desktop's ``redirect_uri`` + and ``client_state`` for the final 302. Raises :class:`PendingNotFound` if + unknown or expired (the entry is GC'd on expiry). + """ + now = int(time.time()) if now is None else now + with _lock: + _gc_locked(now) + entry = _pending.get(broker_state) + if entry is None: + raise PendingNotFound("unknown or expired native authorization") + return entry + + +def complete_pending( + broker_state: str, + *, + session: Session, + now: Optional[int] = None, +) -> str: + """Consume a pending authorization and mint a one-time gateway code. + + Called by ``/auth/callback`` once the upstream :class:`Session` is verified. + Pops the pending entry (single use), binds a fresh ``gw_code`` to the + desktop's ``code_challenge`` + the verified ``session``, and returns the + ``gw_code`` for the loopback redirect. + + Raises :class:`PendingNotFound` if the broker_state is unknown/expired. + """ + now = int(time.time()) if now is None else now + with _lock: + _gc_locked(now) + pending = _pending.pop(broker_state, None) + if pending is None: + raise PendingNotFound("unknown or expired native authorization") + if not _capacity_ok_locked(): + raise NativeFlowError("native-flow code store at capacity") + gw_code = secrets.token_urlsafe(32) + _issued[gw_code] = _IssuedCode( + code_challenge=pending.code_challenge, + session=session, + expires_at=now + _CODE_TTL_SECONDS, + ) + return gw_code + + +def redeem_code( + *, + code: str, + code_verifier: str, + now: Optional[int] = None, +) -> Session: + """Verify PKCE + consume a gateway code; return the bound :class:`Session`. + + Called by ``/auth/native/token``. Enforces: + * the code exists and is unexpired (else :class:`CodeInvalid`); + * ``S256(code_verifier) == code_challenge`` in constant time (RFC 7636); + * single use — the entry is popped BEFORE the PKCE check so a wrong + verifier cannot be retried against the same code. + + On any failure the code is already consumed (no oracle, no replay). + """ + now = int(time.time()) if now is None else now + with _lock: + _gc_locked(now) + issued = _issued.pop(code, None) + # Pop happened under the lock; every return path below has already + # consumed the code, so a replay (valid or not) finds nothing. + if issued is None: + raise CodeInvalid("unknown, expired, or already-redeemed code") + if issued.expires_at < now: + raise CodeInvalid("code expired") + expected = issued.code_challenge + actual = _s256(code_verifier) + if not hmac.compare_digest(expected, actual): + raise CodeInvalid("PKCE verification failed") + return issued.session + + +def _reset_for_tests() -> None: + """Test-only: drop all pending + issued state.""" + with _lock: + _pending.clear() + _issued.clear() diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 5b833e5df7..ed8bcad907 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -245,6 +245,130 @@ async def auth_login(request: Request, provider: str, next: str = ""): return resp +# --------------------------------------------------------------------------- +# Public: RFC 8252 native-app authorization (system browser + loopback + PKCE) +# --------------------------------------------------------------------------- + + +def _validate_loopback_redirect_uri(raw: str) -> str: + """Return ``raw`` if it is a safe loopback redirect_uri, else raise. + + RFC 8252 §7.3 restricts native-app redirects to the loopback interface. + We accept only ``http://127.0.0.1[:port]/...`` and ``http://[::1][:port]/...`` + (and the literal ``localhost`` host, which some OS browsers normalise). + A non-loopback host would let an attacker who can reach ``/auth/native/ + authorize`` (a public route) turn the gateway's authenticated callback + into an open redirect that leaks a live authorization code to an + arbitrary origin — so this check is a security boundary, not ergonomics. + """ + from urllib.parse import urlparse + + if not raw: + raise HTTPException(status_code=400, detail="redirect_uri required") + parsed = urlparse(raw) + if parsed.scheme != "http": + raise HTTPException( + status_code=400, + detail="native redirect_uri must be http:// on the loopback interface", + ) + host = (parsed.hostname or "").lower() + if host not in ("127.0.0.1", "::1", "localhost"): + raise HTTPException( + status_code=400, + detail="native redirect_uri host must be loopback (127.0.0.1 / ::1)", + ) + return raw + + +@router.get("/auth/native/authorize", name="auth_native_authorize") +async def auth_native_authorize( + request: Request, + provider: str = "", + code_challenge: str = "", + code_challenge_method: str = "", + redirect_uri: str = "", + state: str = "", +): + """Begin an RFC 8252 native-app login for the desktop app. + + The desktop opens THIS url in the system browser with its own PKCE + ``code_challenge`` (S256), a loopback ``redirect_uri``, and a CSRF + ``state``. We stash a pending broker authorization, then hand off to the + EXISTING upstream PKCE round trip (``provider.start_login`` → IDP → + ``/auth/callback``), carrying the broker_state in the same PKCE cookie the + cookie flow uses. On the callback we mint a loopback code (see + ``auth_callback``); no browser session cookie is ever set for the desktop. + """ + # PKCE method must be S256 (RFC 7636 — plain is disallowed for native apps). + if code_challenge_method.upper() != "S256": + raise HTTPException( + status_code=400, + detail="code_challenge_method must be S256", + ) + if not code_challenge: + raise HTTPException(status_code=400, detail="code_challenge required") + _validate_loopback_redirect_uri(redirect_uri) + + # Resolve the provider. With exactly one session provider registered + # (the common hosted case) an empty ``provider`` selects it, mirroring + # the auto-SSO convenience so the desktop needn't hardcode the name. + p = get_provider(provider) if provider else None + if p is None and not provider: + sess_providers = list_session_providers() + if len(sess_providers) == 1: + p = sess_providers[0] + if p is None: + raise HTTPException( + status_code=404, detail=f"Unknown provider: {provider!r}" + ) + if not getattr(p, "supports_session", True) or getattr( + p, "supports_password", False + ): + # Native PKCE brokering is only meaningful for redirect/OAuth + # providers; a password provider has no IDP round trip to broker. + raise HTTPException( + status_code=400, + detail=f"Provider does not support native OAuth login: {p.name!r}", + ) + + from hermes_cli.dashboard_auth import native_flow + + try: + broker_state = native_flow.register_pending( + code_challenge=code_challenge, + redirect_uri=redirect_uri, + client_state=state, + ) + except native_flow.NativeFlowError as e: + raise HTTPException(status_code=503, detail=str(e)) + + try: + ls = p.start_login(redirect_uri=_redirect_uri(request)) + except ProviderError as e: + raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}") + + audit_log( + AuditEvent.NATIVE_AUTHORIZE_START, + provider=p.name, + ip=_client_ip(request), + ) + + resp = RedirectResponse(url=ls.redirect_url, status_code=302) + # Thread the provider name + broker_state through the gateway's OWN PKCE + # cookie so the callback can (a) dispatch to the right provider and (b) + # find the pending native authorization. The desktop's challenge/state + # never touch this cookie — only our opaque broker_state does. + pkce = ls.cookie_payload.get("hermes_session_pkce", "") + if "provider=" not in pkce: + pkce = f"provider={p.name};{pkce}" if pkce else f"provider={p.name}" + pkce = f"{pkce};broker={broker_state}" + set_pkce_cookie( + resp, payload=pkce, use_https=detect_https(request), + prefix=_prefix(request), + ) + return resp + + @router.get("/auth/callback", name="auth_callback") async def auth_callback( request: Request, @@ -280,6 +404,11 @@ async def auth_callback( # next= query parameter on the callback URL is attacker-controlled # and MUST be ignored. next_from_cookie = parts.get("next", "") + # RFC 8252 native-app flow: /auth/native/authorize stashed a broker_state + # here so this callback can mint a loopback authorization code for the + # desktop instead of setting browser session cookies. Absent for the + # ordinary cookie/SPA login. + broker_state = parts.get("broker", "") p = get_provider(provider_name) if p is None: @@ -350,6 +479,54 @@ async def auth_callback( ) expires_in = max(60, session.expires_at - int(time.time())) + + # RFC 8252 native-app branch: the desktop initiated this via + # /auth/native/authorize and is waiting on a loopback listener. Mint a + # one-time gateway authorization code bound to the desktop's PKCE + # challenge and 302 the SYSTEM BROWSER to the desktop's loopback + # redirect_uri — no session cookies are set on this response, and the + # tokens are handed to the desktop only at /auth/native/token. This is + # what lets the desktop avoid both the embedded webview and cookie auth. + if broker_state: + from hermes_cli.dashboard_auth import native_flow + + try: + pending = native_flow.get_pending(broker_state) + gw_code = native_flow.complete_pending( + broker_state, session=session + ) + except native_flow.NativeFlowError: + audit_log( + AuditEvent.NATIVE_TOKEN_FAILURE, + provider=provider_name, + reason="pending_not_found", + ip=_client_ip(request), + ) + raise HTTPException( + status_code=400, + detail="Native login expired or unknown; restart sign-in.", + ) + from urllib.parse import urlencode + + sep = "&" if "?" in pending.redirect_uri else "?" + loopback = ( + f"{pending.redirect_uri}{sep}" + f"{urlencode({'code': gw_code, 'state': pending.client_state})}" + ) + audit_log( + AuditEvent.NATIVE_CODE_ISSUED, + provider=provider_name, + user_id=session.user_id, + ip=_client_ip(request), + ) + resp = RedirectResponse(url=loopback, status_code=302) + # Clear the PKCE cookie (its job is done) but set NO session cookies: + # the desktop is not a browser session, it redeems the code for a + # bearer token it stores itself. + clear_pkce_cookie(resp, prefix=_prefix(request)) + clear_sso_attempt_cookie(resp, prefix=_prefix(request)) + return resp + # Honour the ``next=`` value the gate's _unauth_response set in the # /login redirect URL and that /auth/login persisted into the PKCE # cookie. We re-validate against the same-origin rules here — the @@ -642,3 +819,139 @@ async def api_auth_ws_ticket(request: Request): ip=_client_ip(request), ) return {"ticket": ticket, "ttl_seconds": TTL_SECONDS} + + +# --------------------------------------------------------------------------- +# Public: RFC 8252 native-app token exchange (loopback code → bearer tokens) +# --------------------------------------------------------------------------- + + +class _NativeTokenBody(BaseModel): + code: str + code_verifier: str + + +@router.post("/auth/native/token", name="auth_native_token") +async def auth_native_token(request: Request, body: _NativeTokenBody): + """Exchange a loopback gateway code + PKCE verifier for bearer tokens. + + The desktop POSTs this from its loopback listener after catching the + ``?code=`` redirect. We verify ``SHA256(code_verifier) == code_challenge`` + (the challenge captured at ``/auth/native/authorize``), consume the code + (single use), and return the upstream tokens **in the JSON body** — the + desktop stores them in the OS keychain and authenticates with + ``Authorization: Bearer`` thereafter. No cookie is set on this response. + + Failure modes (all deliberately generic — the code is consumed on every + path so there is no verifier oracle and no replay): + * unknown / expired / already-redeemed code, or PKCE mismatch → 400 + """ + from hermes_cli.dashboard_auth import native_flow + + try: + session = native_flow.redeem_code( + code=body.code, code_verifier=body.code_verifier + ) + except native_flow.CodeInvalid: + audit_log( + AuditEvent.NATIVE_TOKEN_FAILURE, + reason="invalid_code_or_pkce", + ip=_client_ip(request), + ) + raise HTTPException( + status_code=400, + detail="Invalid or expired authorization code.", + ) + + audit_log( + AuditEvent.NATIVE_TOKEN_SUCCESS, + provider=session.provider, + user_id=session.user_id, + ip=_client_ip(request), + ) + return { + "access_token": session.access_token, + "refresh_token": session.refresh_token, + "token_type": "Bearer", + "expires_at": session.expires_at, + "provider": session.provider, + "user_id": session.user_id, + } + + +class _NativeRefreshBody(BaseModel): + refresh_token: str + provider: str = "" + + +@router.post("/auth/native/refresh", name="auth_native_refresh") +async def auth_native_refresh(request: Request, body: _NativeRefreshBody): + """Rotate a native-app session using the desktop-held refresh token. + + The desktop owns its refresh token (OS keychain) rather than a cookie, so + it rotates here instead of relying on the gate's transparent cookie + rotation. Mirrors the middleware's ``_attempt_refresh`` provider stacking: + tries each session provider until one rotates the token, returning the new + access/refresh pair **in the JSON body**. + + Failure modes: + * every provider rejects the RT (dead/expired/reuse-detected) → 401 + ``session_expired`` so the desktop starts a fresh native login; + * a provider's IDP is unreachable and none rotated → 503. + """ + from hermes_cli.dashboard_auth import list_session_providers + from hermes_cli.dashboard_auth.base import RefreshExpiredError + + if not body.refresh_token: + raise HTTPException(status_code=400, detail="refresh_token required") + + providers = list_session_providers() + if body.provider: + providers.sort(key=lambda p: p.name != body.provider) + + unreachable: str | None = None + for provider in providers: + try: + session = provider.refresh_session(refresh_token=body.refresh_token) + except RefreshExpiredError: + continue + except ProviderError as e: + if unreachable is None: + unreachable = provider.name + _log.warning( + "dashboard-auth: provider %r unreachable during native refresh: %s", + provider.name, e, + ) + continue + audit_log( + AuditEvent.REFRESH_SUCCESS, + provider=session.provider, + user_id=session.user_id, + ip=_client_ip(request), + ) + return { + "access_token": session.access_token, + "refresh_token": session.refresh_token, + "token_type": "Bearer", + "expires_at": session.expires_at, + "provider": session.provider, + "user_id": session.user_id, + } + + if unreachable is not None: + raise HTTPException( + status_code=503, + detail=f"Auth provider {unreachable!r} unreachable", + ) + audit_log( + AuditEvent.REFRESH_FAILURE, + reason="all_providers_rejected_rt", + ip=_client_ip(request), + ) + return JSONResponse( + { + "error": "session_expired", + "detail": "Refresh token expired or invalid; start a new sign-in.", + }, + status_code=401, + ) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b14fe66196..238b4acf88 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3091,9 +3091,29 @@ 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] = [] + # RFC 8252 native-app capability advertisement. The desktop reads this + # to decide whether it can use the system-browser + loopback + PKCE + # flow (no embedded webview, no session cookies) or must fall back to + # the legacy embedded-webview cookie flow. "cookie" is always available + # in gated mode; "native_pkce" is present only when at least one + # registered session provider is a brokerable OAuth provider (not a + # password or token-only credential). Absent field / missing + # "native_pkce" ⇒ older gateway ⇒ desktop falls back automatically. + auth_flows: list[str] = [] 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()] + if auth_required: + auth_flows.append("cookie") + brokerable = [ + p for p in _list_session_providers() + if not getattr(p, "supports_password", False) + ] + if brokerable: + auth_flows.append("native_pkce") except Exception: # Module not importable yet (early startup) — leave as []. pass @@ -3135,6 +3155,7 @@ async def get_status(profile: Optional[str] = None): "active_sessions": active_sessions, "auth_required": auth_required, "auth_providers": auth_providers, + "auth_flows": auth_flows, "nous_session_valid": nous_session_valid, } diff --git a/tests/hermes_cli/test_dashboard_auth_native_flow.py b/tests/hermes_cli/test_dashboard_auth_native_flow.py new file mode 100644 index 0000000000..a13c997bd6 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_auth_native_flow.py @@ -0,0 +1,456 @@ +"""E2E + unit tests for the RFC 8252 native-app (system-browser + loopback + +PKCE) dashboard-auth flow. + +Covers: + * ``native_flow`` broker unit behaviour — PKCE binding, single-use codes, + expiry, capacity, replay resistance. + * The full ``/auth/native/authorize`` → ``/auth/callback`` → + ``/auth/native/token`` round trip in-process against ``StubAuthProvider``. + * ``/api/status`` capability advertisement (``auth_flows``). + * Cookieless bearer authentication of a gated route (the whole point of the + feature — a desktop authenticates REST with ``Authorization: Bearer`` and + sets/needs no cookie). + * ``/auth/native/refresh`` token rotation and terminal-expiry semantics. + +Run: pytest tests/hermes_cli/test_dashboard_auth_native_flow.py +""" + +from __future__ import annotations + +import hashlib +import base64 +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_flow +from hermes_cli.dashboard_auth.base import Session +from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider + + +# --------------------------------------------------------------------------- +# PKCE helpers (desktop side) +# --------------------------------------------------------------------------- + + +def _b64url_no_pad(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _make_pkce() -> tuple[str, str]: + """Return ``(verifier, challenge)`` — the desktop's PKCE pair.""" + verifier = _b64url_no_pad(b"desktop-verifier-secret-material-0123456789abcd") + challenge = _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest()) + return verifier, challenge + + +# --------------------------------------------------------------------------- +# native_flow broker unit tests +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_broker(): + native_flow._reset_for_tests() + # Snapshot the shared app.state auth fields + provider registry so a test + # that flips auth_required / registers a stub provider can't leak into a + # later test file (e.g. the MCP dashboard-oauth suite shares web_server.app). + prev_required = getattr(web_server.app.state, "auth_required", None) + prev_host = getattr(web_server.app.state, "bound_host", None) + prev_port = getattr(web_server.app.state, "bound_port", None) + yield + native_flow._reset_for_tests() + clear_providers() + web_server.app.state.auth_required = prev_required + web_server.app.state.bound_host = prev_host + web_server.app.state.bound_port = prev_port + + +def _stub_session(exp_offset: int = 3600) -> Session: + now = int(time.time()) + return Session( + user_id="u1", + email="u1@example.test", + display_name="U One", + org_id="org1", + provider="stub", + expires_at=now + exp_offset, + access_token="at-opaque", + refresh_token="rt-opaque", + ) + + +def test_broker_happy_path_binds_pkce_and_returns_session(): + verifier, challenge = _make_pkce() + broker_state = native_flow.register_pending( + code_challenge=challenge, + redirect_uri="http://127.0.0.1:53123/callback", + client_state="client-state-xyz", + ) + pending = native_flow.get_pending(broker_state) + assert pending.redirect_uri == "http://127.0.0.1:53123/callback" + assert pending.client_state == "client-state-xyz" + + sess = _stub_session() + code = native_flow.complete_pending(broker_state, session=sess) + redeemed = native_flow.redeem_code(code=code, code_verifier=verifier) + assert redeemed.access_token == "at-opaque" + assert redeemed.user_id == "u1" + + +def test_broker_rejects_wrong_verifier(): + _verifier, challenge = _make_pkce() + broker_state = native_flow.register_pending( + code_challenge=challenge, + redirect_uri="http://127.0.0.1:1/cb", + client_state="s", + ) + code = native_flow.complete_pending(broker_state, session=_stub_session()) + with pytest.raises(native_flow.CodeInvalid): + native_flow.redeem_code(code=code, code_verifier="wrong-verifier") + + +def test_broker_code_is_single_use(): + verifier, challenge = _make_pkce() + broker_state = native_flow.register_pending( + code_challenge=challenge, redirect_uri="http://127.0.0.1:1/cb", + client_state="s", + ) + code = native_flow.complete_pending(broker_state, session=_stub_session()) + native_flow.redeem_code(code=code, code_verifier=verifier) + # Replay must fail — the code was consumed. + with pytest.raises(native_flow.CodeInvalid): + native_flow.redeem_code(code=code, code_verifier=verifier) + + +def test_broker_wrong_verifier_still_consumes_code_no_oracle(): + """A wrong-verifier attempt must not leave the code redeemable — otherwise + an attacker who steals the loopback code could brute-force the verifier.""" + verifier, challenge = _make_pkce() + broker_state = native_flow.register_pending( + code_challenge=challenge, redirect_uri="http://127.0.0.1:1/cb", + client_state="s", + ) + code = native_flow.complete_pending(broker_state, session=_stub_session()) + with pytest.raises(native_flow.CodeInvalid): + native_flow.redeem_code(code=code, code_verifier="wrong") + # Even the CORRECT verifier now fails: the code was consumed on the first + # (failed) attempt. + with pytest.raises(native_flow.CodeInvalid): + native_flow.redeem_code(code=code, code_verifier=verifier) + + +def test_broker_pending_expiry(): + verifier, challenge = _make_pkce() + now = int(time.time()) + broker_state = native_flow.register_pending( + code_challenge=challenge, redirect_uri="http://127.0.0.1:1/cb", + client_state="s", now=now, + ) + # Past the pending TTL, the entry is gone. + with pytest.raises(native_flow.PendingNotFound): + native_flow.get_pending(broker_state, now=now + 601) + + +def test_broker_code_expiry(): + verifier, challenge = _make_pkce() + now = int(time.time()) + broker_state = native_flow.register_pending( + code_challenge=challenge, redirect_uri="http://127.0.0.1:1/cb", + client_state="s", now=now, + ) + code = native_flow.complete_pending( + broker_state, session=_stub_session(), now=now, + ) + with pytest.raises(native_flow.CodeInvalid): + native_flow.redeem_code( + code=code, code_verifier=verifier, now=now + 121, + ) + + +def test_broker_capacity_fails_closed(): + _verifier, challenge = _make_pkce() + # Fill to capacity. + for _ in range(native_flow._MAX_ENTRIES): + native_flow.register_pending( + code_challenge=challenge, redirect_uri="http://127.0.0.1:1/cb", + client_state="s", + ) + with pytest.raises(native_flow.NativeFlowError): + native_flow.register_pending( + code_challenge=challenge, redirect_uri="http://127.0.0.1:1/cb", + client_state="s", + ) + + +# --------------------------------------------------------------------------- +# Route-level E2E against StubAuthProvider +# --------------------------------------------------------------------------- + + +@pytest.fixture +def gated_client(): + 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 = "fly-app.fly.dev" + web_server.app.state.bound_port = 443 + web_server.app.state.auth_required = True + # follow_redirects=False so we can inspect each 302 leg of the flow. + client = TestClient( + web_server.app, base_url="https://fly-app.fly.dev", + follow_redirects=False, + ) + yield client + clear_providers() + 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 _walk_native_login(client, *, redirect_uri, challenge, state="cli-state"): + """Drive authorize → (stub redirects to callback) → loopback code. + + Returns the ``code`` + ``state`` the gateway put on the loopback redirect. + """ + # 1. Desktop opens the system browser at /auth/native/authorize. + r = client.get( + "/auth/native/authorize", + params={ + "provider": "stub", + "code_challenge": challenge, + "code_challenge_method": "S256", + "redirect_uri": redirect_uri, + "state": state, + }, + ) + assert r.status_code == 302, r.text + # Stub's start_login redirects straight to /auth/callback?code=stub_code. + loc = r.headers["location"] + parsed = urlparse(loc) + cb_qs = parse_qs(parsed.query) + # Carry the gateway PKCE cookie forward (holds broker_state + verifier). + cookies = r.cookies + # 2. Browser hits the gateway callback. + r2 = client.get( + "/auth/callback", + params={"code": cb_qs["code"][0], "state": cb_qs["state"][0]}, + cookies=cookies, + ) + assert r2.status_code == 302, r2.text + # 3. The callback 302s to the desktop's loopback redirect_uri. + loop = urlparse(r2.headers["location"]) + assert f"{loop.scheme}://{loop.netloc}" == redirect_uri.rsplit("/", 1)[0] or \ + loop.netloc in redirect_uri + loop_qs = parse_qs(loop.query) + # No session cookie must be set on the native callback response. + set_cookie = r2.headers.get("set-cookie", "") + assert "hermes_session_at" not in set_cookie, ( + f"native callback must NOT set a session cookie; got {set_cookie!r}" + ) + return loop_qs["code"][0], loop_qs["state"][0] + + +def test_native_full_roundtrip_returns_tokens_no_cookie(gated_client): + verifier, challenge = _make_pkce() + redirect_uri = "http://127.0.0.1:53999/callback" + code, state = _walk_native_login( + gated_client, redirect_uri=redirect_uri, challenge=challenge, + state="my-cli-state", + ) + assert state == "my-cli-state" # client state echoed verbatim + + # 4. Desktop redeems the loopback code + its verifier for tokens. + r = gated_client.post( + "/auth/native/token", + json={"code": code, "code_verifier": verifier}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["token_type"] == "Bearer" + assert body["access_token"] + assert body["refresh_token"] + assert body["provider"] == "stub" + assert body["user_id"] == "stub-user-1" + # No cookie set on the token response either. + assert "set-cookie" not in {k.lower() for k in r.headers} + + +def test_native_token_rejects_wrong_verifier(gated_client): + _verifier, challenge = _make_pkce() + code, _state = _walk_native_login( + gated_client, redirect_uri="http://127.0.0.1:53999/cb", + challenge=challenge, + ) + r = gated_client.post( + "/auth/native/token", + json={"code": code, "code_verifier": "attacker-does-not-have-this"}, + ) + assert r.status_code == 400 + + +def test_native_authorize_rejects_non_loopback_redirect(gated_client): + _verifier, challenge = _make_pkce() + r = gated_client.get( + "/auth/native/authorize", + params={ + "provider": "stub", + "code_challenge": challenge, + "code_challenge_method": "S256", + "redirect_uri": "https://evil.example.com/steal", + "state": "s", + }, + ) + assert r.status_code == 400 + assert "loopback" in r.json()["detail"].lower() + + +def test_native_authorize_requires_s256(gated_client): + _verifier, challenge = _make_pkce() + r = gated_client.get( + "/auth/native/authorize", + params={ + "provider": "stub", + "code_challenge": challenge, + "code_challenge_method": "plain", + "redirect_uri": "http://127.0.0.1:1/cb", + "state": "s", + }, + ) + assert r.status_code == 400 + assert "s256" in r.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# Cookieless bearer auth of a gated route — the core deliverable +# --------------------------------------------------------------------------- + + +def test_bearer_authenticates_gated_route_without_cookie(gated_client): + """A desktop that redeemed tokens can call a gated route with only an + ``Authorization: Bearer`` header — no cookie in the jar.""" + verifier, challenge = _make_pkce() + code, _state = _walk_native_login( + gated_client, redirect_uri="http://127.0.0.1:53999/cb", + challenge=challenge, + ) + tokens = gated_client.post( + "/auth/native/token", + json={"code": code, "code_verifier": verifier}, + ).json() + at = tokens["access_token"] + + # /api/auth/me is gated; a cookieless request with the bearer must pass + # and identify the user. + r = gated_client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {at}"}, + ) + assert r.status_code == 200, r.text + assert r.json()["user_id"] == "stub-user-1" + + +def test_bearer_ws_ticket_mint_without_cookie(gated_client): + """The desktop mints a WS ticket with the bearer (no cookie), proving the + WebSocket path also works cookielessly.""" + verifier, challenge = _make_pkce() + code, _state = _walk_native_login( + gated_client, redirect_uri="http://127.0.0.1:53999/cb", + challenge=challenge, + ) + at = gated_client.post( + "/auth/native/token", + json={"code": code, "code_verifier": verifier}, + ).json()["access_token"] + + r = gated_client.post( + "/api/auth/ws-ticket", + headers={"Authorization": f"Bearer {at}"}, + ) + assert r.status_code == 200, r.text + assert r.json()["ticket"] + + +def test_invalid_bearer_returns_401_envelope(gated_client): + r = gated_client.get( + "/api/auth/me", + headers={"Authorization": "Bearer not-a-real-token"}, + ) + assert r.status_code == 401 + assert r.json()["error"] == "session_expired" + + +# --------------------------------------------------------------------------- +# Capability advertisement on /api/status +# --------------------------------------------------------------------------- + + +def test_status_advertises_native_pkce_flow(gated_client): + r = gated_client.get("/api/status") + assert r.status_code == 200 + body = r.json() + assert body["auth_required"] is True + assert "cookie" in body["auth_flows"] + assert "native_pkce" in body["auth_flows"], ( + "a brokerable OAuth provider must advertise native_pkce so the " + "desktop can pick the system-browser flow" + ) + + +def test_status_loopback_mode_has_no_auth_flows(): + clear_providers() + prev_required = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.auth_required = False + try: + client = TestClient(web_server.app, base_url="http://127.0.0.1:8080") + body = client.get("/api/status").json() + assert body["auth_required"] is False + assert body["auth_flows"] == [] + finally: + web_server.app.state.auth_required = prev_required + + +# --------------------------------------------------------------------------- +# Native refresh +# --------------------------------------------------------------------------- + + +def test_native_refresh_rotates_tokens(gated_client): + verifier, challenge = _make_pkce() + code, _state = _walk_native_login( + gated_client, redirect_uri="http://127.0.0.1:53999/cb", + challenge=challenge, + ) + tokens = gated_client.post( + "/auth/native/token", + json={"code": code, "code_verifier": verifier}, + ).json() + rt = tokens["refresh_token"] + + r = gated_client.post( + "/auth/native/refresh", + json={"refresh_token": rt, "provider": "stub"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["access_token"] + assert body["refresh_token"] + assert body["token_type"] == "Bearer" + + +def test_native_refresh_dead_token_returns_401(gated_client): + r = gated_client.post( + "/auth/native/refresh", + json={"refresh_token": "garbage-not-a-real-rt", "provider": "stub"}, + ) + assert r.status_code == 401 + assert r.json()["error"] == "session_expired" diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 114b2c8957..7474a8322a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1803,6 +1803,13 @@ export interface StatusResponse { * Empty in loopback mode; empty + ``auth_required=true`` is a * fail-closed state (the dashboard will refuse to bind). */ auth_providers?: string[]; + /** Supported dashboard auth flows for the client to choose from. In gated + * mode always includes ``"cookie"``; includes ``"native_pkce"`` when a + * brokerable OAuth provider is registered, signalling that the desktop can + * use the RFC 8252 system-browser + loopback + PKCE flow (no embedded + * webview, no session cookies). Absent / missing ``"native_pkce"`` ⇒ an + * older gateway ⇒ the desktop falls back to the embedded-webview flow. */ + auth_flows?: string[]; /** False when the dashboard is running in a hosted/managed layout where * updates are handled by the outer launcher instead of ``hermes update``. */ can_update_hermes?: boolean; diff --git a/website/docs/guides/desktop-native-signin.md b/website/docs/guides/desktop-native-signin.md new file mode 100644 index 0000000000..96c59048c7 --- /dev/null +++ b/website/docs/guides/desktop-native-signin.md @@ -0,0 +1,119 @@ +--- +sidebar_position: 18 +title: "Desktop Native Sign-In (RFC 8252)" +description: "How the Hermes Desktop app signs in to a gated gateway using your system browser and PKCE — no embedded webview, no session cookies" +--- + +# Desktop Native Sign-In (RFC 8252) + +When the Hermes Desktop app connects to a **gated gateway** (a hosted or +self-hosted dashboard that sits behind an OAuth provider), it can sign in two +ways: + +1. **Native sign-in (RFC 8252)** — the app opens your **real system browser**, + you approve in the browser you already trust, and the app receives tokens it + stores in your OS keychain. **No embedded webview, no browser session + cookies.** This is the default whenever the gateway supports it. +2. **Embedded sign-in (legacy fallback)** — the app opens a small in-app + browser window and captures the gateway's session cookie. Used automatically + when the gateway is an older build that doesn't advertise native sign-in. + +You don't choose between these — the app detects what the gateway supports and +picks the best one. This page explains what happens and why. + +## Why native sign-in + +Embedding a browser inside a native app for OAuth has well-known downsides: +the login page can't see your existing browser session (so you re-type +credentials and re-do MFA), password managers and passkeys often don't work, +and the app relies on reading a session cookie out of a private webview. RFC +8252 ("OAuth 2.0 for Native Apps") is the industry best practice that avoids +all of that: **do the authorization in the system browser and hand the app its +own tokens.** + +For Hermes specifically, native sign-in means: + +- **No embedded webview.** The authorization happens in Safari / Chrome / + Firefox / Edge — whatever you use — with your logins, extensions, and + passkeys intact. +- **No session cookies.** The app holds an OAuth **access token** (short-lived) + and **refresh token**, encrypted at rest via your OS keychain (Electron + `safeStorage`). REST calls and WebSocket tickets are authenticated with an + `Authorization: Bearer` header, not a cookie jar. + +## How it works + +``` +Desktop app Gateway (/auth/native/*) Nous Portal (IDP) + │ 1. open loopback 127.0.0.1: + │ 2. system browser ─► /auth/native/authorize + │ (PKCE challenge) (starts the normal PKCE login) ─► /oauth/authorize + │ ◄──── code ──── /auth/callback ◄──┘ + │ 3. mint one-time gateway code + │ ◄─ 302 127.0.0.1/cb?code=… ─┘ + │ 4. POST /auth/native/token (code + PKCE verifier) + │ ◄─ 5. { access_token, refresh_token, expires_at } ───────┘ + │ 6. store in OS keychain; use Bearer for REST + WS tickets +``` + +The gateway **brokers** the flow: it is the authorization server *to the +desktop app* and an OAuth client *to the upstream identity provider* (Nous +Portal). This is required because the upstream `client_id` and permitted +redirect URIs are bound to the gateway's own origin — a desktop app can't be a +direct client of the Portal. The desktop still gets the full RFC 8252 +experience: its own PKCE pair, its own loopback redirect, and tokens it owns. + +**PKCE (RFC 7636)** protects the loopback hop: the one-time gateway code is +useless without the code verifier, which never leaves the app. The code is +single-use and short-lived. + +## Capability detection & fallback + +The desktop reads the gateway's public `/api/status` endpoint, which advertises +an `auth_flows` array: + +| `auth_flows` value | Meaning | +|--------------------|---------| +| `["cookie", "native_pkce"]` | Gateway supports native sign-in → the app uses it | +| `["cookie"]` | Gateway supports only the legacy flow → the app uses the embedded webview | +| *(field absent)* | Older gateway → the app uses the embedded webview | + +If native sign-in is advertised but fails for a local reason — e.g. a security +tool blocks the loopback listener, or you close the browser tab — the app +**falls back to the embedded flow automatically** so you can still sign in. + +## Token lifecycle + +- **Access token**: short-lived (minutes). Sent as `Authorization: Bearer` on + every REST call and when minting a WebSocket ticket. +- **Refresh token**: longer-lived, rotating. When the access token is near + expiry the app calls `/auth/native/refresh` to rotate both tokens, then + updates the keychain. +- **Terminal expiry**: if the refresh token is dead (expired / revoked / + reuse-detected), the app clears its stored tokens and prompts a fresh + sign-in. +- **Sign out**: clears both the native tokens (keychain) and any legacy session + cookie for that gateway. + +## For gateway operators + +Native sign-in is available automatically on any gated gateway that has a +brokerable OAuth provider registered (e.g. the bundled **Nous** provider). No +configuration is required — the `/auth/native/*` routes and the `auth_flows` +advertisement are part of the dashboard-auth subsystem. Password-only and +token-only providers do not advertise `native_pkce` (there is no upstream +redirect to broker), and those deployments continue to use their existing +login. + +The relevant endpoints (all public, pre-auth bootstrap, same as the existing +`/auth/*` OAuth routes): + +- `GET /auth/native/authorize` — starts the brokered PKCE login +- `POST /auth/native/token` — exchanges the loopback code + verifier for tokens +- `POST /auth/native/refresh` — rotates tokens from the app's refresh token + +## See also + +- [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) — the loopback-callback + pattern for provider/MCP OAuth on remote machines. +- [Run Hermes with Nous Portal](./run-hermes-with-nous-portal.md)