mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop-auth): RFC 8252 native-app loopback login for gated gateways
Let the desktop app log into a gated (OAuth) gateway/dashboard via the user's SYSTEM browser instead of the embedded BrowserWindow webview + HttpOnly session-cookie jar, and hold the resulting tokens itself (Authorization: Bearer) instead of relying on cookies. The desktop probes the gateway and falls back to the existing embedded-webview cookie flow when the gateway doesn't advertise the new capability. Server (hermes_cli/dashboard_auth/): - native_auth.py: in-memory loopback broker (two-phase, ws_tickets-style). Validates RFC 8252 loopback redirect URIs (127.0.0.0/8, ::1, localhost; http only), verifies desktop-side PKCE (S256), single-use one-time code. - routes.py: POST /auth/native/start (register broker, return upstream authorize URL), a native branch in the existing /auth/callback (recognised by upstream state matching a live broker record -> 302 to the desktop's loopback with a one-time code, NO session cookie), POST /auth/native/token (redeem code+verifier -> JSON tokens), and cookieless POST /api/auth/refresh. - middleware.py: gated_auth_middleware now accepts Authorization: Bearer, verified through the SAME verify_session provider stack the cookie path uses, so /api/auth/me, /api/auth/ws-ticket, etc. work identically whether the caller authenticated by cookie or bearer. New /auth/native/* + /api/auth/refresh added to the public allowlist. - web_server.py: /api/status advertises native_loopback_auth (true only when the gate is engaged AND a non-password OAuth provider is registered). Desktop (apps/desktop/electron/): - connection-config.ts: pure, unit-tested helpers — nativeLoopbackSupported (capability detect), buildLoopbackRedirectUri, buildNativeStartBody, parseLoopbackCallback, resolveLoopbackCallback (state/CSRF check first). - main.ts: runNativeLoopbackLogin drives an ephemeral 127.0.0.1 listener + PKCE + shell.openExternal (system browser, no BrowserWindow) + code redemption; the oauth-login IPC handler probes /api/status and picks the native flow when advertised, else falls back to openOauthLoginWindow. Native tokens are stored via safeStorage (in-memory only when encryption is unavailable, never plaintext on disk) and sent as bearers; ws-ticket mint prefers the bearer and refreshes via /api/auth/refresh on 401. Tests: tests/hermes_cli/test_native_loopback_auth.py (27 tests — broker units, full start->callback->token round trip returning JSON with no Set-Cookie, bearer unlocks /api/auth/me + mints a ws-ticket, cookieless refresh, capability flag) and 24 new connection-config vitest cases. Portal/upstream IDP unchanged.
This commit is contained in:
parent
c1af3772fc
commit
27b0b7c5a0
9 changed files with 1795 additions and 12 deletions
|
|
@ -19,16 +19,21 @@ import {
|
|||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
buildLoopbackRedirectUri,
|
||||
buildNativeStartBody,
|
||||
connectionScopeKey,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
nativeLoopbackSupported,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
parseLoopbackCallback,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveLoopbackCallback,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
|
|
@ -458,3 +463,125 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
|||
/mintTicket function is required/
|
||||
)
|
||||
})
|
||||
|
||||
// --- nativeLoopbackSupported (RFC 8252 capability detect) ---
|
||||
|
||||
test('nativeLoopbackSupported reads the /api/status flag', () => {
|
||||
assert.equal(nativeLoopbackSupported({ native_loopback_auth: true }), true)
|
||||
assert.equal(nativeLoopbackSupported({ native_loopback_auth: false }), false)
|
||||
})
|
||||
|
||||
test('nativeLoopbackSupported is false when the field is absent (older gateway)', () => {
|
||||
// Capability detection, not version sniffing: an older gateway that predates
|
||||
// the feature simply omits the field, and the desktop falls back to the
|
||||
// embedded-webview cookie flow.
|
||||
assert.equal(nativeLoopbackSupported({ auth_required: true }), false)
|
||||
assert.equal(nativeLoopbackSupported({}), false)
|
||||
assert.equal(nativeLoopbackSupported(null), false)
|
||||
assert.equal(nativeLoopbackSupported(undefined), false)
|
||||
})
|
||||
|
||||
// --- buildLoopbackRedirectUri ---
|
||||
|
||||
test('buildLoopbackRedirectUri binds 127.0.0.1 with the ephemeral port', () => {
|
||||
assert.equal(buildLoopbackRedirectUri(54123), 'http://127.0.0.1:54123/callback')
|
||||
})
|
||||
|
||||
test('buildLoopbackRedirectUri rejects invalid ports', () => {
|
||||
assert.throws(() => buildLoopbackRedirectUri(0), /Invalid loopback port/)
|
||||
assert.throws(() => buildLoopbackRedirectUri(70000), /Invalid loopback port/)
|
||||
assert.throws(() => buildLoopbackRedirectUri('nope'), /Invalid loopback port/)
|
||||
})
|
||||
|
||||
// --- buildNativeStartBody ---
|
||||
|
||||
test('buildNativeStartBody assembles the /auth/native/start payload', () => {
|
||||
const body = buildNativeStartBody({
|
||||
provider: 'nous',
|
||||
port: 54123,
|
||||
codeChallenge: 'chal-abc',
|
||||
state: 'st-xyz'
|
||||
})
|
||||
|
||||
assert.deepEqual(body, {
|
||||
provider: 'nous',
|
||||
redirect_uri: 'http://127.0.0.1:54123/callback',
|
||||
code_challenge: 'chal-abc',
|
||||
code_challenge_method: 'S256',
|
||||
state: 'st-xyz'
|
||||
})
|
||||
})
|
||||
|
||||
// --- parseLoopbackCallback ---
|
||||
|
||||
test('parseLoopbackCallback extracts code + state on success', () => {
|
||||
const parsed = parseLoopbackCallback('/callback?code=abc123&state=st-1')
|
||||
assert.equal(parsed.code, 'abc123')
|
||||
assert.equal(parsed.state, 'st-1')
|
||||
assert.equal(parsed.error, '')
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback extracts error + description on failure', () => {
|
||||
const parsed = parseLoopbackCallback(
|
||||
'/callback?error=access_denied&error_description=nope&state=st-1'
|
||||
)
|
||||
assert.equal(parsed.error, 'access_denied')
|
||||
assert.equal(parsed.errorDescription, 'nope')
|
||||
assert.equal(parsed.state, 'st-1')
|
||||
assert.equal(parsed.code, '')
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback never throws on garbage', () => {
|
||||
const parsed = parseLoopbackCallback('%%%not a url%%%')
|
||||
assert.equal(typeof parsed.code, 'string')
|
||||
assert.equal(typeof parsed.state, 'string')
|
||||
})
|
||||
|
||||
// --- resolveLoopbackCallback (state check + outcome) ---
|
||||
|
||||
test('resolveLoopbackCallback returns the code when state matches', () => {
|
||||
const out = resolveLoopbackCallback(
|
||||
{ code: 'abc', state: 'st-1', error: '', errorDescription: '' },
|
||||
'st-1'
|
||||
)
|
||||
assert.deepEqual(out, { ok: true, code: 'abc' })
|
||||
})
|
||||
|
||||
test('resolveLoopbackCallback rejects a mismatched state FIRST (CSRF)', () => {
|
||||
// Even a "successful"-looking callback with a code is dropped if the state
|
||||
// doesn't match what we generated — the state check must precede everything.
|
||||
const out = resolveLoopbackCallback(
|
||||
{ code: 'attacker-code', state: 'forged', error: '', errorDescription: '' },
|
||||
'st-1'
|
||||
)
|
||||
assert.deepEqual(out, { ok: false, reason: 'state_mismatch' })
|
||||
})
|
||||
|
||||
test('resolveLoopbackCallback rejects a spoofed error under a mismatched state', () => {
|
||||
const out = resolveLoopbackCallback(
|
||||
{ code: '', state: 'forged', error: 'access_denied', errorDescription: '' },
|
||||
'st-1'
|
||||
)
|
||||
assert.equal(out.ok, false)
|
||||
assert.equal(out.reason, 'state_mismatch')
|
||||
})
|
||||
|
||||
test('resolveLoopbackCallback surfaces an IDP error when state matches', () => {
|
||||
const out = resolveLoopbackCallback(
|
||||
{ code: '', state: 'st-1', error: 'access_denied', errorDescription: 'user said no' },
|
||||
'st-1'
|
||||
)
|
||||
assert.equal(out.ok, false)
|
||||
assert.equal(out.reason, 'idp_error')
|
||||
assert.equal(out.error, 'access_denied')
|
||||
assert.equal(out.errorDescription, 'user said no')
|
||||
})
|
||||
|
||||
test('resolveLoopbackCallback flags a state-valid but code-less callback', () => {
|
||||
const out = resolveLoopbackCallback(
|
||||
{ code: '', state: 'st-1', error: '', errorDescription: '' },
|
||||
'st-1'
|
||||
)
|
||||
assert.deepEqual(out, { ok: false, reason: 'no_code' })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -251,6 +251,111 @@ function authModeFromStatus(statusBody) {
|
|||
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the gateway advertises RFC 8252 native-app (system-browser +
|
||||
* loopback) login on its public /api/status. The desktop uses this to choose
|
||||
* the system-browser flow over the legacy embedded-webview cookie flow.
|
||||
*
|
||||
* Capability detection, not version sniffing: an older gateway simply omits the
|
||||
* field (=> false) and the caller falls back to openOauthLoginWindow. The flag
|
||||
* only lights up when the gate is engaged AND a non-password OAuth provider is
|
||||
* registered (see web_server.py), so a true here means "the three /auth/native
|
||||
* seams are live and there is a provider to broker."
|
||||
*/
|
||||
function nativeLoopbackSupported(statusBody) {
|
||||
return Boolean(statusBody && statusBody.native_loopback_auth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the loopback redirect URI the desktop's transient listener serves and
|
||||
* registers with the broker. RFC 8252 §7.3: http + a loopback IP literal +
|
||||
* an ephemeral port. We use 127.0.0.1 (not `localhost`) so the value never
|
||||
* depends on the host's name resolution, and a fixed `/callback` path.
|
||||
*/
|
||||
function buildLoopbackRedirectUri(port) {
|
||||
const p = Number(port)
|
||||
|
||||
if (!Number.isInteger(p) || p <= 0 || p > 65535) {
|
||||
throw new Error(`Invalid loopback port: ${port}`)
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${p}/callback`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the POST /auth/native/start request body from a PKCE pair + loopback
|
||||
* port + provider. Pure so the flow logic is unit-testable without a live
|
||||
* listener; main.ts supplies the real PKCE + port.
|
||||
*/
|
||||
function buildNativeStartBody({ provider, port, codeChallenge, state }) {
|
||||
return {
|
||||
provider: String(provider || ''),
|
||||
redirect_uri: buildLoopbackRedirectUri(port),
|
||||
code_challenge: String(codeChallenge || ''),
|
||||
code_challenge_method: 'S256',
|
||||
state: String(state || '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the query the broker's 302 lands on the loopback listener with. The
|
||||
* IDP round trip returns EITHER `?code=…&state=…` (success) or
|
||||
* `?error=…&state=…` (failure). Returns a normalized
|
||||
* `{ code, state, error, errorDescription }` — never throws.
|
||||
*/
|
||||
function parseLoopbackCallback(rawUrl) {
|
||||
let parsed
|
||||
|
||||
try {
|
||||
// The listener sees a path+query (no authority); give URL a base to parse.
|
||||
parsed = new URL(String(rawUrl || ''), 'http://127.0.0.1')
|
||||
} catch {
|
||||
return { code: '', state: '', error: 'invalid_request', errorDescription: '' }
|
||||
}
|
||||
|
||||
const q = parsed.searchParams
|
||||
|
||||
return {
|
||||
code: q.get('code') || '',
|
||||
state: q.get('state') || '',
|
||||
error: q.get('error') || '',
|
||||
errorDescription: q.get('error_description') || ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the loopback callback against the state we generated (RFC 8252 CSRF
|
||||
* defense) and surface a normalized outcome. Returns:
|
||||
* - { ok: true, code } → redeem this code
|
||||
* - { ok: false, reason: 'state_mismatch' } → drop (possible CSRF)
|
||||
* - { ok: false, reason: 'idp_error', error, errorDescription }
|
||||
* - { ok: false, reason: 'no_code' } → malformed callback
|
||||
*
|
||||
* The state check is FIRST and unconditional: an attacker who reaches the
|
||||
* loopback listener must not be able to inject a foreign code or a spoofed
|
||||
* error, so a mismatched state is rejected before anything else is read.
|
||||
*/
|
||||
function resolveLoopbackCallback(parsed, expectedState) {
|
||||
if (!parsed || parsed.state !== expectedState) {
|
||||
return { ok: false, reason: 'state_mismatch' }
|
||||
}
|
||||
|
||||
if (parsed.error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'idp_error',
|
||||
error: parsed.error,
|
||||
errorDescription: parsed.errorDescription || ''
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed.code) {
|
||||
return { ok: false, reason: 'no_code' }
|
||||
}
|
||||
|
||||
return { ok: true, code: parsed.code }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective auth mode for a coerce/save operation.
|
||||
* Explicit input wins; otherwise inherit the saved value; default 'token'.
|
||||
|
|
@ -333,17 +438,22 @@ export {
|
|||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
buildLoopbackRedirectUri,
|
||||
buildNativeStartBody,
|
||||
connectionScopeKey,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
nativeLoopbackSupported,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
parseLoopbackCallback,
|
||||
pathWithGlobalRemoteProfile,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveLoopbackCallback,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
|
|
|
|||
|
|
@ -42,16 +42,20 @@ import {
|
|||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
buildNativeStartBody,
|
||||
connectionScopeKey,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
nativeLoopbackSupported,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
parseLoopbackCallback,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveLoopbackCallback,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
} from './connection-config'
|
||||
|
|
@ -5630,14 +5634,76 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
// JSON request authenticated with a native (RFC 8252) bearer access token
|
||||
// instead of the OAuth session cookie. Attaches ``Authorization: Bearer`` and,
|
||||
// on a 401, transparently rotates the stored refresh token via
|
||||
// ``POST /api/auth/refresh`` (cookieless) and retries ONCE — mirroring the
|
||||
// gateway middleware's server-side cookie refresh, but desktop-driven because
|
||||
// the desktop, not a cookie jar, holds the tokens. Throws with statusCode 401
|
||||
// only when the refresh also fails, which callers treat as "needs re-login".
|
||||
async function fetchJsonViaNativeBearer(baseUrl, path, session, options: any = {}) {
|
||||
const url = `${baseUrl}${path}`
|
||||
|
||||
const attempt = accessToken =>
|
||||
fetchPublicJson(url, {
|
||||
...options,
|
||||
headers: { ...(options.headers || {}), Authorization: `Bearer ${accessToken}` }
|
||||
})
|
||||
|
||||
try {
|
||||
return await attempt(session.accessToken)
|
||||
} catch (error: any) {
|
||||
const status = parseInt(String(error?.message || '').split(':')[0], 10)
|
||||
if (status !== 401 || !session.refreshToken) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Access token rejected — rotate via the cookieless refresh endpoint.
|
||||
let rotated: any
|
||||
try {
|
||||
rotated = await fetchPublicJson(`${baseUrl}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 8_000,
|
||||
body: { refresh_token: session.refreshToken, provider: session.provider || '' }
|
||||
})
|
||||
} catch (refreshErr: any) {
|
||||
const err = new Error(
|
||||
'Your session has expired. Open Settings → Gateway and sign in again.'
|
||||
) as any
|
||||
err.statusCode = 401
|
||||
err.needsOauthLogin = true
|
||||
err.cause = refreshErr
|
||||
throw err
|
||||
}
|
||||
|
||||
// Persist the rotated tokens (RT rotation is mandatory — a stale RT would be
|
||||
// reuse-detected and revoke the whole session on the next refresh).
|
||||
persistNativeSession(baseUrl, rotated)
|
||||
|
||||
return attempt(rotated.access_token)
|
||||
}
|
||||
|
||||
// Mint a single-use WS ticket for a gated gateway. Returns the ticket string.
|
||||
// Throws (with statusCode 401) if the session cookie is missing/expired —
|
||||
// callers treat that as "needs re-login".
|
||||
// Throws (with statusCode 401) if the session is missing/expired — callers
|
||||
// treat that as "needs re-login".
|
||||
//
|
||||
// Prefers a native (RFC 8252) bearer session when one exists for this gateway
|
||||
// (the desktop holds the tokens itself); otherwise falls back to the OAuth
|
||||
// session-cookie partition. Both mint the SAME ticket server-side — the gateway
|
||||
// accepts either a verified cookie session or a verified bearer.
|
||||
async function mintGatewayWsTicket(baseUrl) {
|
||||
const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 8_000
|
||||
})) as any
|
||||
const native = getNativeSession(baseUrl)
|
||||
|
||||
const body = native
|
||||
? ((await fetchJsonViaNativeBearer(baseUrl, '/api/auth/ws-ticket', native, {
|
||||
method: 'POST',
|
||||
timeoutMs: 8_000
|
||||
})) as any)
|
||||
: ((await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 8_000
|
||||
})) as any)
|
||||
|
||||
const ticket = body?.ticket
|
||||
|
||||
|
|
@ -5953,6 +6019,175 @@ function trimCloudAgents(body) {
|
|||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RFC 8252 native-app (system browser + loopback) gateway login.
|
||||
//
|
||||
// The system-browser flow that REPLACES the embedded-webview cookie flow when
|
||||
// the gateway advertises it (``/api/status`` ``native_loopback_auth: true``).
|
||||
// Instead of driving the OAuth round trip inside a BrowserWindow and scraping
|
||||
// the HttpOnly cookie out of a session partition, we:
|
||||
//
|
||||
// 1. bind a transient loopback listener on 127.0.0.1:<ephemeral>,
|
||||
// 2. generate a desktop-side PKCE pair + state,
|
||||
// 3. POST /auth/native/start (public) to register the broker + get the
|
||||
// upstream authorize URL,
|
||||
// 4. open that URL in the user's REAL browser (shell.openExternal),
|
||||
// 5. capture the broker's 302 back to the loopback listener (?code&state),
|
||||
// 6. POST /auth/native/token to redeem the code for JSON tokens.
|
||||
//
|
||||
// The returned tokens are held by the desktop (encrypted via safeStorage by the
|
||||
// caller) and sent as ``Authorization: Bearer`` — no cookie jar, no webview.
|
||||
// Pure URL/PKCE/callback logic lives in connection-config.ts (unit-tested);
|
||||
// this function owns only the electron-coupled I/O (listener, openExternal).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// How long to wait for the user to complete sign-in in their browser before
|
||||
// giving up and tearing down the listener. Matches the broker record TTL
|
||||
// (native_auth.BROKER_TTL_SECONDS = 10 min) so the two sides expire together.
|
||||
const NATIVE_LOGIN_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
function _b64urlNoPad(buf) {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
// Generate an RFC 7636 S256 PKCE pair + a CSRF state, all from crypto rng.
|
||||
function generateNativePkce() {
|
||||
const verifier = _b64urlNoPad(crypto.randomBytes(64))
|
||||
const challenge = _b64urlNoPad(crypto.createHash('sha256').update(verifier).digest())
|
||||
const state = _b64urlNoPad(crypto.randomBytes(24))
|
||||
|
||||
return { verifier, challenge, state }
|
||||
}
|
||||
|
||||
// Run the full native loopback login against ``baseUrl`` for ``provider``.
|
||||
// Resolves with the token JSON /auth/native/token returned; rejects on user
|
||||
// cancel (browser closed / timeout), state mismatch, or an IDP error.
|
||||
//
|
||||
// ``deps`` is injected so this is exercisable without real electron/network in
|
||||
// integration tests: { fetchPublicJson, openExternal, createServer }.
|
||||
function runNativeLoopbackLogin(baseUrl, provider, deps: any = {}) {
|
||||
const doFetch = deps.fetchPublicJson || fetchPublicJson
|
||||
const openUrl = deps.openExternal || (url => shell.openExternal(url))
|
||||
const makeServer = deps.createServer || http.createServer
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const { verifier, challenge, state } = generateNativePkce()
|
||||
let settled = false
|
||||
let server: any = null
|
||||
let timeoutTimer: any = null
|
||||
let redirectUri = ''
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = null
|
||||
}
|
||||
try {
|
||||
if (server) {
|
||||
server.close()
|
||||
}
|
||||
} catch {
|
||||
// listener already torn down
|
||||
}
|
||||
}
|
||||
|
||||
const finish = (err, value?) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
// A tiny loopback listener. It answers exactly one meaningful request — the
|
||||
// broker's 302 to /callback?code&state — then hands control back. Any other
|
||||
// path gets a 404 so a stray probe can't be mistaken for the callback.
|
||||
server = makeServer((req, res) => {
|
||||
const parsed = parseLoopbackCallback(req.url || '')
|
||||
const outcome = resolveLoopbackCallback(parsed, state)
|
||||
|
||||
const reply = (status, message) => {
|
||||
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
res.end(
|
||||
`<!doctype html><meta charset="utf-8"><title>Hermes</title>` +
|
||||
`<body style="font:14px system-ui;padding:2rem;color:#111">${message}</body>`
|
||||
)
|
||||
}
|
||||
|
||||
if (!outcome.ok && outcome.reason === 'state_mismatch') {
|
||||
// Could be a stray request that isn't our callback at all — don't tear
|
||||
// the flow down, just 404 and keep waiting for the real one.
|
||||
reply(404, 'Not found.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!outcome.ok) {
|
||||
reply(400, 'Sign-in failed. You can close this tab and return to Hermes.')
|
||||
const detail =
|
||||
outcome.reason === 'idp_error'
|
||||
? `${outcome.error}${outcome.errorDescription ? `: ${outcome.errorDescription}` : ''}`
|
||||
: 'no authorization code returned'
|
||||
finish(new Error(`Native login failed (${detail})`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reply(200, 'Signed in. You can close this tab and return to Hermes.')
|
||||
|
||||
// Redeem the single-use code + our PKCE verifier for JSON tokens.
|
||||
doFetch(`${baseUrl}/auth/native/token`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 30_000,
|
||||
body: { code: outcome.code, code_verifier: verifier, redirect_uri: redirectUri }
|
||||
})
|
||||
.then(tokens => finish(null, tokens))
|
||||
.catch(err => finish(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
|
||||
server.on('error', err => finish(err instanceof Error ? err : new Error(String(err))))
|
||||
|
||||
// Bind an ephemeral port on the loopback interface ONLY (never 0.0.0.0).
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
try {
|
||||
const port = server.address().port
|
||||
redirectUri = `http://127.0.0.1:${port}/callback`
|
||||
|
||||
const startBody = buildNativeStartBody({ provider, port, codeChallenge: challenge, state })
|
||||
const started: any = await doFetch(`${baseUrl}/auth/native/start`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 30_000,
|
||||
body: startBody
|
||||
})
|
||||
|
||||
const authorizationUrl = started && started.authorization_url
|
||||
|
||||
if (!authorizationUrl) {
|
||||
finish(new Error('Gateway did not return an authorization URL for native login.'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Arm the overall timeout only once we're actually waiting on the user.
|
||||
timeoutTimer = setTimeout(
|
||||
() => finish(new Error('Native login timed out. Please try signing in again.')),
|
||||
NATIVE_LOGIN_TIMEOUT_MS
|
||||
)
|
||||
|
||||
await openUrl(authorizationUrl)
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Silent per-agent sign-in: open the selected agent dashboard's /login in the
|
||||
// SAME OAuth partition. Because the user already holds a live portal session
|
||||
// there, the agent's /oauth/authorize auto-approves (org member) and 302s back,
|
||||
|
|
@ -6005,6 +6240,116 @@ function decryptDesktopSecret(secret) {
|
|||
return value
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native-app (RFC 8252) session token store.
|
||||
//
|
||||
// Tokens obtained via runNativeLoopbackLogin are held BY THE DESKTOP (not in a
|
||||
// cookie jar) and sent as ``Authorization: Bearer``. We persist them encrypted
|
||||
// via safeStorage (the OS keychain-backed encryption already used for the
|
||||
// remote session token) keyed by the gateway base URL, so a restart doesn't
|
||||
// force a re-login. Kept in a dedicated file so it never entangles with the
|
||||
// connection.json config schema.
|
||||
//
|
||||
// Security note: safeStorage encryption is only real when the OS provides a
|
||||
// backend (Keychain on macOS, DPAPI on Windows, gnome-keyring/kwallet on
|
||||
// Linux). When it isn't available we DO NOT persist the tokens to disk in
|
||||
// plaintext — they live only in memory for the session and the user re-logs in
|
||||
// next launch. A long-lived bearer token in a plaintext file would be a
|
||||
// downgrade from the HttpOnly-cookie flow it replaces.
|
||||
const DESKTOP_NATIVE_SESSION_PATH = path.join(app.getPath('userData'), 'native-session.json')
|
||||
|
||||
// In-memory cache of decrypted native sessions, keyed by normalized base URL.
|
||||
const _nativeSessions = new Map<string, any>()
|
||||
let _nativeSessionsLoaded = false
|
||||
|
||||
function _loadNativeSessions() {
|
||||
if (_nativeSessionsLoaded) {
|
||||
return
|
||||
}
|
||||
_nativeSessionsLoaded = true
|
||||
try {
|
||||
const raw = fs.readFileSync(DESKTOP_NATIVE_SESSION_PATH, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
for (const [key, secret] of Object.entries(parsed?.sessions || {})) {
|
||||
const json = decryptDesktopSecret(secret)
|
||||
if (json) {
|
||||
try {
|
||||
_nativeSessions.set(key, JSON.parse(json))
|
||||
} catch {
|
||||
// corrupt entry — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No file yet / unreadable — start empty.
|
||||
}
|
||||
}
|
||||
|
||||
function _writeNativeSessions() {
|
||||
// Only persist when encryption is genuinely available (see security note).
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return
|
||||
}
|
||||
const sessions: Record<string, any> = {}
|
||||
for (const [key, value] of _nativeSessions.entries()) {
|
||||
sessions[key] = encryptDesktopSecret(JSON.stringify(value))
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_NATIVE_SESSION_PATH), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
DESKTOP_NATIVE_SESSION_PATH,
|
||||
JSON.stringify({ version: 1, sessions }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
} catch {
|
||||
// Best-effort persistence; the in-memory copy still authenticates this run.
|
||||
}
|
||||
}
|
||||
|
||||
// Store the tokens the broker returned for ``baseUrl``. Persists to the
|
||||
// encrypted store when safeStorage is available; always caches in memory.
|
||||
function persistNativeSession(baseUrl, tokens) {
|
||||
if (!tokens || !tokens.access_token) {
|
||||
return
|
||||
}
|
||||
_loadNativeSessions()
|
||||
_nativeSessions.set(baseUrl, {
|
||||
accessToken: String(tokens.access_token),
|
||||
refreshToken: String(tokens.refresh_token || ''),
|
||||
expiresAt: Number(tokens.expires_at || 0),
|
||||
provider: String(tokens.provider || ''),
|
||||
userId: String(tokens.user_id || '')
|
||||
})
|
||||
_writeNativeSessions()
|
||||
}
|
||||
|
||||
// Return the stored native session for ``baseUrl``, or null.
|
||||
function getNativeSession(baseUrl) {
|
||||
_loadNativeSessions()
|
||||
return _nativeSessions.get(baseUrl) || null
|
||||
}
|
||||
|
||||
// True when we hold ANY native session credential for ``baseUrl`` (access or
|
||||
// refresh token). Like cookiesHaveLiveSession, this answers "is the user signed
|
||||
// in at all?" — an expired access token with a live refresh token is still a
|
||||
// connectable session (the desktop rotates via POST /api/auth/refresh).
|
||||
function hasNativeSession(baseUrl) {
|
||||
const s = getNativeSession(baseUrl)
|
||||
return Boolean(s && (s.accessToken || s.refreshToken))
|
||||
}
|
||||
|
||||
// Drop the stored native session for ``baseUrl`` (logout).
|
||||
function clearNativeSession(baseUrl) {
|
||||
_loadNativeSessions()
|
||||
if (baseUrl) {
|
||||
_nativeSessions.delete(baseUrl)
|
||||
} else {
|
||||
_nativeSessions.clear()
|
||||
}
|
||||
_writeNativeSessions()
|
||||
}
|
||||
|
||||
|
||||
// Validate + normalize the per-profile remote overrides map read from disk.
|
||||
// Drops malformed names/entries and keeps only the recognized fields so a
|
||||
// hand-edited or stale connection.json can't inject junk into resolution.
|
||||
|
|
@ -8017,11 +8362,51 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
|
|||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
// Open the gateway's OAuth login window and wait for the session cookie to
|
||||
// land in the OAuth partition. The caller (settings UI) typically saves the
|
||||
// remote config with authMode='oauth' first, then calls this. We normalize
|
||||
// the URL defensively so a login can be driven from a raw URL too.
|
||||
// Open the gateway's OAuth login and wait until we hold a live session. The
|
||||
// caller (settings UI) typically saves the remote config with
|
||||
// authMode='oauth' first, then calls this. We normalize the URL defensively
|
||||
// so a login can be driven from a raw URL too.
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
|
||||
// Capability-gated flow selection (RFC 8252). Probe the gateway's public
|
||||
// /api/status: a gateway that advertises ``native_loopback_auth`` supports
|
||||
// the system-browser + loopback flow, which needs no embedded webview and no
|
||||
// cookie jar. An older gateway omits the flag, so we fall back to the
|
||||
// embedded-webview cookie flow unchanged. Capability detection, not version
|
||||
// sniffing — see connection-config.nativeLoopbackSupported.
|
||||
let status: any = null
|
||||
try {
|
||||
status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 })
|
||||
} catch {
|
||||
// Unreachable/parse failure → treat as no native support and let the
|
||||
// webview flow surface the real connection error interactively.
|
||||
status = null
|
||||
}
|
||||
|
||||
if (nativeLoopbackSupported(status)) {
|
||||
// Pick the interactive OAuth provider to broker. The status probe already
|
||||
// knows the gate is engaged with ≥1 non-password provider; read the name
|
||||
// from /api/auth/providers (first OAuth-redirect provider).
|
||||
let provider = ''
|
||||
try {
|
||||
const body: any = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })
|
||||
const first = (Array.isArray(body?.providers) ? body.providers : []).find(
|
||||
p => p && typeof p === 'object' && p.name && !p.supports_password
|
||||
)
|
||||
provider = first ? String(first.name) : ''
|
||||
} catch {
|
||||
provider = ''
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const tokens: any = await runNativeLoopbackLogin(baseUrl, provider)
|
||||
persistNativeSession(baseUrl, tokens)
|
||||
|
||||
return { ok: true, baseUrl, connected: hasNativeSession(baseUrl) }
|
||||
}
|
||||
// No brokerable provider name resolved — fall through to the webview flow.
|
||||
}
|
||||
|
||||
await openOauthLoginWindow(baseUrl)
|
||||
|
||||
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
|
||||
|
|
@ -8029,11 +8414,15 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
|||
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
||||
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
||||
await clearOauthSession(baseUrl || undefined)
|
||||
// Also drop any native (RFC 8252) bearer session we hold for this gateway,
|
||||
// so a logout signs the user out of BOTH transports regardless of which flow
|
||||
// established the session.
|
||||
clearNativeSession(baseUrl || undefined)
|
||||
|
||||
// Report against the SAME liveness notion the Settings indicator uses
|
||||
// (AT-or-RT) so a logout that left any session cookie behind is reflected
|
||||
// as still-connected rather than silently signed-out.
|
||||
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
|
||||
return { ok: true, connected: baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false }
|
||||
})
|
||||
|
||||
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ class AuditEvent(enum.Enum):
|
|||
WS_TICKET_REJECTED = "ws_ticket_rejected"
|
||||
TOKEN_AUTH_SUCCESS = "token_auth_success"
|
||||
TOKEN_AUTH_FAILURE = "token_auth_failure"
|
||||
# RFC 8252 native-app loopback login (desktop system-browser flow).
|
||||
NATIVE_LOGIN_START = "native_login_start"
|
||||
NATIVE_LOGIN_SUCCESS = "native_login_success"
|
||||
NATIVE_LOGIN_FAILURE = "native_login_failure"
|
||||
|
||||
|
||||
def _resolve_log_path() -> Path:
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ _GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
|||
"/auth/callback",
|
||||
"/auth/password-login",
|
||||
"/auth/logout",
|
||||
"/auth/native/start",
|
||||
"/auth/native/token",
|
||||
"/api/auth/refresh",
|
||||
"/login",
|
||||
"/api/auth/providers",
|
||||
"/api/mcp/oauth/callback/",
|
||||
|
|
@ -275,6 +278,54 @@ def _safe_next_target(request: Request) -> str:
|
|||
return quote(target, safe="")
|
||||
|
||||
|
||||
def _extract_bearer(request: Request) -> str:
|
||||
"""Return the ``Authorization: Bearer`` token, or "" when absent/malformed.
|
||||
|
||||
Accepts ``<scheme> <token>`` where scheme is "bearer" (case-insensitive).
|
||||
Mirrors ``token_auth.extract_bearer_token`` — kept local so the gate has no
|
||||
import dependency on the token-auth seam (a different, service-caller path).
|
||||
"""
|
||||
auth = request.headers.get("authorization", "")
|
||||
parts = auth.split(" ", 1)
|
||||
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
|
||||
return parts[1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _verify_bearer(
|
||||
access_token: str, *, provider_hint: str | None = None
|
||||
):
|
||||
"""Verify a native-app bearer access token through the session-provider stack.
|
||||
|
||||
Returns ``(session, unreachable_provider_name)``:
|
||||
* ``(Session, None)`` — a provider recognised and accepted the token.
|
||||
* ``(None, None)`` — no provider recognised it (reject 401).
|
||||
* ``(None, name)`` — no provider accepted it AND at least one provider's
|
||||
IDP/JWKS was unreachable (the caller surfaces 503, not 401, so a
|
||||
transient outage doesn't read as "expired session").
|
||||
|
||||
This is the bearer analogue of the cookie path's verify loop: it runs the
|
||||
IDENTICAL ``verify_session`` stack with the identical stacking/unreachable
|
||||
semantics, so a token minted for the cookie flow verifies here unchanged.
|
||||
Never raises — a provider ``ProviderError`` is caught and remembered.
|
||||
"""
|
||||
unreachable_provider: str | None = None
|
||||
for provider in _ordered_session_providers(provider_hint):
|
||||
try:
|
||||
session = provider.verify_session(access_token=access_token)
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during bearer verify: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
if unreachable_provider is None:
|
||||
unreachable_provider = provider.name
|
||||
continue
|
||||
if session is not None:
|
||||
return session, None
|
||||
return None, unreachable_provider
|
||||
|
||||
|
||||
async def gated_auth_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
|
|
@ -298,6 +349,35 @@ async def gated_auth_middleware(
|
|||
if _path_is_public(path):
|
||||
return await call_next(request)
|
||||
|
||||
# RFC 8252 native-app bearer auth. The desktop app holds its access token
|
||||
# itself (obtained via the loopback broker) and presents it as
|
||||
# ``Authorization: Bearer`` instead of a session cookie. Verify it
|
||||
# through the SAME provider ``verify_session`` stack the cookie path uses,
|
||||
# then attach the session and pass through — so every gated route
|
||||
# (``/api/auth/me``, ``/api/auth/ws-ticket``, …) works identically whether
|
||||
# the caller authenticated by cookie or by bearer. Only consulted when a
|
||||
# bearer header is actually present, so the cookie flow is untouched. A
|
||||
# present-but-invalid bearer yields 401 (no cookie fallback for an explicit
|
||||
# bearer caller); the desktop rotates via ``/api/auth/refresh`` and retries.
|
||||
bearer = _extract_bearer(request)
|
||||
if bearer:
|
||||
bearer_session, unreachable = _verify_bearer(
|
||||
bearer, provider_hint=read_session_provider(request)
|
||||
)
|
||||
if bearer_session is not None:
|
||||
request.state.session = bearer_session
|
||||
return await call_next(request)
|
||||
if unreachable is not None:
|
||||
return JSONResponse(
|
||||
{"detail": f"Auth provider {unreachable!r} unreachable"},
|
||||
status_code=503,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": "session_expired", "detail": "Unauthorized",
|
||||
"reason": "invalid_or_expired_bearer"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
at, _rt = read_session_cookies(request)
|
||||
provider_hint = read_session_provider(request)
|
||||
if not at and not _rt:
|
||||
|
|
|
|||
277
hermes_cli/dashboard_auth/native_auth.py
Normal file
277
hermes_cli/dashboard_auth/native_auth.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Native-app (RFC 8252) loopback+PKCE login broker for the desktop app.
|
||||
|
||||
The desktop app authenticates to a **gated** gateway using the user's *system
|
||||
browser* instead of an embedded webview, and holds the resulting tokens itself
|
||||
(sending them as ``Authorization: Bearer``) instead of relying on the HttpOnly
|
||||
session-cookie jar. This module is the gateway-side broker that makes that
|
||||
possible without changing the gateway's own upstream IDP contract.
|
||||
|
||||
Two PKCE contexts are in play — keep them distinct:
|
||||
|
||||
* **gateway ↔ IDP** — the ordinary upstream PKCE the provider's ``start_login``
|
||||
generates. In the cookie flow this verifier rides in the ``hermes_session_pkce``
|
||||
browser cookie. In the native flow the *system browser* has no such cookie
|
||||
(the desktop called ``/auth/native/start`` over its OWN HTTP client), so the
|
||||
broker stores the upstream verifier **server-side**, keyed by the upstream
|
||||
``state`` value the IDP echoes back on the callback.
|
||||
* **desktop ↔ gateway** — a SECOND PKCE pair the desktop generates and keeps to
|
||||
itself. Its challenge is registered at ``start`` and verified at ``token``
|
||||
redemption, exactly as RFC 8252 prescribes for the loopback code exchange.
|
||||
|
||||
Flow (see ``.hermes/plans/2026-07-20-desktop-rfc8252-loopback-auth.md``):
|
||||
|
||||
1. **start** (``POST /auth/native/start``, desktop → gateway) — desktop sends its
|
||||
loopback ``redirect_uri``, its PKCE ``code_challenge``, and its ``state``. The
|
||||
gateway runs the provider's ordinary ``start_login`` against the gateway's own
|
||||
``/auth/callback``, extracts the upstream ``state`` + ``verifier`` from the
|
||||
returned cookie payload, and stores a broker record keyed by the upstream
|
||||
``state``. Returns the upstream ``authorization_url``; the desktop opens it in
|
||||
the system browser.
|
||||
|
||||
2. **callback** — the IDP redirects the *system browser* to the gateway's existing
|
||||
``/auth/callback`` (no cookie present). The callback looks up a broker record
|
||||
by the echoed ``state``; if found it completes the upstream login with the
|
||||
server-stored verifier, attaches the :class:`Session` via
|
||||
:func:`complete_broker`, and 302s the browser to the desktop's loopback
|
||||
``redirect_uri`` carrying a single-use ``code`` + the desktop's ``state``.
|
||||
|
||||
3. **token** (``POST /auth/native/token``, desktop → gateway) — desktop redeems the
|
||||
single-use ``code`` + its PKCE ``code_verifier``; the broker verifies the
|
||||
verifier against the challenge from leg 1 and returns the session tokens as
|
||||
JSON. No cookies set.
|
||||
|
||||
The returned tokens are the provider-issued ``access_token`` / ``refresh_token``
|
||||
the cookie flow would have stored, so a subsequent
|
||||
``Authorization: Bearer <access_token>`` request verifies through the same
|
||||
``verify_session`` provider stack (see ``middleware.gated_auth_middleware``).
|
||||
|
||||
In-memory single-process store, same shape as ``ws_tickets.py``. Time is read via
|
||||
``time.time`` so tests can monkeypatch it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from hermes_cli.dashboard_auth.base import Session
|
||||
|
||||
# The broker record lives for the whole interactive login window: the user may
|
||||
# take a while at the IDP consent screen. 10 minutes matches the PKCE cookie TTL.
|
||||
BROKER_TTL_SECONDS = 10 * 60
|
||||
# The redeemable one-time code is short-lived and single-use: the desktop
|
||||
# redeems it the instant its loopback listener fires.
|
||||
CODE_TTL_SECONDS = 60
|
||||
|
||||
|
||||
class BrokerError(Exception):
|
||||
"""A broker record / code was missing, expired, already used, or mismatched.
|
||||
|
||||
Carries an OAuth-style ``error`` code so the route can surface a stable
|
||||
machine-readable envelope (``invalid_grant`` / ``invalid_request`` / ...).
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, error: str = "invalid_request") -> None:
|
||||
super().__init__(message)
|
||||
self.error = error
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BrokerRecord:
|
||||
# ---- gateway ↔ IDP (server-held upstream PKCE) ----
|
||||
provider: str
|
||||
upstream_verifier: str
|
||||
# ---- desktop ↔ gateway (RFC 8252 loopback exchange) ----
|
||||
code_challenge: str # desktop's PKCE challenge, verified at redeem
|
||||
code_challenge_method: str
|
||||
redirect_uri: str # desktop's loopback URL the callback 302s to
|
||||
desktop_state: str # echoed back to the desktop on that 302
|
||||
# ---- lifecycle ----
|
||||
expires_at: int
|
||||
session: Optional[Session] = None # set by complete_broker
|
||||
code: Optional[str] = None # single-use redemption code
|
||||
code_expires_at: int = 0
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
# Keyed by the UPSTREAM ``state`` (what the IDP echoes on the callback).
|
||||
_records: Dict[str, _BrokerRecord] = {}
|
||||
# Reverse index: one-time redemption code -> upstream_state.
|
||||
_code_index: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _b64url_no_pad(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def is_loopback_redirect_uri(redirect_uri: str) -> bool:
|
||||
"""True iff ``redirect_uri`` is an ``http`` loopback URL (RFC 8252 §7.3).
|
||||
|
||||
Only ``127.0.0.0/8``, ``::1``, and the literal ``localhost`` are accepted,
|
||||
only over ``http``, and a path must be present. Anything else — a public
|
||||
host, ``https``, a custom scheme, a bare authority — is rejected so the
|
||||
broker can never be aimed at an attacker-controlled redirect.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(redirect_uri)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if parsed.scheme != "http":
|
||||
return False
|
||||
host = (parsed.hostname or "").strip()
|
||||
if not host or not parsed.path:
|
||||
return False
|
||||
if host == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _gc_expired_locked(now: int) -> None:
|
||||
"""Drop expired broker records + their code-index entries. Holds ``_lock``."""
|
||||
dead = [state for state, rec in _records.items() if rec.expires_at < now]
|
||||
for state in dead:
|
||||
rec = _records.pop(state, None)
|
||||
if rec and rec.code:
|
||||
_code_index.pop(rec.code, None)
|
||||
|
||||
|
||||
def start_broker(
|
||||
*,
|
||||
upstream_state: str,
|
||||
upstream_verifier: str,
|
||||
provider: str,
|
||||
code_challenge: str,
|
||||
code_challenge_method: str,
|
||||
redirect_uri: str,
|
||||
desktop_state: str,
|
||||
) -> None:
|
||||
"""Register a pending native login, keyed by the upstream ``state``.
|
||||
|
||||
``upstream_state`` / ``upstream_verifier`` come from the provider's
|
||||
``start_login`` (the gateway↔IDP PKCE). ``code_challenge`` is the desktop's
|
||||
own PKCE challenge (S256 only), verified at :func:`redeem_code`.
|
||||
"""
|
||||
if code_challenge_method != "S256":
|
||||
raise BrokerError(
|
||||
f"unsupported code_challenge_method: {code_challenge_method!r}",
|
||||
error="invalid_request",
|
||||
)
|
||||
if not code_challenge:
|
||||
raise BrokerError("code_challenge required", error="invalid_request")
|
||||
if not is_loopback_redirect_uri(redirect_uri):
|
||||
raise BrokerError(
|
||||
"redirect_uri must be an http loopback URL", error="invalid_request"
|
||||
)
|
||||
if not desktop_state:
|
||||
raise BrokerError("state required", error="invalid_request")
|
||||
if not upstream_state:
|
||||
raise BrokerError("internal: missing upstream state", error="server_error")
|
||||
|
||||
now = int(time.time())
|
||||
with _lock:
|
||||
_gc_expired_locked(now)
|
||||
_records[upstream_state] = _BrokerRecord(
|
||||
provider=provider,
|
||||
upstream_verifier=upstream_verifier,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
redirect_uri=redirect_uri,
|
||||
desktop_state=desktop_state,
|
||||
expires_at=now + BROKER_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def get_broker(upstream_state: str) -> Optional[_BrokerRecord]:
|
||||
"""Return a live broker record for an upstream ``state``, or None.
|
||||
|
||||
The ``/auth/callback`` handler calls this to decide whether an incoming
|
||||
callback belongs to a native (loopback) login vs. the ordinary cookie flow.
|
||||
Expired records are evicted on read.
|
||||
"""
|
||||
if not upstream_state:
|
||||
return None
|
||||
now = int(time.time())
|
||||
with _lock:
|
||||
rec = _records.get(upstream_state)
|
||||
if rec is None:
|
||||
return None
|
||||
if rec.expires_at < now:
|
||||
_records.pop(upstream_state, None)
|
||||
if rec.code:
|
||||
_code_index.pop(rec.code, None)
|
||||
return None
|
||||
return rec
|
||||
|
||||
|
||||
def complete_broker(upstream_state: str, session: Session) -> str:
|
||||
"""Attach the minted ``session`` to a broker record; return a one-time code.
|
||||
|
||||
Called by ``/auth/callback`` when the echoed ``state`` matches a broker
|
||||
record. The returned single-use ``code`` is what the browser carries to the
|
||||
desktop's loopback listener.
|
||||
"""
|
||||
now = int(time.time())
|
||||
code = secrets.token_urlsafe(32)
|
||||
with _lock:
|
||||
rec = _records.get(upstream_state)
|
||||
if rec is None or rec.expires_at < now:
|
||||
raise BrokerError("broker session expired", error="invalid_grant")
|
||||
if rec.code: # invalidate any prior code (defensive)
|
||||
_code_index.pop(rec.code, None)
|
||||
rec.session = session
|
||||
rec.code = code
|
||||
rec.code_expires_at = now + CODE_TTL_SECONDS
|
||||
_code_index[code] = upstream_state
|
||||
return code
|
||||
|
||||
|
||||
def redeem_code(*, code: str, code_verifier: str, redirect_uri: str) -> Session:
|
||||
"""Verify the desktop's PKCE + redirect_uri and return the session tokens.
|
||||
|
||||
Single-use: the code (and its broker record) are removed on success AND on a
|
||||
PKCE/redirect mismatch, so a stolen code can't be brute-forced. Raises
|
||||
:class:`BrokerError` (``invalid_grant``) on any failure.
|
||||
"""
|
||||
now = int(time.time())
|
||||
with _lock:
|
||||
upstream_state = _code_index.pop(code, None)
|
||||
rec = _records.get(upstream_state) if upstream_state else None
|
||||
# Whatever happens next, this code is spent — pop the record too so a
|
||||
# failed attempt can't be retried with a guessed verifier.
|
||||
if upstream_state is not None:
|
||||
_records.pop(upstream_state, None)
|
||||
|
||||
if rec is None or rec.code != code:
|
||||
raise BrokerError("unknown or used code", error="invalid_grant")
|
||||
if rec.code_expires_at < now:
|
||||
raise BrokerError("code expired", error="invalid_grant")
|
||||
if rec.session is None:
|
||||
raise BrokerError("login not completed", error="invalid_grant")
|
||||
if redirect_uri != rec.redirect_uri:
|
||||
raise BrokerError("redirect_uri mismatch", error="invalid_grant")
|
||||
if not code_verifier:
|
||||
raise BrokerError("code_verifier required", error="invalid_grant")
|
||||
|
||||
# PKCE S256: BASE64URL(SHA256(verifier)) must equal the stored challenge.
|
||||
computed = _b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest())
|
||||
if not secrets.compare_digest(computed, rec.code_challenge):
|
||||
raise BrokerError("PKCE verification failed", error="invalid_grant")
|
||||
|
||||
return rec.session
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test-only: drop all broker records + code index."""
|
||||
with _lock:
|
||||
_records.clear()
|
||||
_code_index.clear()
|
||||
|
|
@ -35,6 +35,7 @@ from hermes_cli.dashboard_auth.base import (
|
|||
InvalidCodeError,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
clear_pkce_cookie,
|
||||
|
|
@ -253,6 +254,21 @@ async def auth_callback(
|
|||
error: str = "",
|
||||
error_description: str = "",
|
||||
):
|
||||
# RFC 8252 native-app (desktop loopback) branch. The system browser reaches
|
||||
# this callback with NO PKCE cookie (the desktop drove /auth/native/start
|
||||
# over its own HTTP client), so a native login is recognised purely by the
|
||||
# echoed upstream ``state`` matching a live broker record. When it does, the
|
||||
# broker owns the exchange: complete the upstream login with the
|
||||
# server-stored verifier and 302 the browser to the desktop's loopback
|
||||
# listener carrying a single-use code — never setting a session cookie.
|
||||
if state:
|
||||
native_resp = await _maybe_handle_native_callback(
|
||||
request, code=code, state=state, error=error,
|
||||
error_description=error_description,
|
||||
)
|
||||
if native_resp is not None:
|
||||
return native_resp
|
||||
|
||||
pkce_raw = read_pkce_cookie(request)
|
||||
if not pkce_raw:
|
||||
audit_log(
|
||||
|
|
@ -374,6 +390,310 @@ async def auth_callback(
|
|||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: RFC 8252 native-app (desktop loopback + PKCE) login broker
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These three seams let the desktop app authenticate via the user's SYSTEM
|
||||
# browser and hold the resulting tokens itself (Authorization: Bearer) instead
|
||||
# of the embedded-webview + HttpOnly-cookie flow. The gateway brokers to its
|
||||
# existing upstream IDP unchanged; see ``native_auth`` for the full contract.
|
||||
|
||||
|
||||
def _parse_pkce_payload(payload: str) -> Dict[str, str]:
|
||||
"""Parse a provider ``hermes_session_pkce`` payload into a flat dict.
|
||||
|
||||
Shape is ``key=value;key=value`` (e.g. ``state=…;verifier=…``). Mirrors the
|
||||
callback's own parse so the native ``start`` extracts the SAME upstream
|
||||
``state`` + ``verifier`` the cookie flow would have stashed.
|
||||
"""
|
||||
return dict(
|
||||
seg.split("=", 1) for seg in payload.split(";") if "=" in seg
|
||||
)
|
||||
|
||||
|
||||
class _NativeStartBody(BaseModel):
|
||||
provider: str
|
||||
redirect_uri: str
|
||||
code_challenge: str
|
||||
code_challenge_method: str = "S256"
|
||||
state: str
|
||||
|
||||
|
||||
class _NativeTokenBody(BaseModel):
|
||||
code: str
|
||||
code_verifier: str
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class _RefreshBody(BaseModel):
|
||||
refresh_token: str
|
||||
provider: str = ""
|
||||
|
||||
|
||||
def _native_error(error: str, detail: str, status_code: int = 400) -> JSONResponse:
|
||||
"""OAuth-shaped error envelope for the native endpoints."""
|
||||
return JSONResponse({"error": error, "detail": detail}, status_code=status_code)
|
||||
|
||||
|
||||
@router.post("/auth/native/start", name="auth_native_start")
|
||||
async def auth_native_start(request: Request, body: _NativeStartBody):
|
||||
"""Begin an RFC 8252 loopback login; return the upstream authorize URL.
|
||||
|
||||
The desktop supplies its loopback ``redirect_uri`` + its OWN PKCE
|
||||
``code_challenge`` + ``state``. We run the provider's ordinary
|
||||
``start_login`` (gateway↔IDP PKCE) against the gateway's own
|
||||
``/auth/callback``, register a broker record keyed by the upstream
|
||||
``state``, and hand back the upstream ``authorization_url`` for the desktop
|
||||
to open in the system browser.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth import native_auth
|
||||
|
||||
p = get_provider(body.provider)
|
||||
if p is None or not getattr(p, "supports_session", True):
|
||||
return _native_error(
|
||||
"invalid_request", f"Unknown provider: {body.provider!r}", 404
|
||||
)
|
||||
if getattr(p, "supports_password", False):
|
||||
# Password providers have no browser redirect to broker.
|
||||
return _native_error(
|
||||
"invalid_request",
|
||||
f"Provider does not support browser login: {body.provider!r}",
|
||||
400,
|
||||
)
|
||||
if not native_auth.is_loopback_redirect_uri(body.redirect_uri):
|
||||
return _native_error(
|
||||
"invalid_request", "redirect_uri must be an http loopback URL"
|
||||
)
|
||||
|
||||
try:
|
||||
ls = p.start_login(redirect_uri=_redirect_uri(request))
|
||||
except ProviderError as e:
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return _native_error("server_error", f"Provider unreachable: {e}", 503)
|
||||
|
||||
pkce = _parse_pkce_payload(
|
||||
ls.cookie_payload.get("hermes_session_pkce", "")
|
||||
)
|
||||
upstream_state = pkce.get("state", "")
|
||||
upstream_verifier = pkce.get("verifier", "")
|
||||
if not upstream_state:
|
||||
# A provider that doesn't use the state/verifier cookie shape can't be
|
||||
# brokered by state-matching; fail loudly rather than silently.
|
||||
return _native_error(
|
||||
"server_error",
|
||||
"Provider is not compatible with native loopback login",
|
||||
400,
|
||||
)
|
||||
|
||||
try:
|
||||
native_auth.start_broker(
|
||||
upstream_state=upstream_state,
|
||||
upstream_verifier=upstream_verifier,
|
||||
provider=body.provider,
|
||||
code_challenge=body.code_challenge,
|
||||
code_challenge_method=body.code_challenge_method,
|
||||
redirect_uri=body.redirect_uri,
|
||||
desktop_state=body.state,
|
||||
)
|
||||
except native_auth.BrokerError as e:
|
||||
return _native_error(e.error, str(e), 400)
|
||||
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_LOGIN_START,
|
||||
provider=body.provider,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return JSONResponse({"authorization_url": ls.redirect_url})
|
||||
|
||||
|
||||
async def _maybe_handle_native_callback(
|
||||
request: Request,
|
||||
*,
|
||||
code: str,
|
||||
state: str,
|
||||
error: str,
|
||||
error_description: str,
|
||||
):
|
||||
"""Handle ``/auth/callback`` for a native login, or return None.
|
||||
|
||||
Returns None when ``state`` matches no live broker record (the ordinary
|
||||
cookie flow then proceeds). Otherwise completes the upstream login with the
|
||||
server-stored verifier and 302s the browser to the desktop's loopback
|
||||
``redirect_uri`` with a single-use ``code``.
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from hermes_cli.dashboard_auth import native_auth
|
||||
|
||||
rec = native_auth.get_broker(state)
|
||||
if rec is None:
|
||||
return None
|
||||
|
||||
def _to_desktop(params: dict) -> RedirectResponse:
|
||||
sep = "&" if "?" in rec.redirect_uri else "?"
|
||||
return RedirectResponse(
|
||||
url=f"{rec.redirect_uri}{sep}{urlencode(params)}", status_code=302
|
||||
)
|
||||
|
||||
if error:
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_LOGIN_FAILURE,
|
||||
provider=rec.provider,
|
||||
reason="idp_error",
|
||||
error=error,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return _to_desktop(
|
||||
{"error": error, "error_description": error_description,
|
||||
"state": rec.desktop_state}
|
||||
)
|
||||
|
||||
p = get_provider(rec.provider)
|
||||
if p is None:
|
||||
return _to_desktop(
|
||||
{"error": "invalid_request", "state": rec.desktop_state}
|
||||
)
|
||||
|
||||
try:
|
||||
session = p.complete_login(
|
||||
code=code,
|
||||
state=state,
|
||||
code_verifier=rec.upstream_verifier,
|
||||
redirect_uri=_redirect_uri(request),
|
||||
)
|
||||
except InvalidCodeError:
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_LOGIN_FAILURE,
|
||||
provider=rec.provider,
|
||||
reason="invalid_code",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return _to_desktop(
|
||||
{"error": "invalid_grant", "state": rec.desktop_state}
|
||||
)
|
||||
except ProviderError:
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_LOGIN_FAILURE,
|
||||
provider=rec.provider,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return _to_desktop(
|
||||
{"error": "server_error", "state": rec.desktop_state}
|
||||
)
|
||||
|
||||
one_time_code = native_auth.complete_broker(state, session)
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_LOGIN_SUCCESS,
|
||||
provider=rec.provider,
|
||||
user_id=session.user_id,
|
||||
email=session.email,
|
||||
org_id=session.org_id,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return _to_desktop({"code": one_time_code, "state": rec.desktop_state})
|
||||
|
||||
|
||||
@router.post("/auth/native/token", name="auth_native_token")
|
||||
async def auth_native_token(request: Request, body: _NativeTokenBody):
|
||||
"""Redeem a single-use loopback ``code`` + PKCE verifier for session tokens.
|
||||
|
||||
Returns the provider-issued tokens as JSON (NO cookies). The desktop stores
|
||||
them and sends ``Authorization: Bearer`` on subsequent
|
||||
requests; ``expires_at`` lets it schedule a refresh.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth import native_auth
|
||||
|
||||
try:
|
||||
session = native_auth.redeem_code(
|
||||
code=body.code,
|
||||
code_verifier=body.code_verifier,
|
||||
redirect_uri=body.redirect_uri,
|
||||
)
|
||||
except native_auth.BrokerError as e:
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_LOGIN_FAILURE,
|
||||
reason="token_redeem_failed",
|
||||
error=e.error,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return _native_error(e.error, str(e), 400)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"access_token": session.access_token,
|
||||
"refresh_token": session.refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": session.expires_at,
|
||||
"user_id": session.user_id,
|
||||
"email": session.email,
|
||||
"display_name": session.display_name,
|
||||
"org_id": session.org_id,
|
||||
"provider": session.provider,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/auth/refresh", name="auth_native_refresh")
|
||||
async def auth_native_refresh(request: Request, body: _RefreshBody):
|
||||
"""Rotate a native session's tokens without a cookie.
|
||||
|
||||
Runs the same ``refresh_session`` provider stack the cookie middleware uses.
|
||||
The ``provider`` hint only reorders candidates (an opaque foreign refresh
|
||||
token is indistinguishable from an expired one), mirroring
|
||||
``middleware._attempt_refresh``. Returns rotated tokens as JSON, or 401 when
|
||||
every provider rejects the token.
|
||||
"""
|
||||
providers = list_session_providers()
|
||||
if body.provider:
|
||||
providers = sorted(providers, key=lambda pr: pr.name != body.provider)
|
||||
|
||||
unavailable = None
|
||||
for provider in providers:
|
||||
try:
|
||||
session = provider.refresh_session(refresh_token=body.refresh_token)
|
||||
except RefreshExpiredError:
|
||||
continue
|
||||
except ProviderError as e:
|
||||
unavailable = str(e)
|
||||
continue
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_SUCCESS,
|
||||
provider=session.provider,
|
||||
user_id=session.user_id,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"access_token": session.access_token,
|
||||
"refresh_token": session.refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": session.expires_at,
|
||||
"user_id": session.user_id,
|
||||
"email": session.email,
|
||||
"display_name": session.display_name,
|
||||
"org_id": session.org_id,
|
||||
"provider": session.provider,
|
||||
}
|
||||
)
|
||||
|
||||
if unavailable is not None:
|
||||
return _native_error(
|
||||
"server_error", f"Auth provider unreachable: {unavailable}", 503
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE, reason="no_provider_recognises",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return _native_error("invalid_grant", "Refresh token rejected", 401)
|
||||
|
||||
|
||||
def _validate_post_login_target(raw: str) -> str:
|
||||
"""Return ``raw`` if it's a safe same-origin path, else empty string.
|
||||
|
||||
|
|
|
|||
|
|
@ -2900,9 +2900,25 @@ async def get_status(profile: Optional[str] = None):
|
|||
# "loopback only — no auth gate" with no extra round trips.
|
||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||
auth_providers: list[str] = []
|
||||
native_loopback_auth = False
|
||||
try:
|
||||
from hermes_cli.dashboard_auth import list_providers as _list_providers
|
||||
from hermes_cli.dashboard_auth import (
|
||||
list_providers as _list_providers,
|
||||
list_session_providers as _list_session_providers,
|
||||
)
|
||||
auth_providers = [p.name for p in _list_providers()]
|
||||
# RFC 8252 native-app loopback login is offered only when the gate
|
||||
# is engaged AND at least one interactive OAuth (non-password)
|
||||
# session provider is registered — the broker drives that
|
||||
# provider's ``start_login`` redirect. The desktop reads this flag
|
||||
# to choose the system-browser flow vs. the embedded-webview
|
||||
# fallback. Password-only providers have no browser redirect to
|
||||
# broker, so they don't light this up.
|
||||
if auth_required:
|
||||
native_loopback_auth = any(
|
||||
not getattr(p, "supports_password", False)
|
||||
for p in _list_session_providers()
|
||||
)
|
||||
except Exception:
|
||||
# Module not importable yet (early startup) — leave as [].
|
||||
pass
|
||||
|
|
@ -2944,6 +2960,7 @@ async def get_status(profile: Optional[str] = None):
|
|||
"active_sessions": active_sessions,
|
||||
"auth_required": auth_required,
|
||||
"auth_providers": auth_providers,
|
||||
"native_loopback_auth": native_loopback_auth,
|
||||
"nous_session_valid": nous_session_valid,
|
||||
}
|
||||
|
||||
|
|
|
|||
459
tests/hermes_cli/test_native_loopback_auth.py
Normal file
459
tests/hermes_cli/test_native_loopback_auth.py
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
"""End-to-end + unit tests for the RFC 8252 native-app (desktop loopback) login.
|
||||
|
||||
Covers the gateway-side broker that lets the desktop app authenticate via the
|
||||
user's SYSTEM browser and hold the tokens itself (``Authorization: Bearer``)
|
||||
instead of the embedded-webview + HttpOnly-cookie flow:
|
||||
|
||||
* ``native_auth`` broker unit behaviour (loopback validation, PKCE verify,
|
||||
single-use code, TTL expiry).
|
||||
* Full ``/auth/native/start`` → ``/auth/callback`` → ``/auth/native/token``
|
||||
round trip against the in-process ``StubAuthProvider``.
|
||||
* The desktop-held bearer authenticates a gated REST route (``/api/auth/me``)
|
||||
and mints a ws-ticket (``/api/auth/ws-ticket``) — identically to a cookie
|
||||
session — while an invalid bearer is rejected 401.
|
||||
* The cookieless ``/api/auth/refresh`` rotates tokens.
|
||||
* ``/api/status`` advertises ``native_loopback_auth`` so the desktop can
|
||||
choose the flow vs. the embedded-webview fallback.
|
||||
|
||||
Uses ``StubAuthProvider`` so the OAuth round trip completes in-process with no
|
||||
external IDP. The stub's ``start_login`` bounces straight back to the callback
|
||||
with ``code=stub_code``, which is exactly what the broker needs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import (
|
||||
clear_providers,
|
||||
register_provider,
|
||||
)
|
||||
from hermes_cli.dashboard_auth import native_auth
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PKCE helper (desktop side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pkce_pair() -> tuple[str, str]:
|
||||
"""Return ``(verifier, challenge)`` — S256, matching native_auth's verify."""
|
||||
verifier = base64.urlsafe_b64encode(b"x" * 48).rstrip(b"=").decode()
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# native_auth broker — unit
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_broker():
|
||||
native_auth._reset_for_tests()
|
||||
yield
|
||||
native_auth._reset_for_tests()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uri,ok",
|
||||
[
|
||||
("http://127.0.0.1:8765/callback", True),
|
||||
("http://localhost:8765/callback", True),
|
||||
("http://[::1]:8765/callback", True),
|
||||
("https://127.0.0.1:8765/callback", False), # https not allowed for loopback
|
||||
("http://evil.example.com/callback", False), # public host
|
||||
("http://127.0.0.1:8765", False), # no path
|
||||
("hermes://callback", False), # custom scheme
|
||||
("http://10.0.0.5/callback", False), # private but not loopback
|
||||
("not a url", False),
|
||||
],
|
||||
)
|
||||
def test_is_loopback_redirect_uri(uri, ok):
|
||||
assert native_auth.is_loopback_redirect_uri(uri) is ok
|
||||
|
||||
|
||||
def test_start_broker_rejects_non_s256():
|
||||
_, challenge = _pkce_pair()
|
||||
with pytest.raises(native_auth.BrokerError):
|
||||
native_auth.start_broker(
|
||||
upstream_state="us",
|
||||
upstream_verifier="uv",
|
||||
provider="stub",
|
||||
code_challenge=challenge,
|
||||
code_challenge_method="plain",
|
||||
redirect_uri="http://127.0.0.1:9/cb",
|
||||
desktop_state="ds",
|
||||
)
|
||||
|
||||
|
||||
def test_start_broker_rejects_non_loopback_redirect():
|
||||
_, challenge = _pkce_pair()
|
||||
with pytest.raises(native_auth.BrokerError):
|
||||
native_auth.start_broker(
|
||||
upstream_state="us",
|
||||
upstream_verifier="uv",
|
||||
provider="stub",
|
||||
code_challenge=challenge,
|
||||
code_challenge_method="S256",
|
||||
redirect_uri="https://evil.example.com/cb",
|
||||
desktop_state="ds",
|
||||
)
|
||||
|
||||
|
||||
def _mk_session():
|
||||
p = StubAuthProvider()
|
||||
ls = p.start_login(redirect_uri="https://gw/auth/callback")
|
||||
pkce = dict(
|
||||
item.split("=", 1)
|
||||
for item in ls.cookie_payload["hermes_session_pkce"].split(";")
|
||||
)
|
||||
return p.complete_login(
|
||||
code="stub_code",
|
||||
state=pkce["state"],
|
||||
code_verifier=pkce["verifier"],
|
||||
redirect_uri="https://gw/auth/callback",
|
||||
)
|
||||
|
||||
|
||||
def test_redeem_happy_path():
|
||||
verifier, challenge = _pkce_pair()
|
||||
native_auth.start_broker(
|
||||
upstream_state="us1",
|
||||
upstream_verifier="uv",
|
||||
provider="stub",
|
||||
code_challenge=challenge,
|
||||
code_challenge_method="S256",
|
||||
redirect_uri="http://127.0.0.1:9/cb",
|
||||
desktop_state="ds",
|
||||
)
|
||||
session = _mk_session()
|
||||
code = native_auth.complete_broker("us1", session)
|
||||
got = native_auth.redeem_code(
|
||||
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
|
||||
)
|
||||
assert got.user_id == session.user_id
|
||||
assert got.access_token == session.access_token
|
||||
|
||||
|
||||
def test_redeem_pkce_mismatch_rejected_and_code_burned():
|
||||
_, challenge = _pkce_pair()
|
||||
native_auth.start_broker(
|
||||
upstream_state="us2",
|
||||
upstream_verifier="uv",
|
||||
provider="stub",
|
||||
code_challenge=challenge,
|
||||
code_challenge_method="S256",
|
||||
redirect_uri="http://127.0.0.1:9/cb",
|
||||
desktop_state="ds",
|
||||
)
|
||||
code = native_auth.complete_broker("us2", _mk_session())
|
||||
# Wrong verifier → invalid_grant.
|
||||
with pytest.raises(native_auth.BrokerError):
|
||||
native_auth.redeem_code(
|
||||
code=code,
|
||||
code_verifier="wrong-verifier",
|
||||
redirect_uri="http://127.0.0.1:9/cb",
|
||||
)
|
||||
# And the code is burned — a subsequent correct attempt also fails.
|
||||
verifier, _ = _pkce_pair()
|
||||
with pytest.raises(native_auth.BrokerError):
|
||||
native_auth.redeem_code(
|
||||
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
|
||||
)
|
||||
|
||||
|
||||
def test_redeem_is_single_use():
|
||||
verifier, challenge = _pkce_pair()
|
||||
native_auth.start_broker(
|
||||
upstream_state="us3",
|
||||
upstream_verifier="uv",
|
||||
provider="stub",
|
||||
code_challenge=challenge,
|
||||
code_challenge_method="S256",
|
||||
redirect_uri="http://127.0.0.1:9/cb",
|
||||
desktop_state="ds",
|
||||
)
|
||||
code = native_auth.complete_broker("us3", _mk_session())
|
||||
native_auth.redeem_code(
|
||||
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
|
||||
)
|
||||
with pytest.raises(native_auth.BrokerError):
|
||||
native_auth.redeem_code(
|
||||
code=code, code_verifier=verifier, redirect_uri="http://127.0.0.1:9/cb"
|
||||
)
|
||||
|
||||
|
||||
def test_redeem_redirect_uri_mismatch_rejected():
|
||||
verifier, challenge = _pkce_pair()
|
||||
native_auth.start_broker(
|
||||
upstream_state="us4",
|
||||
upstream_verifier="uv",
|
||||
provider="stub",
|
||||
code_challenge=challenge,
|
||||
code_challenge_method="S256",
|
||||
redirect_uri="http://127.0.0.1:9/cb",
|
||||
desktop_state="ds",
|
||||
)
|
||||
code = native_auth.complete_broker("us4", _mk_session())
|
||||
with pytest.raises(native_auth.BrokerError):
|
||||
native_auth.redeem_code(
|
||||
code=code,
|
||||
code_verifier=verifier,
|
||||
redirect_uri="http://127.0.0.1:9999/cb", # different port
|
||||
)
|
||||
|
||||
|
||||
def test_broker_record_expiry(monkeypatch):
|
||||
_, challenge = _pkce_pair()
|
||||
native_auth.start_broker(
|
||||
upstream_state="us5",
|
||||
upstream_verifier="uv",
|
||||
provider="stub",
|
||||
code_challenge=challenge,
|
||||
code_challenge_method="S256",
|
||||
redirect_uri="http://127.0.0.1:9/cb",
|
||||
desktop_state="ds",
|
||||
)
|
||||
# Jump past the broker TTL — get_broker evicts on read.
|
||||
real_time = time.time
|
||||
monkeypatch.setattr(
|
||||
native_auth.time, "time",
|
||||
lambda: real_time() + native_auth.BROKER_TTL_SECONDS + 5,
|
||||
)
|
||||
assert native_auth.get_broker("us5") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# HTTP round trip against the gated app
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_app():
|
||||
"""web_server.app in gated mode with the stub OAuth provider registered."""
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "gw.fly.dev"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
native_auth._reset_for_tests()
|
||||
client = TestClient(web_server.app, base_url="https://gw.fly.dev")
|
||||
yield client
|
||||
clear_providers()
|
||||
native_auth._reset_for_tests()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
def _run_native_flow(client) -> dict:
|
||||
"""Drive start → callback → token; return the token JSON the desktop gets."""
|
||||
verifier, challenge = _pkce_pair()
|
||||
redirect_uri = "http://127.0.0.1:8765/callback"
|
||||
desktop_state = "desktop-state-xyz"
|
||||
|
||||
# 1) Desktop POSTs /auth/native/start (its own HTTP client, no cookie).
|
||||
r_start = client.post(
|
||||
"/auth/native/start",
|
||||
json={
|
||||
"provider": "stub",
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": desktop_state,
|
||||
},
|
||||
)
|
||||
assert r_start.status_code == 200, r_start.text
|
||||
authorization_url = r_start.json()["authorization_url"]
|
||||
|
||||
# The stub's authorize URL bounces straight back to the gateway callback
|
||||
# with ?code=stub_code&state=<upstream_state>. Extract those params and
|
||||
# hit /auth/callback the way the system browser would (NO pkce cookie).
|
||||
q = parse_qs(urlparse(authorization_url).query)
|
||||
upstream_code = q["code"][0]
|
||||
upstream_state = q["state"][0]
|
||||
|
||||
# 2) System browser → /auth/callback. The broker recognises the login by
|
||||
# upstream state and 302s to the desktop's loopback with a one-time code.
|
||||
r_cb = client.get(
|
||||
f"/auth/callback?code={upstream_code}&state={upstream_state}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r_cb.status_code == 302, r_cb.text
|
||||
loc = r_cb.headers["location"]
|
||||
assert loc.startswith(redirect_uri), loc
|
||||
# No session cookie is set on the native callback (cookieless flow).
|
||||
assert "set-cookie" not in {k.lower() for k in r_cb.headers.keys()}
|
||||
cb_q = parse_qs(urlparse(loc).query)
|
||||
assert cb_q["state"][0] == desktop_state # desktop CSRF check
|
||||
one_time_code = cb_q["code"][0]
|
||||
|
||||
# 3) Desktop redeems the code + its PKCE verifier for JSON tokens.
|
||||
r_tok = client.post(
|
||||
"/auth/native/token",
|
||||
json={
|
||||
"code": one_time_code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
)
|
||||
assert r_tok.status_code == 200, r_tok.text
|
||||
return r_tok.json()
|
||||
|
||||
|
||||
def test_full_native_round_trip_returns_json_tokens(gated_app):
|
||||
tokens = _run_native_flow(gated_app)
|
||||
assert tokens["token_type"] == "Bearer"
|
||||
assert tokens["access_token"]
|
||||
assert tokens["refresh_token"]
|
||||
assert tokens["user_id"] == "stub-user-1"
|
||||
assert tokens["provider"] == "stub"
|
||||
assert isinstance(tokens["expires_at"], int)
|
||||
|
||||
|
||||
def test_bearer_unlocks_gated_api_me(gated_app):
|
||||
"""The desktop-held bearer authenticates a gated route with NO cookie."""
|
||||
tokens = _run_native_flow(gated_app)
|
||||
r = gated_app.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
me = r.json()
|
||||
assert me["user_id"] == "stub-user-1"
|
||||
assert me["provider"] == "stub"
|
||||
|
||||
|
||||
def test_bearer_mints_ws_ticket(gated_app):
|
||||
"""POST /api/auth/ws-ticket works under a bearer exactly like under a cookie."""
|
||||
tokens = _run_native_flow(gated_app)
|
||||
r = gated_app.post(
|
||||
"/api/auth/ws-ticket",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("ticket")
|
||||
|
||||
|
||||
def test_invalid_bearer_is_401(gated_app):
|
||||
r = gated_app.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer not-a-real-token"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json().get("error") == "session_expired"
|
||||
|
||||
|
||||
def test_native_start_rejects_non_loopback_redirect(gated_app):
|
||||
_, challenge = _pkce_pair()
|
||||
r = gated_app.post(
|
||||
"/auth/native/start",
|
||||
json={
|
||||
"provider": "stub",
|
||||
"redirect_uri": "https://evil.example.com/cb",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": "s",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"] == "invalid_request"
|
||||
|
||||
|
||||
def test_native_start_unknown_provider_404(gated_app):
|
||||
_, challenge = _pkce_pair()
|
||||
r = gated_app.post(
|
||||
"/auth/native/start",
|
||||
json={
|
||||
"provider": "nope",
|
||||
"redirect_uri": "http://127.0.0.1:9/cb",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": "s",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_native_token_rejects_bad_code(gated_app):
|
||||
verifier, _ = _pkce_pair()
|
||||
r = gated_app.post(
|
||||
"/auth/native/token",
|
||||
json={
|
||||
"code": "never-issued",
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": "http://127.0.0.1:9/cb",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"] == "invalid_grant"
|
||||
|
||||
|
||||
def test_cookieless_refresh_rotates_tokens(gated_app):
|
||||
tokens = _run_native_flow(gated_app)
|
||||
r = gated_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": tokens["refresh_token"], "provider": "stub"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
rotated = r.json()
|
||||
assert rotated["access_token"]
|
||||
assert rotated["user_id"] == "stub-user-1"
|
||||
# The rotated access token verifies against a gated route.
|
||||
r2 = gated_app.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {rotated['access_token']}"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
def test_refresh_bad_token_401(gated_app):
|
||||
r = gated_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": "garbage", "provider": "stub"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json()["error"] == "invalid_grant"
|
||||
|
||||
|
||||
def test_status_advertises_native_loopback_capability(gated_app):
|
||||
r = gated_app.get("/api/status")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["auth_required"] is True
|
||||
assert body["native_loopback_auth"] is True
|
||||
|
||||
|
||||
def test_status_native_capability_false_in_loopback_mode():
|
||||
"""Loopback (no gate) → native_loopback_auth is False."""
|
||||
clear_providers()
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
||||
body = client.get("/api/status").json()
|
||||
assert body["auth_required"] is False
|
||||
assert body["native_loopback_auth"] is False
|
||||
finally:
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
Loading…
Add table
Add a link
Reference in a new issue