Spaces:
Running
Running
Upload 189 files
Browse files- src/gateway/auth.test.ts +45 -0
- src/gateway/auth.ts +11 -1
- src/gateway/control-ui.ts +25 -1
- src/gateway/server/ws-connection/message-handler.ts +1 -0
src/gateway/auth.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { describe, expect, it } from "vitest";
|
|
|
|
| 2 |
import { authorizeGatewayConnect } from "./auth.js";
|
| 3 |
|
| 4 |
describe("gateway auth", () => {
|
|
@@ -98,4 +99,48 @@ describe("gateway auth", () => {
|
|
| 98 |
expect(res.method).toBe("tailscale");
|
| 99 |
expect(res.user).toBe("peter");
|
| 100 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
});
|
|
|
|
| 1 |
import { describe, expect, it } from "vitest";
|
| 2 |
+
import crypto from "node:crypto";
|
| 3 |
import { authorizeGatewayConnect } from "./auth.js";
|
| 4 |
|
| 5 |
describe("gateway auth", () => {
|
|
|
|
| 99 |
expect(res.method).toBe("tailscale");
|
| 100 |
expect(res.user).toBe("peter");
|
| 101 |
});
|
| 102 |
+
|
| 103 |
+
it("allows Control UI oauth session cookie to satisfy token mode auth", async () => {
|
| 104 |
+
const prevEnv = {
|
| 105 |
+
OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_ID: process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_ID,
|
| 106 |
+
OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_SECRET: process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_SECRET,
|
| 107 |
+
OPENCLAW_CONTROL_UI_SESSION_SECRET: process.env.OPENCLAW_CONTROL_UI_SESSION_SECRET,
|
| 108 |
+
OPENCLAW_CONTROL_UI_ALLOWED_GOOGLE_EMAILS: process.env.OPENCLAW_CONTROL_UI_ALLOWED_GOOGLE_EMAILS,
|
| 109 |
+
};
|
| 110 |
+
try {
|
| 111 |
+
process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_ID = "cid";
|
| 112 |
+
process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_SECRET = "csecret";
|
| 113 |
+
process.env.OPENCLAW_CONTROL_UI_SESSION_SECRET = "session-secret";
|
| 114 |
+
process.env.OPENCLAW_CONTROL_UI_ALLOWED_GOOGLE_EMAILS = "admin@example.com";
|
| 115 |
+
|
| 116 |
+
const payload = {
|
| 117 |
+
v: 1,
|
| 118 |
+
exp: Math.floor(Date.now() / 1000) + 60,
|
| 119 |
+
provider: "google",
|
| 120 |
+
sub: "sub-1",
|
| 121 |
+
email: "admin@example.com",
|
| 122 |
+
};
|
| 123 |
+
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
| 124 |
+
const sig = crypto.createHmac("sha256", "session-secret").update(body).digest("base64url");
|
| 125 |
+
const cookieToken = `${body}.${sig}`;
|
| 126 |
+
|
| 127 |
+
const res = await authorizeGatewayConnect({
|
| 128 |
+
auth: { mode: "token", token: "gateway-token", allowTailscale: false },
|
| 129 |
+
connectAuth: null,
|
| 130 |
+
client: { id: "openclaw-control-ui", version: "test", platform: "web", mode: "webchat" },
|
| 131 |
+
req: {
|
| 132 |
+
socket: { remoteAddress: "203.0.113.1" },
|
| 133 |
+
headers: { host: "example.com", cookie: `openclaw_ui_session=${encodeURIComponent(cookieToken)}` },
|
| 134 |
+
} as never,
|
| 135 |
+
});
|
| 136 |
+
expect(res.ok).toBe(true);
|
| 137 |
+
expect(res.method).toBe("control-ui-oauth");
|
| 138 |
+
expect(res.user).toBe("admin@example.com");
|
| 139 |
+
} finally {
|
| 140 |
+
process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_ID = prevEnv.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_ID;
|
| 141 |
+
process.env.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_SECRET = prevEnv.OPENCLAW_CONTROL_UI_GOOGLE_CLIENT_SECRET;
|
| 142 |
+
process.env.OPENCLAW_CONTROL_UI_SESSION_SECRET = prevEnv.OPENCLAW_CONTROL_UI_SESSION_SECRET;
|
| 143 |
+
process.env.OPENCLAW_CONTROL_UI_ALLOWED_GOOGLE_EMAILS = prevEnv.OPENCLAW_CONTROL_UI_ALLOWED_GOOGLE_EMAILS;
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
});
|
src/gateway/auth.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { IncomingMessage } from "node:http";
|
|
| 2 |
import { timingSafeEqual } from "node:crypto";
|
| 3 |
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
| 4 |
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
|
|
|
|
|
|
| 5 |
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
| 6 |
export type ResolvedGatewayAuthMode = "token" | "password";
|
| 7 |
|
|
@@ -14,7 +16,7 @@ export type ResolvedGatewayAuth = {
|
|
| 14 |
|
| 15 |
export type GatewayAuthResult = {
|
| 16 |
ok: boolean;
|
| 17 |
-
method?: "token" | "password" | "tailscale" | "device-token";
|
| 18 |
user?: string;
|
| 19 |
reason?: string;
|
| 20 |
};
|
|
@@ -241,11 +243,19 @@ export async function authorizeGatewayConnect(params: {
|
|
| 241 |
req?: IncomingMessage;
|
| 242 |
trustedProxies?: string[];
|
| 243 |
tailscaleWhois?: TailscaleWhoisLookup;
|
|
|
|
| 244 |
}): Promise<GatewayAuthResult> {
|
| 245 |
const { auth, connectAuth, req, trustedProxies } = params;
|
| 246 |
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
| 247 |
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
if (auth.allowTailscale && !localDirect) {
|
| 250 |
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
| 251 |
req,
|
|
|
|
| 2 |
import { timingSafeEqual } from "node:crypto";
|
| 3 |
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
| 4 |
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
| 5 |
+
import { GATEWAY_CLIENT_IDS, type GatewayClientInfo } from "./protocol/client-info.js";
|
| 6 |
+
import { readControlUiOauthSessionIdentityFromRequest } from "./control-ui.js";
|
| 7 |
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
| 8 |
export type ResolvedGatewayAuthMode = "token" | "password";
|
| 9 |
|
|
|
|
| 16 |
|
| 17 |
export type GatewayAuthResult = {
|
| 18 |
ok: boolean;
|
| 19 |
+
method?: "token" | "password" | "tailscale" | "device-token" | "control-ui-oauth";
|
| 20 |
user?: string;
|
| 21 |
reason?: string;
|
| 22 |
};
|
|
|
|
| 243 |
req?: IncomingMessage;
|
| 244 |
trustedProxies?: string[];
|
| 245 |
tailscaleWhois?: TailscaleWhoisLookup;
|
| 246 |
+
client?: GatewayClientInfo | null;
|
| 247 |
}): Promise<GatewayAuthResult> {
|
| 248 |
const { auth, connectAuth, req, trustedProxies } = params;
|
| 249 |
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
| 250 |
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
| 251 |
|
| 252 |
+
if (auth.mode === "token" && params.client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI && req) {
|
| 253 |
+
const session = readControlUiOauthSessionIdentityFromRequest(req);
|
| 254 |
+
if (session) {
|
| 255 |
+
return { ok: true, method: "control-ui-oauth", user: session.email ?? session.login ?? session.sub };
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
if (auth.allowTailscale && !localDirect) {
|
| 260 |
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
| 261 |
req,
|
src/gateway/control-ui.ts
CHANGED
|
@@ -327,7 +327,7 @@ function getRequestOrigin(req: IncomingMessage) {
|
|
| 327 |
}
|
| 328 |
|
| 329 |
function controlUiCookiePath(basePath: string) {
|
| 330 |
-
return
|
| 331 |
}
|
| 332 |
|
| 333 |
function buildBaseUrlPath(basePath: string, suffix: string) {
|
|
@@ -409,6 +409,30 @@ function readSessionFromRequest(req: IncomingMessage, cfg: ControlUiOauthConfig)
|
|
| 409 |
return value;
|
| 410 |
}
|
| 411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
type OAuthStatePayload = { v: 1; exp: number; nonce: string; provider: ControlUiOauthProvider };
|
| 413 |
|
| 414 |
function issueOAuthState(res: ServerResponse, cfg: ControlUiOauthConfig, basePath: string, secure: boolean, provider: ControlUiOauthProvider) {
|
|
|
|
| 327 |
}
|
| 328 |
|
| 329 |
function controlUiCookiePath(basePath: string) {
|
| 330 |
+
return "/";
|
| 331 |
}
|
| 332 |
|
| 333 |
function buildBaseUrlPath(basePath: string, suffix: string) {
|
|
|
|
| 409 |
return value;
|
| 410 |
}
|
| 411 |
|
| 412 |
+
export type ControlUiOauthSessionIdentity = {
|
| 413 |
+
provider: "google" | "github";
|
| 414 |
+
sub: string;
|
| 415 |
+
email?: string;
|
| 416 |
+
login?: string;
|
| 417 |
+
name?: string;
|
| 418 |
+
};
|
| 419 |
+
|
| 420 |
+
export function readControlUiOauthSessionIdentityFromRequest(
|
| 421 |
+
req: IncomingMessage,
|
| 422 |
+
): ControlUiOauthSessionIdentity | null {
|
| 423 |
+
const cfg = resolveControlUiOauthConfig();
|
| 424 |
+
if (!isControlUiOauthEnabled(cfg)) return null;
|
| 425 |
+
const session = readSessionFromRequest(req, cfg);
|
| 426 |
+
if (!session) return null;
|
| 427 |
+
return {
|
| 428 |
+
provider: session.provider,
|
| 429 |
+
sub: session.sub,
|
| 430 |
+
email: session.email,
|
| 431 |
+
login: session.login,
|
| 432 |
+
name: session.name,
|
| 433 |
+
};
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
type OAuthStatePayload = { v: 1; exp: number; nonce: string; provider: ControlUiOauthProvider };
|
| 437 |
|
| 438 |
function issueOAuthState(res: ServerResponse, cfg: ControlUiOauthConfig, basePath: string, secure: boolean, provider: ControlUiOauthProvider) {
|
src/gateway/server/ws-connection/message-handler.ts
CHANGED
|
@@ -572,6 +572,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|
| 572 |
connectAuth: connectParams.auth,
|
| 573 |
req: upgradeReq,
|
| 574 |
trustedProxies,
|
|
|
|
| 575 |
});
|
| 576 |
let authOk = authResult.ok;
|
| 577 |
let authMethod =
|
|
|
|
| 572 |
connectAuth: connectParams.auth,
|
| 573 |
req: upgradeReq,
|
| 574 |
trustedProxies,
|
| 575 |
+
client: connectParams.client,
|
| 576 |
});
|
| 577 |
let authOk = authResult.ok;
|
| 578 |
let authMethod =
|