From 22751c8fd9b9d8835ce452d93092b43b96a1980f Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 11 Sep 2026 05:15:48 -0700 Subject: [PATCH] fix(desktop): preserve remote auth through refresh failures and login races Salvage native refresh coordination and cookie fallback without losing forced bearer rotation or replaying REST mutations. Co-authored-by: Sora-bluesky Co-authored-by: Zeus-Deus --- apps/desktop/electron/main.ts | 362 +++++------------- .../electron/native-access-token.test.ts | 224 +++++++++++ apps/desktop/electron/native-access-token.ts | 134 +++++++ .../electron/oauth-rest-request.test.ts | 124 ++++++ apps/desktop/electron/oauth-rest-request.ts | 104 +++++ 5 files changed, 682 insertions(+), 266 deletions(-) create mode 100644 apps/desktop/electron/native-access-token.test.ts create mode 100644 apps/desktop/electron/native-access-token.ts create mode 100644 apps/desktop/electron/oauth-rest-request.test.ts create mode 100644 apps/desktop/electron/oauth-rest-request.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 24a3b2b690..40036eedf3 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -106,6 +106,7 @@ import { cookiesHaveSession, gatewayWsUrlIpcResult, hostLabelFromBaseUrl, + isGatewayAuthRejection, localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, @@ -256,13 +257,11 @@ import { } from './managed-ssh-update' import { registerMcpOauthCallbackIpc } from './mcp-oauth-callback-ipc' import { createMediaProtocolHandler, MEDIA_PROTOCOL } from './media-protocol' +import { createNativeAccessTokenCoordinator, NativeAuthChangedError } from './native-access-token' import { oauthSessionIsLive, - resolveGatedDownloadAuth, resolveJsonBody, - resolveOauthRestAuth, - resolveReadinessProbeAuth, - shouldRotateNativeTokenAfterRejection + resolveReadinessProbeAuth } from './native-auth-decisions' import { nativeRefreshUrl, @@ -276,6 +275,7 @@ import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } fr import { registerNativeNotifications } from './notification-ipc' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition' +import { mintGatewayWsTicket as mintOauthGatewayWsTicket, requestWithOauthFallback } from './oauth-rest-request' import { wireOauthSessionResponse } from './oauth-session-response' import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' import { registerPetOverlayIpc } from './pet-overlay-ipc' @@ -1361,7 +1361,7 @@ protocol.registerSchemesAsPrivileged([ function registerMediaProtocol() { const handler = createMediaProtocolHandler({ - ensureRemoteBearer: baseUrl => ensureNativeAccessToken(baseUrl).catch(() => null), + ensureRemoteBearer: baseUrl => ensureNativeAccessToken(baseUrl), fetchLocal: (resolvedPath, headers, method) => electronNet.fetch(pathToFileURL(resolvedPath).toString(), { bypassCustomProtocolHandlers: true, @@ -6531,25 +6531,19 @@ async function gatewayAuthProviders(baseUrl, headers = {}) { // answers before the SPA catch-all). `probeIsCredentialed` tells // waitForHermesReady how to read a 401 — rejected session vs gated route. async function buildReadinessHealthProbe(baseUrl, authMode, token) { - const nativeAt = authMode === 'oauth' ? await ensureNativeAccessToken(baseUrl).catch(() => null) : null - const probeAuth = resolveReadinessProbeAuth(authMode, nativeAt, token) - - if (probeAuth.kind === 'bearer') { + if (authMode === 'oauth') { return { - // fetchJson takes the bearer via `options.bearer` — a raw `headers` - // option is ignored, so passing one here would silently probe - // uncredentialed and reintroduce the 401 loop. - probeHealth: (url, options: any = {}) => fetchJson(url, null, { ...options, bearer: probeAuth.token }), + probeHealth: (url: string, options: any = {}) => + requestWithOauthFallback(baseUrl, { + ensureNativeAccessToken, + requestWithBearer: bearer => fetchJson(url, null, { ...options, bearer }), + requestWithCookie: () => fetchJsonViaOauthSession(url, options) + }), probeIsCredentialed: true } } - if (probeAuth.kind === 'cookie') { - return { - probeHealth: (url, options: any = {}) => fetchJsonViaOauthSession(url, options), - probeIsCredentialed: true - } - } + const probeAuth = resolveReadinessProbeAuth(authMode, null, token) if (probeAuth.kind === 'token' && probeAuth.token) { return { @@ -7920,6 +7914,7 @@ function _persistNativeTokens(baseUrl: string, tokens: NativeTokenSet | null) { } function _loadNativeTokens(baseUrl: string): NativeTokenSet | null { + baseUrl = normalizeRemoteBaseUrl(baseUrl) const cached = _nativeTokens.get(baseUrl) if (cached) { @@ -7936,8 +7931,8 @@ function _loadNativeTokens(baseUrl: string): NativeTokenSet | null { } function _storeNativeTokens(baseUrl: string, tokens: NativeTokenSet) { - _nativeTokens.set(baseUrl, tokens) _persistNativeTokens(baseUrl, tokens) + _nativeTokens.set(baseUrl, tokens) } function _clearNativeTokens(baseUrl: string) { @@ -7963,56 +7958,26 @@ function postJsonNoAuth(url: string, body: unknown, opts: any = {}) { return fetchJson(url, null, { method: 'POST', body: resolveJsonBody(body), ...opts }) } -// Return a valid native access token for baseUrl, refreshing via -// /auth/native/refresh if the stored one is at/near expiry. Returns null when -// there are no tokens or the refresh is terminally rejected (caller re-logins). -// `forceRefresh` rotates even a locally-unexpired access token: the gateway -// never rotates a native bearer server-side, so after the gate rejects one -// the desktop must run the refresh itself before the rejection is confirmed. -async function ensureNativeAccessToken( - baseUrl: string, - options: { forceRefresh?: boolean } = {} -): Promise { - const tokens = _loadNativeTokens(baseUrl) +// All explicit mutations go through the coordinator; only its refresh/store +// dependencies may call the raw persistence helpers above. +const nativeAccessTokenCoordinator = createNativeAccessTokenCoordinator({ + clearTokens: _clearNativeTokens, + isRefreshAuthRejection: error => readStatusCode(error) === 401, + loadTokens: _loadNativeTokens, + normalizeBaseUrl: normalizeRemoteBaseUrl, + refreshTokens: async (baseUrl, tokens) => + parseTokenResponse( + await postJsonNoAuth( + nativeRefreshUrl(baseUrl), + { refresh_token: tokens.refreshToken, provider: tokens.provider }, + { timeoutMs: 10_000 } + ) + ), + storeTokens: _storeNativeTokens, + tokenNeedsRefresh +}) - if (!tokens) { - return null - } - - if (!options.forceRefresh && !tokenNeedsRefresh(tokens, Math.floor(Date.now() / 1000))) { - return tokens.accessToken - } - - if (!tokens.refreshToken) { - // Access token expired and no RT to rotate — force re-login. - _clearNativeTokens(baseUrl) - - return null - } - - try { - const body = await postJsonNoAuth( - nativeRefreshUrl(baseUrl), - { refresh_token: tokens.refreshToken, provider: tokens.provider }, - { timeoutMs: 10_000 } - ) - - const rotated = parseTokenResponse(body) - _storeNativeTokens(baseUrl, rotated) - - return rotated.accessToken - } catch (error: any) { - // A 401 means the RT is dead (session_expired) — drop tokens so the UI - // prompts a fresh native login. A 503/transient keeps them for a retry. - if (error && error.statusCode === 401) { - _clearNativeTokens(baseUrl) - - return null - } - - throw error - } -} +const ensureNativeAccessToken = nativeAccessTokenCoordinator.ensure // OAuth-session download that streams the response body straight to a // user-selected destination (via finalizeGatewayDownload). The connect timeout @@ -8179,13 +8144,6 @@ interface GatewayFileSavePayload { suggestedName?: unknown } -async function gatedFileAuth(connection: GatewayFileConnection) { - const nativeAt = - connection.authMode === 'oauth' ? await ensureNativeAccessToken(connection.baseUrl).catch(() => null) : null - - return resolveGatedDownloadAuth(connection.authMode, nativeAt, connection.token) -} - function gatewayFileRequestPath( connection: GatewayFileConnection, connectionId: null | string, @@ -8222,17 +8180,15 @@ async function saveGatewayFile(payload: GatewayFileSavePayload = {}) { const url = `${connection.baseUrl}${requestPaths.download}` try { - const auth = await gatedFileAuth(connection) - - if (auth.kind === 'bearer') { - return await downloadViaTokenToFile(url, auth.token, ctx, { bearer: auth.token }) + if (connection.authMode === 'oauth') { + return await requestWithOauthFallback(connection.baseUrl, { + ensureNativeAccessToken, + requestWithBearer: bearer => downloadViaTokenToFile(url, null, ctx, { bearer }), + requestWithCookie: () => downloadViaOauthSessionToFile(url, ctx) + }) } - if (auth.kind === 'cookie') { - return await downloadViaOauthSessionToFile(url, ctx) - } - - return await downloadViaTokenToFile(url, auth.token, ctx) + return await downloadViaTokenToFile(url, connection.token, ctx) } catch (error) { // Desktop and the remote gateway update independently. A gateway predating // /api/fs/download 404s here; fall back (ONLY on 404) to the older capped @@ -8254,17 +8210,7 @@ async function saveGatewayFileViaDataUrl( requestPath: string, ctx: GatewayFileSaveContext ) { - const url = `${connection.baseUrl}${requestPath}` - const auth = await gatedFileAuth(connection) - let json: unknown - - if (auth.kind === 'bearer') { - json = await fetchJson(url, null, { bearer: auth.token }) - } else if (auth.kind === 'cookie') { - json = await fetchJsonViaOauthSession(url) - } else { - json = await fetchJson(url, auth.token) - } + const json = await fetchJsonForBackend(connection, requestPath) const dataUrl = json && typeof json === 'object' && 'dataUrl' in json && typeof json.dataUrl === 'string' ? json.dataUrl : '' @@ -8292,83 +8238,24 @@ async function saveGatewayFileViaDataUrl( return { path: result.filePath, saved: true } } -// Mint a single-use WS ticket for a gated gateway. Returns the ticket string. -// Prefers a native bearer token (cookieless RFC 8252 flow) when present, -// falling back to the OAuth cookie partition otherwise. -// Throws (with statusCode 401) if the session cookie is missing/expired — -// callers treat that as "needs re-login". -// Transient transport blips (brief host unreachable, 5xx, timeouts) are retried -// a few times before failing — those 1-3s flaps were promoting into the -// full-screen "couldn't start" lockout on reconnect. +// Mint a single-use WS ticket for a gated gateway. +// Ticket POSTs are replay-safe; arbitrary REST mutations never use this retry loop. async function mintGatewayWsTicket(baseUrl, headers = {}) { - return withTransientRetries(async () => { - // Native flow: mint the ticket with the bearer token, no cookie involved. - const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null) - - if (nativeAt) { - try { - return await mintGatewayWsTicketWithBearer(baseUrl, nativeAt, headers) - } catch (error) { - // The gate rejected a bearer the desktop still considered valid. It - // never rotates a native access token server-side (only cookie - // sessions get the transparent refresh; the native flow is told to - // call /auth/native/refresh itself), so run ONE forced rotation here: - // a live refresh token yields a fresh bearer and the mint is retried - // once; a dead one drops the stored set, and the original 401 stands - // as a CONFIRMED rejection that latches into the Sign in overlay - // instead of being replayed by every boot retry (#95701). - if (!shouldRotateNativeTokenAfterRejection(error)) { - throw error - } - - // A dead refresh token returns null (tokens dropped) and the original - // 401 stands. A refresh that could not be evaluated at all (5xx, - // timeout, ECONNRESET) is a transport blip, not a verdict on the - // session — let it propagate so the boot stays retryable. - const rotatedAt = await ensureNativeAccessToken(baseUrl, { forceRefresh: true }) - - if (!rotatedAt || rotatedAt === nativeAt) { - throw error - } - - return await mintGatewayWsTicketWithBearer(baseUrl, rotatedAt, headers) - } + return withTransientRetries( + () => + mintOauthGatewayWsTicket( + baseUrl, + { + ensureNativeAccessToken, + fetchJson, + fetchJsonViaOauthSession + }, + headers + ), + { + isRetryable: (error: unknown) => !(error instanceof NativeAuthChangedError) && !isGatewayAuthRejection(error) } - - const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { - method: 'POST', - timeoutMs: 8_000, - headers - })) as any - - const ticket = body?.ticket - - if (!ticket || typeof ticket !== 'string') { - throw new Error('Gateway did not return a WS ticket.') - } - - return ticket - }) -} - -// One bearer-authenticated ticket mint. Kept separate from the rotation -// decision in mintGatewayWsTicket so the retry-after-refresh leg presents the -// rotated bearer through exactly the same request shape as the first attempt. -async function mintGatewayWsTicketWithBearer(baseUrl, bearer, headers = {}) { - const body = (await fetchJson(`${baseUrl}/api/auth/ws-ticket`, null, { - method: 'POST', - timeoutMs: 8_000, - bearer, - headers - })) as any - - const ticket = body?.ticket - - if (!ticket || typeof ticket !== 'string') { - throw new Error('Gateway did not return a WS ticket.') - } - - return ticket + ) } // Build a fresh WS URL for the *current* connection. Critical for reconnects: @@ -11067,22 +10954,8 @@ async function fetchJsonForProfile(profile, path) { // Issue an arbitrary method against a profile's resolved backend, parsed JSON. async function requestJsonForProfile(profile: string, path: string, method: string, body?: string) { const conn = await ensureBackend(profile) - const url = `${conn.baseUrl}${path}` - const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } - if (conn.authMode === 'oauth') { - // Native RFC 8252 flow: authenticate with the bearer token (cookieless) - // when we hold one for this gateway; otherwise use the cookie partition. - const nativeAt = await ensureNativeAccessToken(conn.baseUrl).catch(() => null) - - if (nativeAt) { - return fetchJson(url, null, { ...opts, bearer: nativeAt, headers: conn.headers }) - } - - return fetchJsonViaOauthSession(url, { ...opts, headers: conn.headers }) - } - - return fetchJson(url, conn.token, { ...opts, headers: conn.headers }) + return fetchJsonForBackend(conn, path, { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }) } async function probeRemoteAuthMode(rawUrl) { @@ -11307,25 +11180,9 @@ async function testDesktopConnectionConfig(input: any = {}) { } async function fetchConnectionStatus(baseUrl, authMode, token, headers = {}) { - const url = `${baseUrl}/api/status` - - if (authMode === 'oauth') { - // Native PKCE bearer first, OAuth session cookies second — the same two - // credentials real traffic uses, in the same order. A refresh failure is - // NOT a silent downgrade to an anonymous probe: the cookie path is still - // an authenticated request, and if neither credential works the probe - // fails, which is the correct answer for a gateway we cannot reach with - // the credentials we hold. - const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null) - - if (nativeAt) { - return fetchJson(url, null, { timeoutMs: 8_000, bearer: nativeAt, headers }) - } - - return fetchJsonViaOauthSession(url, { timeoutMs: 8_000, headers }) - } - - return fetchJson(url, token, { timeoutMs: 8_000, headers }) + // /api/status is public on newer gateways; the subsequent ticket/WS probe + // remains authoritative. Older gated status routes retain cookie fallback. + return fetchJsonForBackend({ baseUrl, authMode, token, headers }, '/api/status', { timeoutMs: 8_000 }) } function resetBootProgressForReconnect() { @@ -16068,23 +15925,17 @@ async function fetchJsonForBackend( throw new Error('File uploads are not supported against OAuth-gated remote backends yet.') } - const nativeAt = await ensureNativeAccessToken(descriptor.baseUrl).catch(() => null) - - if (nativeAt) { - return fetchJson(url, null, { - method: opts.method, - body: opts.body, - timeoutMs: opts.timeoutMs, - bearer: nativeAt, - headers: descriptor.headers - }) - } - - return fetchJsonViaOauthSession(url, { + const options = { method: opts.method, body: opts.body, timeoutMs: opts.timeoutMs, headers: descriptor.headers + } + + return requestWithOauthFallback(descriptor.baseUrl, { + ensureNativeAccessToken, + requestWithBearer: bearer => fetchJson(url, null, { ...options, bearer }), + requestWithCookie: () => fetchJsonViaOauthSession(url, options) }) } @@ -16111,6 +15962,8 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => // - a failed native login reports the error rather than auto-falling back // to the embedded flow — one sign-in action opens at most one window. const baseUrl = normalizeRemoteBaseUrl(rawUrl) + // Order login attempts without interrupting rotation of the existing session. + const authIsCurrent = nativeAccessTokenCoordinator.beginLogin(baseUrl) let statusBody: any = null @@ -16126,6 +15979,10 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => const strategy = resolveLoginStrategy(statusBody, { providers }) + if (!authIsCurrent()) { + throw new NativeAuthChangedError() + } + if (strategy === 'native') { try { const tokens = await runNativeLogin(baseUrl, { @@ -16134,7 +15991,11 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => rememberLog }) - _storeNativeTokens(baseUrl, tokens) + if (!authIsCurrent()) { + throw new NativeAuthChangedError() + } + + nativeAccessTokenCoordinator.storeTokens(baseUrl, tokens) // Confirmed sign-in — release the reauth latch so the next // startHermes() re-dials instead of replaying the stale rejection. remoteReauthFailure = null @@ -16155,7 +16016,13 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => // Only a CONFIRMED sign-in releases the latch. A cancelled/closed login // window must leave it set, or the overlay's "Sign in" button starts // flickering again on the next retry. + if (!authIsCurrent()) { + throw new NativeAuthChangedError() + } + if (connected) { + // A confirmed cookie login supersedes any older native identity. + nativeAccessTokenCoordinator.clearTokens(baseUrl) remoteReauthFailure = null } @@ -16163,11 +16030,13 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => }) ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = normalizeRemoteBaseUrl(rawUrl) - await clearOauthSession(baseUrl) // Also drop any native (RFC 8252) bearer tokens for this gateway so a // logout clears BOTH auth shapes. - _clearNativeTokens(baseUrl) + // Clear before awaiting cookie I/O: a pending login/refresh cannot restore + // logout, and a later login must not be erased when cookie clearing settles. + nativeAccessTokenCoordinator.clearTokens(baseUrl) + await clearOauthSession(baseUrl) // 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 @@ -16750,51 +16619,12 @@ async function handleHermesApiRequest(request) { const connection = await ensureBackend(routeProfile, { passive: request?.passive }) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - const url = `${connection.baseUrl}${apiRoute.requestPath}` - - // OAuth gateways authenticate REST via EITHER a native bearer token - // (cookieless RFC 8252 flow) OR the HttpOnly session cookie held in the OAuth - // partition. Prefer the native bearer when present (mirroring - // mintGatewayWsTicket): the native flow never sets a cookie, so routing an - // oauth-mode REST call through the cookie-only path returns 401 no_cookie even - // though a valid bearer is held. Cookie mode rides Electron's net stack bound - // to the OAuth partition so the cookie attaches automatically. Token/local - // modes keep using the static session-token header. - if (connection.authMode === 'oauth') { - // The OAuth path rides electron.net with JSON headers; multipart isn't - // wired there. Fail loudly rather than corrupting the upload. - if (request?.upload) { - throw new Error('File uploads are not supported against OAuth-gated remote backends yet.') - } - - // Native bearer first (cookieless). ensureNativeAccessToken transparently - // refreshes a near-expiry AT via /auth/native/refresh; a null return means - // no native session (resolveOauthRestAuth then selects the cookie path). - const nativeAt = await ensureNativeAccessToken(connection.baseUrl).catch(() => null) - const restAuth = resolveOauthRestAuth(nativeAt) - - if (restAuth.kind === 'bearer') { - response = await fetchJson(url, null, { - method: request?.method, - body: request?.body, - timeoutMs, - bearer: restAuth.token - }) - } else { - response = await fetchJsonViaOauthSession(url, { - method: request?.method, - body: request?.body, - timeoutMs - }) - } - } else { - response = await fetchJson(url, connection.token, { - method: request?.method, - body: request?.body, - upload: request?.upload, - timeoutMs - }) - } + response = await fetchJsonForBackend(connection, apiRoute.requestPath, { + method: request?.method, + body: request?.body, + upload: request?.upload, + timeoutMs + }) } catch (error) { // A failed rename PATCH must not strand the app on the temporary primary: // restore the original active profile and restart its backend. diff --git a/apps/desktop/electron/native-access-token.test.ts b/apps/desktop/electron/native-access-token.test.ts new file mode 100644 index 0000000000..3200531741 --- /dev/null +++ b/apps/desktop/electron/native-access-token.test.ts @@ -0,0 +1,224 @@ +import { expect, test } from 'vitest' + +import { normalizeRemoteBaseUrl } from './connection-config' +import { createNativeAccessTokenCoordinator } from './native-access-token' +import { type NativeTokenSet, tokenNeedsRefresh } from './native-oauth' + +const tokenSet = (name: string, expiresAt = 2_000): NativeTokenSet => ({ + accessToken: name, + refreshToken: `${name}-rt`, + expiresAt, + provider: 'nous', + userId: 'user' +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + + return { promise, resolve, reject } +} + +// Covers forced rotation too: a locally live bearer can be rejected server-side. +test('normalised hosts share a rotation, and late bearer rejections reuse its winner', async () => { + const store = new Map([['https://gw.test', tokenSet('old')]]) + const pending = deferred() + let rotations = 0 + + const coordinator = createNativeAccessTokenCoordinator({ + normalizeBaseUrl: normalizeRemoteBaseUrl, + nowSeconds: () => 1_000, + tokenNeedsRefresh, + loadTokens: host => store.get(host) ?? null, + storeTokens: (host, tokens) => { + store.set(host, tokens) + }, + clearTokens: host => { + store.delete(host) + }, + isRefreshAuthRejection: error => (error as { statusCode?: number })?.statusCode === 401, + refreshTokens: async () => { + rotations++ + + return pending.promise + } + }) + + const first = coordinator.ensure('https://GW.test/', { forceRefresh: true, rejectedAccessToken: 'old' }) + const second = coordinator.ensure('https://gw.test', { forceRefresh: true, rejectedAccessToken: 'old' }) + const ordinary = coordinator.ensure('https://gw.test') + expect(rotations).toBe(1) + pending.resolve(tokenSet('winner')) + expect(await Promise.all([first, second, ordinary])).toEqual(['winner', 'winner', 'winner']) + expect(await coordinator.ensure('https://gw.test', { forceRefresh: true, rejectedAccessToken: 'old' })).toBe('winner') + expect(rotations).toBe(1) +}) + +test('a pending or abandoned login preserves the existing refresh flight and its rotation', async () => { + const host = 'https://gw.test' + const store = new Map([[host, tokenSet('old', 1_000)]]) + const pending = deferred() + const refreshTokens: string[] = [] + + const coordinator = createNativeAccessTokenCoordinator({ + normalizeBaseUrl: normalizeRemoteBaseUrl, + nowSeconds: () => 1_000, + tokenNeedsRefresh, + loadTokens: key => store.get(key) ?? null, + storeTokens: (key, tokens) => { + store.set(key, tokens) + }, + clearTokens: key => { + store.delete(key) + }, + isRefreshAuthRejection: () => false, + refreshTokens: async (_key, tokens) => { + refreshTokens.push(tokens.refreshToken!) + + return pending.promise + } + }) + + const first = coordinator.ensure(host) + const loginIsCurrent = coordinator.beginLogin('https://GW.test/') + const parallel = coordinator.ensure(host, { forceRefresh: true, rejectedAccessToken: 'old' }) + const results = Promise.allSettled([first, parallel]) + pending.resolve(tokenSet('rotated')) + expect(await results).toEqual([ + { status: 'fulfilled', value: 'rotated' }, + { status: 'fulfilled', value: 'rotated' } + ]) + expect(loginIsCurrent()).toBe(true) + // No login completion/store: closing the browser must leave the rotation intact. + expect(store.get(host)?.refreshToken).toBe('rotated-rt') + expect(await coordinator.ensure(host, { forceRefresh: true, rejectedAccessToken: 'old' })).toBe('rotated') + expect(refreshTokens).toEqual(['old-rt']) +}) + +test('explicit token mutations fence stale success and rejection without affecting other hosts', async () => { + for (const rejection of [false, true]) { + const host = 'https://gw.test' + const sibling = 'https://other.test' + + const store = new Map([ + [host, tokenSet('old', 1_000)], + [sibling, tokenSet('other', 1_000)] + ]) + + const pending = deferred() + const other = deferred() + + const coordinator = createNativeAccessTokenCoordinator({ + normalizeBaseUrl: normalizeRemoteBaseUrl, + nowSeconds: () => 1_000, + tokenNeedsRefresh, + loadTokens: key => store.get(key) ?? null, + storeTokens: (key, value) => { + store.set(key, value) + }, + clearTokens: key => { + store.delete(key) + }, + isRefreshAuthRejection: error => (error as { statusCode?: number })?.statusCode === 401, + refreshTokens: key => (key === host ? pending.promise : other.promise) + }) + + const old = coordinator.ensure(host) + const stale = expect(old).rejects.toThrow('Authentication changed') + const otherFlight = coordinator.ensure(sibling) + const login = coordinator.beginLogin('https://GW.test/') + coordinator.clearTokens(host) // logout supersedes the pending login before cookie I/O + expect(login()).toBe(false) + expect(await coordinator.ensure(host)).toBeNull() + + if (rejection) { + pending.reject({ statusCode: 401 }) + } else { + pending.resolve(tokenSet('stale')) + } + + other.resolve(tokenSet('other-winner')) + await stale + expect(await otherFlight).toBe('other-winner') + expect(store.has(host)).toBe(false) + expect(await coordinator.ensure(host)).toBeNull() + } +}) + +test('newer login intent wins out-of-order completions and fences an older refresh only on store', async () => { + for (const rejection of [false, true]) { + const host = 'https://gw.test' + const store = new Map([[host, tokenSet('old', 1_000)]]) + const pending = deferred() + + const coordinator = createNativeAccessTokenCoordinator({ + normalizeBaseUrl: normalizeRemoteBaseUrl, + nowSeconds: () => 1_000, + tokenNeedsRefresh, + loadTokens: key => store.get(key) ?? null, + storeTokens: (key, tokens) => { + store.set(key, tokens) + }, + clearTokens: key => { + store.delete(key) + }, + isRefreshAuthRejection: () => true, + refreshTokens: () => pending.promise + }) + + const completeLogin = async (rawHost: string, completion: Promise) => { + const isCurrent = coordinator.beginLogin(rawHost) + const tokens = await completion + + if (!isCurrent()) { + return false + } + + coordinator.storeTokens(rawHost, tokens) + + return true + } + + const refresh = coordinator.ensure(host) + const stale = expect(refresh).rejects.toThrow('Authentication changed') + const oldCompletion = deferred() + const newerCompletion = deferred() + const oldLogin = completeLogin('https://GW.test/', oldCompletion.promise) + const newerLogin = completeLogin(host, newerCompletion.promise) + + if (!rejection) { + oldCompletion.resolve(tokenSet('stale-login')) + expect(await oldLogin).toBe(false) // newer intent wins even before it completes + } + + newerCompletion.resolve(tokenSet('new-login')) + expect(await newerLogin).toBe(true) + expect(await coordinator.ensure(host)).toBe('new-login') + + if (rejection) { + oldCompletion.resolve(tokenSet('stale-login')) + expect(await oldLogin).toBe(false) + } + + if (rejection) { + pending.reject({ statusCode: 401 }) + } else { + pending.resolve(tokenSet('stale-refresh')) + } + + await stale + expect(store.get(host)?.accessToken).toBe('new-login') + + const logoutCompletion = deferred() + const loggedOutLogin = completeLogin(host, logoutCompletion.promise) + coordinator.clearTokens('https://GW.test/') + logoutCompletion.resolve(tokenSet('after-logout')) + expect(await loggedOutLogin).toBe(false) + expect(await coordinator.ensure(host)).toBeNull() + } +}) diff --git a/apps/desktop/electron/native-access-token.ts b/apps/desktop/electron/native-access-token.ts new file mode 100644 index 0000000000..c29bc2b128 --- /dev/null +++ b/apps/desktop/electron/native-access-token.ts @@ -0,0 +1,134 @@ +import type { NativeTokenSet } from './native-oauth' + +export interface NativeAccessTokenOptions { + forceRefresh?: boolean + /** A late 401 must reuse a concurrent rotation rather than rotate its winner again. */ + rejectedAccessToken?: string +} + +export interface NativeAccessTokenCoordinatorDeps { + clearTokens: (baseUrl: string) => void + isRefreshAuthRejection: (error: unknown) => boolean + loadTokens: (baseUrl: string) => NativeTokenSet | null + normalizeBaseUrl: (baseUrl: string) => string + nowSeconds?: () => number + refreshTokens: (baseUrl: string, tokens: NativeTokenSet) => Promise + storeTokens: (baseUrl: string, tokens: NativeTokenSet) => void + tokenNeedsRefresh: (tokens: NativeTokenSet, nowSeconds: number) => boolean +} + +export class NativeAuthChangedError extends Error { + constructor() { + super('Authentication changed while the request was in progress. Try again.') + } +} + +/** + * One owner for refresh flights and explicit native-token mutations. Per-host + * token epochs fence refreshes; login epochs order pending browser flows without + * discarding a valid rotation if a login is abandoned. Other hosts are independent. + */ +export function createNativeAccessTokenCoordinator(deps: NativeAccessTokenCoordinatorDeps) { + const refreshFlights = new Map>() + const authEpochs = new Map() + const loginEpochs = new Map() + const epochFor = (baseUrl: string) => authEpochs.get(baseUrl) ?? 0 + + function beginLogin(rawBaseUrl: string): () => boolean { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + const epoch = (loginEpochs.get(baseUrl) ?? 0) + 1 + loginEpochs.set(baseUrl, epoch) + + return () => loginEpochs.get(baseUrl) === epoch + } + + function beginExplicitAuthChange(baseUrl: string): void { + beginLogin(baseUrl) + authEpochs.set(baseUrl, epochFor(baseUrl) + 1) + refreshFlights.delete(baseUrl) + } + + async function ensure(rawBaseUrl: string, options: NativeAccessTokenOptions = {}): Promise { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + const existingFlight = refreshFlights.get(baseUrl) + + if (existingFlight) { + return existingFlight + } + + const tokens = deps.loadTokens(baseUrl) + + if (!tokens) { + return null + } + + const nowSeconds = deps.nowSeconds?.() ?? Math.floor(Date.now() / 1_000) + const rejectedCurrentToken = !options.rejectedAccessToken || options.rejectedAccessToken === tokens.accessToken + + if (!(options.forceRefresh && rejectedCurrentToken) && !deps.tokenNeedsRefresh(tokens, nowSeconds)) { + return tokens.accessToken + } + + if (!tokens.refreshToken) { + deps.clearTokens(baseUrl) + + return null + } + + const flightEpoch = epochFor(baseUrl) + + const assertCurrent = () => { + if (epochFor(baseUrl) !== flightEpoch) { + throw new NativeAuthChangedError() + } + } + + const refreshFlight = (async (): Promise => { + let rotated: NativeTokenSet + + try { + rotated = await deps.refreshTokens(baseUrl, tokens) + } catch (error) { + assertCurrent() + + if (deps.isRefreshAuthRejection(error)) { + deps.clearTokens(baseUrl) + + return null + } + + throw error + } + + assertCurrent() + deps.storeTokens(baseUrl, rotated) + + return rotated.accessToken + })() + + refreshFlights.set(baseUrl, refreshFlight) + + try { + return await refreshFlight + } finally { + if (refreshFlights.get(baseUrl) === refreshFlight) { + refreshFlights.delete(baseUrl) + } + } + } + + return { + ensure, + beginLogin, + storeTokens: (rawBaseUrl: string, tokens: NativeTokenSet) => { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + beginExplicitAuthChange(baseUrl) + deps.storeTokens(baseUrl, tokens) + }, + clearTokens: (rawBaseUrl: string) => { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + beginExplicitAuthChange(baseUrl) + deps.clearTokens(baseUrl) + } + } +} diff --git a/apps/desktop/electron/oauth-rest-request.test.ts b/apps/desktop/electron/oauth-rest-request.test.ts new file mode 100644 index 0000000000..5e7e6dc28e --- /dev/null +++ b/apps/desktop/electron/oauth-rest-request.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from 'vitest' + +import { httpStatusError } from './api-transport' +import { isGatewayAuthRejection } from './connection-config' +import { NativeAuthChangedError } from './native-access-token' +import { mintGatewayWsTicket, requestWithOauthFallback } from './oauth-rest-request' + +test('native failures remain transport failures unless an independent cookie session succeeds', async () => { + for (const nativeError of [new Error('timeout'), httpStatusError(503, 'down'), new Error('malformed response')]) { + for (const cookieWorks of [true, false]) { + const run = () => + requestWithOauthFallback('https://gw.test', { + ensureNativeAccessToken: async () => { + throw nativeError + }, + requestWithBearer: async () => 'bearer', + requestWithCookie: async () => { + if (cookieWorks) { + return 'cookie' + } + + throw httpStatusError(401, 'no cookie') + } + }) + + if (cookieWorks) { + expect(await run()).toBe('cookie') + } else { + await expect(run()).rejects.toBe(nativeError) + expect(isGatewayAuthRejection(nativeError)).toBe(false) + } + } + } + + let cookieCalls = 0 + await expect( + requestWithOauthFallback('https://gw.test', { + ensureNativeAccessToken: async () => { + throw new NativeAuthChangedError() + }, + requestWithBearer: async () => 'bearer', + requestWithCookie: async () => { + cookieCalls++ + + return 'cookie' + } + }) + ).rejects.toThrow('Authentication changed') + expect(cookieCalls).toBe(0) +}) + +test('ticket mints force one rotation on bearer 401; ordinary requests never replay a mutation', async () => { + for (const status of [401, 403, 503]) { + for (const rotated of ['fresh', null, 'transient'] as const) { + const calls: unknown[] = [] + let refreshes = 0 + + const run = () => + mintGatewayWsTicket( + 'https://gw.test', + { + ensureNativeAccessToken: async (_host, options) => { + if (!options?.forceRefresh) { + return 'old' + } + + expect(options.rejectedAccessToken).toBe('old') + refreshes++ + + if (rotated === 'transient') { + throw new Error('refresh timeout') + } + + return rotated + }, + fetchJson: async (_url, _token, options) => { + calls.push(options.bearer) + expect(options.headers).toEqual({ 'x-proxy': 'test' }) + + if (options.bearer === 'old') { + throw httpStatusError(status, 'rejected') + } + + return { ticket: 'ticket' } + }, + fetchJsonViaOauthSession: async () => { + throw httpStatusError(401, 'no cookie') + } + }, + { 'x-proxy': 'test' } + ) + + if (status === 401 && rotated === 'fresh') { + expect(await run()).toBe('ticket') + } else { + await expect(run()).rejects.toThrow(status === 401 && rotated === 'transient' ? 'refresh timeout' : 'rejected') + } + + expect(refreshes).toBe(status === 401 ? 1 : 0) + expect(calls).toEqual(status === 401 && rotated === 'fresh' ? ['old', 'fresh'] : ['old']) + } + } + + for (const error of [httpStatusError(401, 'rejected'), new Error('socket reset after body sent')]) { + let submissions = 0 + let cookies = 0 + await expect( + requestWithOauthFallback('https://gw.test', { + ensureNativeAccessToken: async () => 'live', + requestWithBearer: async () => { + submissions++ + throw error + }, + requestWithCookie: async () => { + cookies++ + + return 'duplicate mutation' + } + }) + ).rejects.toBe(error) + expect(submissions).toBe(1) + expect(cookies).toBe(0) + } +}) diff --git a/apps/desktop/electron/oauth-rest-request.ts b/apps/desktop/electron/oauth-rest-request.ts new file mode 100644 index 0000000000..2539be94c4 --- /dev/null +++ b/apps/desktop/electron/oauth-rest-request.ts @@ -0,0 +1,104 @@ +import { isGatewayAuthRejection } from './connection-config' +import { type NativeAccessTokenOptions, NativeAuthChangedError } from './native-access-token' +import { shouldRotateNativeTokenAfterRejection } from './native-auth-decisions' + +export interface OauthRestRequestDeps { + ensureNativeAccessToken: (baseUrl: string, options?: NativeAccessTokenOptions) => Promise + requestWithBearer: (accessToken: string) => Promise + requestWithCookie: () => Promise +} + +async function cookieFallback(request: () => Promise, nativeError: unknown): Promise { + // An old request must not cross into a newly selected identity's cookie jar. + if (nativeError instanceof NativeAuthChangedError) { + throw nativeError + } + + try { + return await request() + } catch (cookieError) { + if (isGatewayAuthRejection(cookieError)) { + throw nativeError + } + + throw cookieError + } +} + +/** + * Native-first OAuth selection for REST, readiness, downloads and media. + * Cookie coexistence can recover a failed refresh, but an empty cookie jar + * cannot turn that transport failure into a terminal sign-in verdict. + * Never replay a submitted request: it may be a non-idempotent mutation. + */ +export async function requestWithOauthFallback(baseUrl: string, deps: OauthRestRequestDeps): Promise { + let nativeAccessToken: string | null + + try { + nativeAccessToken = await deps.ensureNativeAccessToken(baseUrl) + } catch (error) { + return cookieFallback(deps.requestWithCookie, error) + } + + return nativeAccessToken ? deps.requestWithBearer(nativeAccessToken) : deps.requestWithCookie() +} + +export interface MintGatewayWsTicketDeps { + ensureNativeAccessToken: OauthRestRequestDeps['ensureNativeAccessToken'] + fetchJson: (url: string, token: string | null, options: any) => Promise + fetchJsonViaOauthSession: (url: string, options: any) => Promise +} + +/** Ticket minting is replay-safe, unlike arbitrary REST mutations. */ +export async function mintGatewayWsTicket( + baseUrl: string, + deps: MintGatewayWsTicketDeps, + headers: Record = {} +): Promise { + const url = `${baseUrl}/api/auth/ws-ticket` + const options = { method: 'POST', timeoutMs: 8_000, headers } + + const ticketFrom = async (request: Promise): Promise => { + const body = await request + + if (!body?.ticket || typeof body.ticket !== 'string') { + throw new Error('Gateway did not return a WS ticket.') + } + + return body.ticket + } + + const mintWithBearer = (bearer: string) => ticketFrom(deps.fetchJson(url, null, { ...options, bearer })) + const requestWithCookie = () => ticketFrom(deps.fetchJsonViaOauthSession(url, options)) + + return requestWithOauthFallback(baseUrl, { + ensureNativeAccessToken: deps.ensureNativeAccessToken, + requestWithCookie, + requestWithBearer: async nativeAt => { + try { + try { + return await mintWithBearer(nativeAt) + } catch (error) { + // Preserve #107990: the gate does not rotate a native bearer itself. + // Only a structured 401 earns one forced refresh; never a 403. + if (!shouldRotateNativeTokenAfterRejection(error)) { + throw error + } + + const rotatedAt = await deps.ensureNativeAccessToken(baseUrl, { + forceRefresh: true, + rejectedAccessToken: nativeAt + }) + + if (!rotatedAt || rotatedAt === nativeAt) { + throw error + } + + return await mintWithBearer(rotatedAt) + } + } catch (error) { + return cookieFallback(requestWithCookie, error) + } + } + }) +}