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)