Files
aiturk-hermes-ide/apps/desktop/electron/oauth-partition.ts
T

156 lines
5.6 KiB
TypeScript

/**
* oauth-partition.ts
*
* Per-connection cookie-jar isolation for cookie-authenticated (OAuth /
* dashboard basic-auth) remote gateways (#92183).
*
* Historically every cookie-mode remote rode ONE Electron session partition
* (`persist:hermes-remote-oauth`) — the jar was keyed on the auth *mode*, not
* on the connection's identity. Chromium cookie jars scope by host and ignore
* the port, so two registered gateways on the same host (the #92183 VPN
* setup: one box, two dashboards) fought over the same `hermes_session*`
* cookies: signing in to gateway B evicted gateway A's session, and A's
* cookie was silently PRESENTED to B on every request — a cross-connection
* credential leak.
*
* This module is the pure decision seam: given a request/base URL and a
* snapshot of the v2 connections registry, decide which session partition the
* request must ride. Rules:
*
* - A NON-primary v2 registry `remote` entry with cookie auth
* (`authMode: 'oauth'`, which covers dashboard basic/password providers —
* they authenticate via session cookies too) gets its own partition
* derived from the connection id. Fail closed: its requests can never see
* another connection's cookies, and its login window can never evict them.
* - The registry PRIMARY and the v1 single-connection remote stay on the
* LEGACY shared partition, so existing signed-in users are not signed out
* by the upgrade.
* - `cloud` entries stay on the legacy partition: the silent per-agent
* cascade deliberately shares one jar with the Nous Portal session.
* - Token-auth remotes, portal URLs, and anything unmatched or malformed
* fall back to the legacy partition (cookie-free flows are unaffected).
*
* Kept free of `electron` imports so it unit-tests in the electron vitest
* project; main.ts owns session.fromPartition() and injects nothing here.
*/
export const LEGACY_OAUTH_PARTITION = 'persist:hermes-remote-oauth'
const CONNECTION_PARTITION_PREFIX = `${LEGACY_OAUTH_PARTITION}:conn:`
export interface PartitionRegistrySnapshot {
primary?: unknown
connections?: unknown
}
export interface ResolveOauthPartitionOptions {
registry?: PartitionRegistrySnapshot | null
/** v1 single-connection remote URL (connection.json `remote.url`), when set. */
v1RemoteUrl?: unknown
}
/**
* Normalize a URL for base-url matching: lowercased scheme+host, explicit
* default ports elided (URL does this), trailing slashes trimmed, query and
* fragment dropped. Returns null for anything that is not a plain http(s) URL.
*/
function normalizeForMatch(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw.trim()) {
return null
}
try {
const parsed = new URL(raw.trim())
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
const path = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${path}`
} catch {
return null
}
}
/**
* True when `requestNorm` is the entry base URL itself or a path underneath
* it. Both sides are pre-normalized; the '/' requirement is what stops
* `https://gw.example.com.evil.tld` from matching `https://gw.example.com`.
*/
function matchesBase(requestNorm: string, baseNorm: string): boolean {
return requestNorm === baseNorm || requestNorm.startsWith(`${baseNorm}/`)
}
/** Partition names must stay printable/simple; connection ids are user data. */
function sanitizePartitionComponent(id: string): string {
return encodeURIComponent(id).replace(/%/g, '_')
}
/**
* Decide the Electron session partition a cookie-authenticated request against
* `requestUrl` must ride. See the module header for the rules.
*/
export function resolveOauthPartition(requestUrl: unknown, opts: ResolveOauthPartitionOptions = {}): string {
const requestNorm = normalizeForMatch(requestUrl)
if (!requestNorm) {
return LEGACY_OAUTH_PARTITION
}
const registry = opts.registry
if (!registry || typeof registry !== 'object' || !Array.isArray(registry.connections)) {
return LEGACY_OAUTH_PARTITION
}
const primaryId = typeof registry.primary === 'string' ? registry.primary : ''
const v1Norm = normalizeForMatch(opts.v1RemoteUrl)
let best: { baseNorm: string; id: string } | null = null
for (const entry of registry.connections) {
if (!entry || typeof entry !== 'object') {
continue
}
const id = typeof (entry as any).id === 'string' ? (entry as any).id.trim() : ''
// Only NON-primary v2 `remote` entries with cookie-flow auth get their own
// jar. Cloud entries need the shared portal jar; token entries never use
// cookies; the primary (and the v1 remote it migrated from) keeps the
// legacy jar so an upgrade does not sign the user out.
if (!id || id === primaryId) {
continue
}
if ((entry as any).kind !== 'remote' || (entry as any).authMode !== 'oauth') {
continue
}
const baseNorm = normalizeForMatch((entry as any).url)
if (!baseNorm || (v1Norm && baseNorm === v1Norm)) {
continue
}
if (!matchesBase(requestNorm, baseNorm)) {
continue
}
// Longest base-url prefix wins (sub-path gateways behind one proxy);
// identical URLs tie-break on the lexicographically smallest id so the
// choice is deterministic across processes and launches.
if (!best || baseNorm.length > best.baseNorm.length || (baseNorm.length === best.baseNorm.length && id < best.id)) {
best = { baseNorm, id }
}
}
if (!best) {
return LEGACY_OAUTH_PARTITION
}
return `${CONNECTION_PARTITION_PREFIX}${sanitizePartitionComponent(best.id)}`
}