fix(desktop): treat ticket 401 as sign-in when native tokens are unreadable

This commit is contained in:
Ravi Tharuma
2026-08-20 10:55:08 +02:00
committed by Teknium
parent 024b9c0545
commit 949f5169de
4 changed files with 96 additions and 7 deletions
+2 -1
View File
@@ -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.'
)
}
@@ -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', () => {
+69 -6
View File
@@ -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
@@ -0,0 +1 @@
RaviTharuma