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.
190 lines
6.5 KiB
TypeScript
190 lines
6.5 KiB
TypeScript
/**
|
||
* Tests for electron/native-oauth.ts — the pure RFC 8252 native-app login
|
||
* helpers (PKCE, capability detection, URL building, loopback callback
|
||
* parsing, token-response normalization, refresh-timing).
|
||
*
|
||
* Run with: node --test electron/native-oauth.test.ts
|
||
* (Wired into the vitest `electron` project via electron/**\/*.test.ts.)
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { createHash } from 'node:crypto'
|
||
|
||
import { test } from 'vitest'
|
||
|
||
import {
|
||
buildNativeAuthorizeUrl,
|
||
generatePkcePair,
|
||
generateState,
|
||
NATIVE_FLOW_ID,
|
||
nativeRefreshUrl,
|
||
nativeTokenUrl,
|
||
parseLoopbackCallback,
|
||
parseTokenResponse,
|
||
resolveLoginStrategy,
|
||
statusSupportsNativeFlow,
|
||
tokenNeedsRefresh
|
||
} from './native-oauth'
|
||
|
||
// --- PKCE ---
|
||
|
||
test('generatePkcePair produces a valid S256 verifier/challenge', () => {
|
||
const pair = generatePkcePair()
|
||
|
||
assert.equal(pair.method, 'S256')
|
||
// Verifier length within RFC 7636 range (43–128).
|
||
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
|
||
// Challenge must be the base64url SHA-256 of the verifier.
|
||
const expected = createHash('sha256')
|
||
.update(pair.verifier, 'ascii')
|
||
.digest('base64')
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_')
|
||
.replace(/=+$/, '')
|
||
assert.equal(pair.challenge, expected)
|
||
// No padding / URL-unsafe chars.
|
||
assert.doesNotMatch(pair.verifier, /[+/=]/)
|
||
assert.doesNotMatch(pair.challenge, /[+/=]/)
|
||
})
|
||
|
||
test('generatePkcePair is unique per call', () => {
|
||
assert.notEqual(generatePkcePair().verifier, generatePkcePair().verifier)
|
||
})
|
||
|
||
test('generateState is non-empty and URL-safe', () => {
|
||
const s = generateState()
|
||
|
||
assert.ok(s.length > 0)
|
||
assert.doesNotMatch(s, /[+/=]/)
|
||
})
|
||
|
||
// --- capability detection ---
|
||
|
||
test('statusSupportsNativeFlow reads the auth_flows array', () => {
|
||
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie', NATIVE_FLOW_ID] }), true)
|
||
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie'] }), false)
|
||
// Older gateway: no auth_flows field at all ⇒ not supported.
|
||
assert.equal(statusSupportsNativeFlow({ auth_required: true }), false)
|
||
assert.equal(statusSupportsNativeFlow({}), false)
|
||
assert.equal(statusSupportsNativeFlow(null), false)
|
||
// Malformed field shapes never throw.
|
||
assert.equal(statusSupportsNativeFlow({ auth_flows: 'native_pkce' }), false)
|
||
})
|
||
|
||
test('resolveLoginStrategy picks native only when advertised and not forced', () => {
|
||
const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] }
|
||
const legacy = { auth_required: true, auth_flows: ['cookie'] }
|
||
|
||
assert.equal(resolveLoginStrategy(gated), 'native')
|
||
// Compatibility fallback: an older gateway lacking native_pkce ⇒ embedded.
|
||
assert.equal(resolveLoginStrategy(legacy), 'embedded')
|
||
// A user/env override can pin the legacy flow even on a capable gateway.
|
||
assert.equal(resolveLoginStrategy(gated, { forceEmbedded: true }), 'embedded')
|
||
})
|
||
|
||
// --- URL building ---
|
||
|
||
test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
|
||
const url = buildNativeAuthorizeUrl('https://gw.example.com', {
|
||
challenge: 'CHAL',
|
||
redirectUri: 'http://127.0.0.1:51000/callback',
|
||
state: 'STATE',
|
||
provider: 'nous'
|
||
})
|
||
const parsed = new URL(url)
|
||
|
||
assert.equal(parsed.origin, 'https://gw.example.com')
|
||
assert.equal(parsed.pathname, '/auth/native/authorize')
|
||
assert.equal(parsed.searchParams.get('code_challenge'), 'CHAL')
|
||
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256')
|
||
assert.equal(parsed.searchParams.get('redirect_uri'), 'http://127.0.0.1:51000/callback')
|
||
assert.equal(parsed.searchParams.get('state'), 'STATE')
|
||
assert.equal(parsed.searchParams.get('provider'), 'nous')
|
||
})
|
||
|
||
test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix', () => {
|
||
const url = buildNativeAuthorizeUrl('https://gw.example.com/hermes', {
|
||
challenge: 'C',
|
||
redirectUri: 'http://127.0.0.1:1/cb',
|
||
state: 'S'
|
||
})
|
||
const parsed = new URL(url)
|
||
|
||
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
|
||
assert.equal(parsed.searchParams.get('provider'), null)
|
||
})
|
||
|
||
test('nativeTokenUrl / nativeRefreshUrl build the right endpoints', () => {
|
||
assert.equal(nativeTokenUrl('https://gw.example.com'), 'https://gw.example.com/auth/native/token')
|
||
assert.equal(nativeRefreshUrl('https://gw.example.com/hermes'), 'https://gw.example.com/hermes/auth/native/refresh')
|
||
})
|
||
|
||
// --- loopback callback parsing ---
|
||
|
||
test('parseLoopbackCallback returns the code on a state match', () => {
|
||
const { code } = parseLoopbackCallback('/callback?code=abc123&state=xyz', 'xyz')
|
||
|
||
assert.equal(code, 'abc123')
|
||
})
|
||
|
||
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
|
||
assert.throws(
|
||
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
|
||
/state mismatch/i
|
||
)
|
||
})
|
||
|
||
test('parseLoopbackCallback surfaces a gateway error param', () => {
|
||
assert.throws(
|
||
() => parseLoopbackCallback('/callback?error=access_denied&error_description=nope', 'xyz'),
|
||
/access_denied.*nope/i
|
||
)
|
||
})
|
||
|
||
test('parseLoopbackCallback throws when the code is absent', () => {
|
||
assert.throws(() => parseLoopbackCallback('/callback?state=xyz', 'xyz'), /missing authorization code/i)
|
||
})
|
||
|
||
// --- token response normalization ---
|
||
|
||
test('parseTokenResponse maps a well-formed body', () => {
|
||
const t = parseTokenResponse({
|
||
access_token: 'AT',
|
||
refresh_token: 'RT',
|
||
token_type: 'Bearer',
|
||
expires_at: 1893456000,
|
||
provider: 'nous',
|
||
user_id: 'u-1'
|
||
})
|
||
|
||
assert.equal(t.accessToken, 'AT')
|
||
assert.equal(t.refreshToken, 'RT')
|
||
assert.equal(t.expiresAt, 1893456000)
|
||
assert.equal(t.provider, 'nous')
|
||
assert.equal(t.userId, 'u-1')
|
||
})
|
||
|
||
test('parseTokenResponse throws on a missing access token', () => {
|
||
assert.throws(() => parseTokenResponse({ refresh_token: 'RT' }), /missing access_token/i)
|
||
})
|
||
|
||
test('parseTokenResponse tolerates an absent refresh token / expiry', () => {
|
||
const t = parseTokenResponse({ access_token: 'AT' })
|
||
|
||
assert.equal(t.refreshToken, '')
|
||
assert.equal(t.expiresAt, 0)
|
||
})
|
||
|
||
// --- refresh timing ---
|
||
|
||
test('tokenNeedsRefresh respects the skew window', () => {
|
||
const now = 1_000_000
|
||
// Expires comfortably in the future ⇒ no refresh.
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: now + 3600 }, now), false)
|
||
// Within the 60s skew ⇒ refresh early.
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: now + 30 }, now), true)
|
||
// Already expired ⇒ refresh.
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: now - 10 }, now), true)
|
||
// Unknown expiry ⇒ refresh (validate before use).
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: 0 }, now), true)
|
||
})
|