From d3c12d8a01d015c5227f29694e596e5c2844645f Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 20 Jul 2026 17:52:18 +1000 Subject: [PATCH 1/2] feat: add runtime reauthentication endpoint --- src/app/api/runtimes/[id]/route.test.ts | 212 +++++++++++++++++++++++- src/app/api/runtimes/[id]/route.ts | 167 +++++++++++++++++++ 2 files changed, 378 insertions(+), 1 deletion(-) diff --git a/src/app/api/runtimes/[id]/route.test.ts b/src/app/api/runtimes/[id]/route.test.ts index a9eebf4..37f9516 100644 --- a/src/app/api/runtimes/[id]/route.test.ts +++ b/src/app/api/runtimes/[id]/route.test.ts @@ -8,6 +8,9 @@ type RuntimeRow = { ownerUserId: string | null; ownerCompanyId: string | null; isPrimary: boolean; + runtimeType: string; + gatewayUrl: string; + metadata: Record; }; const mocks = vi.hoisted(() => ({ @@ -15,6 +18,17 @@ const mocks = vi.hoisted(() => ({ cleanup: vi.fn(), deleteManagedResources: vi.fn(), listManagedResources: vi.fn(), + probeGateway: vi.fn(), + sealDeviceKey: vi.fn((_value: string) => "sealed-device-key"), + storeDeviceAuth: vi.fn(( + metadata: Record, + _deviceId: string, + _deviceAuth: unknown, + ) => ({ + ...metadata, + openclawDeviceAuth: { stored: true }, + })), + sealAuthToken: vi.fn((value: string) => `sealed-token:${value}`), deletedTables: [] as string[], updatedTables: [] as string[], updatedValues: [] as Record[], @@ -25,6 +39,12 @@ const mocks = vi.hoisted(() => ({ ownerUserId: "user-1", ownerCompanyId: null, isPrimary: false, + runtimeType: "openclaw", + gatewayUrl: "ws://localhost:18789", + metadata: { + devicePrivateKeyPem: "sealed-existing-key", + retained: true, + }, } as RuntimeRow, })); @@ -38,6 +58,11 @@ vi.mock("@/db/schema", () => ({ ownerUserId: "companyRuntimes.ownerUserId", ownerCompanyId: "companyRuntimes.ownerCompanyId", isPrimary: "companyRuntimes.isPrimary", + authToken: "companyRuntimes.authToken", + metadata: "companyRuntimes.metadata", + status: "companyRuntimes.status", + lastPing: "companyRuntimes.lastPing", + updatedAt: "companyRuntimes.updatedAt", }, cronJobs: { table: "cronJobs", id: "cronJobs.id" }, })); @@ -94,7 +119,26 @@ vi.mock("@/lib/runtime-api-dto", () => ({ toBrowserSafeRuntime: (runtime: unknown) => runtime, })); -import { DELETE } from "./route"; +vi.mock("@/lib/gateway-client", () => ({ + probeGateway: (...args: unknown[]) => mocks.probeGateway(...args), + resolveDeviceIdentity: () => ({ deviceId: "device-1" }), +})); + +vi.mock("@/lib/runtime-device-auth", () => ({ + sealRuntimeDevicePrivateKey: (value: string) => mocks.sealDeviceKey(value), + storeRuntimeDeviceAuth: ( + metadata: Record, + deviceId: string, + deviceAuth: unknown, + ) => mocks.storeDeviceAuth(metadata, deviceId, deviceAuth), +})); + +vi.mock("@/lib/runtime-token-crypto", () => ({ + isEncryptedRuntimeAuthToken: (value: string) => value.startsWith("sealed-"), + sealRuntimeAuthToken: (value: string) => mocks.sealAuthToken(value), +})); + +import { DELETE, PATCH } from "./route"; function makeRequest(cleanup?: string) { const url = new URL("http://localhost/api/runtimes/runtime-1"); @@ -102,6 +146,14 @@ function makeRequest(cleanup?: string) { return new NextRequest(url, { method: "DELETE" }); } +function makeReauthenticateRequest(body: Record) { + return new NextRequest("http://localhost/api/runtimes/runtime-1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + function context() { return { params: Promise.resolve({ id: "runtime-1" }) }; } @@ -160,3 +212,161 @@ describe("DELETE /api/runtimes/[id]", () => { expect(mocks.deleteManagedResources).not.toHaveBeenCalled(); }); }); + +describe("PATCH /api/runtimes/[id] reauthenticate", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.updatedTables.length = 0; + mocks.updatedValues.length = 0; + mocks.access.mockResolvedValue({ userId: "user-1" }); + mocks.runtime.ownerType = "user"; + mocks.runtime.ownerUserId = "user-1"; + mocks.runtime.runtimeType = "openclaw"; + mocks.runtime.gatewayUrl = "ws://localhost:18789"; + mocks.runtime.metadata = { + devicePrivateKeyPem: "sealed-existing-key", + retained: true, + }; + }); + + it("verifies the replacement before atomically storing it", async () => { + mocks.probeGateway.mockResolvedValue({ + ok: true, + agents: [], + models: [], + devicePrivateKeyPem: "raw-private-key", + }); + + const response = await PATCH(makeReauthenticateRequest({ + action: "reauthenticate", + authToken: "new-token", + }), context()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + authenticationMode: "local-shared-token", + }); + expect(mocks.probeGateway).toHaveBeenCalledWith( + "ws://localhost:18789", + "new-token", + "sealed-existing-key", + ); + expect(mocks.updatedTables).toEqual(["companyRuntimes"]); + expect(mocks.updatedValues).toHaveLength(1); + expect(mocks.updatedValues[0]).toMatchObject({ + authToken: "sealed-token:new-token", + metadata: { + devicePrivateKeyPem: "sealed-device-key", + retained: true, + }, + status: "connected", + }); + }); + + it("retains the approved device token returned by a paired gateway", async () => { + const deviceAuth = { + token: "device-token", + role: "operator", + scopes: ["operator.admin"], + }; + mocks.probeGateway.mockResolvedValue({ + ok: true, + agents: [], + models: [], + devicePrivateKeyPem: "raw-private-key", + deviceAuth, + }); + + const response = await PATCH(makeReauthenticateRequest({ + action: "reauthenticate", + authToken: "new-token", + }), context()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + authenticationMode: "paired-device", + }); + expect(mocks.storeDeviceAuth).toHaveBeenCalledWith( + expect.objectContaining({ devicePrivateKeyPem: "sealed-device-key" }), + "device-1", + deviceAuth, + ); + expect(mocks.updatedValues[0]).toMatchObject({ + metadata: { openclawDeviceAuth: { stored: true } }, + }); + }); + + it("keeps the stored credential unchanged when verification fails", async () => { + mocks.probeGateway.mockResolvedValue({ + ok: false, + error: "unauthorized: gateway token mismatch", + agents: [], + models: [], + }); + + const response = await PATCH(makeReauthenticateRequest({ + action: "reauthenticate", + authToken: "wrong-token", + }), context()); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + code: "RUNTIME_REAUTHENTICATION_FAILED", + }); + expect(mocks.updatedTables).toEqual([]); + expect(mocks.sealAuthToken).not.toHaveBeenCalled(); + }); + + it("returns only a sealed identity for a pairing retry", async () => { + mocks.runtime.metadata = { retained: true }; + mocks.probeGateway.mockResolvedValue({ + ok: false, + error: "pairing_required", + pairingInstructions: "Approve the pending request", + agents: [], + models: [], + devicePrivateKeyPem: "raw-pending-private-key", + }); + + const response = await PATCH(makeReauthenticateRequest({ + action: "reauthenticate", + authToken: "new-token", + }), context()); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body).toMatchObject({ + code: "PAIRING_REQUIRED", + pairingRequired: true, + deviceKeyPem: "sealed-device-key", + }); + expect(JSON.stringify(body)).not.toContain("raw-pending-private-key"); + expect(mocks.updatedTables).toEqual([]); + }); + + it("rejects a browser-supplied plaintext device key", async () => { + const response = await PATCH(makeReauthenticateRequest({ + action: "reauthenticate", + authToken: "new-token", + deviceKeyPem: "-----BEGIN PRIVATE KEY-----", + }), context()); + + expect(response.status).toBe(400); + expect(mocks.probeGateway).not.toHaveBeenCalled(); + expect(mocks.updatedTables).toEqual([]); + }); + + it("does not probe a runtime owned by another user", async () => { + mocks.runtime.ownerUserId = "user-2"; + + const response = await PATCH(makeReauthenticateRequest({ + action: "reauthenticate", + authToken: "new-token", + }), context()); + + expect(response.status).toBe(403); + expect(mocks.probeGateway).not.toHaveBeenCalled(); + expect(mocks.updatedTables).toEqual([]); + }); +}); diff --git a/src/app/api/runtimes/[id]/route.ts b/src/app/api/runtimes/[id]/route.ts index edeabf1..11c2106 100644 --- a/src/app/api/runtimes/[id]/route.ts +++ b/src/app/api/runtimes/[id]/route.ts @@ -11,8 +11,19 @@ import { listRuntimeManagedResources, } from "@/lib/runtime-managed-resources"; import { toBrowserSafeRuntime } from "@/lib/runtime-api-dto"; +import { probeGateway, resolveDeviceIdentity } from "@/lib/gateway-client"; +import { + sealRuntimeDevicePrivateKey, + storeRuntimeDeviceAuth, +} from "@/lib/runtime-device-auth"; +import { + isEncryptedRuntimeAuthToken, + sealRuntimeAuthToken, +} from "@/lib/runtime-token-crypto"; export const dynamic = "force-dynamic"; +const MAX_AUTH_TOKEN_LENGTH = 16_384; +const MAX_SEALED_DEVICE_KEY_LENGTH = 16_384; export async function GET( _request: Request, @@ -59,6 +70,162 @@ export async function GET( } } +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + if (!db) { + return NextResponse.json({ error: "Database not available" }, { status: 503 }); + } + + const access = await getAgentAccessContext(); + if (!access.userId) { + return NextResponse.json({ error: "Authentication required" }, { status: 401 }); + } + + const body = await request.json().catch(() => null); + if (!body || body.action !== "reauthenticate") { + return NextResponse.json( + { error: 'Unsupported action. Use "reauthenticate".' }, + { status: 400 } + ); + } + + const authToken = typeof body.authToken === "string" ? body.authToken.trim() : ""; + if (!authToken) { + return NextResponse.json({ error: "Gateway auth token is required" }, { status: 400 }); + } + if (authToken.length > MAX_AUTH_TOKEN_LENGTH) { + return NextResponse.json({ error: "Gateway auth token is too long" }, { status: 400 }); + } + + const suppliedDeviceKey = typeof body.deviceKeyPem === "string" + ? body.deviceKeyPem.trim() + : ""; + if ( + suppliedDeviceKey + && ( + suppliedDeviceKey.length > MAX_SEALED_DEVICE_KEY_LENGTH + || !isEncryptedRuntimeAuthToken(suppliedDeviceKey) + ) + ) { + return NextResponse.json({ error: "Invalid pending device identity" }, { status: 400 }); + } + + const { id } = await params; + const [runtime] = await withRetry(() => + db! + .select() + .from(companyRuntimes) + .where(eq(companyRuntimes.id, id)) + .limit(1) + ); + + if (!runtime) { + return NextResponse.json({ error: "Runtime not found" }, { status: 404 }); + } + + const canReauthenticate = runtime.ownerType === "user" + ? runtime.ownerUserId === access.userId + : canManageCompanyOwnedAgent(access, runtime.ownerCompanyId ?? runtime.companyId); + if (!canReauthenticate) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (runtime.runtimeType !== "openclaw") { + return NextResponse.json( + { error: "Re-authentication is currently supported only for OpenClaw runtimes" }, + { status: 400 } + ); + } + if (!runtime.gatewayUrl) { + return NextResponse.json({ error: "Runtime has no gateway URL" }, { status: 400 }); + } + + const metadata = runtime.metadata && typeof runtime.metadata === "object" && !Array.isArray(runtime.metadata) + ? { ...(runtime.metadata as Record) } + : {}; + const storedDeviceKey = typeof metadata.devicePrivateKeyPem === "string" + ? metadata.devicePrivateKeyPem + : undefined; + const result = await probeGateway( + runtime.gatewayUrl, + authToken, + storedDeviceKey ?? (suppliedDeviceKey || undefined) + ); + + if (!result.ok) { + if (result.error === "pairing_required" && result.devicePrivateKeyPem) { + return NextResponse.json( + { + error: "OpenClaw device approval is required", + code: "PAIRING_REQUIRED", + pairingRequired: true, + pairingInstructions: result.pairingInstructions, + deviceKeyPem: sealRuntimeDevicePrivateKey(result.devicePrivateKeyPem), + }, + { status: 409 } + ); + } + + const message = result.error || "Failed to authenticate with OpenClaw"; + const status = /unauthorized|token mismatch|auth token/i.test(message) ? 401 : 502; + return NextResponse.json( + { error: message, code: "RUNTIME_REAUTHENTICATION_FAILED" }, + { status } + ); + } + + if (!result.devicePrivateKeyPem) { + return NextResponse.json( + { error: "OpenClaw did not return a reusable device identity" }, + { status: 502 } + ); + } + + let nextMetadata: Record = { + ...metadata, + devicePrivateKeyPem: sealRuntimeDevicePrivateKey(result.devicePrivateKeyPem), + }; + if (result.deviceAuth) { + const device = resolveDeviceIdentity(result.devicePrivateKeyPem); + nextMetadata = storeRuntimeDeviceAuth( + nextMetadata, + device.deviceId, + result.deviceAuth, + ); + } + + const sealedAuthToken = sealRuntimeAuthToken(authToken); + if (!sealedAuthToken) { + return NextResponse.json({ error: "Gateway auth token is required" }, { status: 400 }); + } + + const now = new Date(); + await withRetry(() => + db! + .update(companyRuntimes) + .set({ + authToken: sealedAuthToken, + metadata: nextMetadata, + status: "connected", + lastPing: now, + updatedAt: now, + }) + .where(eq(companyRuntimes.id, id)) + ); + + return NextResponse.json({ + ok: true, + authenticationMode: result.deviceAuth ? "paired-device" : "local-shared-token", + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> } From 173900863c427823357ca2bc1b84b4e084f8d0ee Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 20 Jul 2026 17:52:25 +1000 Subject: [PATCH 2/2] feat: add personal runtime reauthentication UI --- src/app/settings/page.tsx | 144 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 3cca413..a4ea385 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -25,6 +25,11 @@ interface FlashMessage { text: string; } +interface RuntimeReauthFeedback { + type: "error" | "pairing"; + text: string; +} + interface RuntimeRecord { id: string; runtimeType: string; @@ -61,6 +66,11 @@ export default function SettingsPage() { const [loadingRuntimes, setLoadingRuntimes] = useState(true); const [deletingRuntimeId, setDeletingRuntimeId] = useState(null); const [reconcilingRuntime, setReconcilingRuntime] = useState(null); + const [reauthRuntimeId, setReauthRuntimeId] = useState(null); + const [reauthToken, setReauthToken] = useState(""); + const [reauthDeviceKey, setReauthDeviceKey] = useState(null); + const [reauthFeedback, setReauthFeedback] = useState(null); + const [reauthenticatingRuntimeId, setReauthenticatingRuntimeId] = useState(null); const [heartbeatSecret, setHeartbeatSecret] = useState(null); const [heartbeatSecretLoading, setHeartbeatSecretLoading] = useState(false); const [heartbeatSecretRevealed, setHeartbeatSecretRevealed] = useState(false); @@ -163,6 +173,70 @@ export default function SettingsPage() { } } + function openRuntimeReauthentication(runtimeId: string) { + setReauthRuntimeId(runtimeId); + setReauthToken(""); + setReauthDeviceKey(null); + setReauthFeedback(null); + setMessage(null); + } + + function closeRuntimeReauthentication() { + setReauthRuntimeId(null); + setReauthToken(""); + setReauthDeviceKey(null); + setReauthFeedback(null); + } + + async function handleReauthenticateRuntime(runtimeId: string) { + const token = reauthToken.trim(); + if (!token || reauthenticatingRuntimeId !== null) return; + + setReauthenticatingRuntimeId(runtimeId); + setReauthFeedback(null); + setMessage(null); + try { + const res = await fetch(`/api/runtimes/${encodeURIComponent(runtimeId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "reauthenticate", + authToken: token, + ...(reauthDeviceKey ? { deviceKeyPem: reauthDeviceKey } : {}), + }), + }); + const data = await res.json().catch(() => ({})); + + if (res.status === 409 && data.code === "PAIRING_REQUIRED" && data.deviceKeyPem) { + setReauthDeviceKey(data.deviceKeyPem); + setReauthFeedback({ + type: "pairing", + text: "Approve the pending CrewCMD device in OpenClaw, then retry without closing this form.", + }); + return; + } + if (!res.ok) { + throw new Error(data.error || "Failed to re-authenticate runtime"); + } + + closeRuntimeReauthentication(); + setMessage({ + type: "success", + text: data.authenticationMode === "paired-device" + ? "OpenClaw runtime re-authenticated with its paired device identity." + : "Local OpenClaw gateway token verified and updated. No device approval was required.", + }); + await loadRuntimes(); + } catch (error) { + setReauthFeedback({ + type: "error", + text: error instanceof Error ? error.message : "Failed to re-authenticate runtime", + }); + } finally { + setReauthenticatingRuntimeId(null); + } + } + async function fetchHeartbeatSecret() { setHeartbeatSecretLoading(true); try { @@ -596,6 +670,16 @@ export default function SettingsPage() { />
+ {runtime.runtimeType === "openclaw" ? ( + + ) : null}
+ {reauthRuntimeId === runtime.id ? ( +
+
+
+

Replace gateway credential

+

+ CrewCMD verifies this token with the existing gateway before replacing the encrypted credential. +

+
+ +
+
+ setReauthToken(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void handleReauthenticateRuntime(runtime.id); + }} + placeholder="OpenClaw gateway auth token" + autoComplete="new-password" + className={`${inputClassName} font-mono`} + autoFocus + /> + +
+ {reauthFeedback ? ( +
+

{reauthFeedback.text}

+ {reauthFeedback.type === "pairing" ? ( + + openclaw devices approve --latest + + ) : null} +
+ ) : null} +
+ ) : null} ); })