diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7bf789ccf11..ef68060cb5d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -80,19 +80,6 @@ import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' -import { runNativeLogin } from './native-oauth-login' -import { - nativeRefreshUrl, - parseTokenResponse, - resolveLoginStrategy, - tokenNeedsRefresh, - type NativeTokenSet -} from './native-oauth' -import { - oauthSessionIsLive, - resolveJsonBody, - resolveOauthRestAuth -} from './native-auth-decisions' import { scanGitRepos } from './git-repo-scan' import { fileDiffVsHead, @@ -129,6 +116,15 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' +import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' +import { + nativeRefreshUrl, + type NativeTokenSet, + parseTokenResponse, + resolveLoginStrategy, + tokenNeedsRefresh +} from './native-oauth' +import { runNativeLogin } from './native-oauth-login' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' @@ -5851,6 +5847,7 @@ async function ensureNativeAccessToken(baseUrl: string): Promise { refresh_token: tokens.refreshToken, provider: tokens.provider }, { timeoutMs: 10_000 } ) + const rotated = parseTokenResponse(body) _storeNativeTokens(baseUrl, rotated) @@ -5883,6 +5880,7 @@ async function mintGatewayWsTicket(baseUrl) { timeoutMs: 8_000, bearer: nativeAt })) as any + const ticket = body?.ticket if (!ticket || typeof ticket !== 'string') { @@ -6459,10 +6457,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // RFC 8252 flow) counts as connected too — otherwise a completed native // sign-in shows "not connected" in Settings. The authoritative liveness // check is the ws-ticket mint in resolveRemoteBackend at actual connect time. - remoteOauthConnected = oauthSessionIsLive( - hasNativeSession(remoteUrl), - await hasLiveOauthSession(remoteUrl) - ) + remoteOauthConnected = oauthSessionIsLive(hasNativeSession(remoteUrl), await hasLiveOauthSession(remoteUrl)) } catch { remoteOauthConnected = false } @@ -8955,6 +8950,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => postJson: (url, body, opts) => postJsonNoAuth(url, body, opts), rememberLog }) + _storeNativeTokens(baseUrl, tokens) return { ok: true, baseUrl, connected: true } @@ -8977,6 +8973,7 @@ 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 tokens for this gateway so a // logout clears BOTH auth shapes. if (baseUrl) { @@ -8986,9 +8983,7 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) = // Report against the SAME liveness notion the Settings indicator uses // (AT-or-RT cookie, or a native token) so a logout that left any session // behind is reflected as still-connected rather than silently signed-out. - const connected = baseUrl - ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) - : false + const connected = baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false return { ok: true, connected } }) diff --git a/apps/desktop/electron/native-auth-decisions.test.ts b/apps/desktop/electron/native-auth-decisions.test.ts index 6343c4d1c1c..09f648c8719 100644 --- a/apps/desktop/electron/native-auth-decisions.test.ts +++ b/apps/desktop/electron/native-auth-decisions.test.ts @@ -10,11 +10,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { - oauthSessionIsLive, - resolveJsonBody, - resolveOauthRestAuth -} from './native-auth-decisions' +import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' // --- 1. body encoding (guards the double-JSON.stringify 422) --- diff --git a/apps/desktop/electron/native-auth-decisions.ts b/apps/desktop/electron/native-auth-decisions.ts index f85f8c59a57..d76746f669a 100644 --- a/apps/desktop/electron/native-auth-decisions.ts +++ b/apps/desktop/electron/native-auth-decisions.ts @@ -44,9 +44,7 @@ export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: bo return hasNativeToken || hasCookieSession } -export type OauthRestAuth = - | { kind: 'bearer'; token: string } - | { kind: 'cookie' } +export type OauthRestAuth = { kind: 'bearer'; token: string } | { kind: 'cookie' } /** * Decide how an oauth-mode REST request authenticates: prefer the native diff --git a/apps/desktop/electron/native-oauth-login.test.ts b/apps/desktop/electron/native-oauth-login.test.ts index 23b7e9c7275..6d1651bc715 100644 --- a/apps/desktop/electron/native-oauth-login.test.ts +++ b/apps/desktop/electron/native-oauth-login.test.ts @@ -22,14 +22,18 @@ function makeFakeServerFactory(port = 51234) { const createServer: any = (handler: any) => { state.handler = handler const server: any = new EventEmitter() + server.listen = (_port: number, _host: string, cb: () => void) => { state.listening = true cb() } + server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port }) + server.close = () => { state.closed = true } + state.server = server return server diff --git a/apps/desktop/electron/native-oauth-login.ts b/apps/desktop/electron/native-oauth-login.ts index 20d53d10ab7..6179e30fab3 100644 --- a/apps/desktop/electron/native-oauth-login.ts +++ b/apps/desktop/electron/native-oauth-login.ts @@ -32,10 +32,10 @@ import { buildNativeAuthorizeUrl, generatePkcePair, generateState, + type NativeTokenSet, nativeTokenUrl, parseLoopbackCallback, - parseTokenResponse, - type NativeTokenSet + parseTokenResponse } from './native-oauth' // Loopback login must complete inside this window (user opens browser, @@ -90,6 +90,7 @@ export async function runNativeLogin( return new Promise((resolve, reject) => { let settled = false let timer: NodeJS.Timeout | null = null + const server = createServer((req, res) => { // Only the callback path carries the code; any other path (favicon, // etc.) still gets the friendly page so the browser tab looks sane. @@ -179,6 +180,7 @@ export async function runNativeLogin( } const redirectUri = `http://127.0.0.1:${addr.port}/callback` + const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, { challenge, redirectUri, diff --git a/apps/desktop/electron/native-oauth.test.ts b/apps/desktop/electron/native-oauth.test.ts index 2f3da633ccd..58d863c4b1a 100644 --- a/apps/desktop/electron/native-oauth.test.ts +++ b/apps/desktop/electron/native-oauth.test.ts @@ -34,6 +34,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => { 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') @@ -41,6 +42,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') + assert.equal(pair.challenge, expected) // No padding / URL-unsafe chars. assert.doesNotMatch(pair.verifier, /[+/=]/) @@ -91,6 +93,7 @@ test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => { state: 'STATE', provider: 'nous' }) + const parsed = new URL(url) assert.equal(parsed.origin, 'https://gw.example.com') @@ -108,6 +111,7 @@ test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix redirectUri: 'http://127.0.0.1:1/cb', state: 'S' }) + const parsed = new URL(url) assert.equal(parsed.pathname, '/hermes/auth/native/authorize') @@ -128,10 +132,7 @@ test('parseLoopbackCallback returns the code on a state match', () => { }) test('parseLoopbackCallback throws on state mismatch (CSRF)', () => { - assert.throws( - () => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), - /state mismatch/i - ) + assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i) }) test('parseLoopbackCallback surfaces a gateway error param', () => { diff --git a/apps/desktop/electron/native-oauth.ts b/apps/desktop/electron/native-oauth.ts index 9e8bc6fdbd2..691cd32caca 100644 --- a/apps/desktop/electron/native-oauth.ts +++ b/apps/desktop/electron/native-oauth.ts @@ -86,10 +86,7 @@ export function statusSupportsNativeFlow(statusBody: any): boolean { * (e.g. a corporate proxy that blocks loopback). Precedence written down here, * in one place, as a pure function — per the desktop "observable ladder" rule. */ -export function resolveLoginStrategy( - statusBody: any, - opts: { forceEmbedded?: boolean } = {} -): 'native' | 'embedded' { +export function resolveLoginStrategy(statusBody: any, opts: { forceEmbedded?: boolean } = {}): 'native' | 'embedded' { if (opts.forceEmbedded) { return 'embedded' } @@ -109,6 +106,7 @@ export function buildNativeAuthorizeUrl( ): string { const parsed = new URL(baseUrl) const prefix = parsed.pathname.replace(/\/+$/, '') + const q = new URLSearchParams({ code_challenge: params.challenge, code_challenge_method: 'S256', @@ -145,10 +143,7 @@ export function nativeRefreshUrl(baseUrl: string): string { * `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch * throws rather than proceeding. */ -export function parseLoopbackCallback( - requestUrl: string, - expectedState: string -): { code: string } { +export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } { // requestUrl is the path+query the loopback server received, e.g. // "/callback?code=...&state=...". Resolve against a dummy origin to parse. const parsed = new URL(requestUrl, 'http://127.0.0.1') @@ -203,7 +198,11 @@ export function parseTokenResponse(body: any): NativeTokenSet { * before use. `skewSeconds` refreshes slightly early to avoid a race where * the token expires in flight (mirrors the server's 60s cookie floor). */ -export function tokenNeedsRefresh(tokens: Pick, nowSeconds: number, skewSeconds = 60): boolean { +export function tokenNeedsRefresh( + tokens: Pick, + nowSeconds: number, + skewSeconds = 60 +): boolean { if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) { // Unknown expiry ⇒ treat as needing refresh so we validate before use. return true