Skip to content
Merged
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: 33 additions & 10 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ import { createMediaProtocolHandler, MEDIA_PROTOCOL } from './media-protocol'
import {
oauthGuardMayHardFail,
oauthSessionIsLive,
resolveGatedDownloadAuth,
resolveJsonBody,
resolveOauthRestAuth,
resolveReadinessProbeAuth
Expand Down Expand Up @@ -4858,7 +4859,8 @@ function fetchJson(url, token, options: any = {}) {
// Token-auth download that streams the response body straight to a
// user-selected destination (via finalizeGatewayDownload) instead of buffering
// the whole file in memory. The connect timeout is cleared once headers arrive
// so a slow save dialog or a large stream doesn't trip it.
// so a slow save dialog or a large stream doesn't trip it. `options.bearer`
// switches the header to Authorization (RFC 8252 native flow), matching fetchJson.
function downloadViaTokenToFile(url, token, ctx, options: any = {}) {
return new Promise((resolve, reject) => {
let parsed
Expand All @@ -4884,9 +4886,9 @@ function downloadViaTokenToFile(url, token, ctx, options: any = {}) {
parsed,
{
method: 'GET',
headers: {
'X-Hermes-Session-Token': token
}
headers: options.bearer
? { Authorization: `Bearer ${options.bearer}` }
: { 'X-Hermes-Session-Token': token }
},
res => {
// Headers arrived — the connection phase is done. Drop the idle timeout
Expand Down Expand Up @@ -7250,6 +7252,13 @@ function readGatewayErrorText(res): Promise<string> {
})
}

async function gatedFileAuth(connection) {
const nativeAt =
connection.authMode === 'oauth' ? await ensureNativeAccessToken(connection.baseUrl).catch(() => null) : null

return resolveGatedDownloadAuth(connection.authMode, nativeAt, connection.token)
}

async function saveGatewayFile(payload: any = {}) {
const filePath = gatewayFilePath(payload.path)

Expand All @@ -7272,9 +7281,17 @@ async function saveGatewayFile(payload: any = {}) {
const url = `${connection.baseUrl}${requestPath}`

try {
return await (connection.authMode === 'oauth'
? downloadViaOauthSessionToFile(url, ctx)
: downloadViaTokenToFile(url, connection.token, ctx))
const auth = await gatedFileAuth(connection)

if (auth.kind === 'bearer') {
return await downloadViaTokenToFile(url, auth.token, ctx, { bearer: auth.token })
}

if (auth.kind === 'cookie') {
return await downloadViaOauthSessionToFile(url, ctx)
}

return await downloadViaTokenToFile(url, auth.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
Expand All @@ -7299,10 +7316,16 @@ async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any
)

const url = `${connection.baseUrl}${requestPath}`
const auth = await gatedFileAuth(connection)
let json: any

const json = (
connection.authMode === 'oauth' ? await fetchJsonViaOauthSession(url) : await fetchJson(url, connection.token)
) as any
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 dataUrl = json?.dataUrl

Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/electron/native-auth-decisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { test } from 'vitest'
import {
oauthGuardMayHardFail,
oauthSessionIsLive,
resolveGatedDownloadAuth,
resolveJsonBody,
resolveOauthRestAuth,
resolveReadinessProbeAuth
Expand Down Expand Up @@ -130,3 +131,20 @@ test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', (
assert.equal(oauthGuardMayHardFail('nonsense' as any), true)
assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true)
})

// --- 6. gated download auth (guards the Files-panel 401 on cookieless native) ---

test('resolveGatedDownloadAuth matches oauth REST: bearer first, then cookie', () => {
assert.deepEqual(resolveGatedDownloadAuth('oauth', 'native-at'), { kind: 'bearer', token: 'native-at' })
assert.deepEqual(resolveGatedDownloadAuth('oauth', null), { kind: 'cookie' })
assert.deepEqual(resolveGatedDownloadAuth('oauth', ''), { kind: 'cookie' })
})

test('resolveGatedDownloadAuth uses the session token for token and local modes', () => {
assert.deepEqual(resolveGatedDownloadAuth('token', 'native-at', 'session-token'), {
kind: 'token',
token: 'session-token'
})
assert.deepEqual(resolveGatedDownloadAuth('local', null, 'sess'), { kind: 'token', token: 'sess' })
assert.deepEqual(resolveGatedDownloadAuth(undefined, null, null), { kind: 'token', token: null })
})
31 changes: 29 additions & 2 deletions apps/desktop/electron/native-auth-decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* native-auth-decisions.ts
*
* Pure decision helpers extracted from main.ts for the RFC 8252 native-app
* auth flow. These encode three choices that were each the site of a real
* auth flow. These encode six choices that were each the site of a real
* runtime bug — invisible to the mocked flow tests because the tests never
* exercised the real main.ts internals. Keeping them pure + unit-tested here
* prevents silent regressions:
Expand Down Expand Up @@ -31,7 +31,12 @@
* can satisfy neither the native-bearer nor the OAuth-partition-cookie
* check by design, so the pre-flight guard must not hard-fail it.
*
* All five are trivial once named; the value is the test that pins the
* 6. resolveGatedDownloadAuth — file save/read must present the SAME
* credentials as oauth REST. `saveGatewayFile` used to always ride the
* OAuth cookie partition, so a cookieless native (or native-password)
* session could list files via `hermes:api` and still 401 on Download.
*
* All six are trivial once named; the value is the test that pins the
* contract so the god-file call sites can't drift back to the buggy shape.
*/

Expand Down Expand Up @@ -106,6 +111,28 @@ export function resolveReadinessProbeAuth(
return { kind: 'public' }
}

export type GatedDownloadAuth = OauthRestAuth | { kind: 'token'; token: string | null }

/**
* Decide how a gated file download authenticates.
*
* Must match oauth REST (`resolveOauthRestAuth`): native bearer when present,
* else the OAuth cookie partition. Token/local connections keep the static
* session-token header. A cookie-only download against a cookieless native
* session is the #88987 401 — Files panel listing works, Download does not.
*/
export function resolveGatedDownloadAuth(
authMode: string | null | undefined,
nativeAccessToken?: string | null,
connectionToken?: string | null
): GatedDownloadAuth {
if (authMode === 'oauth') {
return resolveOauthRestAuth(nativeAccessToken)
}

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

export interface AdvertisedAuthProvider {
name?: string
supportsPassword?: boolean
Expand Down
Loading