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 <sora.bluesky.dev@gmail.com> Co-authored-by: Zeus-Deus <github.commits@widow.cc>
This commit is contained in:
+96
-266
@@ -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<string | null> {
|
||||
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.
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
|
||||
const promise = new Promise<T>((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<NativeTokenSet>()
|
||||
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<NativeTokenSet>()
|
||||
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<NativeTokenSet>()
|
||||
const other = deferred<NativeTokenSet>()
|
||||
|
||||
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<NativeTokenSet>()
|
||||
|
||||
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<NativeTokenSet>) => {
|
||||
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<NativeTokenSet>()
|
||||
const newerCompletion = deferred<NativeTokenSet>()
|
||||
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<NativeTokenSet>()
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -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<NativeTokenSet>
|
||||
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<string, Promise<string | null>>()
|
||||
const authEpochs = new Map<string, number>()
|
||||
const loginEpochs = new Map<string, number>()
|
||||
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<string | null> {
|
||||
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<string | null> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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<T> {
|
||||
ensureNativeAccessToken: (baseUrl: string, options?: NativeAccessTokenOptions) => Promise<string | null>
|
||||
requestWithBearer: (accessToken: string) => Promise<T>
|
||||
requestWithCookie: () => Promise<T>
|
||||
}
|
||||
|
||||
async function cookieFallback<T>(request: () => Promise<T>, nativeError: unknown): Promise<T> {
|
||||
// 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<T>(baseUrl: string, deps: OauthRestRequestDeps<T>): Promise<T> {
|
||||
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<unknown>['ensureNativeAccessToken']
|
||||
fetchJson: (url: string, token: string | null, options: any) => Promise<any>
|
||||
fetchJsonViaOauthSession: (url: string, options: any) => Promise<any>
|
||||
}
|
||||
|
||||
/** Ticket minting is replay-safe, unlike arbitrary REST mutations. */
|
||||
export async function mintGatewayWsTicket(
|
||||
baseUrl: string,
|
||||
deps: MintGatewayWsTicketDeps,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<string> {
|
||||
const url = `${baseUrl}/api/auth/ws-ticket`
|
||||
const options = { method: 'POST', timeoutMs: 8_000, headers }
|
||||
|
||||
const ticketFrom = async (request: Promise<any>): Promise<string> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user