diff --git a/CHANGELOG.md b/CHANGELOG.md index 393f84cfc5e..7ee7221de75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead. - Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz. - Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. +- Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit `auth.deviceToken` support in connect frames and tests. - Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior. - Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang. - Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc. @@ -55,6 +56,7 @@ Docs: https://docs.openclaw.ai - Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber. - Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728. - ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen. +- Gateway/Auth: preserve `OPENCLAW_GATEWAY_PASSWORD` env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation. - Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). - Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. - Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/acp/server.ts b/src/acp/server.ts index 0c17ca429d1..931d0493178 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -3,9 +3,9 @@ import { Readable, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; import { loadConfig } from "../config/config.js"; -import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { GatewayClient } from "../gateway/client.js"; +import { resolveGatewayCredentialsFromConfig } from "../gateway/credentials.js"; import { isMainModule } from "../infra/is-main.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { readSecretFromFile } from "./secret-file.js"; @@ -18,21 +18,14 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise void; @@ -64,8 +57,8 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise []); const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); +const loadDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); +const storeDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); const clearDevicePairingMock = vi.hoisted(() => vi.fn()); const logDebugMock = vi.hoisted(() => vi.fn()); @@ -20,6 +22,7 @@ class MockWebSocket { private messageHandlers: WsEventHandlers["message"][] = []; private closeHandlers: WsEventHandlers["close"][] = []; private errorHandlers: WsEventHandlers["error"][] = []; + readonly sent: string[] = []; constructor(_url: string, _options?: unknown) { wsInstances.push(this); @@ -50,6 +53,22 @@ class MockWebSocket { close(_code?: number, _reason?: string): void {} + send(data: string): void { + this.sent.push(data); + } + + emitOpen(): void { + for (const handler of this.openHandlers) { + handler(); + } + } + + emitMessage(data: string): void { + for (const handler of this.messageHandlers) { + handler(data); + } + } + emitClose(code: number, reason: string): void { for (const handler of this.closeHandlers) { handler(code, Buffer.from(reason)); @@ -65,6 +84,8 @@ vi.mock("../infra/device-auth-store.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + loadDeviceAuthToken: (...args: unknown[]) => loadDeviceAuthTokenMock(...args), + storeDeviceAuthToken: (...args: unknown[]) => storeDeviceAuthTokenMock(...args), clearDeviceAuthToken: (...args: unknown[]) => clearDeviceAuthTokenMock(...args), }; }); @@ -267,3 +288,94 @@ describe("GatewayClient close handling", () => { client.stop(); }); }); + +describe("GatewayClient connect auth payload", () => { + beforeEach(() => { + wsInstances.length = 0; + loadDeviceAuthTokenMock.mockReset(); + storeDeviceAuthTokenMock.mockReset(); + }); + + function connectFrameFrom(ws: MockWebSocket) { + const raw = ws.sent.find((frame) => frame.includes('"method":"connect"')); + if (!raw) { + throw new Error("missing connect frame"); + } + const parsed = JSON.parse(raw) as { + params?: { + auth?: { + token?: string; + deviceToken?: string; + password?: string; + }; + }; + }; + return parsed.params?.auth ?? {}; + } + + function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1") { + ws.emitMessage( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce }, + }), + ); + } + + it("uses explicit shared token and does not inject stored device token", () => { + loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: "shared-token", + }); + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); + emitConnectChallenge(ws); + + expect(connectFrameFrom(ws)).toMatchObject({ + token: "shared-token", + }); + expect(connectFrameFrom(ws).deviceToken).toBeUndefined(); + client.stop(); + }); + + it("uses stored device token when shared token is not provided", () => { + loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + }); + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); + emitConnectChallenge(ws); + + expect(connectFrameFrom(ws)).toMatchObject({ + token: "stored-device-token", + deviceToken: "stored-device-token", + }); + client.stop(); + }); + + it("prefers explicit deviceToken over stored device token", () => { + loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + deviceToken: "explicit-device-token", + }); + + client.start(); + const ws = getLatestWs(); + ws.emitOpen(); + emitConnectChallenge(ws); + + expect(connectFrameFrom(ws)).toMatchObject({ + token: "explicit-device-token", + deviceToken: "explicit-device-token", + }); + client.stop(); + }); +}); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 5cfe52eb87d..775dba77ff7 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -45,6 +45,7 @@ export type GatewayClientOptions = { connectDelayMs?: number; tickWatchMinIntervalMs?: number; token?: string; + deviceToken?: string; password?: string; instanceId?: string; clientName?: GatewayClientName; @@ -237,17 +238,25 @@ export class GatewayClient { this.connectTimer = null; } const role = this.opts.role ?? "operator"; + const explicitGatewayToken = this.opts.token?.trim() || undefined; + const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined; const storedToken = this.opts.deviceIdentity ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token : null; - // Prefer explicitly provided credentials (e.g. CLI `--token`) over any persisted - // device-auth tokens. Persisted tokens are only used when no token is provided. - const authToken = this.opts.token ?? storedToken ?? undefined; + // Keep shared gateway credentials explicit. Persisted per-device tokens only + // participate when no explicit shared token is provided. + const resolvedDeviceToken = + explicitDeviceToken ?? (!explicitGatewayToken ? (storedToken ?? undefined) : undefined); + // Legacy compatibility: keep `auth.token` populated for device-token auth when + // no explicit shared token is present. + const authToken = explicitGatewayToken ?? resolvedDeviceToken; + const authPassword = this.opts.password?.trim() || undefined; const auth = - authToken || this.opts.password + authToken || authPassword || resolvedDeviceToken ? { token: authToken, - password: this.opts.password, + deviceToken: resolvedDeviceToken, + password: authPassword, } : undefined; const signedAtMs = Date.now(); diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts new file mode 100644 index 00000000000..6c3e5f15935 --- /dev/null +++ b/src/gateway/credentials.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveGatewayCredentialsFromConfig } from "./credentials.js"; + +function cfg(input: Partial): OpenClawConfig { + return input as OpenClawConfig; +} + +describe("resolveGatewayCredentialsFromConfig", () => { + it("prefers explicit credentials over config and environment", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + auth: { token: "config-token", password: "config-password" }, + }, + }), + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + explicitAuth: { token: "explicit-token", password: "explicit-password" }, + }); + expect(resolved).toEqual({ + token: "explicit-token", + password: "explicit-password", + }); + }); + + it("returns empty credentials when url override is used without explicit auth", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + auth: { token: "config-token", password: "config-password" }, + }, + }), + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + urlOverride: "wss://example.com", + }); + expect(resolved).toEqual({}); + }); + + it("uses local-mode environment values before local config", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + mode: "local", + auth: { token: "config-token", password: "config-password" }, + }, + }), + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + }); + expect(resolved).toEqual({ + token: "env-token", + password: "env-password", + }); + }); + + it("uses remote-mode remote credentials before env and local config", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + mode: "remote", + remote: { token: "remote-token", password: "remote-password" }, + auth: { token: "config-token", password: "config-password" }, + }, + }), + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + }); + expect(resolved).toEqual({ + token: "remote-token", + password: "remote-password", + }); + }); + + it("falls back to env/config when remote mode omits remote credentials", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + mode: "remote", + remote: {}, + auth: { token: "config-token", password: "config-password" }, + }, + }), + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + }); + expect(resolved).toEqual({ + token: "env-token", + password: "env-password", + }); + }); + + it("supports env-first password override in remote mode for gateway call path", () => { + const resolved = resolveGatewayCredentialsFromConfig({ + cfg: cfg({ + gateway: { + mode: "remote", + remote: { token: "remote-token", password: "remote-password" }, + auth: { token: "config-token", password: "config-password" }, + }, + }), + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + OPENCLAW_GATEWAY_PASSWORD: "env-password", + } as NodeJS.ProcessEnv, + remotePasswordPrecedence: "env-first", + }); + expect(resolved).toEqual({ + token: "remote-token", + password: "env-password", + }); + }); +}); diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts new file mode 100644 index 00000000000..38a6f246ecd --- /dev/null +++ b/src/gateway/credentials.ts @@ -0,0 +1,61 @@ +import type { OpenClawConfig } from "../config/config.js"; + +export type ExplicitGatewayAuth = { + token?: string; + password?: string; +}; + +export type ResolvedGatewayCredentials = { + token?: string; + password?: string; +}; + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function resolveGatewayCredentialsFromConfig(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; + urlOverride?: string; + remotePasswordPrecedence?: "remote-first" | "env-first"; +}): ResolvedGatewayCredentials { + const env = params.env ?? process.env; + const explicitToken = trimToUndefined(params.explicitAuth?.token); + const explicitPassword = trimToUndefined(params.explicitAuth?.password); + if (explicitToken || explicitPassword) { + return { token: explicitToken, password: explicitPassword }; + } + if (trimToUndefined(params.urlOverride)) { + return {}; + } + + const isRemoteMode = params.cfg.gateway?.mode === "remote"; + const remote = isRemoteMode ? params.cfg.gateway?.remote : undefined; + + const envToken = + trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN); + const envPassword = + trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD) ?? + trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD); + + const remoteToken = trimToUndefined(remote?.token); + const remotePassword = trimToUndefined(remote?.password); + const localToken = trimToUndefined(params.cfg.gateway?.auth?.token); + const localPassword = trimToUndefined(params.cfg.gateway?.auth?.password); + + const token = isRemoteMode ? (remoteToken ?? envToken ?? localToken) : (envToken ?? localToken); + const passwordPrecedence = params.remotePasswordPrecedence ?? "remote-first"; + const password = isRemoteMode + ? passwordPrecedence === "env-first" + ? (envPassword ?? remotePassword ?? localPassword) + : (remotePassword ?? envPassword ?? localPassword) + : (envPassword ?? localPassword); + + return { token, password }; +} diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index 6a43c121dd1..d01aa83cc33 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -56,6 +56,7 @@ export const ConnectParamsSchema = Type.Object( Type.Object( { token: Type.Optional(Type.String()), + deviceToken: Type.Optional(Type.String()), password: Type.Optional(Type.String()), }, { additionalProperties: false }, diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 23b4b29f33b..07194620ff6 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -957,6 +957,42 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("accepts explicit auth.deviceToken when shared token is omitted", async () => { + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws); + + ws.close(); + + const ws2 = await openWs(port); + const res2 = await connectReq(ws2, { + skipDefaultAuth: true, + deviceToken, + }); + expect(res2.ok).toBe(true); + + ws2.close(); + await server.close(); + restoreGatewayToken(prevToken); + }); + + test("uses explicit auth.deviceToken fallback when shared token is wrong", async () => { + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws); + + ws.close(); + + const ws2 = await openWs(port); + const res2 = await connectReq(ws2, { + token: "wrong", + deviceToken, + }); + expect(res2.ok).toBe(true); + + ws2.close(); + await server.close(); + restoreGatewayToken(prevToken); + }); + test("keeps shared-secret lockout separate from device-token auth", async () => { const { server, port, prevToken, deviceToken } = await startRateLimitedTokenServerWithPairedDeviceToken(); diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts new file mode 100644 index 00000000000..4354f05000c --- /dev/null +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -0,0 +1,133 @@ +import type { IncomingMessage } from "node:http"; +import { + AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + type AuthRateLimiter, + type RateLimitCheckResult, +} from "../../auth-rate-limit.js"; +import { + authorizeHttpGatewayConnect, + authorizeWsControlUiGatewayConnect, + type GatewayAuthResult, + type ResolvedGatewayAuth, +} from "../../auth.js"; + +type HandshakeConnectAuth = { + token?: string; + deviceToken?: string; + password?: string; +}; + +export type ConnectAuthState = { + authResult: GatewayAuthResult; + authOk: boolean; + authMethod: GatewayAuthResult["method"]; + sharedAuthOk: boolean; + sharedAuthProvided: boolean; + deviceTokenCandidate?: string; +}; + +function trimToUndefined(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveSharedConnectAuth( + connectAuth: HandshakeConnectAuth | null | undefined, +): { token?: string; password?: string } | undefined { + const token = trimToUndefined(connectAuth?.token); + const password = trimToUndefined(connectAuth?.password); + if (!token && !password) { + return undefined; + } + return { token, password }; +} + +function resolveDeviceTokenCandidate( + connectAuth: HandshakeConnectAuth | null | undefined, +): string | undefined { + const explicitDeviceToken = trimToUndefined(connectAuth?.deviceToken); + if (explicitDeviceToken) { + return explicitDeviceToken; + } + return trimToUndefined(connectAuth?.token); +} + +export async function resolveConnectAuthState(params: { + resolvedAuth: ResolvedGatewayAuth; + connectAuth: HandshakeConnectAuth | null | undefined; + hasDeviceIdentity: boolean; + req: IncomingMessage; + trustedProxies: string[]; + allowRealIpFallback: boolean; + rateLimiter?: AuthRateLimiter; + clientIp?: string; +}): Promise { + const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth); + const sharedAuthProvided = Boolean(sharedConnectAuth); + const deviceTokenCandidate = params.hasDeviceIdentity + ? resolveDeviceTokenCandidate(params.connectAuth) + : undefined; + const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate); + + let authResult: GatewayAuthResult = await authorizeWsControlUiGatewayConnect({ + auth: params.resolvedAuth, + connectAuth: sharedConnectAuth, + req: params.req, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + rateLimiter: hasDeviceTokenCandidate ? undefined : params.rateLimiter, + clientIp: params.clientIp, + rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + }); + + if ( + hasDeviceTokenCandidate && + authResult.ok && + params.rateLimiter && + (authResult.method === "token" || authResult.method === "password") + ) { + const sharedRateCheck: RateLimitCheckResult = params.rateLimiter.check( + params.clientIp, + AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + ); + if (!sharedRateCheck.allowed) { + authResult = { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: sharedRateCheck.retryAfterMs, + }; + } else { + params.rateLimiter.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + } + } + + const sharedAuthResult = + sharedConnectAuth && + (await authorizeHttpGatewayConnect({ + auth: { ...params.resolvedAuth, allowTailscale: false }, + connectAuth: sharedConnectAuth, + req: params.req, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + // Shared-auth probe only; rate-limit side effects are handled in the + // primary auth flow (or deferred for device-token candidates). + rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + })); + const sharedAuthOk = + sharedAuthResult?.ok === true && + (sharedAuthResult.method === "token" || sharedAuthResult.method === "password"); + + return { + authResult, + authOk: authResult.ok, + authMethod: + authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"), + sharedAuthOk, + sharedAuthProvided, + deviceTokenCandidate, + }; +} diff --git a/src/gateway/server/ws-connection/auth-messages.ts b/src/gateway/server/ws-connection/auth-messages.ts index 4f6e993a3ce..bf7cc32e10d 100644 --- a/src/gateway/server/ws-connection/auth-messages.ts +++ b/src/gateway/server/ws-connection/auth-messages.ts @@ -2,7 +2,7 @@ import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-chan import type { ResolvedGatewayAuth } from "../../auth.js"; import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; -export type AuthProvidedKind = "token" | "password" | "none"; +export type AuthProvidedKind = "token" | "device-token" | "password" | "none"; export function formatGatewayAuthFailureMessage(params: { authMode: ResolvedGatewayAuth["mode"]; @@ -57,6 +57,9 @@ export function formatGatewayAuthFailureMessage(params: { if (authMode === "token" && authProvided === "none") { return `unauthorized: gateway token missing (${tokenHint})`; } + if (authMode === "token" && authProvided === "device-token") { + return "unauthorized: device token rejected (pair/repair this device, or provide gateway token)"; + } if (authMode === "password" && authProvided === "none") { return `unauthorized: gateway password missing (${passwordHint})`; } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index b659d0635f7..6b9ed0ad979 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -24,17 +24,9 @@ import type { createSubsystemLogger } from "../../../logging/subsystem.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; -import { - AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, - AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, - type AuthRateLimiter, -} from "../../auth-rate-limit.js"; +import { AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, type AuthRateLimiter } from "../../auth-rate-limit.js"; import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; -import { - authorizeHttpGatewayConnect, - authorizeWsControlUiGatewayConnect, - isLocalDirectRequest, -} from "../../auth.js"; +import { isLocalDirectRequest } from "../../auth.js"; import { buildCanvasScopedHostUrl, CANVAS_CAPABILITY_TTL_MS, @@ -75,6 +67,7 @@ import { refreshGatewayHealthSnapshot, } from "../health-state.js"; import type { GatewayWsClient } from "../ws-types.js"; +import { resolveConnectAuthState } from "./auth-context.js"; import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js"; import { evaluateMissingDeviceIdentity, @@ -362,87 +355,40 @@ export function attachGatewayWsMessageHandler(params: { }); const device = controlUiAuthPolicy.device; - const resolveAuthState = async () => { - const hasDeviceTokenCandidate = Boolean(connectParams.auth?.token && device); - let nextAuthResult: GatewayAuthResult = await authorizeWsControlUiGatewayConnect({ - auth: resolvedAuth, + let { authResult, authOk, authMethod, sharedAuthOk, deviceTokenCandidate } = + await resolveConnectAuthState({ + resolvedAuth, connectAuth: connectParams.auth, + hasDeviceIdentity: Boolean(device), req: upgradeReq, trustedProxies, allowRealIpFallback, - rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter, + rateLimiter, clientIp, - rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, }); - - if ( - hasDeviceTokenCandidate && - nextAuthResult.ok && - rateLimiter && - (nextAuthResult.method === "token" || nextAuthResult.method === "password") - ) { - const sharedRateCheck = rateLimiter.check( - clientIp, - AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, - ); - if (!sharedRateCheck.allowed) { - nextAuthResult = { - ok: false, - reason: "rate_limited", - rateLimited: true, - retryAfterMs: sharedRateCheck.retryAfterMs, - }; - } else { - rateLimiter.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); - } - } - - const nextAuthMethod = - nextAuthResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token"); - const sharedAuthResult = hasSharedAuth - ? await authorizeHttpGatewayConnect({ - auth: { ...resolvedAuth, allowTailscale: false }, - connectAuth: connectParams.auth, - req: upgradeReq, - trustedProxies, - allowRealIpFallback, - // Shared-auth probe only; rate-limit side effects are handled in - // the primary auth flow (or deferred for device-token candidates). - rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, - }) - : null; - const nextSharedAuthOk = - sharedAuthResult?.ok === true && - (sharedAuthResult.method === "token" || sharedAuthResult.method === "password"); - - return { - authResult: nextAuthResult, - authOk: nextAuthResult.ok, - authMethod: nextAuthMethod, - sharedAuthOk: nextSharedAuthOk, - }; - }; - - let { authResult, authOk, authMethod, sharedAuthOk } = await resolveAuthState(); const rejectUnauthorized = (failedAuth: GatewayAuthResult) => { markHandshakeFailure("unauthorized", { authMode: resolvedAuth.mode, - authProvided: connectParams.auth?.token - ? "token" - : connectParams.auth?.password - ? "password" - : "none", + authProvided: connectParams.auth?.password + ? "password" + : connectParams.auth?.token + ? "token" + : connectParams.auth?.deviceToken + ? "device-token" + : "none", authReason: failedAuth.reason, allowTailscale: resolvedAuth.allowTailscale, }); logWsControl.warn( `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${failedAuth.reason ?? "unknown"}`, ); - const authProvided: AuthProvidedKind = connectParams.auth?.token - ? "token" - : connectParams.auth?.password - ? "password" - : "none"; + const authProvided: AuthProvidedKind = connectParams.auth?.password + ? "password" + : connectParams.auth?.token + ? "token" + : connectParams.auth?.deviceToken + ? "device-token" + : "none"; const authMessage = formatGatewayAuthFailureMessage({ authMode: resolvedAuth.mode, authProvided, @@ -545,7 +491,7 @@ export function attachGatewayWsMessageHandler(params: { role, scopes, signedAtMs: signedAt, - token: connectParams.auth?.token ?? null, + token: connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? null, nonce: providedNonce, }); const rejectDeviceSignatureInvalid = () => @@ -562,7 +508,7 @@ export function attachGatewayWsMessageHandler(params: { } } - if (!authOk && connectParams.auth?.token && device) { + if (!authOk && device && deviceTokenCandidate) { if (rateLimiter) { const deviceRateCheck = rateLimiter.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); if (!deviceRateCheck.allowed) { @@ -577,7 +523,7 @@ export function attachGatewayWsMessageHandler(params: { if (!authResult.rateLimited) { const tokenCheck = await verifyDeviceToken({ deviceId: device.id, - token: connectParams.auth.token, + token: deviceTokenCandidate, role, scopes, }); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index a9b7ef9fede..d60fc2490c2 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -442,6 +442,7 @@ export async function connectReq( ws: WebSocket, opts?: { token?: string; + deviceToken?: string; password?: string; skipDefaultAuth?: boolean; minProtocol?: number; @@ -494,7 +495,9 @@ export async function connectReq( ? ((testState.gatewayAuth as { password?: string }).password ?? undefined) : process.env.OPENCLAW_GATEWAY_PASSWORD; const token = opts?.token ?? defaultToken; + const deviceToken = opts?.deviceToken?.trim() || undefined; const password = opts?.password ?? defaultPassword; + const authTokenForSignature = token ?? deviceToken; const requestedScopes = Array.isArray(opts?.scopes) ? opts.scopes : role === "operator" @@ -524,7 +527,7 @@ export async function connectReq( role, scopes: requestedScopes, signedAtMs, - token: token ?? null, + token: authTokenForSignature ?? null, nonce: connectChallengeNonce, }); return { @@ -550,9 +553,10 @@ export async function connectReq( role, scopes: requestedScopes, auth: - token || password + token || password || deviceToken ? { token, + deviceToken, password, } : undefined,