refactor: share scoped gateway http auth

This commit is contained in:
Peter Steinberger
2026-04-20 14:37:05 +01:00
parent e8ad3573c0
commit 655e0be3d7
4 changed files with 69 additions and 50 deletions

View File

@@ -17,6 +17,7 @@ vi.mock("../config/config.js", () => ({
vi.mock("./http-common.js", () => ({
sendGatewayAuthFailure: vi.fn(),
sendJson: vi.fn(),
}));
const { authorizeHttpGatewayConnect } = await import("./auth.js");

View File

@@ -8,6 +8,7 @@ import {
resolveDefaultModelForAgent,
} from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
import {
normalizeLowercaseStringOrEmpty,
@@ -20,8 +21,9 @@ import {
type GatewayAuthResult,
type ResolvedGatewayAuth,
} from "./auth.js";
import { sendGatewayAuthFailure } from "./http-common.js";
import { sendGatewayAuthFailure, sendJson } from "./http-common.js";
import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
export const OPENCLAW_MODEL_ID = "openclaw";
@@ -119,6 +121,48 @@ export async function authorizeGatewayHttpRequestOrReply(params: {
};
}
export async function authorizeScopedGatewayHttpRequestOrReply(params: {
req: IncomingMessage;
res: ServerResponse;
auth: ResolvedGatewayAuth;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
operatorMethod: string;
resolveOperatorScopes: (
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
) => string[];
}): Promise<{ cfg: OpenClawConfig; requestAuth: AuthorizedGatewayHttpRequest } | null> {
const cfg = loadConfig();
const requestAuth = await authorizeGatewayHttpRequestOrReply({
req: params.req,
res: params.res,
auth: params.auth,
trustedProxies: params.trustedProxies ?? cfg.gateway?.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
rateLimiter: params.rateLimiter,
});
if (!requestAuth) {
return null;
}
const requestedScopes = params.resolveOperatorScopes(params.req, requestAuth);
const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, requestedScopes);
if (!scopeAuth.allowed) {
sendJson(params.res, 403, {
ok: false,
error: {
type: "forbidden",
message: `missing scope: ${scopeAuth.missingScope}`,
},
});
return null;
}
return { cfg, requestAuth };
}
export function isGatewayBearerHttpRequest(
req: IncomingMessage,
auth?: SharedSecretGatewayAuth,

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import { loadConfig } from "../config/config.js";
import { loadSessionStore } from "../config/sessions.js";
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import {
@@ -17,11 +16,10 @@ import {
setSseHeaders,
} from "./http-common.js";
import {
authorizeGatewayHttpRequestOrReply,
authorizeScopedGatewayHttpRequestOrReply,
getHeader,
resolveTrustedHttpOperatorScopes,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS } from "./server-methods/chat.js";
import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-history-state.js";
import {
@@ -108,33 +106,22 @@ export async function handleSessionHistoryHttpRequest(
return true;
}
const cfg = loadConfig();
const requestAuth = await authorizeGatewayHttpRequestOrReply({
// HTTP callers must declare the same least-privilege operator scopes they
// intend to use over WS so both transport surfaces enforce the same gate.
const authResult = await authorizeScopedGatewayHttpRequestOrReply({
req,
res,
auth: opts.auth,
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
operatorMethod: "chat.history",
resolveOperatorScopes: resolveTrustedHttpOperatorScopes,
});
if (!requestAuth) {
return true;
}
// HTTP callers must declare the same least-privilege operator scopes they
// intend to use over WS so both transport surfaces enforce the same gate.
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes);
if (!scopeAuth.allowed) {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message: `missing scope: ${scopeAuth.missingScope}`,
},
});
if (!authResult) {
return true;
}
const { cfg } = authResult;
const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey });
const store = loadSessionStore(target.storePath);

View File

@@ -4,7 +4,6 @@ import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js";
import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
@@ -23,12 +22,11 @@ import {
sendMethodNotAllowed,
} from "./http-common.js";
import {
authorizeGatewayHttpRequestOrReply,
authorizeScopedGatewayHttpRequestOrReply,
getHeader,
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
@@ -155,34 +153,23 @@ export async function handleToolsInvokeHttpRequest(
return true;
}
const cfg = loadConfig();
const requestAuth = await authorizeGatewayHttpRequestOrReply({
req,
res,
auth: opts.auth,
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
});
if (!requestAuth) {
return true;
}
// /tools/invoke intentionally uses the same shared-secret HTTP trust model as
// the OpenAI-compatible APIs: token/password bearer auth is full operator
// access for the gateway, not a narrower per-request scope boundary.
const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth);
const scopeAuth = authorizeOperatorScopesForMethod("agent", requestedScopes);
if (!scopeAuth.allowed) {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message: `missing scope: ${scopeAuth.missingScope}`,
},
});
const authResult = await authorizeScopedGatewayHttpRequestOrReply({
req,
res,
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
operatorMethod: "agent",
resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes,
});
if (!authResult) {
return true;
}
const { cfg, requestAuth } = authResult;
const bodyUnknown = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? DEFAULT_BODY_BYTES);
if (bodyUnknown === undefined) {