From 949f5169dee79d1f5fd42b7aa6b1c9890b361169 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Thu, 20 Aug 2026 10:55:08 +0200 Subject: [PATCH] fix(desktop): treat ticket 401 as sign-in when native tokens are unreadable --- apps/desktop/electron/main.ts | 3 +- .../electron/native-auth-decisions.test.ts | 24 ++++++ .../desktop/electron/native-auth-decisions.ts | 75 +++++++++++++++++-- .../RaviTharuma@users.noreply.github.com | 1 + 4 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 contributors/emails/RaviTharuma@users.noreply.github.com diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 06b2eae673..c40263be9c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -226,6 +226,7 @@ import { createMediaProtocolHandler, MEDIA_PROTOCOL } from './media-protocol' import { oauthGuardMayHardFail, oauthSessionIsLive, + oauthTicketFailureAuthMessage, resolveGatedDownloadAuth, resolveJsonBody, resolveOauthRestAuth, @@ -9427,7 +9428,7 @@ async function buildRemoteConnection( throw gatewayTicketFailure( error, - 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', + oauthTicketFailureAuthMessage(hasNativeSession(baseUrl)), 'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.' ) } diff --git a/apps/desktop/electron/native-auth-decisions.test.ts b/apps/desktop/electron/native-auth-decisions.test.ts index 6eb7a10dac..0434d432cd 100644 --- a/apps/desktop/electron/native-auth-decisions.test.ts +++ b/apps/desktop/electron/native-auth-decisions.test.ts @@ -11,8 +11,10 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { + normalizeAdvertisedAuthProviders, oauthGuardMayHardFail, oauthSessionIsLive, + oauthTicketFailureAuthMessage, resolveGatedDownloadAuth, resolveJsonBody, resolveOauthRestAuth, @@ -132,6 +134,28 @@ test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', ( assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true) }) + +test('oauthGuardMayHardFail treats status-shaped string basic as password-only', () => { + assert.equal(oauthGuardMayHardFail(['basic'] as any), false) + assert.equal(oauthGuardMayHardFail([' basic '] as any), false) +}) + +test('oauthGuardMayHardFail keeps the strict guard for string oauth providers', () => { + assert.equal(oauthGuardMayHardFail(['nous'] as any), true) + assert.equal(oauthGuardMayHardFail(['nous', 'basic'] as any), true) +}) + +test('normalizeAdvertisedAuthProviders maps snake_case supports_password', () => { + assert.deepEqual(normalizeAdvertisedAuthProviders([{ name: 'basic', supports_password: true }]), [ + { name: 'basic', supportsPassword: true } + ]) +}) + +test('oauthTicketFailureAuthMessage is expired only with a decryptable native session', () => { + assert.match(oauthTicketFailureAuthMessage(true), /session has expired/) + assert.match(oauthTicketFailureAuthMessage(false), /not signed in/) +}) + // --- 6. gated download auth (guards the Files-panel 401 on cookieless native) --- test('resolveGatedDownloadAuth matches oauth REST: bearer first, then cookie', () => { diff --git a/apps/desktop/electron/native-auth-decisions.ts b/apps/desktop/electron/native-auth-decisions.ts index 9e620bd906..0596f53ed9 100644 --- a/apps/desktop/electron/native-auth-decisions.ts +++ b/apps/desktop/electron/native-auth-decisions.ts @@ -138,6 +138,73 @@ export interface AdvertisedAuthProvider { supportsPassword?: boolean } +/** Dashboard `basic` auth is username/password; `/api/status` often lists it as a bare string. */ +const PASSWORD_PROVIDER_NAMES = new Set(['basic']) + +const OAUTH_NOT_SIGNED_IN_MESSAGE = + 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' + +const OAUTH_SESSION_EXPIRED_MESSAGE = + 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.' + +/** + * Normalize `/api/auth/providers` objects *or* `/api/status` `auth_providers` + * string names into the shape `oauthGuardMayHardFail` understands. + */ +export function normalizeAdvertisedAuthProviders(providers: unknown): AdvertisedAuthProvider[] { + if (!Array.isArray(providers)) { + return [] + } + + const out: AdvertisedAuthProvider[] = [] + + for (const provider of providers) { + if (typeof provider === 'string') { + const name = provider.trim() + + if (!name) { + continue + } + + out.push({ name, supportsPassword: PASSWORD_PROVIDER_NAMES.has(name) }) + continue + } + + if (!provider || typeof provider !== 'object') { + continue + } + + const raw = provider as AdvertisedAuthProvider & { supports_password?: boolean } + const name = typeof raw.name === 'string' ? raw.name.trim() : '' + + if (!name) { + continue + } + + const supportsPassword = + typeof raw.supportsPassword === 'boolean' + ? raw.supportsPassword + : typeof raw.supports_password === 'boolean' + ? raw.supports_password + : PASSWORD_PROVIDER_NAMES.has(name) + + out.push({ name, supportsPassword }) + } + + return out +} + +/** + * A 401/403 on `POST /api/auth/ws-ticket` is "session expired" only when we + * actually had a decryptable native token set. Stale partition cookies plus + * an unreadable keychain otherwise look like a live oauth session and the + * ticket mint 401s — that must send the user to Sign in, not "expired". + */ +export function oauthTicketFailureAuthMessage(hasDecryptableNativeSession: boolean): string { + return hasDecryptableNativeSession ? OAUTH_SESSION_EXPIRED_MESSAGE : OAUTH_NOT_SIGNED_IN_MESSAGE +} + /** * Whether the oauth pre-flight guard may hard-fail a connection for "not * signed in". @@ -156,12 +223,8 @@ export interface AdvertisedAuthProvider { * unknown or empty list keeps the strict guard, so backends that predate * `/api/auth/providers` are unaffected. */ -export function oauthGuardMayHardFail(providers: AdvertisedAuthProvider[] | null | undefined): boolean { - if (!Array.isArray(providers) || providers.length === 0) { - return true - } - - const named = providers.filter(provider => provider && typeof provider === 'object' && provider.name) +export function oauthGuardMayHardFail(providers: unknown): boolean { + const named = normalizeAdvertisedAuthProviders(providers) if (named.length === 0) { return true diff --git a/contributors/emails/RaviTharuma@users.noreply.github.com b/contributors/emails/RaviTharuma@users.noreply.github.com new file mode 100644 index 0000000000..e3420e4c11 --- /dev/null +++ b/contributors/emails/RaviTharuma@users.noreply.github.com @@ -0,0 +1 @@ +RaviTharuma