mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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 <access_token>, 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.
215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
/**
|
||
* 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:<port>/...).
|
||
* `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<NativeTokenSet, 'expiresAt'>, 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 }
|