mirror of https://github.com/openclaw/openclaw.git
refactor(gateway): unify auth credential resolution
This commit is contained in:
parent
ded9a59f78
commit
66529c7aa5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|||
config: cfg,
|
||||
url: opts.gatewayUrl,
|
||||
});
|
||||
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
|
||||
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
|
||||
|
||||
const token =
|
||||
opts.gatewayToken ??
|
||||
(isRemoteMode ? remote?.token?.trim() : undefined) ??
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN ??
|
||||
auth.token;
|
||||
const password =
|
||||
opts.gatewayPassword ??
|
||||
(isRemoteMode ? remote?.password?.trim() : undefined) ??
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD ??
|
||||
auth.password;
|
||||
const creds = resolveGatewayCredentialsFromConfig({
|
||||
cfg,
|
||||
env: process.env,
|
||||
explicitAuth: {
|
||||
token: opts.gatewayToken,
|
||||
password: opts.gatewayPassword,
|
||||
},
|
||||
});
|
||||
|
||||
let agent: AcpGatewayAgent | null = null;
|
||||
let onClosed!: () => void;
|
||||
|
|
@ -64,8 +57,8 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void
|
|||
|
||||
const gateway = new GatewayClient({
|
||||
url: connection.url,
|
||||
token: token || undefined,
|
||||
password: password || undefined,
|
||||
token: creds.token,
|
||||
password: creds.password,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
clientDisplayName: "ACP",
|
||||
clientVersion: "acp",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type GatewayClientName,
|
||||
} from "../utils/message-channel.js";
|
||||
import { GatewayClient } from "./client.js";
|
||||
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
|
||||
import {
|
||||
CLI_DEFAULT_OPERATOR_SCOPES,
|
||||
resolveLeastPrivilegeOperatorScopesForMethod,
|
||||
|
|
@ -244,27 +245,13 @@ function resolveGatewayCredentials(context: ResolvedGatewayCallContext): {
|
|||
token?: string;
|
||||
password?: string;
|
||||
} {
|
||||
const authToken = context.config.gateway?.auth?.token;
|
||||
const authPassword = context.config.gateway?.auth?.password;
|
||||
const token =
|
||||
context.explicitAuth.token ||
|
||||
(!context.urlOverride
|
||||
? context.isRemoteMode
|
||||
? trimToUndefined(context.remote?.token)
|
||||
: trimToUndefined(process.env.OPENCLAW_GATEWAY_TOKEN) ||
|
||||
trimToUndefined(process.env.CLAWDBOT_GATEWAY_TOKEN) ||
|
||||
trimToUndefined(authToken)
|
||||
: undefined);
|
||||
const password =
|
||||
context.explicitAuth.password ||
|
||||
(!context.urlOverride
|
||||
? trimToUndefined(process.env.OPENCLAW_GATEWAY_PASSWORD) ||
|
||||
trimToUndefined(process.env.CLAWDBOT_GATEWAY_PASSWORD) ||
|
||||
(context.isRemoteMode
|
||||
? trimToUndefined(context.remote?.password)
|
||||
: trimToUndefined(authPassword))
|
||||
: undefined);
|
||||
return { token, password };
|
||||
return resolveGatewayCredentialsFromConfig({
|
||||
cfg: context.config,
|
||||
env: process.env,
|
||||
explicitAuth: context.explicitAuth,
|
||||
urlOverride: context.urlOverride,
|
||||
remotePasswordPrecedence: "env-first",
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveGatewayTlsFingerprint(params: {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { DeviceIdentity } from "../infra/device-identity.js";
|
|||
|
||||
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
|
||||
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<typeof import("../infra/device-auth-store.js")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>): 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ConnectAuthState> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue