fix(desktop): treat ticket 401 as sign-in when native tokens are unreadable
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user