diff --git a/ui/src/app/control-ui-auth.test.ts b/ui/src/app/control-ui-auth.test.ts new file mode 100644 index 000000000000..5f695e01e4b4 --- /dev/null +++ b/ui/src/app/control-ui-auth.test.ts @@ -0,0 +1,28 @@ +// Candidate ordering is a product contract: shared secrets first, because +// several gateway byte routes (plugin/catalog/workspace icons) reject device +// tokens, and each rejected attempt pays the shared-secret brute-force +// penalty on the gateway (delay escalation, remote IP lockout). +import { describe, expect, it } from "vitest"; +import { resolveControlUiAuthCandidates } from "./control-ui-auth.ts"; + +describe("resolveControlUiAuthCandidates", () => { + it("orders shared secrets before the hello device token", () => { + expect( + resolveControlUiAuthCandidates({ + hello: { auth: { deviceToken: "device-token" } } as never, + settings: { token: "shared-token" }, + password: "shared-password", + }), + ).toEqual(["shared-token", "shared-password", "device-token"]); + }); + + it("keeps the device token for pairing-only browsers", () => { + expect( + resolveControlUiAuthCandidates({ + hello: { auth: { deviceToken: "device-token" } } as never, + settings: { token: "" }, + password: "", + }), + ).toEqual(["device-token"]); + }); +}); diff --git a/ui/src/app/control-ui-auth.ts b/ui/src/app/control-ui-auth.ts index a613818bb466..a34afc664be3 100644 --- a/ui/src/app/control-ui-auth.ts +++ b/ui/src/app/control-ui-auth.ts @@ -40,12 +40,17 @@ export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string // call sites that can retry a single request against an alternate credential // when the first returns 401 — for example, recovering from a stale // `settings.token` when the live session is authenticated via `password`. +// Shared secrets go first: several byte routes (plugin/catalog/workspace +// icons) only accept the gateway shared secret, so leading with the hello +// device token made every icon fetch 401 first — and each of those 401s pays +// the shared-secret brute-force penalty on the gateway. Pairing-only browsers +// still reach the device token as the last candidate. export function resolveControlUiAuthCandidates(source: ControlUiAuthSource): string[] { return uniqueStrings( [ - normalizeOptionalString(source.hello?.auth?.deviceToken), normalizeOptionalString(source.settings?.token), normalizeOptionalString(source.password), + normalizeOptionalString(source.hello?.auth?.deviceToken), ].flatMap((raw) => sanitizeHeaderToken(raw ?? null) ?? []), ); } diff --git a/ui/src/pages/profile/profile-page.ts b/ui/src/pages/profile/profile-page.ts index d611df0b961c..9219a60068ce 100644 --- a/ui/src/pages/profile/profile-page.ts +++ b/ui/src/pages/profile/profile-page.ts @@ -14,7 +14,7 @@ import { type ApplicationContext, type ApplicationGatewaySnapshot, } from "../../app/context.ts"; -import { resolveControlUiAuthToken } from "../../app/control-ui-auth.ts"; +import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts"; import type { AuthenticatedUser } from "../../app/user-profile.ts"; import { resolveCurrentSelfUser, userProfileAvatarUrl } from "../../app/user-profile.ts"; import { icons } from "../../components/icons.ts"; @@ -61,7 +61,7 @@ export class ProfilePage extends OpenClawLightDomElement { private client: GatewayBrowserClient | null = null; private connected = false; - private heroAvatarAuthToken: string | null = null; + private heroAvatarAuthCandidates: string[] = []; private heroAvatarAuthReady = false; private readonly heroAvatarLoader = new AuthenticatedAvatarRouteLoader(() => { if (this.isConnected) { @@ -88,7 +88,7 @@ export class ProfilePage extends OpenClawLightDomElement { this.subscriptions = []; this.identityRequestId += 1; this.heroAvatarLoader.reset(); - this.heroAvatarAuthToken = null; + this.heroAvatarAuthCandidates = []; this.heroAvatarAuthReady = false; this.client = null; this.connected = false; @@ -96,13 +96,17 @@ export class ProfilePage extends OpenClawLightDomElement { } private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) { - const nextHeroAvatarAuthToken = resolveControlUiAuthToken({ + // The /api/users avatar route only accepts shared secrets; a single + // device-token candidate would 401 forever. Offer the full ordered list. + const nextHeroAvatarAuthCandidates = resolveControlUiAuthCandidates({ hello: snapshot.hello, settings: { token: this.context.gateway.connection.token }, password: this.context.gateway.connection.password, }); - if (nextHeroAvatarAuthToken !== this.heroAvatarAuthToken) { - this.heroAvatarAuthToken = nextHeroAvatarAuthToken; + if ( + nextHeroAvatarAuthCandidates.join("\u0000") !== this.heroAvatarAuthCandidates.join("\u0000") + ) { + this.heroAvatarAuthCandidates = nextHeroAvatarAuthCandidates; } this.heroAvatarAuthReady = Boolean( snapshot.hello || @@ -359,10 +363,7 @@ export class ProfilePage extends OpenClawLightDomElement { private renderAvatar(avatarUrl: string | null, textAvatar: string | null, name: string) { const imageUrl = avatarUrl?.startsWith("/") ? this.heroAvatarAuthReady - ? this.heroAvatarLoader.resolve( - avatarUrl, - this.heroAvatarAuthToken ? [this.heroAvatarAuthToken] : [], - ) + ? this.heroAvatarLoader.resolve(avatarUrl, this.heroAvatarAuthCandidates) : null : avatarUrl; if (avatarUrl && avatarUrl !== this.failedHeroAvatarUrl) {