Skip to content

Commit c684b13

Browse files
authored
feat(gateway): thread authenticated user identity into presence (#111179)
* feat(gateway): thread authenticated user identity into presence * feat(gateway): regenerate Swift protocol bindings for presence user
1 parent cc57514 commit c684b13

9 files changed

Lines changed: 226 additions & 3 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,7 @@ public struct PresenceEntry: Codable, Sendable {
947947
public let roles: [String]?
948948
public let scopes: [String]?
949949
public let instanceid: String?
950+
public let user: [String: AnyCodable]?
950951

951952
public init(
952953
host: String? = nil,
@@ -964,7 +965,8 @@ public struct PresenceEntry: Codable, Sendable {
964965
deviceid: String? = nil,
965966
roles: [String]? = nil,
966967
scopes: [String]? = nil,
967-
instanceid: String? = nil)
968+
instanceid: String? = nil,
969+
user: [String: AnyCodable]? = nil)
968970
{
969971
self.host = host
970972
self.ip = ip
@@ -982,6 +984,7 @@ public struct PresenceEntry: Codable, Sendable {
982984
self.roles = roles
983985
self.scopes = scopes
984986
self.instanceid = instanceid
987+
self.user = user
985988
}
986989

987990
private enum CodingKeys: String, CodingKey {
@@ -1001,6 +1004,7 @@ public struct PresenceEntry: Codable, Sendable {
10011004
case roles
10021005
case scopes
10031006
case instanceid = "instanceId"
1007+
case user
10041008
}
10051009
}
10061010

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Gateway Protocol snapshot schema tests cover optional presence identity.
2+
import { Value } from "typebox/value";
3+
import { describe, expect, it } from "vitest";
4+
import { SnapshotSchema } from "./snapshot.js";
5+
6+
function snapshotWithPresence(presence: Record<string, unknown>) {
7+
return {
8+
presence: [presence],
9+
health: {},
10+
stateVersion: { presence: 1, health: 1 },
11+
uptimeMs: 1,
12+
};
13+
}
14+
15+
describe("SnapshotSchema", () => {
16+
it("accepts a presence user identity", () => {
17+
expect(
18+
Value.Check(
19+
SnapshotSchema,
20+
snapshotWithPresence({
21+
ts: 1,
22+
user: { id: "[email protected]", email: "[email protected]" },
23+
}),
24+
),
25+
).toBe(true);
26+
});
27+
28+
it("keeps presence user identity optional", () => {
29+
expect(Value.Check(SnapshotSchema, snapshotWithPresence({ ts: 1 }))).toBe(true);
30+
});
31+
});

packages/gateway-protocol/src/schema/snapshot.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ export const PresenceEntrySchema = closedObject({
2828
roles: Type.Optional(Type.Array(NonEmptyString)),
2929
scopes: Type.Optional(Type.Array(NonEmptyString)),
3030
instanceId: Type.Optional(NonEmptyString),
31+
user: Type.Optional(
32+
closedObject({
33+
/** Opaque identity key: authenticated email today, durable profile id later. Clients group presence by this. */
34+
id: NonEmptyString,
35+
email: Type.Optional(NonEmptyString),
36+
name: Type.Optional(NonEmptyString),
37+
avatarUrl: Type.Optional(NonEmptyString),
38+
}),
39+
),
3140
});
3241

3342
/** Health snapshot is intentionally opaque because providers contribute nested shapes. */

src/gateway/server/ws-connection.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,31 @@ describe("attachGatewayWsConnectionHandler", () => {
433433
expect(logWsControl.warn).not.toHaveBeenCalled();
434434
});
435435

436+
it("logs the authenticated user when a connection closes", async () => {
437+
const { socket, logWsControl, passed } = await connectTestWs();
438+
const handlerParams = passed as {
439+
setClient: (client: never) => boolean;
440+
};
441+
442+
expect(
443+
handlerParams.setClient({
444+
socket,
445+
connect: { client: { id: "openclaw-control-ui", mode: "ui" } },
446+
connId: "conn-authenticated-user",
447+
authenticatedUserId: "[email protected]",
448+
usesSharedGatewayAuth: false,
449+
} as never),
450+
).toBe(true);
451+
452+
socket.emit("close", 1000, Buffer.from("done"));
453+
454+
expect(logWsControl.info).toHaveBeenCalledWith(
455+
expect.stringMatching(
456+
/^authenticated user disconnected code=1000 reason=done conn=.+ user=alice@example\.com$/,
457+
),
458+
);
459+
});
460+
436461
it("skips node presence disconnects for stale reconnected sockets", async () => {
437462
const unregister = vi.fn(() => null);
438463
const { socket } = attachGatewayWsForTest({

src/gateway/server/ws-connection.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
import { clearNodeWakeState } from "../server-methods/nodes-wake-state.js";
2929
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
3030
import { formatError } from "../server-utils.js";
31-
import { logWs } from "../ws-log.js";
31+
import { formatForLog, logWs } from "../ws-log.js";
3232
import { getHealthVersion, incrementPresenceVersion } from "./health-state.js";
3333
import type { PreauthConnectionBudget } from "./preauth-connection-budget.js";
3434
import { broadcastPresenceSnapshot } from "./presence-events.js";
@@ -542,6 +542,11 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
542542
`webchat disconnected code=${code} reason=${logReason || "n/a"} conn=${connId}`,
543543
);
544544
}
545+
if (client?.authenticatedUserId) {
546+
logWsControl.info(
547+
`authenticated user disconnected code=${code} reason=${logReason || "n/a"} conn=${connId} user=${formatForLog(client.authenticatedUserId)}`,
548+
);
549+
}
545550
if (connectionKind === "gateway") {
546551
const context = buildRequestContext();
547552
context.unsubscribeAllSessionEvents(connId);

src/gateway/server/ws-connection/connect-session.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Gateway WebSocket connect finalization attaches node/session state and sends hello-ok.
22
import os from "node:os";
3+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
34
import type { WebSocket } from "ws";
45
import {
56
GATEWAY_CLIENT_IDS,
@@ -105,6 +106,7 @@ export async function attachAuthenticatedGatewayConnect(
105106
role,
106107
scopes,
107108
device,
109+
authResult,
108110
authMethod,
109111
pairingLocality,
110112
sessionUsesSharedGatewayAuth,
@@ -120,6 +122,7 @@ export async function attachAuthenticatedGatewayConnect(
120122
const clientId = connectParams.client.id;
121123
const instanceId = connectParams.client.instanceId;
122124
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
125+
const authenticatedUserId = normalizeOptionalString(authResult.user);
123126

124127
if (isClosed()) {
125128
await releasePendingNodePairingCleanup();
@@ -222,6 +225,7 @@ export async function attachAuthenticatedGatewayConnect(
222225
usesSharedGatewayAuth: sessionUsesSharedGatewayAuth,
223226
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
224227
presenceKey,
228+
...(authenticatedUserId ? { authenticatedUserId } : {}),
225229
clientIp: reportedClientIp,
226230
...(internal ? { internal } : {}),
227231
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
@@ -295,6 +299,12 @@ export async function attachAuthenticatedGatewayConnect(
295299
auth: authMethod,
296300
});
297301

302+
if (authenticatedUserId) {
303+
logWsControl.info(
304+
`authenticated user connected conn=${connId} user=${formatForLog(authenticatedUserId)}`,
305+
);
306+
}
307+
298308
if (isWebchatConnect(connectParams)) {
299309
logWsControl.info(
300310
`webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
@@ -314,6 +324,9 @@ export async function attachAuthenticatedGatewayConnect(
314324
roles: [role],
315325
scopes,
316326
instanceId: device?.id ?? instanceId,
327+
...(authenticatedUserId
328+
? { user: { id: authenticatedUserId, email: authenticatedUserId } }
329+
: {}),
317330
reason: "connect",
318331
});
319332
incrementPresenceVersion();

src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ function attachGatewayHarness(options: {
187187
refreshHealthSnapshot?: GatewayRequestContext["refreshHealthSnapshot"];
188188
requestOrigin?: string;
189189
requestHost?: string;
190+
headers?: Record<string, string>;
190191
remoteAddr?: string;
191192
localAddr?: string;
192193
resolvedAuth?: ResolvedGatewayAuth;
@@ -220,12 +221,14 @@ function attachGatewayHarness(options: {
220221
allowTailscale: false,
221222
};
222223
const advanceHandshakePhase = vi.fn();
224+
const logWsControl = createLogger();
223225
attachGatewayWsMessageHandler({
224226
socket,
225227
upgradeReq: {
226228
headers: {
227229
host: requestHost,
228230
...(options.requestOrigin ? { origin: options.requestOrigin } : {}),
231+
...options.headers,
229232
},
230233
socket: { localAddress: localAddr, remoteAddress: remoteAddr },
231234
} as unknown as IncomingMessage,
@@ -259,14 +262,15 @@ function attachGatewayHarness(options: {
259262
originCheckMetrics: { hostHeaderFallbackAccepted: 0 },
260263
logGateway: createLogger() as never,
261264
logHealth: createLogger() as never,
262-
logWsControl: createLogger() as never,
265+
logWsControl: logWsControl as never,
263266
});
264267
if (onMessage === undefined) {
265268
throw new Error("expected websocket message handler");
266269
}
267270
const sendMessage = onMessage;
268271
return {
269272
advanceHandshakePhase,
273+
logWsControl,
270274
send,
271275
socketSend,
272276
sendRequest: (id: string, method: string, params: Record<string, unknown> = {}) => {
@@ -529,6 +533,131 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
529533
resolveRefresh?.();
530534
});
531535

536+
it("projects trusted-proxy identity into presence and the connected client", async () => {
537+
loadConfigMock.mockImplementationOnce(() => ({
538+
gateway: {
539+
auth: {
540+
mode: "trusted-proxy",
541+
trustedProxy: {
542+
userHeader: "x-forwarded-user",
543+
requiredHeaders: ["x-forwarded-proto"],
544+
},
545+
},
546+
trustedProxies: ["10.0.0.1"],
547+
controlUi: {
548+
allowedOrigins: ["http://127.0.0.1:19001"],
549+
dangerouslyDisableDeviceAuth: true,
550+
},
551+
},
552+
}));
553+
const harness = attachGatewayHarness({
554+
connId: "conn-trusted-proxy-user",
555+
connectNonce: "nonce-trusted-proxy-user",
556+
requestHost: "gateway.example.com:18789",
557+
requestOrigin: "http://127.0.0.1:19001",
558+
remoteAddr: "10.0.0.1",
559+
resolvedAuth: {
560+
mode: "trusted-proxy",
561+
allowTailscale: false,
562+
trustedProxy: {
563+
userHeader: "x-forwarded-user",
564+
requiredHeaders: ["x-forwarded-proto"],
565+
},
566+
},
567+
headers: {
568+
"x-forwarded-user": "[email protected]",
569+
"x-forwarded-proto": "https",
570+
},
571+
});
572+
573+
harness.sendConnect("connect-trusted-proxy-user", {
574+
minProtocol: PROTOCOL_VERSION,
575+
maxProtocol: PROTOCOL_VERSION,
576+
client: {
577+
id: "openclaw-control-ui",
578+
version: "dev",
579+
platform: "test",
580+
mode: "ui",
581+
},
582+
role: "operator",
583+
caps: [],
584+
});
585+
586+
await waitForFast(() => {
587+
expect(harness.socketSend.mock.calls.length + harness.send.mock.calls.length).toBeGreaterThan(
588+
0,
589+
);
590+
});
591+
const trustedProxyHello = harness.socketSend.mock.calls.at(0)?.[0];
592+
expect(
593+
typeof trustedProxyHello === "string"
594+
? JSON.parse(trustedProxyHello)
595+
: harness.send.mock.calls.at(0)?.[0],
596+
).toMatchObject({
597+
ok: true,
598+
});
599+
await waitForFast(() => {
600+
expect(upsertPresenceMock).toHaveBeenCalledWith(
601+
"conn-trusted-proxy-user",
602+
expect.objectContaining({
603+
user: { id: "[email protected]", email: "[email protected]" },
604+
}),
605+
);
606+
});
607+
expect(harness.client).toMatchObject({ authenticatedUserId: "[email protected]" });
608+
expect(harness.logWsControl.info).toHaveBeenCalledWith(
609+
"authenticated user connected conn=conn-trusted-proxy-user [email protected]",
610+
);
611+
});
612+
613+
it("keeps token-authenticated presence free of user identity", async () => {
614+
const harness = attachGatewayHarness({
615+
connId: "conn-token-userless",
616+
connectNonce: "nonce-token-userless",
617+
requestHost: "gateway.example.com:18789",
618+
requestOrigin: "http://127.0.0.1:19001",
619+
remoteAddr: "203.0.113.50",
620+
resolvedAuth: {
621+
mode: "token",
622+
token: "gateway-token",
623+
allowTailscale: false,
624+
},
625+
});
626+
627+
harness.sendConnect("connect-token-userless", {
628+
minProtocol: PROTOCOL_VERSION,
629+
maxProtocol: PROTOCOL_VERSION,
630+
client: {
631+
id: "openclaw-control-ui",
632+
version: "dev",
633+
platform: "test",
634+
mode: "ui",
635+
},
636+
role: "operator",
637+
caps: [],
638+
auth: { token: "gateway-token" },
639+
});
640+
641+
await waitForFast(() => {
642+
expect(harness.socketSend.mock.calls.length + harness.send.mock.calls.length).toBeGreaterThan(
643+
0,
644+
);
645+
});
646+
const tokenHello = harness.socketSend.mock.calls.at(0)?.[0];
647+
expect(
648+
typeof tokenHello === "string" ? JSON.parse(tokenHello) : harness.send.mock.calls.at(0)?.[0],
649+
).toMatchObject({
650+
ok: true,
651+
});
652+
await waitForFast(() => {
653+
expect(upsertPresenceMock).toHaveBeenCalledWith(
654+
"conn-token-userless",
655+
expect.not.objectContaining({ user: expect.anything() }),
656+
);
657+
});
658+
expect(harness.client).not.toMatchObject({ authenticatedUserId: expect.anything() });
659+
});
660+
532661
it("emits a security event for rejected gateway auth", async () => {
533662
const close = createCloseMock();
534663
const harness = attachGatewayHarness({

src/gateway/server/ws-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
3232
usesSharedGatewayAuth: boolean;
3333
sharedGatewaySessionGeneration?: string;
3434
presenceKey?: string;
35+
authenticatedUserId?: string;
3536
clientIp?: string;
3637
internal?: {
3738
approvalRuntime?: boolean;

src/infra/system-presence.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export type SystemPresence = {
2626
roles?: string[];
2727
scopes?: string[];
2828
instanceId?: string;
29+
user?: {
30+
id: string;
31+
email?: string;
32+
name?: string;
33+
avatarUrl?: string;
34+
};
2935
text: string;
3036
ts: number;
3137
};

0 commit comments

Comments
 (0)