Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 23 additions & 20 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth,
resolveProfileRestAuth,
resolveReadinessProbeAuth
} from './native-auth-decisions'
import {
Expand Down Expand Up @@ -7374,19 +7375,23 @@ async function requestJsonForProfile(profile: string, path: string, method: stri
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)
// Native RFC 8252 flow: authenticate with the bearer token (cookieless) when
// we hold one for this gateway; otherwise use the cookie partition. Only a
// non-oauth gateway may use conn.token — see resolveProfileRestAuth.
const nativeAt =
conn.authMode === 'oauth' ? await ensureNativeAccessToken(conn.baseUrl).catch(() => null) : null

if (nativeAt) {
return fetchJson(url, null, { ...opts, bearer: nativeAt })
}
const auth = resolveProfileRestAuth(conn.authMode, nativeAt, conn.token)

if (auth.kind === 'bearer') {
return fetchJson(url, null, { ...opts, bearer: auth.token })
}

if (auth.kind === 'cookie') {
return fetchJsonViaOauthSession(url, opts)
}

return fetchJson(url, conn.token, opts)
return fetchJson(url, auth.token, opts)
}

async function probeRemoteAuthMode(rawUrl) {
Expand Down Expand Up @@ -9555,12 +9560,11 @@ async function fetchProfilesSessionSlice(searchParams, remoteProfiles) {
return remoteSessionList(requested, searchParams)
}

const primary = await ensureBackend(null)

return fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
method: 'GET',
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
return fetchJsonForProfile(null, `/api/profiles/sessions?${searchParams}`).catch(() => ({
sessions: [],
total: 0,
profile_totals: {}
}))
}

return mergeRemoteProfileSessions(searchParams, remoteProfiles)
Expand All @@ -9575,12 +9579,11 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active'

const primary = await ensureBackend(null)

const base = (await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
method: 'GET',
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))) as any
const base = (await fetchJsonForProfile(null, `/api/profiles/sessions?${searchParams}`).catch(() => ({
sessions: [],
total: 0,
profile_totals: {}
}))) as any

// Over-fetch each remote from offset 0 (limit+offset rows) so the merged window
// is correct for this page — mirrors the primary's per-profile over-fetch.
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/electron/native-auth-decisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth,
resolveProfileRestAuth,
resolveReadinessProbeAuth
} from './native-auth-decisions'

Expand Down Expand Up @@ -130,3 +131,49 @@ test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', (
assert.equal(oauthGuardMayHardFail('nonsense' as any), true)
assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true)
})

// --- Per-profile REST auth selection (guards the empty sidebar of #67600) ---

test('resolveProfileRestAuth uses the connection token on a token-auth gateway', () => {
assert.deepEqual(resolveProfileRestAuth('token', null, 'conn-token'), {
kind: 'connection-token',
token: 'conn-token'
})
})

test('resolveProfileRestAuth keeps the connection token when authMode is unknown', () => {
// Deliberate divergence from resolveReadinessProbeAuth, which treats an
// unknown gateway as public: a REST call must not silently drop its
// credential just because the mode has not been resolved yet.
assert.deepEqual(resolveProfileRestAuth('unknown', null, 'conn-token'), {
kind: 'connection-token',
token: 'conn-token'
})
assert.deepEqual(resolveProfileRestAuth(undefined, null, 'conn-token'), {
kind: 'connection-token',
token: 'conn-token'
})
assert.deepEqual(resolveProfileRestAuth(null, null, null), { kind: 'connection-token', token: null })
})

test('resolveProfileRestAuth prefers the native bearer on an oauth gateway', () => {
assert.deepEqual(resolveProfileRestAuth('oauth', 'bearer-token-123', 'conn-token'), {
kind: 'bearer',
token: 'bearer-token-123'
})
})

test('resolveProfileRestAuth falls back to the cookie partition on an oauth gateway', () => {
assert.deepEqual(resolveProfileRestAuth('oauth', null, 'conn-token'), { kind: 'cookie' })
})

test('resolveProfileRestAuth never authenticates an oauth gateway with the connection token', () => {
// The #67600 regression: call sites that ignored authMode passed the
// connection token to an oauth-gated gateway, which 401s. Whatever the
// native token state, an oauth gateway must never resolve to that token.
for (const nativeAt of ['bearer-token-123', null, undefined, '']) {
const auth = resolveProfileRestAuth('oauth', nativeAt, 'conn-token')

assert.notEqual(auth.kind, 'connection-token')
}
})
30 changes: 30 additions & 0 deletions apps/desktop/electron/native-auth-decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,33 @@ export function oauthGuardMayHardFail(providers: AdvertisedAuthProvider[] | null

return !named.every(provider => provider.supportsPassword)
}

export type ProfileRestAuth = OauthRestAuth | { kind: 'connection-token'; token: string | null }

/**
* Decide how a per-profile REST request authenticates. Same branch as
* resolveReadinessProbeAuth, with one deliberate difference: a REST call keeps
* sending the connection token when authMode is not yet known, where the
* readiness probe treats an unknown gateway as public. Probing anonymously is
* safe; dropping the credential from a real request is not.
*
* This is the seam the /api/profiles/sessions call sites skipped: they resolved
* the connection and passed `conn.token` straight to fetchJson, ignoring
* authMode entirely. Against an oauth gateway that token is not a valid
* credential, so the request 401s — and because those callers collapse a
* rejected fetch into an empty slice, the session sidebar renders empty with no
* error surfaced anywhere (#67600).
*/
export function resolveProfileRestAuth(
authMode: string | null | undefined,
nativeAccessToken: string | null | undefined,
connectionToken: string | null | undefined
): ProfileRestAuth {
const probe = resolveReadinessProbeAuth(authMode, nativeAccessToken, connectionToken)

if (probe.kind === 'bearer' || probe.kind === 'cookie') {
return probe
}

return { kind: 'connection-token', token: connectionToken ?? null }
}