From f88c164636baedb1b4c35a899caa14cc859cb8d3 Mon Sep 17 00:00:00 2001 From: devSejung Date: Mon, 20 Jul 2026 16:12:29 +0900 Subject: [PATCH] feat(platformclaw): add employee browser auth --- docs/platformclaw/control-plane-phase-1.md | 19 +- docs/platformclaw/decisions.md | 26 +- docs/platformclaw/employee-auth.md | 110 +++++++ docs/platformclaw/index.md | 8 +- .../src/browser-auth-http.test.ts | 166 ++++++++++ .../src/browser-auth-http.ts | 246 ++++++++++++++ .../src/browser-auth-service.test.ts | 216 +++++++++++++ .../src/browser-auth-service.ts | 272 ++++++++++++++++ .../src/contracts.ts | 3 + .../src/employee-auth-client.test.ts | 162 ++++++++++ .../src/employee-auth-client.ts | 301 ++++++++++++++++++ .../platformclaw-control-plane/src/index.ts | 3 + .../src/memory-store.test.ts | 24 +- .../src/memory-store.ts | 42 ++- .../src/sqlite-store.test.ts | 31 ++ .../src/sqlite-store.ts | 40 ++- 16 files changed, 1649 insertions(+), 20 deletions(-) create mode 100644 docs/platformclaw/employee-auth.md create mode 100644 packages/platformclaw-control-plane/src/browser-auth-http.test.ts create mode 100644 packages/platformclaw-control-plane/src/browser-auth-http.ts create mode 100644 packages/platformclaw-control-plane/src/browser-auth-service.test.ts create mode 100644 packages/platformclaw-control-plane/src/browser-auth-service.ts create mode 100644 packages/platformclaw-control-plane/src/employee-auth-client.test.ts create mode 100644 packages/platformclaw-control-plane/src/employee-auth-client.ts diff --git a/docs/platformclaw/control-plane-phase-1.md b/docs/platformclaw/control-plane-phase-1.md index 3afc8ec3d87..170841d217d 100644 --- a/docs/platformclaw/control-plane-phase-1.md +++ b/docs/platformclaw/control-plane-phase-1.md @@ -34,11 +34,12 @@ After Phase 1: ## Implementation status -Slices 1 and 2 are implemented in `packages/platformclaw-control-plane`. They +Slices 1 through 3 are implemented in `packages/platformclaw-control-plane`. They define the identity, browser-session, personal-agent provisioning, Knox room -binding, authenticated Knox DM routing, and managed group/part contracts. The -in-memory store covers contract behavior, and the SQLite store persists the -approved schema version 1. +binding, authenticated Knox DM routing, managed group/part contracts, +employee-auth HTTP adapter, opaque-session login service, and framework-neutral +browser-auth HTTP boundary. The in-memory store covers contract behavior, and +the SQLite store persists the approved schema version 1. Focused tests cover LDAP metadata refresh, LDAP-to-SAML identity linking, employee ID correction conflicts, concurrent personal and room provisioning, @@ -46,8 +47,13 @@ room agent ID compatibility, browser session limits and expiry, account disablement, Knox DM ownership checks, restart persistence, administrator bootstrap, managed group/part permissions, and audit events. -These slices do not contain credential encryption, LDAP or SAML network calls, -HTTP endpoints, Knox transport code, OpenClaw agent creation, or Gateway +The LDAP-compatible adapter calls the existing employee-auth HTTP service. The +HTTP boundary exposes login, logout, and current-session handlers, but still +requires its host process to inject bounded JSON parsing, a trusted client IP, +TLS state, and an auth rate limiter. + +These slices do not contain credential encryption, SAML protocol handling, +Knox transport code, OpenClaw agent creation, workspace file writes, or Gateway mutations. ## Process boundary @@ -86,6 +92,7 @@ Authentication adapters normalize provider-specific results into one shape: type EnterprisePrincipal = { provider: "ldap" | "saml"; subject: string; + accountId?: string; employeeId: string; displayName?: string; email?: string; diff --git a/docs/platformclaw/decisions.md b/docs/platformclaw/decisions.md index 7cfeda44c7f..8a3d2066e53 100644 --- a/docs/platformclaw/decisions.md +++ b/docs/platformclaw/decisions.md @@ -158,11 +158,33 @@ account ID supplied by deployment configuration backed by a Docker secret. There is no source-code fallback administrator. After bootstrap, administrator role changes use the control-plane management API and audit log. +### PC-112 Reject a fourth browser session + +When a user already has three active browser sessions, a new login returns a +session-limit response and does not silently terminate another device. Expired +and revoked sessions do not count toward the limit. This policy needs no schema +migration if product policy later changes. + +### PC-113 Normalize employee authentication outside OpenClaw core + +`platformclaw-control` calls the current employee authentication service using +the URL in `PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL`. The optional service bearer +uses `PLATFORMCLAW_EMPLOYEE_AUTH_BEARER_TOKEN`. LDAP results normalize into the +provider-independent principal contract; SAML can replace the adapter later. +External `agentId` and `sessionKey` fields are ignored because the control-plane +binding is routing authority. + +The directory profile passed to personal-agent provisioning includes employee +ID, display name, email, department, part, Confluence space, notes, directory +groups, and explicitly supplied extensible attributes. The session cookie never +contains those fields. Workspace `USER.md` rendering belongs to the provisioner, +not the authentication adapter. + ## Open operational decisions No remaining decision blocks the SQLite v1 store. Deployment work still needs -backup frequency and retention, behavior when a fourth browser session is -created, and any named workflow that requires browser control. +backup frequency and retention and any named workflow that requires browser +control. ## Decision procedure diff --git a/docs/platformclaw/employee-auth.md b/docs/platformclaw/employee-auth.md new file mode 100644 index 00000000000..6a4197358f9 --- /dev/null +++ b/docs/platformclaw/employee-auth.md @@ -0,0 +1,110 @@ +--- +summary: "PlatformClaw employee authentication, browser session, and profile provisioning contract" +read_when: + - Integrating the current LDAP-compatible employee login service + - Implementing the PlatformClaw web BFF or USER.md provisioning +title: "PlatformClaw employee authentication" +--- + +# PlatformClaw employee authentication + +The current login adapter preserves the deployed employee-auth service boundary +without copying enterprise authentication into OpenClaw core. The login service +URL is deployment configuration: + +```text +PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL=http://127.0.0.1:18080/login +PLATFORMCLAW_EMPLOYEE_AUTH_BEARER_TOKEN= +``` + +The bearer belongs in the deployment secret store. Neither value belongs in +source control. There is no fallback to the legacy `OPENCLAW_*` variable names; +deployment explicitly moves to the PlatformClaw-owned names. + +The production URL must use HTTPS because the request contains the employee +password and may contain the service bearer. Plain HTTP is accepted only for a +loopback mock (`localhost`, `127.0.0.0/8`, or `::1`). + +## Password login contract + +The adapter sends a JSON `POST` with `identifier`, `password`, and optional +request context (`clientIp`, `gatewayUrl`, and `userAgent`). The password exists +only for that request and is never stored by the control-plane. + +An authenticated response requires: + +```json +{ + "authenticated": true, + "employeeId": "account.name" +} +``` + +The deployed response may additionally provide `accountId`, `subject`, `name`, +`displayName`, `email`, `department`, `part`, `confluenceSpace`, `notes` (or +legacy `note`), `groups`, and a nested `attributes` object containing string or +string-array values. Missing optional fields do not fail login. + +During the LDAP phase, `accountId` falls back to `employeeId`, and `subject` +falls back to the normalized account ID. A future SAML adapter must provide a +stable `subject`; it may not use the LDAP fallback implicitly. + +Legacy `agentId` and `sessionKey` response fields are deliberately ignored. +The personal agent binding derives `agentId` from the canonical account ID by +replacing `.` with `_`, and current OpenClaw routing owns the session key. + +## Profile provisioning + +Successful authentication produces two different objects: + +- `EnterprisePrincipal`: stable identity plus mutable authorization metadata + used by the control-plane store. +- `EmployeeDirectoryProfile`: approved workspace bootstrap input, including + part, Confluence space, notes, groups, and explicit extensible attributes. + +The personal-agent provisioner receives the profile after the binding is +reserved. It owns OpenClaw `agents.create`, workspace validation, and the +managed block in `USER.md`. Authentication code does not write workspaces. +Provisioning failure marks a new binding `failed` and does not issue a browser +session. A later login retries the failed binding idempotently. + +## Browser BFF contract + +The framework-neutral HTTP boundary currently handles: + +- `POST /platformclaw/api/auth/login` +- `GET /platformclaw/api/auth/session` +- `POST /platformclaw/api/auth/logout` + +The host process must inject bounded JSON parsing, a trusted client IP, TLS +state, an exact same-origin allowlist, and an authentication rate limiter. +Login accepts only `application/json`; login and logout reject disallowed +origins. Production TLS state must produce a +`Secure`, `HttpOnly`, `SameSite=Lax` cookie named `platformclaw_session`. + +The cookie contains a random opaque token. Only its SHA-256 hash is stored in +SQLite. It contains no directory profile, agent ID, VM credential, or Gateway +operator credential. The server enforces the 12-hour idle timeout, seven-day +absolute timeout, three-session limit, logout revocation, and account-disable +revocation. + +Schema v1 has not shipped as a tagged PlatformClaw release. Development +databases created before the employee-auth adapter introduced a distinct +`accountId` contract must be recreated; PC-109 deliberately provides no runtime +legacy reader or migration. A migration requires a separate approved schema +change after deployment begins. + +## Legacy mock smoke + +Until the mock is moved into this repository, start the legacy fixture from the +adjacent `platform-agent` checkout: + +```powershell +python ..\platform-agent\scripts\mock_employee_auth.py --bind 127.0.0.1 --port 18080 +$env:PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL = "http://127.0.0.1:18080/login" +``` + +That mock intentionally implements only a subset of the production profile. +Use an `--accounts-file` fixture when testing optional profile fields. Tests +must use fake employees and must never copy a production auth response into the +repository. diff --git a/docs/platformclaw/index.md b/docs/platformclaw/index.md index 7a4dafbe163..0f77d148dc2 100644 --- a/docs/platformclaw/index.md +++ b/docs/platformclaw/index.md @@ -23,11 +23,13 @@ and Knox direct or group conversations: - [Control plane phase 1](/platformclaw/control-plane-phase-1) - [Architecture decisions](/platformclaw/decisions) +- [Employee authentication](/platformclaw/employee-auth) -The first two implementation slices are in the private +The first three implementation slices are in the private `packages/platformclaw-control-plane` package. They contain contracts, an -in-memory store, and the approved SQLite v1 persistent store. Ingress processes -remain later work. +in-memory store, the approved SQLite v1 persistent store, the employee-auth +adapter, opaque browser-session service, and browser-auth HTTP boundary. The +deployable ingress process and Gateway proxy remain later work. The VM execution backend is a later phase. It depends on Phase 1 because the sandbox must resolve an authenticated agent owner before selecting a VM profile diff --git a/packages/platformclaw-control-plane/src/browser-auth-http.test.ts b/packages/platformclaw-control-plane/src/browser-auth-http.test.ts new file mode 100644 index 00000000000..916f5880d08 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-auth-http.test.ts @@ -0,0 +1,166 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { + handlePlatformClawBrowserAuthRequest, + PLATFORMCLAW_LOGIN_PATH, + PLATFORMCLAW_SESSION_COOKIE, +} from "./browser-auth-http.js"; +import { BrowserAuthService } from "./browser-auth-service.js"; +import type { ControlPlaneIdFactory } from "./contracts.js"; +import { InMemoryControlPlaneStore } from "./memory-store.js"; + +function ids(): ControlPlaneIdFactory { + let value = 0; + return { + nextUserId: () => `user-${++value}`, + nextBindingId: () => `binding-${++value}`, + nextSessionId: () => `session-${++value}`, + nextManagedScopeId: () => `scope-${++value}`, + nextAuditEventId: () => `audit-${++value}`, + }; +} + +function responseHarness() { + const headers = new Map(); + let body = ""; + const res = { + statusCode: 200, + setHeader: (name: string, value: string | string[]) => headers.set(name, value), + getHeader: (name: string) => headers.get(name), + end: (value?: unknown) => { + body = String(value ?? ""); + }, + } as unknown as ServerResponse; + return { res, headers, body: () => body }; +} + +function createService() { + return new BrowserAuthService({ + store: new InMemoryControlPlaneStore({ + idFactory: ids(), + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }), + authenticator: { + async authenticatePassword() { + return { + status: "authenticated" as const, + principal: { + provider: "ldap" as const, + subject: "seungon.jung", + accountId: "seungon.jung", + employeeId: "seungon.jung", + displayName: "Seungon Jung", + department: "Platform", + }, + profile: { + employeeId: "seungon.jung", + accountId: "seungon.jung", + subject: "seungon.jung", + displayName: "Seungon Jung", + department: "Platform", + groups: [], + attributes: {}, + }, + }; + }, + }, + provisioner: { provisionOrRefresh: vi.fn(async () => undefined) }, + now: () => 1_000, + tokenFactory: () => "test-token-factory", + }); +} + +function createRateLimiter() { + return { + check: () => ({ allowed: true, retryAfterMs: 0 }), + recordFailure: vi.fn(), + }; +} + +describe("PlatformClaw browser auth HTTP boundary", () => { + it("sets an HttpOnly secure cookie and never returns the opaque token in JSON", async () => { + const harness = responseHarness(); + const handled = await handlePlatformClawBrowserAuthRequest( + { + url: PLATFORMCLAW_LOGIN_PATH, + method: "POST", + headers: { "content-type": "application/json", "user-agent": "test-browser" }, + } as IncomingMessage, + harness.res, + { + service: createService(), + requestIsSecure: true, + isMutationOriginAllowed: () => true, + rateLimiter: createRateLimiter(), + readJsonBody: async () => ({ + ok: true, + value: { identifier: "seungon.jung", password: "test-password" }, + }), + }, + ); + + expect(handled).toBe(true); + expect(harness.res.statusCode).toBe(200); + expect(harness.headers.get("Set-Cookie")).toContain( + `${PLATFORMCLAW_SESSION_COOKIE}=test-token-factory`, + ); + expect(harness.headers.get("Set-Cookie")).toContain("HttpOnly"); + expect(harness.headers.get("Set-Cookie")).toContain("Secure"); + expect(harness.headers.get("Set-Cookie")).toContain("SameSite=Lax"); + expect(harness.body()).not.toContain("test-token-factory"); + expect(JSON.parse(harness.body())).toMatchObject({ + authenticated: true, + user: { accountId: "seungon.jung" }, + agent: { agentId: "seungon_jung", state: "active" }, + }); + }); + + it("rate limits before reading credentials or calling the auth service", async () => { + const readJsonBody = vi.fn(); + const harness = responseHarness(); + await handlePlatformClawBrowserAuthRequest( + { + url: PLATFORMCLAW_LOGIN_PATH, + method: "POST", + headers: { "content-type": "application/json" }, + } as IncomingMessage, + harness.res, + { + service: createService(), + requestIsSecure: false, + isMutationOriginAllowed: () => true, + readJsonBody, + clientIp: "192.0.2.10", + rateLimiter: { + check: () => ({ allowed: false, retryAfterMs: 15_000 }), + recordFailure: vi.fn(), + }, + }, + ); + expect(harness.res.statusCode).toBe(429); + expect(harness.headers.get("Retry-After")).toBe("15"); + expect(readJsonBody).not.toHaveBeenCalled(); + }); + + it("rejects a disallowed login origin before reading the password", async () => { + const readJsonBody = vi.fn(); + const harness = responseHarness(); + await handlePlatformClawBrowserAuthRequest( + { + url: PLATFORMCLAW_LOGIN_PATH, + method: "POST", + headers: { "content-type": "application/json", origin: "https://cross-site.test" }, + } as IncomingMessage, + harness.res, + { + service: createService(), + requestIsSecure: true, + isMutationOriginAllowed: () => false, + rateLimiter: createRateLimiter(), + readJsonBody, + }, + ); + expect(harness.res.statusCode).toBe(403); + expect(readJsonBody).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platformclaw-control-plane/src/browser-auth-http.ts b/packages/platformclaw-control-plane/src/browser-auth-http.ts new file mode 100644 index 00000000000..b19949c4cc7 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-auth-http.ts @@ -0,0 +1,246 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { BrowserAuthService } from "./browser-auth-service.js"; +import { BROWSER_SESSION_POLICY } from "./contracts.js"; + +export const PLATFORMCLAW_SESSION_COOKIE = "platformclaw_session"; +export const PLATFORMCLAW_LOGIN_PATH = "/platformclaw/api/auth/login"; +export const PLATFORMCLAW_LOGOUT_PATH = "/platformclaw/api/auth/logout"; +export const PLATFORMCLAW_SESSION_PATH = "/platformclaw/api/auth/session"; + +const LOGIN_BODY_LIMIT_BYTES = 64 * 1024; +const LOGIN_RATE_LIMIT_SCOPE = "platformclaw-browser-login"; + +export type BrowserLoginRateLimiter = { + check( + clientIp: string | undefined, + scope: string, + ): { + allowed: boolean; + retryAfterMs: number; + }; + recordFailure(clientIp: string | undefined, scope: string): void; +}; + +export type JsonBodyReader = ( + req: IncomingMessage, + maxBytes: number, +) => Promise<{ ok: true; value: unknown } | { ok: false; error: string }>; + +export type BrowserAuthHttpOptions = { + service: BrowserAuthService; + readJsonBody: JsonBodyReader; + clientIp?: string; + gatewayUrl?: string; + requestIsSecure: boolean; + isMutationOriginAllowed(req: IncomingMessage): boolean; + rateLimiter: BrowserLoginRateLimiter; +}; + +type LoginBody = { + identifier?: unknown; + username?: unknown; + password?: unknown; +}; + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Pragma", "no-cache"); + res.end(JSON.stringify(body)); +} + +function appendSetCookie(res: ServerResponse, cookie: string): void { + const current = res.getHeader("Set-Cookie"); + if (!current) { + res.setHeader("Set-Cookie", cookie); + } else if (Array.isArray(current)) { + res.setHeader("Set-Cookie", [...current, cookie]); + } else { + res.setHeader("Set-Cookie", [String(current), cookie]); + } +} + +function serializeSessionCookie(value: string, secure: boolean, clear = false): string { + const parts = [`${PLATFORMCLAW_SESSION_COOKIE}=${encodeURIComponent(value)}`]; + if (clear) { + parts.push("Max-Age=0", "Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + } else { + parts.push(`Max-Age=${Math.floor(BROWSER_SESSION_POLICY.absoluteTimeoutMs / 1000)}`); + } + parts.push("Path=/", "HttpOnly", "SameSite=Lax"); + if (secure) { + parts.push("Secure"); + } + return parts.join("; "); +} + +export function readPlatformClawSessionCookie(req: IncomingMessage): string | undefined { + const raw = Array.isArray(req.headers.cookie) + ? req.headers.cookie.join("; ") + : (req.headers.cookie ?? ""); + for (const item of raw.split(";")) { + const [name, ...rest] = item.split("="); + if (name?.trim() !== PLATFORMCLAW_SESSION_COOKIE) { + continue; + } + const value = rest.join("=").trim(); + try { + return decodeURIComponent(value); + } catch { + return undefined; + } + } + return undefined; +} + +function clearSessionCookie(res: ServerResponse, secure: boolean): void { + appendSetCookie(res, serializeSessionCookie("", secure, true)); +} + +function methodNotAllowed(res: ServerResponse, allowed: string): void { + res.statusCode = 405; + res.setHeader("Allow", allowed); + res.end("Method Not Allowed"); +} + +export async function handlePlatformClawBrowserAuthRequest( + req: IncomingMessage, + res: ServerResponse, + options: BrowserAuthHttpOptions, +): Promise { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const method = (req.method ?? "GET").toUpperCase(); + + if (pathname === PLATFORMCLAW_LOGIN_PATH) { + if (method !== "POST") { + methodNotAllowed(res, "POST"); + return true; + } + if (!options.isMutationOriginAllowed(req)) { + sendJson(res, 403, { authenticated: false, message: "origin not allowed" }); + return true; + } + const contentType = req.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/json") { + sendJson(res, 415, { authenticated: false, message: "application/json required" }); + return true; + } + const limited = options.rateLimiter.check(options.clientIp, LOGIN_RATE_LIMIT_SCOPE); + if (limited && !limited.allowed) { + res.setHeader("Retry-After", String(Math.max(1, Math.ceil(limited.retryAfterMs / 1000)))); + sendJson(res, 429, { authenticated: false, message: "too many sign-in attempts" }); + return true; + } + const body = await options.readJsonBody(req, LOGIN_BODY_LIMIT_BYTES); + if (!body.ok || !body.value || typeof body.value !== "object" || Array.isArray(body.value)) { + sendJson(res, 400, { + authenticated: false, + message: body.ok ? "invalid login payload" : body.error, + }); + return true; + } + const login = body.value as LoginBody; + const identifierRaw = login.identifier ?? login.username; + const identifier = typeof identifierRaw === "string" ? identifierRaw.trim() : ""; + const password = typeof login.password === "string" ? login.password : ""; + const currentSessionValue = readPlatformClawSessionCookie(req); + const result = await options.service.loginPassword({ + login: { identifier, password }, + currentSession: currentSessionValue ? { value: currentSessionValue } : undefined, + context: { + clientIp: options.clientIp, + gatewayUrl: options.gatewayUrl, + userAgent: req.headers["user-agent"], + }, + }); + if (result.status === "rejected") { + options.rateLimiter.recordFailure(options.clientIp, LOGIN_RATE_LIMIT_SCOPE); + sendJson(res, 401, { authenticated: false, message: result.message }); + return true; + } + if (result.status !== "authenticated") { + const status = + result.status === "session-limit" ? 409 : result.status === "account-disabled" ? 403 : 503; + sendJson(res, status, { authenticated: false, reason: result.status }); + return true; + } + appendSetCookie(res, serializeSessionCookie(result.token, options.requestIsSecure)); + sendJson(res, 200, { + authenticated: true, + user: { + accountId: result.user.accountId, + displayName: result.user.displayName, + department: result.user.department, + globalRole: result.user.globalRole, + }, + agent: { + agentId: result.binding.agentId, + state: result.binding.state, + }, + session: { + idleExpiresAt: result.session.idleExpiresAt, + absoluteExpiresAt: result.session.absoluteExpiresAt, + }, + }); + return true; + } + + if (pathname === PLATFORMCLAW_SESSION_PATH) { + if (method !== "GET" && method !== "HEAD") { + methodNotAllowed(res, "GET, HEAD"); + return true; + } + const token = readPlatformClawSessionCookie(req); + if (!token) { + sendJson(res, 200, method === "HEAD" ? undefined : { authenticated: false }); + return true; + } + const result = await options.service.authenticateToken(token); + if (result.status !== "active") { + clearSessionCookie(res, options.requestIsSecure); + sendJson(res, 200, method === "HEAD" ? undefined : { authenticated: false }); + return true; + } + sendJson( + res, + 200, + method === "HEAD" + ? undefined + : { + authenticated: true, + user: { + accountId: result.user.accountId, + displayName: result.user.displayName, + department: result.user.department, + globalRole: result.user.globalRole, + }, + session: { + idleExpiresAt: result.session.idleExpiresAt, + absoluteExpiresAt: result.session.absoluteExpiresAt, + }, + }, + ); + return true; + } + + if (pathname === PLATFORMCLAW_LOGOUT_PATH) { + if (method !== "POST") { + methodNotAllowed(res, "POST"); + return true; + } + if (!options.isMutationOriginAllowed(req)) { + sendJson(res, 403, { ok: false, message: "origin not allowed" }); + return true; + } + const token = readPlatformClawSessionCookie(req); + if (token) { + await options.service.logout(token); + } + clearSessionCookie(res, options.requestIsSecure); + sendJson(res, 200, { ok: true }); + return true; + } + + return false; +} diff --git a/packages/platformclaw-control-plane/src/browser-auth-service.test.ts b/packages/platformclaw-control-plane/src/browser-auth-service.test.ts new file mode 100644 index 00000000000..23c0f5bb378 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-auth-service.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from "vitest"; +import { BrowserAuthService, hashBrowserSessionToken } from "./browser-auth-service.js"; +import type { ControlPlaneIdFactory } from "./contracts.js"; +import type { EmployeeAuthenticationResult } from "./employee-auth-client.js"; +import { InMemoryControlPlaneStore } from "./memory-store.js"; + +function createIdFactory(): ControlPlaneIdFactory { + let user = 0; + let binding = 0; + let session = 0; + let scope = 0; + let audit = 0; + return { + nextUserId: () => `user-${++user}`, + nextBindingId: () => `binding-${++binding}`, + nextSessionId: () => `session-${++session}`, + nextManagedScopeId: () => `scope-${++scope}`, + nextAuditEventId: () => `audit-${++audit}`, + }; +} + +function authResult(): EmployeeAuthenticationResult { + return { + status: "authenticated", + principal: { + provider: "ldap", + subject: "seungon.jung", + accountId: "seungon.jung", + employeeId: "employee-123", + displayName: "Seungon Jung", + department: "Platform", + groups: ["platform"], + }, + profile: { + employeeId: "employee-123", + accountId: "seungon.jung", + subject: "seungon.jung", + displayName: "Seungon Jung", + department: "Platform", + part: "Agent Part", + confluenceSpace: "PLATFORM", + notes: "profile notes", + groups: ["platform"], + attributes: {}, + }, + }; +} + +function createHarness() { + let now = 1_000; + const store = new InMemoryControlPlaneStore({ + idFactory: createIdFactory(), + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }); + const authenticator = { + async authenticatePassword() { + return authResult(); + }, + }; + const provisioner = { provisionOrRefresh: vi.fn(async () => undefined) }; + const service = new BrowserAuthService({ + store, + authenticator, + provisioner, + now: () => now, + tokenFactory: () => "test-token-factory", + }); + return { service, store, authenticator, provisioner, setNow: (value: number) => (now = value) }; +} + +describe("BrowserAuthService", () => { + it("provisions the personal agent, derives its id, and stores only the token hash", async () => { + const { service, provisioner } = createHarness(); + const result = await service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + }); + + expect(result.status).toBe("authenticated"); + if (result.status !== "authenticated") { + return; + } + expect(result.binding).toMatchObject({ agentId: "seungon_jung", state: "active" }); + expect(result.token).toBe("test-token-factory"); + expect(result.session.tokenHash).toBe(hashBrowserSessionToken("test-token-factory")); + expect(result.session.tokenHash).not.toContain("test-token-factory"); + expect(provisioner.provisionOrRefresh).toHaveBeenCalledWith( + expect.objectContaining({ + createdBinding: true, + profile: expect.objectContaining({ + part: "Agent Part", + confluenceSpace: "PLATFORM", + notes: "profile notes", + }), + }), + ); + }); + + it("resolves and revokes an opaque browser session", async () => { + const { service, setNow } = createHarness(); + await service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + }); + setNow(2_000); + await expect(service.authenticateToken("test-token-factory")).resolves.toMatchObject({ + status: "active", + user: { accountId: "seungon.jung" }, + }); + await expect(service.logout("test-token-factory")).resolves.toBe(true); + await expect(service.authenticateToken("test-token-factory")).resolves.toEqual({ + status: "unauthenticated", + reason: "revoked", + }); + }); + + it("reuses the current session when the same user signs in again", async () => { + const { service } = createHarness(); + const first = await service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + }); + expect(first.status).toBe("authenticated"); + if (first.status !== "authenticated") { + throw new Error("expected authenticated test session"); + } + const second = await service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + currentSession: { value: first.token }, + }); + expect(second).toMatchObject({ + status: "authenticated", + session: { id: "session-1" }, + }); + if (second.status === "authenticated") { + expect(second.token).toBe(first.token); + } + }); + + it("single-flights concurrent provisioning for one personal binding", async () => { + const store = new InMemoryControlPlaneStore({ + idFactory: createIdFactory(), + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }); + let releaseProvisioning: (() => void) | undefined; + const provisioningGate = new Promise((resolve) => { + releaseProvisioning = resolve; + }); + const provisioner = { provisionOrRefresh: vi.fn(() => provisioningGate) }; + const service = new BrowserAuthService({ + store, + authenticator: { + async authenticatePassword() { + return authResult(); + }, + }, + provisioner, + now: () => 1_000, + }); + + const first = service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + }); + const second = service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + }); + await vi.waitFor(() => expect(provisioner.provisionOrRefresh).toHaveBeenCalledTimes(1)); + releaseProvisioning?.(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ status: "authenticated" }), + expect.objectContaining({ status: "authenticated" }), + ]); + expect(provisioner.provisionOrRefresh).toHaveBeenCalledTimes(2); + }); + + it("marks a new binding failed when workspace provisioning fails", async () => { + const { service, store, provisioner } = createHarness(); + provisioner.provisionOrRefresh.mockRejectedValueOnce(new Error("workspace unavailable")); + + await expect( + service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + }), + ).resolves.toEqual({ status: "provisioning-failed", message: "workspace unavailable" }); + + const user = await store.getUserByEmployeeId("employee-123"); + expect(user).not.toBeNull(); + const binding = await store.reservePersonalAgent(user!.id, 2_000); + expect(binding.binding).toMatchObject({ state: "failed", failureCode: "provisioner_error" }); + }); + + it("creates the browser session at the post-provisioning time", async () => { + const store = new InMemoryControlPlaneStore({ + idFactory: createIdFactory(), + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + }); + const times = [1_000, 5_000]; + const service = new BrowserAuthService({ + store, + authenticator: { + async authenticatePassword() { + return authResult(); + }, + }, + provisioner: { async provisionOrRefresh() {} }, + now: () => times.shift() ?? 5_000, + tokenFactory: () => "test-token-factory", + }); + + const result = await service.loginPassword({ + login: { identifier: "seungon.jung", password: "test-password" }, + }); + expect(result).toMatchObject({ + status: "authenticated", + session: { createdAt: 5_000 }, + }); + }); +}); diff --git a/packages/platformclaw-control-plane/src/browser-auth-service.ts b/packages/platformclaw-control-plane/src/browser-auth-service.ts new file mode 100644 index 00000000000..d827c461bf5 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-auth-service.ts @@ -0,0 +1,272 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { + BrowserSession, + BrowserSessionResolution, + ControlPlaneStore, + PersonalAgentBinding, + PlatformUser, +} from "./contracts.js"; +import type { + EmployeeAuthenticator, + EmployeeAuthRequestContext, + EmployeeDirectoryProfile, + EmployeePasswordCredentials, +} from "./employee-auth-client.js"; + +export type PersonalAgentProvisioningRequest = { + user: PlatformUser; + binding: PersonalAgentBinding; + profile: EmployeeDirectoryProfile; + createdBinding: boolean; +}; + +export interface PersonalAgentProvisioner { + provisionOrRefresh(request: PersonalAgentProvisioningRequest): Promise; +} + +export type BrowserLoginResult = + | { + status: "authenticated"; + token: string; + session: BrowserSession; + user: PlatformUser; + binding: PersonalAgentBinding; + createdUser: boolean; + createdBinding: boolean; + } + | { status: "rejected"; message: string } + | { status: "auth-unavailable"; message: string } + | { status: "account-disabled" } + | { status: "session-limit"; activeSessionCount: number } + | { status: "provisioning-failed"; message: string }; + +export type BrowserAuthenticationResult = + | Exclude + | { status: "unauthenticated"; reason: "revoked" | "expired" | "user-disabled" }; + +function defaultTokenFactory(): string { + return randomBytes(32).toString("base64url"); +} + +export function hashBrowserSessionToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export class BrowserAuthService { + private readonly provisioningByBindingId = new Map>(); + + constructor( + private readonly options: { + store: ControlPlaneStore; + authenticator: EmployeeAuthenticator; + provisioner: PersonalAgentProvisioner; + now?: () => number; + tokenFactory?: () => string; + }, + ) {} + + async loginPassword(params: { + login: EmployeePasswordCredentials; + context?: EmployeeAuthRequestContext; + currentSession?: { value: string }; + }): Promise { + const auth = await this.options.authenticator.authenticatePassword({ + login: params.login, + context: params.context ?? {}, + }); + if (auth.status === "rejected") { + return auth; + } + if (auth.status === "unavailable") { + return { status: "auth-unavailable", message: auth.message }; + } + + // Version directory data by completed authentication order, not request start order. + const authenticatedAt = (this.options.now ?? Date.now)(); + const upserted = await this.options.store.upsertPrincipal(auth.principal, authenticatedAt); + if (upserted.user.status !== "active") { + return { status: "account-disabled" }; + } + const reservation = await this.options.store.reservePersonalAgent( + upserted.user.id, + authenticatedAt, + ); + if (reservation.binding.state === "disabled") { + return { status: "account-disabled" }; + } + let binding: PersonalAgentBinding; + try { + binding = await this.provisionPersonalAgent({ + user: upserted.user, + binding: reservation.binding, + profile: auth.profile, + createdBinding: reservation.created, + changedAt: authenticatedAt, + }); + } catch (error) { + return { + status: "provisioning-failed", + message: error instanceof Error ? error.message : "personal agent provisioning failed", + }; + } + + const sessionAt = (this.options.now ?? Date.now)(); + const currentSession = params.currentSession + ? await this.options.store.resolveBrowserSession({ + tokenHash: hashBrowserSessionToken(params.currentSession.value), + resolvedAt: sessionAt, + touch: true, + }) + : undefined; + if (currentSession?.status === "active" && currentSession.user.id === upserted.user.id) { + return { + status: "authenticated", + token: params.currentSession.value, + session: currentSession.session, + user: upserted.user, + binding, + createdUser: upserted.createdUser, + createdBinding: reservation.created, + }; + } + + const token = (this.options.tokenFactory ?? defaultTokenFactory)(); + if (!token) { + throw new Error("browser session token factory returned an empty token"); + } + const created = await this.options.store.createBrowserSession({ + userId: upserted.user.id, + tokenHash: hashBrowserSessionToken(token), + createdAt: sessionAt, + }); + if (created.status === "limit-reached") { + return { status: "session-limit", activeSessionCount: created.activeSessionCount }; + } + if (currentSession?.status === "active") { + await this.options.store.revokeBrowserSession(currentSession.session.id, sessionAt); + } + return { + status: "authenticated", + token, + session: created.session, + user: upserted.user, + binding, + createdUser: upserted.createdUser, + createdBinding: reservation.created, + }; + } + + private provisionPersonalAgent(params: PersonalAgentProvisioningRequest & { changedAt: number }) { + const existing = this.provisioningByBindingId.get(params.binding.id); + if (existing) { + // Preserve authenticated profile order while the initial workspace creation is in flight. + // A later login refreshes after the earlier write instead of dropping newer metadata. + const refresh = existing.then(async (binding) => { + await this.options.provisioner.provisionOrRefresh({ + ...params, + binding, + createdBinding: false, + }); + return binding; + }); + this.trackProvisioning(params.binding.id, refresh); + return refresh; + } + // platformclaw-control is one process in phase 1. This single-flight prevents two + // logins in that process from racing the binding state machine and workspace writes. + const task = this.runPersonalAgentProvisioning(params); + this.trackProvisioning(params.binding.id, task); + return task; + } + + private trackProvisioning(bindingId: string, task: Promise): void { + this.provisioningByBindingId.set(bindingId, task); + const cleanup = () => { + if (this.provisioningByBindingId.get(bindingId) === task) { + this.provisioningByBindingId.delete(bindingId); + } + }; + void task.then(cleanup, cleanup); + } + + private async runPersonalAgentProvisioning( + params: PersonalAgentProvisioningRequest & { changedAt: number }, + ): Promise { + let binding = params.binding; + if (binding.state === "failed") { + const transitioned = await this.options.store.transitionAgent({ + bindingId: binding.id, + state: "provisioning", + changedAt: params.changedAt, + }); + if (transitioned.kind !== "personal") { + throw new Error("personal agent reservation changed kind"); + } + binding = transitioned; + } + try { + await this.options.provisioner.provisionOrRefresh({ ...params, binding }); + if (binding.state !== "provisioning") { + return binding; + } + const transitioned = await this.options.store.transitionAgent({ + bindingId: binding.id, + state: "active", + changedAt: params.changedAt, + }); + if (transitioned.kind !== "personal") { + throw new Error("personal agent reservation changed kind"); + } + return transitioned; + } catch (error) { + if (binding.state === "provisioning") { + await this.options.store.transitionAgent({ + bindingId: binding.id, + state: "failed", + changedAt: params.changedAt, + failureCode: "provisioner_error", + }); + } + throw error; + } + } + + async authenticateToken(token: string, touch = true): Promise { + if (!token) { + return { status: "unauthenticated", reason: "expired" }; + } + const result = await this.options.store.resolveBrowserSession({ + tokenHash: hashBrowserSessionToken(token), + resolvedAt: (this.options.now ?? Date.now)(), + touch, + }); + if ( + result.status === "revoked" || + result.status === "expired" || + result.status === "user-disabled" + ) { + return { + status: "unauthenticated", + reason: result.status === "user-disabled" ? "user-disabled" : result.status, + }; + } + return result; + } + + async logout(token: string): Promise { + const resolution = await this.options.store.resolveBrowserSession({ + tokenHash: hashBrowserSessionToken(token), + resolvedAt: (this.options.now ?? Date.now)(), + touch: false, + }); + if (resolution.status !== "active") { + return false; + } + return Boolean( + await this.options.store.revokeBrowserSession( + resolution.session.id, + (this.options.now ?? Date.now)(), + ), + ); + } +} diff --git a/packages/platformclaw-control-plane/src/contracts.ts b/packages/platformclaw-control-plane/src/contracts.ts index 55f252d0367..91788e1cc5a 100644 --- a/packages/platformclaw-control-plane/src/contracts.ts +++ b/packages/platformclaw-control-plane/src/contracts.ts @@ -9,6 +9,7 @@ export type EnterpriseAuthProvider = "ldap" | "saml"; export type EnterprisePrincipal = { provider: EnterpriseAuthProvider; subject: string; + accountId?: string; employeeId: string; displayName?: string; email?: string; @@ -245,6 +246,8 @@ export interface ControlPlaneManagementStore { } export type ControlPlaneConflictCode = + | "account_id_conflict" + | "account_id_mismatch" | "employee_id_conflict" | "employee_id_mismatch" | "stale_authentication" diff --git a/packages/platformclaw-control-plane/src/employee-auth-client.test.ts b/packages/platformclaw-control-plane/src/employee-auth-client.test.ts new file mode 100644 index 00000000000..299caa609eb --- /dev/null +++ b/packages/platformclaw-control-plane/src/employee-auth-client.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; +import { + EMPLOYEE_AUTH_BEARER_TOKEN_ENV, + EMPLOYEE_AUTH_LOGIN_URL_ENV, + HttpEmployeeAuthenticator, + loadEmployeeAuthClientConfig, +} from "./employee-auth-client.js"; + +describe("employee auth client config", () => { + it("loads the canonical PlatformClaw environment variables", () => { + expect( + loadEmployeeAuthClientConfig({ + [EMPLOYEE_AUTH_LOGIN_URL_ENV]: " http://127.0.0.1:18080/login ", + [EMPLOYEE_AUTH_BEARER_TOKEN_ENV]: " test-bearer-token ", + }), + ).toEqual({ + loginUrl: "http://127.0.0.1:18080/login", + bearerToken: "test-bearer-token", + provider: "ldap", + }); + }); + + it("rejects missing and non-http login URLs", () => { + expect(() => loadEmployeeAuthClientConfig({})).toThrow(EMPLOYEE_AUTH_LOGIN_URL_ENV); + expect(() => + loadEmployeeAuthClientConfig({ [EMPLOYEE_AUTH_LOGIN_URL_ENV]: "file:///auth" }), + ).toThrow("must use https"); + expect(() => + loadEmployeeAuthClientConfig({ + [EMPLOYEE_AUTH_LOGIN_URL_ENV]: "http://employee-auth.example.test/login", + }), + ).toThrow("loopback mock"); + }); +}); + +describe("HttpEmployeeAuthenticator", () => { + it("normalizes the full legacy profile while control-plane owns agent routing", async () => { + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + authenticated: true, + employeeId: "Seungon.Jung", + name: "Seungon Jung", + email: "seungon.jung@example.test", + department: "Platform", + part: "Agent Part", + confluenceSpace: "PLATFORM", + notes: "Jira and Confluence user", + groups: ["developers", "platform", "developers"], + attributes: { title: "Engineer", costCenters: ["A", "B"] }, + agentId: "external-agent-must-be-ignored", + sessionKey: "external-session-must-be-ignored", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const authenticator = new HttpEmployeeAuthenticator( + { + loginUrl: "https://auth.test/login", + bearerToken: "test-bearer-token", + }, + fetchImpl, + ); + + const result = await authenticator.authenticatePassword({ + login: { identifier: " seungon.jung ", password: "test-password" }, + context: { clientIp: "192.0.2.10", userAgent: "browser" }, + }); + + expect(result).toEqual({ + status: "authenticated", + principal: { + provider: "ldap", + subject: "seungon.jung", + accountId: "seungon.jung", + employeeId: "Seungon.Jung", + displayName: "Seungon Jung", + email: "seungon.jung@example.test", + department: "Platform", + groups: ["developers", "platform"], + }, + profile: { + employeeId: "Seungon.Jung", + accountId: "seungon.jung", + subject: "seungon.jung", + displayName: "Seungon Jung", + email: "seungon.jung@example.test", + department: "Platform", + part: "Agent Part", + confluenceSpace: "PLATFORM", + notes: "Jira and Confluence user", + groups: ["developers", "platform"], + attributes: { title: "Engineer", costCenters: ["A", "B"] }, + }, + }); + const [, request] = fetchImpl.mock.calls[0] ?? []; + expect(request?.headers).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer test-bearer-token", + }); + expect(request?.redirect).toBe("error"); + expect(JSON.parse(String(request?.body))).toEqual({ + identifier: "seungon.jung", + password: "test-password", + clientIp: "192.0.2.10", + gatewayUrl: null, + userAgent: "browser", + }); + }); + + it("separates credential rejection from an invalid upstream response", async () => { + const rejected = new HttpEmployeeAuthenticator( + { loginUrl: "https://auth.test/login" }, + vi.fn( + async () => + new Response(JSON.stringify({ authenticated: false, message: "invalid credentials" }), { + status: 401, + }), + ), + ); + await expect( + rejected.authenticatePassword({ + login: { identifier: "eon", password: "wrong" }, + context: {}, + }), + ).resolves.toEqual({ status: "rejected", message: "invalid credentials" }); + + const invalid = new HttpEmployeeAuthenticator( + { loginUrl: "https://auth.test/login" }, + vi.fn(async () => new Response(JSON.stringify({ authenticated: true }), { status: 200 })), + ); + await expect( + invalid.authenticatePassword({ + login: { identifier: "eon", password: "test-password" }, + context: {}, + }), + ).resolves.toEqual({ + status: "unavailable", + message: "employee authentication response was invalid", + }); + + const failedService = new HttpEmployeeAuthenticator( + { loginUrl: "https://auth.test/login" }, + vi.fn( + async () => + new Response(JSON.stringify({ authenticated: false, message: "backend failed" }), { + status: 500, + }), + ), + ); + await expect( + failedService.authenticatePassword({ + login: { identifier: "eon", password: "test-password" }, + context: {}, + }), + ).resolves.toEqual({ + status: "unavailable", + message: "employee authentication failed (500)", + }); + }); +}); diff --git a/packages/platformclaw-control-plane/src/employee-auth-client.ts b/packages/platformclaw-control-plane/src/employee-auth-client.ts new file mode 100644 index 00000000000..364a5762ce5 --- /dev/null +++ b/packages/platformclaw-control-plane/src/employee-auth-client.ts @@ -0,0 +1,301 @@ +import { isIP } from "node:net"; +import type { EnterpriseAuthProvider, EnterprisePrincipal } from "./contracts.js"; + +export const EMPLOYEE_AUTH_LOGIN_URL_ENV = "PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL"; +export const EMPLOYEE_AUTH_BEARER_TOKEN_ENV = "PLATFORMCLAW_EMPLOYEE_AUTH_BEARER_TOKEN"; + +const DEFAULT_AUTH_TIMEOUT_MS = 10_000; + +export type EmployeeDirectoryProfile = { + employeeId: string; + accountId: string; + subject: string; + displayName?: string; + email?: string; + department?: string; + part?: string; + confluenceSpace?: string; + notes?: string; + groups: string[]; + attributes: Readonly>; +}; + +export type EmployeeAuthRequestContext = { + clientIp?: string; + gatewayUrl?: string; + userAgent?: string; +}; + +export type EmployeePasswordCredentials = { + identifier: string; + password: string; +}; + +export type EmployeeAuthenticationResult = + | { + status: "authenticated"; + principal: EnterprisePrincipal; + profile: EmployeeDirectoryProfile; + } + | { status: "rejected"; message: string } + | { status: "unavailable"; message: string }; + +export interface EmployeeAuthenticator { + authenticatePassword(params: { + login: EmployeePasswordCredentials; + context: EmployeeAuthRequestContext; + }): Promise; +} + +export type EmployeeAuthClientConfig = { + loginUrl: string; + bearerToken?: string; + provider?: EnterpriseAuthProvider; + timeoutMs?: number; +}; + +type Fetch = typeof globalThis.fetch; + +type ExternalAuthFailure = { + authenticated: false; + message?: string; +}; + +type ExternalAuthSuccess = { + authenticated: true; + employeeId: string; + accountId?: string; + subject?: string; + email?: string; + name?: string; + displayName?: string; + department?: string; + part?: string; + confluenceSpace?: string; + notes?: string; + groups?: string[]; + attributes?: Record; +}; + +type ExternalAuthResponse = ExternalAuthFailure | ExternalAuthSuccess; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return [ + ...new Set(value.map(optionalString).filter((entry): entry is string => Boolean(entry))), + ].toSorted(); +} + +function parseAttributes(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const attributes: Record = {}; + for (const [key, raw] of Object.entries(value)) { + const normalizedKey = key.trim(); + if (!normalizedKey) { + continue; + } + const scalar = optionalString(raw); + if (scalar) { + attributes[normalizedKey] = scalar; + continue; + } + const values = stringArray(raw); + if (values.length > 0) { + attributes[normalizedKey] = values; + } + } + return attributes; +} + +function parseResponse(value: unknown): ExternalAuthResponse | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + if (record.authenticated === false) { + return { authenticated: false, message: optionalString(record.message) }; + } + if (record.authenticated !== true) { + return null; + } + const employeeId = optionalString(record.employeeId); + if (!employeeId) { + return null; + } + return { + authenticated: true, + employeeId, + accountId: optionalString(record.accountId), + subject: optionalString(record.subject), + email: optionalString(record.email), + name: optionalString(record.name), + displayName: optionalString(record.displayName), + department: optionalString(record.department), + part: optionalString(record.part), + confluenceSpace: optionalString(record.confluenceSpace), + notes: optionalString(record.notes) ?? optionalString(record.note), + groups: stringArray(record.groups), + attributes: parseAttributes(record.attributes), + }; +} + +function normalizeUrl(raw: string): string { + const url = new URL(raw); + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + const ipVersion = isIP(hostname); + const isLoopback = + hostname === "localhost" || + (ipVersion === 4 && hostname.split(".", 1)[0] === "127") || + (ipVersion === 6 && hostname === "::1"); + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) { + throw new Error( + `${EMPLOYEE_AUTH_LOGIN_URL_ENV} must use https; http is allowed only for a loopback mock`, + ); + } + return url.toString(); +} + +export function loadEmployeeAuthClientConfig( + env: NodeJS.ProcessEnv = process.env, +): EmployeeAuthClientConfig { + const loginUrl = env[EMPLOYEE_AUTH_LOGIN_URL_ENV]?.trim(); + if (!loginUrl) { + throw new Error(`${EMPLOYEE_AUTH_LOGIN_URL_ENV} is required`); + } + const bearerToken = optionalString(env[EMPLOYEE_AUTH_BEARER_TOKEN_ENV]); + return { + loginUrl: normalizeUrl(loginUrl), + ...(bearerToken ? { bearerToken } : {}), + provider: "ldap", + }; +} + +export class HttpEmployeeAuthenticator implements EmployeeAuthenticator { + private readonly config: Required< + Pick + > & + Pick; + + constructor( + config: EmployeeAuthClientConfig, + private readonly fetchImpl: Fetch = globalThis.fetch, + ) { + this.config = { + ...config, + loginUrl: normalizeUrl(config.loginUrl), + provider: config.provider ?? "ldap", + timeoutMs: config.timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS, + }; + if (!Number.isFinite(this.config.timeoutMs) || this.config.timeoutMs <= 0) { + throw new Error("employee auth timeout must be a positive number"); + } + } + + async authenticatePassword(params: { + login: EmployeePasswordCredentials; + context: EmployeeAuthRequestContext; + }): Promise { + const identifier = params.login.identifier.trim(); + if (!identifier || !params.login.password) { + return { status: "rejected", message: "identifier and password are required" }; + } + + const headers: Record = { "Content-Type": "application/json" }; + if (this.config.bearerToken) { + headers.Authorization = `Bearer ${this.config.bearerToken}`; + } + + let response: Response; + try { + response = await this.fetchImpl(this.config.loginUrl, { + method: "POST", + redirect: "error", + headers, + signal: AbortSignal.timeout(this.config.timeoutMs), + body: JSON.stringify({ + identifier, + password: params.login.password, + clientIp: params.context.clientIp ?? null, + gatewayUrl: params.context.gatewayUrl ?? null, + userAgent: params.context.userAgent ?? null, + }), + }); + } catch { + return { status: "unavailable", message: "employee authentication service unavailable" }; + } + + let parsed: ExternalAuthResponse | null = null; + try { + parsed = parseResponse(await response.json()); + } catch { + parsed = null; + } + if (!parsed) { + return { status: "unavailable", message: "employee authentication response was invalid" }; + } + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return { + status: "rejected", + message: parsed.authenticated + ? "invalid credentials" + : (parsed.message ?? "invalid credentials"), + }; + } + return { + status: "unavailable", + message: `employee authentication failed (${response.status})`, + }; + } + if (!parsed.authenticated) { + return { status: "rejected", message: parsed.message ?? "invalid credentials" }; + } + + const accountId = parsed.accountId?.toLowerCase() ?? parsed.employeeId.toLowerCase(); + // LDAP has no separate stable subject in the deployed contract. SAML can supply one later + // without changing the control-plane identity contract. + const subject = parsed.subject ?? accountId; + if (this.config.provider === "saml" && !parsed.subject) { + return { + status: "unavailable", + message: "SAML authentication response was missing its stable subject", + }; + } + const displayName = parsed.displayName ?? parsed.name; + const profile: EmployeeDirectoryProfile = { + employeeId: parsed.employeeId, + accountId, + subject, + displayName, + email: parsed.email, + department: parsed.department, + part: parsed.part, + confluenceSpace: parsed.confluenceSpace, + notes: parsed.notes, + groups: parsed.groups ?? [], + attributes: parsed.attributes ?? {}, + }; + return { + status: "authenticated", + profile, + principal: { + provider: this.config.provider, + subject, + accountId, + employeeId: parsed.employeeId, + displayName, + email: profile.email, + department: profile.department, + groups: profile.groups, + }, + }; + } +} diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts index 80666355281..2d88b834842 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -1,4 +1,7 @@ +export * from "./browser-auth-service.js"; +export * from "./browser-auth-http.js"; export * from "./contracts.js"; +export * from "./employee-auth-client.js"; export * from "./ids.js"; export * from "./memory-store.js"; export * from "./sqlite-schema.js"; diff --git a/packages/platformclaw-control-plane/src/memory-store.test.ts b/packages/platformclaw-control-plane/src/memory-store.test.ts index 8c52885c6f5..4c2672cd7c3 100644 --- a/packages/platformclaw-control-plane/src/memory-store.test.ts +++ b/packages/platformclaw-control-plane/src/memory-store.test.ts @@ -79,16 +79,38 @@ describe("personal agent id", () => { it("preserves the deployed account-id dot replacement", async () => { const store = createStore(); const principal = await store.upsertPrincipal( - ldapPrincipal({ employeeId: "seungon.jung" }), + ldapPrincipal({ accountId: "seungon.jung", employeeId: "employee-123" }), 1_000, ); const reservation = await store.reservePersonalAgent(principal.user.id, 2_000); expect(reservation.binding.agentId).toBe("seungon_jung"); + expect(principal.user).toMatchObject({ + accountId: "seungon.jung", + employeeId: "employee-123", + }); }); }); describe("InMemoryControlPlaneStore enterprise identity", () => { + it("preserves the canonical account when a linked provider omits accountId", async () => { + const store = createStore(); + const ldap = await store.upsertPrincipal( + ldapPrincipal({ accountId: "account.name", employeeId: "employee-123" }), + 1_000, + ); + const saml = await store.upsertPrincipal( + ldapPrincipal({ + provider: "saml", + subject: "saml-subject-1", + employeeId: "employee-123", + }), + 2_000, + ); + + expect(saml.user).toMatchObject({ id: ldap.user.id, accountId: "account.name" }); + }); + it("keeps one user while mutable LDAP attributes change", async () => { const store = createStore(); const first = await store.upsertPrincipal(ldapPrincipal(), 1_000); diff --git a/packages/platformclaw-control-plane/src/memory-store.ts b/packages/platformclaw-control-plane/src/memory-store.ts index 18481624b16..a9fab4b2a56 100644 --- a/packages/platformclaw-control-plane/src/memory-store.ts +++ b/packages/platformclaw-control-plane/src/memory-store.ts @@ -97,6 +97,7 @@ export class InMemoryControlPlaneStore implements ControlPlaneStore { private readonly sessionPolicy: BrowserSessionPolicy; private readonly initialAdminAccountIds: ReadonlySet; private readonly users = new Map(); + private readonly userIdByAccountId = new Map(); private readonly userIdByEmployeeId = new Map(); private readonly identities = new Map(); private readonly bindings = new Map(); @@ -124,11 +125,20 @@ export class InMemoryControlPlaneStore implements ControlPlaneStore { ): Promise { const subject = requireNonEmpty(principal.subject, "principal.subject"); const employeeId = normalizeEmployeeId(principal.employeeId); + const requestedAccountId = principal.accountId + ? normalizeEmployeeId(principal.accountId) + : undefined; const key = identityKey(principal.provider, subject); const existingIdentity = this.identities.get(key); if (existingIdentity) { const user = this.requireUser(existingIdentity.userId); + if (requestedAccountId && requestedAccountId !== user.accountId) { + throw new ControlPlaneConflictError( + "account_id_mismatch", + `identity account id disagrees with the canonical user: ${principal.provider}:${subject}`, + ); + } if ( authenticatedAt < existingIdentity.lastAuthenticatedAt || (authenticatedAt === existingIdentity.lastAuthenticatedAt && @@ -150,11 +160,29 @@ export class InMemoryControlPlaneStore implements ControlPlaneStore { }; } - const existingUserId = this.userIdByEmployeeId.get(employeeId); + const accountOwnerId = requestedAccountId + ? this.userIdByAccountId.get(requestedAccountId) + : undefined; + const employeeOwnerId = this.userIdByEmployeeId.get(employeeId); + if (accountOwnerId && employeeOwnerId && accountOwnerId !== employeeOwnerId) { + throw new ControlPlaneConflictError( + "account_id_conflict", + `account id and employee id belong to different users: ${requestedAccountId}`, + ); + } + const existingUserId = accountOwnerId ?? employeeOwnerId; + const existingUser = existingUserId ? this.requireUser(existingUserId) : undefined; + const accountId = requestedAccountId ?? existingUser?.accountId ?? employeeId; const user = existingUserId - ? this.requireUser(existingUserId) - : this.createUser(employeeId, principal, authenticatedAt); - if (existingUserId) { + ? existingUser! + : this.createUser(accountId, employeeId, principal, authenticatedAt); + if (existingUser) { + if (requestedAccountId && user.accountId !== requestedAccountId) { + throw new ControlPlaneConflictError( + "account_id_mismatch", + `account id disagrees with the canonical user: ${requestedAccountId}`, + ); + } this.updateUserFromPrincipal(user, principal, authenticatedAt); } @@ -412,22 +440,24 @@ export class InMemoryControlPlaneStore implements ControlPlaneStore { } private createUser( + accountId: string, employeeId: string, principal: EnterprisePrincipal, createdAt: number, ): PlatformUser { const user: PlatformUser = { id: this.idFactory.nextUserId(), - accountId: employeeId, + accountId, employeeId, status: "active", - globalRole: this.initialAdminAccountIds.has(employeeId.toLowerCase()) ? "admin" : "member", + globalRole: this.initialAdminAccountIds.has(accountId) ? "admin" : "member", groups: normalizeGroups(principal.groups), createdAt, updatedAt: createdAt, ...this.profileFields(principal), }; this.users.set(user.id, user); + this.userIdByAccountId.set(accountId, user.id); this.userIdByEmployeeId.set(employeeId, user.id); return user; } diff --git a/packages/platformclaw-control-plane/src/sqlite-store.test.ts b/packages/platformclaw-control-plane/src/sqlite-store.test.ts index 541940a0465..d46a8cb4fb3 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store.test.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store.test.ts @@ -63,6 +63,37 @@ afterEach(() => { }); describe("SqliteControlPlaneStore", () => { + it("keeps account and employee IDs distinct when deriving a personal agent", async () => { + const store = createStore(); + const created = await store.upsertPrincipal( + principal("employee-123", { accountId: "seungon.jung" }), + 1_000, + ); + const reservation = await store.reservePersonalAgent(created.user.id, 2_000); + + expect(created.user).toMatchObject({ + accountId: "seungon.jung", + employeeId: "employee-123", + }); + expect(reservation.binding.agentId).toBe("seungon_jung"); + store.close(); + }); + + it("preserves the canonical account when a linked provider omits accountId", async () => { + const store = createStore(); + const ldap = await store.upsertPrincipal( + principal("employee-123", { accountId: "account.name" }), + 1_000, + ); + const saml = await store.upsertPrincipal( + principal("employee-123", { provider: "saml", subject: "saml-subject-1" }), + 2_000, + ); + + expect(saml.user).toMatchObject({ id: ldap.user.id, accountId: "account.name" }); + store.close(); + }); + it.skipIf(process.platform === "win32")( "secures the database directory and WAL files before storing control-plane data", () => { diff --git a/packages/platformclaw-control-plane/src/sqlite-store.ts b/packages/platformclaw-control-plane/src/sqlite-store.ts index 9a2a26e88b9..f35aaca791d 100644 --- a/packages/platformclaw-control-plane/src/sqlite-store.ts +++ b/packages/platformclaw-control-plane/src/sqlite-store.ts @@ -171,6 +171,10 @@ function normalizeEmployeeId(employeeId: string): string { return required(employeeId, "principal.employeeId").toLowerCase(); } +function normalizeAccountId(accountId: string): string { + return required(accountId, "principal.accountId").toLowerCase(); +} + function normalizeScopeName(name: string): string { return required(name, "scope.name").toLocaleLowerCase("en-US"); } @@ -315,6 +319,9 @@ export class SqliteControlPlaneStore implements ControlPlaneStore, ControlPlaneM return runImmediateTransaction(this.db, () => { const subject = required(principal.subject, "principal.subject"); const employeeId = normalizeEmployeeId(principal.employeeId); + const requestedAccountId = principal.accountId + ? normalizeAccountId(principal.accountId) + : undefined; const identity = this.selectIdentity(principal.provider, subject); if (identity) { if ( @@ -328,6 +335,12 @@ export class SqliteControlPlaneStore implements ControlPlaneStore, ControlPlaneM ); } const user = this.requireUserRow(identity.user_id); + if (requestedAccountId && requestedAccountId !== user.account_id) { + throw new ControlPlaneConflictError( + "account_id_mismatch", + `identity account id disagrees with the canonical user: ${principal.provider}:${subject}`, + ); + } if (employeeId !== user.employee_id) { if (identity.employee_id !== user.employee_id) { throw new ControlPlaneConflictError( @@ -362,11 +375,21 @@ export class SqliteControlPlaneStore implements ControlPlaneStore, ControlPlaneM }; } - let user = this.selectUserByEmployeeId(employeeId); + const accountOwner = requestedAccountId + ? this.selectUserByAccountId(requestedAccountId) + : undefined; + const employeeOwner = this.selectUserByEmployeeId(employeeId); + if (accountOwner && employeeOwner && accountOwner.id !== employeeOwner.id) { + throw new ControlPlaneConflictError( + "account_id_conflict", + `account id and employee id belong to different users: ${requestedAccountId}`, + ); + } + let user = accountOwner ?? employeeOwner; + const accountId = requestedAccountId ?? user?.account_id ?? employeeId; const createdUser = !user; if (!user) { const id = this.idFactory.nextUserId(); - const accountId = employeeId.toLowerCase(); executeSync( this.db, this.query.insertInto("platform_users").values({ @@ -386,6 +409,12 @@ export class SqliteControlPlaneStore implements ControlPlaneStore, ControlPlaneM ); user = this.requireUserRow(id); } else { + if (requestedAccountId && user.account_id !== requestedAccountId) { + throw new ControlPlaneConflictError( + "account_id_mismatch", + `account id disagrees with the canonical user: ${requestedAccountId}`, + ); + } this.updateUserFromPrincipal(user.id, employeeId, principal, authenticatedAt); this.bootstrapExistingAdminIfNeeded(user.id, authenticatedAt); } @@ -1107,6 +1136,13 @@ export class SqliteControlPlaneStore implements ControlPlaneStore, ControlPlaneM ); } + private selectUserByAccountId(accountId: string): PlatformUserRow | undefined { + return takeFirstSync( + this.db, + this.query.selectFrom("platform_users").selectAll().where("account_id", "=", accountId), + ); + } + private requireUserRow(userId: string): PlatformUserRow { const row = this.selectUserById(userId); if (!row) {