desktop: OAuth-aware remote gateway connection
The desktop remote-gateway settings now auto-detect whether a gateway
authenticates with OAuth or a static session token and present the
matching UI + connection mechanism.
Detection: an unauthenticated GET {base}/api/status reads auth_required
(true => OAuth, false => session token); /api/auth/providers supplies the
provider label. The settings UI debounce-probes the entered URL and shows
either a 'Sign in with <provider>' button or the session-token box.
OAuth connection mechanism:
- REST is authed by the HttpOnly session cookie held in a persistent
Electron session partition (persist:hermes-remote-oauth); main-process
REST routes through electron net bound to that partition so the cookie
attaches automatically.
- Login opens a BrowserWindow on {base}/login in that partition and
resolves once the hermes_session_at cookie lands.
- WebSocket upgrades use a single-use ?ticket= minted at
POST /api/auth/ws-ticket (the gateway rejects ?token= in gated mode);
getGatewayWsUrl() re-mints before every (re)connect since tickets are
single-use and short-lived.
- Missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in
(Nous Portal contract v1 issues no refresh token).
Local and token modes are unchanged.
Pure helpers (URL normalize, ws-url token/ticket builders, auth-mode
classify/resolve, cookie detector) are extracted to a standalone
connection-config.cjs (no electron import) and unit-tested with
node --test (26 tests), matching the backend-probes.cjs pattern.
This commit is contained in:
@@ -230,7 +230,11 @@ export function useGatewayBoot({
|
||||
progress: 95
|
||||
})
|
||||
publish(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
// Mint a fresh WS URL right before connecting. For OAuth gateways the
|
||||
// ticket is single-use with a short TTL, so the ticket baked into
|
||||
// conn.wsUrl can already be stale; getGatewayWsUrl() re-mints it.
|
||||
const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
|
||||
@@ -45,7 +45,10 @@ export function useGatewayRequest() {
|
||||
const conn = await desktop.getConnection()
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
await existing.connect(conn.wsUrl)
|
||||
// Re-mint the WS URL before reconnecting — OAuth tickets are single-use
|
||||
// and short-lived, so the cached conn.wsUrl ticket is stale here.
|
||||
const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl
|
||||
await existing.connect(wsUrl)
|
||||
|
||||
return existing
|
||||
} catch {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -10,10 +11,14 @@ import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
mode: Mode
|
||||
remoteAuthMode: AuthMode
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
@@ -22,6 +27,8 @@ interface GatewaySettingsState {
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
envOverride: false,
|
||||
mode: 'local',
|
||||
remoteAuthMode: 'token',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
@@ -71,10 +78,18 @@ export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
|
||||
// its public /api/status) whether it gates with OAuth or a static session
|
||||
// token, so we can show the right control (login button vs token box).
|
||||
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
|
||||
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
|
||||
const probeSeq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const desktop = window.hermesDesktop
|
||||
@@ -104,15 +119,95 @@ export function GatewaySettings() {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const canUseRemote = useMemo(
|
||||
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
|
||||
[remoteToken, state.remoteTokenSet, state.remoteUrl]
|
||||
)
|
||||
// Debounced probe of the entered remote URL. Only runs in remote mode with a
|
||||
// syntactically plausible URL. The probe result drives whether we render the
|
||||
// OAuth login button or the session-token entry box. The effective auth mode
|
||||
// prefers a fresh probe result over the saved value.
|
||||
const trimmedUrl = state.remoteUrl.trim()
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
|
||||
setProbeStatus('idle')
|
||||
setProbe(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.probeConnectionConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++probeSeq.current
|
||||
setProbeStatus('probing')
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
desktop
|
||||
.probeConnectionConfig(trimmedUrl)
|
||||
.then(result => {
|
||||
if (seq !== probeSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(result)
|
||||
setProbeStatus(result.reachable ? 'done' : 'error')
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== probeSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(null)
|
||||
setProbeStatus('error')
|
||||
})
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [state.mode, trimmedUrl])
|
||||
|
||||
// Effective auth mode: a reachable probe wins; otherwise fall back to the
|
||||
// saved config's mode so a re-open of settings doesn't flicker.
|
||||
const authMode: AuthMode = useMemo(() => {
|
||||
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
|
||||
return probe.authMode
|
||||
}
|
||||
|
||||
return state.remoteAuthMode
|
||||
}, [probe, probeStatus, state.remoteAuthMode])
|
||||
|
||||
const providerLabel = useMemo(() => {
|
||||
const providers: DesktopAuthProvider[] = probe?.providers ?? []
|
||||
|
||||
if (providers.length === 1) {
|
||||
return providers[0].displayName || providers[0].name
|
||||
}
|
||||
|
||||
if (providers.length > 1) {
|
||||
return providers.map(p => p.displayName || p.name).join(' / ')
|
||||
}
|
||||
|
||||
return 'your identity provider'
|
||||
}, [probe])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
if (!trimmedUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
return oauthConnected
|
||||
}
|
||||
|
||||
return Boolean(remoteToken.trim()) || state.remoteTokenSet
|
||||
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
|
||||
|
||||
const payload = () => ({
|
||||
mode: state.mode,
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const save = async (apply: boolean) => {
|
||||
@@ -120,7 +215,10 @@ export function GatewaySettings() {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before switching to remote.'
|
||||
message:
|
||||
authMode === 'oauth'
|
||||
? 'Enter a remote URL and sign in before switching to remote.'
|
||||
: 'Enter a remote URL and session token before switching to remote.'
|
||||
})
|
||||
|
||||
return
|
||||
@@ -147,12 +245,73 @@ export function GatewaySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
|
||||
// the URL the login window needs), then open the gateway login window and
|
||||
// refresh the connection status from the saved config once it completes.
|
||||
const signIn = async () => {
|
||||
if (!trimmedUrl) {
|
||||
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
// Save (don't apply/restart) so the login window has a URL to use and the
|
||||
// oauth mode is persisted, without yet flipping the live connection.
|
||||
const saved = await window.hermesDesktop.saveConnectionConfig({
|
||||
mode: state.mode,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
setState(saved)
|
||||
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
|
||||
|
||||
if (result.connected) {
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
|
||||
} else {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Sign-in incomplete',
|
||||
message: 'The login window closed before authentication finished.'
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-in failed')
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-out failed')
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before testing.'
|
||||
message:
|
||||
authMode === 'oauth'
|
||||
? 'Enter a remote URL and sign in before testing.'
|
||||
: 'Enter a remote URL and session token before testing.'
|
||||
})
|
||||
|
||||
return
|
||||
@@ -164,8 +323,9 @@ export function GatewaySettings() {
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
|
||||
@@ -229,7 +389,7 @@ export function GatewaySettings() {
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description="Connect this desktop shell to a remote Hermes backend using its session token."
|
||||
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth; self-hosted ones may use a session token."
|
||||
disabled={state.envOverride}
|
||||
icon={Globe}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
@@ -251,23 +411,71 @@ export function GatewaySettings() {
|
||||
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
|
||||
title="Remote URL"
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
||||
title="Session token"
|
||||
/>
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking how this gateway authenticates…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'error' ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* OAuth gateways: present a sign-in button + connection status. */}
|
||||
{state.mode === 'remote' && authMode === 'oauth' ? (
|
||||
<ListRow
|
||||
action={
|
||||
oauthConnected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" /> Signed in
|
||||
</Pill>
|
||||
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
Sign in with {providerLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={
|
||||
oauthConnected
|
||||
? 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
|
||||
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
|
||||
}
|
||||
title="Authentication"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Session-token gateways: keep the existing token entry box. */}
|
||||
{state.mode === 'remote' && authMode === 'token' ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
||||
title="Session token"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
Vendored
+33
@@ -4,11 +4,15 @@ declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
getConnection: () => Promise<HermesConnection>
|
||||
getGatewayWsUrl: () => Promise<string>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: () => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
|
||||
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
|
||||
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
|
||||
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
|
||||
api: <T>(request: HermesApiRequest) => Promise<T>
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
@@ -141,6 +145,7 @@ export interface HermesConnection {
|
||||
baseUrl: string
|
||||
isFullscreen: boolean
|
||||
mode?: 'local' | 'remote'
|
||||
authMode?: 'oauth' | 'token'
|
||||
nativeOverlayWidth: number
|
||||
source?: 'env' | 'local' | 'settings'
|
||||
token: string
|
||||
@@ -163,6 +168,8 @@ export interface HermesWindowState {
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
remoteAuthMode: 'oauth' | 'token'
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
@@ -170,6 +177,7 @@ export interface DesktopConnectionConfig {
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
}
|
||||
@@ -180,6 +188,31 @@ export interface DesktopConnectionTestResult {
|
||||
version: string | null
|
||||
}
|
||||
|
||||
export interface DesktopAuthProvider {
|
||||
name: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface DesktopConnectionProbeResult {
|
||||
baseUrl: string
|
||||
reachable: boolean
|
||||
authMode: 'oauth' | 'token' | 'unknown'
|
||||
providers: DesktopAuthProvider[]
|
||||
version: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface DesktopOauthLoginResult {
|
||||
ok: boolean
|
||||
baseUrl: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface DesktopOauthLogoutResult {
|
||||
ok: boolean
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface DesktopBootProgress {
|
||||
error: string | null
|
||||
fakeMode: boolean
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
IconLoader2 as Loader2,
|
||||
IconLoader2 as Loader2Icon,
|
||||
IconLock as Lock,
|
||||
IconLogin as LogIn,
|
||||
IconMessageCircle as MessageCircle,
|
||||
IconMessage2 as MessageSquareText,
|
||||
IconMicrophone as Mic,
|
||||
@@ -148,6 +149,7 @@ export {
|
||||
Loader2,
|
||||
Loader2Icon,
|
||||
Lock,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
|
||||
Reference in New Issue
Block a user