From 5e6e1165c363eb77742b083c5aea731f1e3877a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:27:39 +0000 Subject: [PATCH] Add agent auth MVP: first-class agent identity with scoped capabilities Co-Authored-By: madison@stack-auth.com --- apps/backend/package.json | 1 + .../migration.sql | 100 ++++++++ .../tests/agent-auth-mvp.ts | 165 ++++++++++++ apps/backend/prisma/schema.prisma | 115 +++++++++ apps/backend/scripts/agent-auth-demo.ts | 237 ++++++++++++++++++ .../.well-known/agent-configuration/route.ts | 54 ++++ .../agent-auth/agents/[agentId]/route.ts | 52 ++++ .../agent-auth/agents/register/route.ts | 177 +++++++++++++ .../app/api/latest/agent-auth/agents/route.ts | 42 ++++ .../agent-auth/capabilities/execute/route.ts | 167 ++++++++++++ .../src/lib/agent-auth/capabilities.ts | 235 +++++++++++++++++ apps/backend/src/lib/agent-auth/constants.ts | 17 ++ apps/backend/src/lib/agent-auth/jwt.ts | 192 ++++++++++++++ apps/backend/src/lib/agent-auth/requests.ts | 33 +++ .../src/lib/agent-auth/serialization.ts | 78 ++++++ apps/backend/src/lib/events.tsx | 44 ++++ .../agent-auth-app/page-client.tsx | 198 +++++++++++++++ .../[projectId]/agent-auth-app/page.tsx | 5 + apps/dashboard/src/lib/apps-frontend.tsx | 15 ++ apps/e2e/tests/backend/agent-auth.test.ts | 191 ++++++++++++++ docs-mintlify/openapi/admin.json | 173 +++++++++++++ docs-mintlify/openapi/client.json | 43 ++++ docs-mintlify/openapi/server.json | 173 +++++++++++++ .../docs-json.generated.ts | 11 +- packages/shared/src/apps/apps-config.ts | 7 + 25 files changed, 2521 insertions(+), 4 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/migration.sql create mode 100644 apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/tests/agent-auth-mvp.ts create mode 100644 apps/backend/scripts/agent-auth-demo.ts create mode 100644 apps/backend/src/app/.well-known/agent-configuration/route.ts create mode 100644 apps/backend/src/app/api/latest/agent-auth/agents/[agentId]/route.ts create mode 100644 apps/backend/src/app/api/latest/agent-auth/agents/register/route.ts create mode 100644 apps/backend/src/app/api/latest/agent-auth/agents/route.ts create mode 100644 apps/backend/src/app/api/latest/agent-auth/capabilities/execute/route.ts create mode 100644 apps/backend/src/lib/agent-auth/capabilities.ts create mode 100644 apps/backend/src/lib/agent-auth/constants.ts create mode 100644 apps/backend/src/lib/agent-auth/jwt.ts create mode 100644 apps/backend/src/lib/agent-auth/requests.ts create mode 100644 apps/backend/src/lib/agent-auth/serialization.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page.tsx create mode 100644 apps/e2e/tests/backend/agent-auth.test.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 06aae6931..d21af5c0a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,6 +38,7 @@ "db:seed": "pnpm run with-env:dev tsx scripts/db-migrations.ts seed", "db:init": "pnpm run with-env:dev tsx scripts/db-migrations.ts init", "db:migrate": "pnpm run with-env:dev tsx scripts/db-migrations.ts migrate", + "agent-auth-demo": "pnpm run with-env:dev tsx scripts/agent-auth-demo.ts", "db:backfill-bulldozer-from-prisma": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-bulldozer-from-prisma", "db:backfill-internal-free-plans": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-internal-free-plans", "db:regen-internal-subscriptions-to-latest": "pnpm run with-env:dev tsx scripts/db-migrations.ts regen-internal-subscriptions-to-latest", diff --git a/apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/migration.sql b/apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/migration.sql new file mode 100644 index 000000000..93b4299dc --- /dev/null +++ b/apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/migration.sql @@ -0,0 +1,100 @@ +-- CreateEnum +CREATE TYPE "AgentHostStatus" AS ENUM ('ACTIVE', 'PENDING', 'REVOKED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "AgentMode" AS ENUM ('DELEGATED', 'AUTONOMOUS'); + +-- CreateEnum +CREATE TYPE "AgentStatus" AS ENUM ('PENDING', 'ACTIVE', 'EXPIRED', 'REVOKED', 'REJECTED', 'CLAIMED'); + +-- CreateEnum +CREATE TYPE "AgentCapabilityGrantStatus" AS ENUM ('ACTIVE', 'PENDING', 'DENIED'); + +-- CreateTable +CREATE TABLE "AgentHost" ( + "tenancyId" UUID NOT NULL, + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "projectUserId" UUID, + "name" TEXT NOT NULL, + "publicJwk" JSONB NOT NULL, + "jwkThumbprint" TEXT NOT NULL, + "status" "AgentHostStatus" NOT NULL DEFAULT 'PENDING', + "defaultCapabilities" TEXT[] DEFAULT ARRAY[]::TEXT[], + + CONSTRAINT "AgentHost_pkey" PRIMARY KEY ("tenancyId", "id") +); + +-- CreateTable +CREATE TABLE "Agent" ( + "tenancyId" UUID NOT NULL, + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "hostId" UUID NOT NULL, + "name" TEXT NOT NULL, + "mode" "AgentMode" NOT NULL, + "projectUserId" UUID, + "publicJwk" JSONB NOT NULL, + "jwkThumbprint" TEXT NOT NULL, + "status" "AgentStatus" NOT NULL DEFAULT 'PENDING', + "reason" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "maxLifetimeEndsAt" TIMESTAMP(3) NOT NULL, + "absoluteLifetimeEndsAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Agent_pkey" PRIMARY KEY ("tenancyId", "id") +); + +-- CreateTable +CREATE TABLE "AgentCapabilityGrant" ( + "tenancyId" UUID NOT NULL, + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "agentId" UUID NOT NULL, + "capability" TEXT NOT NULL, + "status" "AgentCapabilityGrantStatus" NOT NULL DEFAULT 'PENDING', + "constraints" JSONB, + "grantedByProjectUserId" UUID, + "reason" TEXT, + "expiresAt" TIMESTAMP(3), + + CONSTRAINT "AgentCapabilityGrant_pkey" PRIMARY KEY ("tenancyId", "id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentHost_tenancyId_jwkThumbprint_key" ON "AgentHost"("tenancyId", "jwkThumbprint"); + +-- CreateIndex +CREATE UNIQUE INDEX "Agent_tenancyId_jwkThumbprint_key" ON "Agent"("tenancyId", "jwkThumbprint"); + +-- CreateIndex +CREATE INDEX "Agent_tenancyId_hostId_idx" ON "Agent"("tenancyId", "hostId"); + +-- CreateIndex +CREATE INDEX "Agent_tenancyId_projectUserId_idx" ON "Agent"("tenancyId", "projectUserId"); + +-- CreateIndex +CREATE INDEX "AgentCapabilityGrant_tenancyId_agentId_idx" ON "AgentCapabilityGrant"("tenancyId", "agentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentCapabilityGrant_tenancyId_agentId_capability_key" ON "AgentCapabilityGrant"("tenancyId", "agentId", "capability"); + +-- AddForeignKey +ALTER TABLE "AgentHost" ADD CONSTRAINT "AgentHost_tenancyId_projectUserId_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Agent" ADD CONSTRAINT "Agent_tenancyId_hostId_fkey" FOREIGN KEY ("tenancyId", "hostId") REFERENCES "AgentHost"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Agent" ADD CONSTRAINT "Agent_tenancyId_projectUserId_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentCapabilityGrant" ADD CONSTRAINT "AgentCapabilityGrant_tenancyId_agentId_fkey" FOREIGN KEY ("tenancyId", "agentId") REFERENCES "Agent"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentCapabilityGrant" ADD CONSTRAINT "AgentCapabilityGrant_tenancyId_grantedByProjectUserId_fkey" FOREIGN KEY ("tenancyId", "grantedByProjectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/tests/agent-auth-mvp.ts b/apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/tests/agent-auth-mvp.ts new file mode 100644 index 000000000..51f4a9c54 --- /dev/null +++ b/apps/backend/prisma/migrations/20260707225521_add_agent_auth_mvp/tests/agent-auth-mvp.ts @@ -0,0 +1,165 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + const projectUserId = randomUUID(); + + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Agent Auth Test Project', '', false) + `; + await sql` + INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") + VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue") + `; + await sql` + INSERT INTO "ProjectUser" ( + "projectUserId", + "tenancyId", + "mirroredProjectId", + "mirroredBranchId", + "createdAt", + "updatedAt", + "lastActiveAt" + ) + VALUES (${projectUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW()) + `; + + return { projectId, tenancyId, projectUserId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const enumRows = await sql` + SELECT typname + FROM pg_type + WHERE typname IN ('AgentHostStatus', 'AgentMode', 'AgentStatus', 'AgentCapabilityGrantStatus') + ORDER BY typname + `; + expect(enumRows.map((row) => row.typname)).toEqual([ + "AgentCapabilityGrantStatus", + "AgentHostStatus", + "AgentMode", + "AgentStatus", + ]); + + const hostPublicJwk = { kty: "OKP", crv: "Ed25519", x: "host-public-key" }; + const agentPublicJwk = { kty: "OKP", crv: "Ed25519", x: "agent-public-key" }; + + await sql` + INSERT INTO "AgentHost" ( + "tenancyId", + "id", + "createdAt", + "updatedAt", + "projectUserId", + "name", + "publicJwk", + "jwkThumbprint", + "status" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + ${randomUUID()}::uuid, + NOW(), + NOW(), + ${ctx.projectUserId}::uuid, + 'Host', + ${sql.json(hostPublicJwk)}, + 'host-thumbprint', + 'ACTIVE'::"AgentHostStatus" + ) + `; + + await sql` + INSERT INTO "Agent" ( + "tenancyId", + "id", + "createdAt", + "updatedAt", + "hostId", + "name", + "mode", + "projectUserId", + "publicJwk", + "jwkThumbprint", + "status", + "expiresAt", + "maxLifetimeEndsAt", + "absoluteLifetimeEndsAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + ${randomUUID()}::uuid, + NOW(), + NOW(), + (SELECT "id" FROM "AgentHost" WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "jwkThumbprint" = 'host-thumbprint'), + 'Agent', + 'DELEGATED'::"AgentMode", + ${ctx.projectUserId}::uuid, + ${sql.json(agentPublicJwk)}, + 'agent-thumbprint', + 'ACTIVE'::"AgentStatus", + NOW() + INTERVAL '30 minutes', + NOW() + INTERVAL '24 hours', + NOW() + INTERVAL '7 days' + ) + `; + + await sql` + INSERT INTO "AgentCapabilityGrant" ( + "tenancyId", + "id", + "createdAt", + "updatedAt", + "agentId", + "capability", + "status", + "constraints", + "grantedByProjectUserId", + "reason" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + ${randomUUID()}::uuid, + NOW(), + NOW(), + (SELECT "id" FROM "Agent" WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "jwkThumbprint" = 'agent-thumbprint'), + 'list_users', + 'ACTIVE'::"AgentCapabilityGrantStatus", + ${sql.json({ limit: { min: 1, max: 10 } })}, + ${ctx.projectUserId}::uuid, + 'Test grant' + ) + `; + + const countsBeforeDelete = await sql` + SELECT + (SELECT COUNT(*) FROM "AgentHost" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS host_count, + (SELECT COUNT(*) FROM "Agent" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS agent_count, + (SELECT COUNT(*) FROM "AgentCapabilityGrant" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS grant_count + `; + expect(countsBeforeDelete).toHaveLength(1); + expect(Number(countsBeforeDelete[0].host_count)).toBe(1); + expect(Number(countsBeforeDelete[0].agent_count)).toBe(1); + expect(Number(countsBeforeDelete[0].grant_count)).toBe(1); + + await sql` + DELETE FROM "ProjectUser" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "projectUserId" = ${ctx.projectUserId}::uuid + `; + + const countsAfterDelete = await sql` + SELECT + (SELECT COUNT(*) FROM "AgentHost" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS host_count, + (SELECT COUNT(*) FROM "Agent" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS agent_count, + (SELECT COUNT(*) FROM "AgentCapabilityGrant" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS grant_count + `; + expect(countsAfterDelete).toHaveLength(1); + expect(Number(countsAfterDelete[0].host_count)).toBe(0); + expect(Number(countsAfterDelete[0].agent_count)).toBe(0); + expect(Number(countsAfterDelete[0].grant_count)).toBe(0); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7d7f55b62..8b283a1d0 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -334,6 +334,9 @@ model ProjectUser { teamMembers TeamMember[] contactChannels ContactChannel[] authMethods AuthMethod[] + agentHosts AgentHost[] @relation("AgentHostProjectUser") + agents Agent[] @relation("AgentProjectUser") + agentCapabilityGrantsGranted AgentCapabilityGrant[] @relation("AgentCapabilityGrantGrantedByProjectUser") // some backlinks for the unique constraints on some auth methods passwordAuthMethod PasswordAuthMethod[] @@ -783,6 +786,118 @@ model ProjectApiKey { @@id([tenancyId, id]) } +enum AgentHostStatus { + ACTIVE + PENDING + REVOKED + REJECTED +} + +enum AgentMode { + DELEGATED + AUTONOMOUS +} + +enum AgentStatus { + PENDING + ACTIVE + EXPIRED + REVOKED + REJECTED + CLAIMED +} + +enum AgentCapabilityGrantStatus { + ACTIVE + PENDING + DENIED +} + +model AgentHost { + tenancyId String @db.Uuid + + id String @default(uuid()) @db.Uuid + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime? + + projectUserId String? @db.Uuid + projectUser ProjectUser? @relation("AgentHostProjectUser", fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) + + name String + publicJwk Json + jwkThumbprint String + status AgentHostStatus @default(PENDING) + defaultCapabilities String[] @default([]) + + agents Agent[] + + @@id([tenancyId, id]) + @@unique([tenancyId, jwkThumbprint]) +} + +model Agent { + tenancyId String @db.Uuid + + id String @default(uuid()) @db.Uuid + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime? + + hostId String @db.Uuid + host AgentHost @relation(fields: [tenancyId, hostId], references: [tenancyId, id], onDelete: Cascade) + + name String + mode AgentMode + + projectUserId String? @db.Uuid + projectUser ProjectUser? @relation("AgentProjectUser", fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) + + publicJwk Json + jwkThumbprint String + status AgentStatus @default(PENDING) + reason String? + + expiresAt DateTime + maxLifetimeEndsAt DateTime + absoluteLifetimeEndsAt DateTime + + capabilityGrants AgentCapabilityGrant[] + + @@id([tenancyId, id]) + @@unique([tenancyId, jwkThumbprint]) + @@index([tenancyId, hostId]) + @@index([tenancyId, projectUserId]) +} + +model AgentCapabilityGrant { + tenancyId String @db.Uuid + + id String @default(uuid()) @db.Uuid + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + agentId String @db.Uuid + agent Agent @relation(fields: [tenancyId, agentId], references: [tenancyId, id], onDelete: Cascade) + + capability String + status AgentCapabilityGrantStatus @default(PENDING) + constraints Json? + + grantedByProjectUserId String? @db.Uuid + grantedByProjectUser ProjectUser? @relation("AgentCapabilityGrantGrantedByProjectUser", fields: [tenancyId, grantedByProjectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) + + reason String? + expiresAt DateTime? + + @@id([tenancyId, id]) + @@unique([tenancyId, agentId, capability]) + @@index([tenancyId, agentId]) +} + enum EmailTemplateType { EMAIL_VERIFICATION PASSWORD_RESET diff --git a/apps/backend/scripts/agent-auth-demo.ts b/apps/backend/scripts/agent-auth-demo.ts new file mode 100644 index 000000000..aefb869b1 --- /dev/null +++ b/apps/backend/scripts/agent-auth-demo.ts @@ -0,0 +1,237 @@ +import * as jose from "jose"; +import { HexclaveAdminApp, HexclaveServerApp } from "@hexclave/next"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { Client } from "pg"; + +const hexclavePortPrefix = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81"); +const backendBaseUrl = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_API_URL", `http://localhost:${hexclavePortPrefix}02`); +const projectId = getEnvVariable("HEXCLAVE_INTERNAL_PROJECT_ID", "internal"); +const publishableClientKey = getEnvVariable("HEXCLAVE_INTERNAL_PROJECT_CLIENT_KEY", getEnvVariable("STACK_INTERNAL_PROJECT_CLIENT_KEY", "")); +const secretServerKey = getEnvVariable("HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY", getEnvVariable("STACK_INTERNAL_PROJECT_SERVER_KEY", "")); +const superSecretAdminKey = getEnvVariable("HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"); + +const registerAudience = new URL("/api/latest/agent-auth/agents/register", backendBaseUrl).toString(); +const executeAudience = new URL("/api/latest/agent-auth/capabilities/execute", backendBaseUrl).toString(); + +async function generateKeyPair() { + const { publicKey, privateKey } = await jose.generateKeyPair("EdDSA", { crv: "Ed25519" }); + const jwk = await jose.exportJWK(publicKey); + const thumbprint = await jose.calculateJwkThumbprint(jwk); + return { + privateKey, + publicJwk: { + ...jwk, + alg: "EdDSA", + crv: "Ed25519", + kid: thumbprint, + use: "sig", + }, + thumbprint, + }; +} + +async function signJwt(options: { + privateKey: Awaited>["privateKey"], + thumbprint: string, + typ: "host+jwt" | "agent+jwt", + audience: string, + expiresInSeconds: number, +}) { + return await new jose.SignJWT({}) + .setProtectedHeader({ alg: "EdDSA", typ: options.typ, kid: options.thumbprint }) + .setIssuer(options.thumbprint) + .setAudience(options.audience) + .setIssuedAt() + .setExpirationTime(`${options.expiresInSeconds}s`) + .sign(options.privateKey); +} + +async function main() { + console.log("Agent Auth demo starting..."); + console.log(`Backend: ${backendBaseUrl}`); + console.log(`Project: ${projectId}`); + + const adminApp = new HexclaveAdminApp({ + baseUrl: backendBaseUrl, + projectId, + publishableClientKey, + secretServerKey, + superSecretAdminKey, + tokenStore: "memory", + redirectMethod: "none", + }); + const serverApp = new HexclaveServerApp({ + baseUrl: backendBaseUrl, + projectId, + publishableClientKey, + secretServerKey, + tokenStore: "memory", + redirectMethod: "none", + }); + const project = await adminApp.getProject(); + + console.log("Enabling Agent Auth for the project..."); + await project.updateConfig({ + apps: { + installed: { + "agent-auth": { + enabled: true, + }, + }, + }, + }); + + const users = await serverApp.listUsers({ + includeRestricted: true, + orderBy: "signedUpAt", + limit: 1, + }); + const linkedUser = users.at(0); + if (linkedUser == null) { + throw new Error("No existing users found in the internal project"); + } + console.log(`Linked real user: ${linkedUser.id} (${linkedUser.primaryEmail ?? "no primary email"})`); + + const hostKeys = await generateKeyPair(); + const agentKeys = await generateKeyPair(); + const hostJwt = await signJwt({ + privateKey: hostKeys.privateKey, + thumbprint: hostKeys.thumbprint, + typ: "host+jwt", + audience: registerAudience, + expiresInSeconds: 300, + }); + + console.log("Registering the host + agent..."); + const registerResponse = await fetch(registerAudience, { + method: "POST", + headers: { + "content-type": "application/json", + "x-stack-access-type": "server", + "x-stack-project-id": projectId, + "x-stack-publishable-client-key": publishableClientKey, + "x-stack-secret-server-key": secretServerKey, + authorization: `Bearer ${hostJwt}`, + }, + body: JSON.stringify({ + host_public_jwk: hostKeys.publicJwk, + agent_public_jwk: agentKeys.publicJwk, + host_name: "demo-host", + agent_name: "demo-agent", + mode: "delegated", + user_id: linkedUser.id, + requested_capabilities: [ + { + name: "list_users", + constraints: { + limit: { + max: 3, + }, + }, + }, + { + name: "get_project_info", + }, + ], + }), + }); + if (!registerResponse.ok) { + throw new Error(`Register failed: ${registerResponse.status} ${await registerResponse.text()}`); + } + const registerBody = await registerResponse.json() as { + agent_id: string, + host_id: string, + agent_thumbprint: string, + host_thumbprint: string, + granted_capabilities: Array<{ name: string, status: string }>, + }; + console.log(`Registered agent ${registerBody.agent_id} under host ${registerBody.host_id}`); + console.log(`Host thumbprint: ${registerBody.host_thumbprint}`); + console.log(`Agent thumbprint: ${registerBody.agent_thumbprint}`); + console.log(`Granted capabilities: ${registerBody.granted_capabilities.map((grant) => `${grant.name}:${grant.status}`).join(", ")}`); + + const agentJwt = await signJwt({ + privateKey: agentKeys.privateKey, + thumbprint: agentKeys.thumbprint, + typ: "agent+jwt", + audience: executeAudience, + expiresInSeconds: 60, + }); + + const execute = async (capability: string, input: Record) => { + const response = await fetch(executeAudience, { + method: "POST", + headers: { + "content-type": "application/json", + "x-stack-access-type": "server", + "x-stack-project-id": projectId, + "x-stack-publishable-client-key": publishableClientKey, + "x-stack-secret-server-key": secretServerKey, + authorization: `Bearer ${agentJwt}`, + }, + body: JSON.stringify({ capability, input }), + }); + if (!response.ok) { + throw new Error(`Capability ${capability} failed: ${response.status} ${await response.text()}`); + } + return await response.json() as { result: unknown }; + }; + + const listUsersResult = await execute("list_users", { limit: 2 }); + console.log("Capability list_users result:"); + console.log(JSON.stringify(listUsersResult.result, null, 2)); + + const projectInfoResult = await execute("get_project_info", {}); + console.log("Capability get_project_info result:"); + console.log(JSON.stringify(projectInfoResult.result, null, 2)); + + const agentsResponse = await fetch(new URL("/api/latest/agent-auth/agents", backendBaseUrl).toString(), { + headers: { + "x-stack-access-type": "admin", + "x-stack-project-id": projectId, + "x-stack-publishable-client-key": publishableClientKey, + "x-stack-secret-server-key": secretServerKey, + "x-stack-super-secret-admin-key": superSecretAdminKey, + }, + }); + if (!agentsResponse.ok) { + throw new Error(`GET agents failed: ${agentsResponse.status} ${await agentsResponse.text()}`); + } + const agentsBody = await agentsResponse.json() as { agents: Array<{ id: string, name: string, status: string, host: { name: string } }> }; + console.log("Registered agents:"); + console.log(JSON.stringify(agentsBody.agents.filter((agent) => agent.id === registerBody.agent_id), null, 2)); + + const auditConnectionString = getEnvVariable( + "HEXCLAVE_DATABASE_CONNECTION_STRING", + getEnvVariable("STACK_DATABASE_CONNECTION_STRING", ""), + ); + if (auditConnectionString === "") { + throw new Error("A local database connection string is required for audit lookup"); + } + const auditClient = new Client({ connectionString: auditConnectionString }); + await auditClient.connect(); + const audit = await auditClient.query<{ + systemEventTypeIds: string[], + data: { actor?: { type?: string, agentId?: string, hostId?: string, userId?: string } } | null, + }>( + ` + SELECT "systemEventTypeIds", data + FROM "Event" + WHERE "systemEventTypeIds" && ARRAY[$1, $2] + ORDER BY "createdAt" DESC + LIMIT 10 + `, + ["$agent-registered", "$agent-capability-executed"], + ); + await auditClient.end(); + console.log("Audit events showing the agent actor envelope:"); + console.log(JSON.stringify(audit.rows, null, 2)); + + console.log("Agent Auth demo complete."); +} + +// eslint-disable-next-line no-restricted-syntax -- standalone script handles its own fatal error path +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/backend/src/app/.well-known/agent-configuration/route.ts b/apps/backend/src/app/.well-known/agent-configuration/route.ts new file mode 100644 index 000000000..3e0ea7675 --- /dev/null +++ b/apps/backend/src/app/.well-known/agent-configuration/route.ts @@ -0,0 +1,54 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { AGENT_AUTH_AGENTS_PATH, AGENT_AUTH_CAPABILITY_EXECUTE_PATH, AGENT_AUTH_DISCOVERY_PATH, AGENT_AUTH_MODES, AGENT_AUTH_PROTOCOL_VERSION, AGENT_AUTH_REGISTER_PATH } from "@/lib/agent-auth/constants"; +import { AGENT_CAPABILITIES } from "@/lib/agent-auth/capabilities"; +import { getAgentAuthTenancy, getHeader } from "@/lib/agent-auth/requests"; + +function buildDiscoveryUrl(baseUrl: string, path: string) { + return new URL(path, new URL(baseUrl).origin).toString(); +} + +export const GET = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: yupString(), + user: jsonSchema, + project: jsonSchema, + }).nullable(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jsonSchema.defined(), + }), + handler: async (_req, fullReq) => { + const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id"); + if (!projectId) { + throw new StatusError(StatusError.BadRequest, "Project id is required"); + } + await getAgentAuthTenancy(projectId); + + return { + statusCode: 200, + bodyType: "json", + body: { + version: AGENT_AUTH_PROTOCOL_VERSION, + provider: new URL(fullReq.url).origin, + issuer: new URL(fullReq.url).origin, + algorithms: ["Ed25519"], + modes: [...AGENT_AUTH_MODES], + endpoints: { + discovery: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_DISCOVERY_PATH), + register: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_REGISTER_PATH), + agents: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_AGENTS_PATH), + execute: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_CAPABILITY_EXECUTE_PATH), + }, + capabilities: Object.values(AGENT_CAPABILITIES).map((capability) => ({ + name: capability.name, + description: capability.description, + })), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/agent-auth/agents/[agentId]/route.ts b/apps/backend/src/app/api/latest/agent-auth/agents/[agentId]/route.ts new file mode 100644 index 000000000..f9eaaa1f1 --- /dev/null +++ b/apps/backend/src/app/api/latest/agent-auth/agents/[agentId]/route.ts @@ -0,0 +1,52 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { serializeAgent } from "@/lib/agent-auth/serialization"; +import { getAgentAuthTenancy, getHeader } from "@/lib/agent-auth/requests"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: yupString().oneOf(["server", "admin"]).defined(), + }).defined(), + params: yupObject({ + agentId: yupString().uuid().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jsonSchema.defined(), + }), + handler: async (req, fullReq) => { + const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id"); + if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required"); + const tenancy = await getAgentAuthTenancy(projectId); + const prisma = await getPrismaClientForTenancy(tenancy); + const agent = await prisma.agent.findUnique({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: req.params.agentId, + }, + }, + include: { + host: true, + projectUser: true, + capabilityGrants: true, + }, + }); + if (!agent) { + throw new StatusError(StatusError.NotFound, "Agent not found"); + } + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + agent: serializeAgent(agent), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/agent-auth/agents/register/route.ts b/apps/backend/src/app/api/latest/agent-auth/agents/register/route.ts new file mode 100644 index 000000000..dd9f92952 --- /dev/null +++ b/apps/backend/src/app/api/latest/agent-auth/agents/register/route.ts @@ -0,0 +1,177 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { getAgentCapability, normalizeGrantConstraints } from "@/lib/agent-auth/capabilities"; +import { AGENT_AUTH_ABSOLUTE_LIFETIME_MILLIS, AGENT_AUTH_MAX_LIFETIME_MILLIS, AGENT_AUTH_MODES, AGENT_AUTH_SESSION_TTL_MILLIS } from "@/lib/agent-auth/constants"; +import { getAgentAuthAudience, getAgentAuthTenancy, getBearerToken, getHeader } from "@/lib/agent-auth/requests"; +import { getAgentAuthJwkThumbprint, normalizeAgentAuthPublicJwk, verifyHostJwt } from "@/lib/agent-auth/jwt"; +import { logEvent, SystemEventTypes } from "@/lib/events"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { jsonSchema, yupArray, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: yupString().oneOf(["server", "admin"]).defined(), + }).defined(), + body: yupObject({ + host_public_jwk: jsonSchema.defined(), + agent_public_jwk: jsonSchema.defined(), + host_name: yupString().defined(), + agent_name: yupString().defined(), + mode: yupString().oneOf([...AGENT_AUTH_MODES]).defined(), + user_id: yupString().uuid().defined(), + requested_capabilities: yupArray( + yupObject({ + name: yupString().defined(), + constraints: jsonSchema.optional(), + }), + ).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([201]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jsonSchema.defined(), + }), + handler: async (req, fullReq) => { + const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id"); + if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required"); + const tenancy = await getAgentAuthTenancy(projectId); + const prisma = await getPrismaClientForTenancy(tenancy); + const hostJwt = getBearerToken(fullReq); + if (!hostJwt) throw new StatusError(StatusError.Unauthorized, "invalid_host_token"); + + const hostPublicJwk = normalizeAgentAuthPublicJwk(req.body.host_public_jwk); + const agentPublicJwk = normalizeAgentAuthPublicJwk(req.body.agent_public_jwk); + const hostAudience = getAgentAuthAudience(fullReq.url, "/api/latest/agent-auth/agents/register"); + const verifiedHost = await verifyHostJwt({ + jwt: hostJwt, + publicJwk: hostPublicJwk, + audience: hostAudience, + }); + if (verifiedHost.payload.iss !== verifiedHost.thumbprint) { + throw new StatusError(StatusError.Unauthorized, "invalid_host_token"); + } + + const existingUser = await prisma.projectUser.findUnique({ + where: { + tenancyId_projectUserId: { + tenancyId: tenancy.id, + projectUserId: req.body.user_id, + }, + }, + }); + if (!existingUser) { + throw new StatusError(StatusError.BadRequest, "user_not_found"); + } + + const requestedCapabilities = req.body.requested_capabilities.map((capability) => { + const definition = getAgentCapability(capability.name); + const constraints = normalizeGrantConstraints(capability.name, capability.constraints); + return { + name: definition.name, + constraints, + }; + }); + + const now = new Date(); + const sessionExpiresAt = new Date(now.getTime() + AGENT_AUTH_SESSION_TTL_MILLIS); + const maxLifetimeEndsAt = new Date(now.getTime() + AGENT_AUTH_MAX_LIFETIME_MILLIS); + const absoluteLifetimeEndsAt = new Date(now.getTime() + AGENT_AUTH_ABSOLUTE_LIFETIME_MILLIS); + + const hostThumbprint = verifiedHost.thumbprint; + const host = await prisma.agentHost.upsert({ + where: { + tenancyId_jwkThumbprint: { + tenancyId: tenancy.id, + jwkThumbprint: hostThumbprint, + }, + }, + update: { + name: req.body.host_name, + publicJwk: JSON.parse(JSON.stringify(hostPublicJwk)), + status: "ACTIVE", + projectUserId: existingUser.projectUserId, + lastUsedAt: now, + }, + create: { + tenancyId: tenancy.id, + name: req.body.host_name, + publicJwk: JSON.parse(JSON.stringify(hostPublicJwk)), + jwkThumbprint: hostThumbprint, + status: "ACTIVE", + defaultCapabilities: requestedCapabilities.map((capability) => capability.name), + projectUserId: existingUser.projectUserId, + lastUsedAt: now, + }, + }); + + const agentThumbprint = await getAgentAuthJwkThumbprint(agentPublicJwk); + + const agent = await prisma.agent.create({ + data: { + tenancyId: tenancy.id, + hostId: host.id, + name: req.body.agent_name, + mode: "DELEGATED", + projectUserId: existingUser.projectUserId, + publicJwk: JSON.parse(JSON.stringify(agentPublicJwk)), + jwkThumbprint: agentThumbprint, + status: "ACTIVE", + expiresAt: sessionExpiresAt, + maxLifetimeEndsAt, + absoluteLifetimeEndsAt, + lastUsedAt: now, + }, + }); + + if (requestedCapabilities.length > 0) { + await prisma.agentCapabilityGrant.createMany({ + data: requestedCapabilities.map((capability) => ({ + tenancyId: tenancy.id, + agentId: agent.id, + capability: capability.name, + status: "ACTIVE", + constraints: capability.constraints == null ? undefined : JSON.parse(JSON.stringify(capability.constraints)), + grantedByProjectUserId: existingUser.projectUserId, + expiresAt: sessionExpiresAt, + })), + }); + } + + await logEvent([SystemEventTypes.AgentRegistered], { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + actor: { + type: "agent", + agentId: agent.id, + hostId: host.id, + userId: existingUser.projectUserId, + }, + hostId: host.id, + agentId: agent.id, + requestedCapabilities, + mode: req.body.mode, + agentName: req.body.agent_name, + hostName: req.body.host_name, + }, { + billingTeamId: null, + }); + + return { + statusCode: 201 as const, + bodyType: "json" as const, + body: { + host_id: host.id, + agent_id: agent.id, + host_thumbprint: host.jwkThumbprint, + agent_thumbprint: agent.jwkThumbprint, + granted_capabilities: requestedCapabilities.map((capability) => ({ + name: capability.name, + status: "active", + constraints: capability.constraints, + })), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/agent-auth/agents/route.ts b/apps/backend/src/app/api/latest/agent-auth/agents/route.ts new file mode 100644 index 000000000..5583a0baa --- /dev/null +++ b/apps/backend/src/app/api/latest/agent-auth/agents/route.ts @@ -0,0 +1,42 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { serializeAgent } from "@/lib/agent-auth/serialization"; +import { getAgentAuthTenancy, getHeader } from "@/lib/agent-auth/requests"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: yupString().oneOf(["server", "admin"]).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jsonSchema.defined(), + }), + handler: async (_req, fullReq) => { + const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id"); + if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required"); + const tenancy = await getAgentAuthTenancy(projectId); + const prisma = await getPrismaClientForTenancy(tenancy); + const agents = await prisma.agent.findMany({ + where: { tenancyId: tenancy.id }, + include: { + host: true, + projectUser: true, + capabilityGrants: true, + }, + orderBy: { createdAt: "desc" }, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + agents: agents.map(serializeAgent), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/agent-auth/capabilities/execute/route.ts b/apps/backend/src/app/api/latest/agent-auth/capabilities/execute/route.ts new file mode 100644 index 000000000..ae93a7d33 --- /dev/null +++ b/apps/backend/src/app/api/latest/agent-auth/capabilities/execute/route.ts @@ -0,0 +1,167 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { AGENT_AUTH_SESSION_TTL_MILLIS } from "@/lib/agent-auth/constants"; +import { executeAgentCapability, getAgentCapability, normalizeGrantConstraints } from "@/lib/agent-auth/capabilities"; +import { getAgentAuthAudience, getAgentAuthTenancy, getBearerToken, getHeader } from "@/lib/agent-auth/requests"; +import { normalizeAgentAuthPublicJwk, verifyAgentJwt } from "@/lib/agent-auth/jwt"; +import { logEvent, SystemEventTypes } from "@/lib/events"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import * as jose from "jose"; + +function resolveAgentStatusError(status: string): never { + if (status === "REVOKED" || status === "REJECTED") { + throw new StatusError(StatusError.Forbidden, "agent_not_active"); + } + throw new StatusError(StatusError.Forbidden, "agent_expired"); +} + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({}).nullable().optional(), + body: yupObject({ + capability: yupString().defined(), + input: jsonSchema.optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: jsonSchema.defined(), + }), + handler: async (req, fullReq) => { + const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id"); + if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required"); + const tenancy = await getAgentAuthTenancy(projectId); + const prisma = await getPrismaClientForTenancy(tenancy); + const agentJwt = getBearerToken(fullReq); + if (!agentJwt) { + throw new StatusError(StatusError.Unauthorized, "invalid_agent_token"); + } + + const decodedHeader = jose.decodeProtectedHeader(agentJwt); + if (decodedHeader.typ !== "agent+jwt") { + throw new StatusError(StatusError.Unauthorized, "invalid_agent_token"); + } + + const decodedPayload = jose.decodeJwt(agentJwt); + const principalThumbprint = typeof decodedPayload.iss === "string" ? decodedPayload.iss : null; + if (!principalThumbprint) { + throw new StatusError(StatusError.Unauthorized, "invalid_agent_token"); + } + + const agent = await prisma.agent.findUnique({ + where: { + tenancyId_jwkThumbprint: { + tenancyId: tenancy.id, + jwkThumbprint: principalThumbprint, + }, + }, + include: { + host: true, + projectUser: true, + capabilityGrants: true, + }, + }); + if (!agent) { + throw new StatusError(StatusError.Unauthorized, "agent_expired"); + } + + const agentTokenAudience = getAgentAuthAudience(fullReq.url, "/api/latest/agent-auth/capabilities/execute"); + const verified = await verifyAgentJwt({ + jwt: agentJwt, + publicJwk: normalizeAgentAuthPublicJwk(agent.publicJwk), + audience: agentTokenAudience, + }); + if (verified.payload.iss !== agent.jwkThumbprint) { + throw new StatusError(StatusError.Unauthorized, "invalid_agent_token"); + } + + const now = new Date(); + if (agent.status !== "ACTIVE") { + resolveAgentStatusError(agent.status); + } + if (agent.expiresAt.getTime() <= now.getTime() || agent.maxLifetimeEndsAt.getTime() <= now.getTime() || agent.absoluteLifetimeEndsAt.getTime() <= now.getTime()) { + await prisma.agent.update({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: agent.id, + }, + }, + data: { status: "EXPIRED" }, + }); + throw new StatusError(StatusError.Forbidden, "agent_expired"); + } + + const grant = agent.capabilityGrants.find((candidate) => candidate.capability === req.body.capability && candidate.status === "ACTIVE"); + if (!grant) { + throw new StatusError(StatusError.Forbidden, "capability_not_granted"); + } + if (grant.expiresAt != null && grant.expiresAt.getTime() <= now.getTime()) { + throw new StatusError(StatusError.Forbidden, "capability_not_granted"); + } + if (agent.mode === "DELEGATED" && !agent.projectUser) { + throw new StatusError(StatusError.Forbidden, "agent_not_active"); + } + const linkedUserId = agent.projectUserId ?? (() => { + throw new StatusError(StatusError.Forbidden, "agent_not_active"); + })(); + + const capability = getAgentCapability(req.body.capability); + const normalizedInput = req.body.input ?? {}; + const normalizedConstraints = normalizeGrantConstraints(req.body.capability, grant.constraints); + const result = await executeAgentCapability({ + tenancy, + capabilityName: req.body.capability, + input: normalizedInput, + constraints: normalizedConstraints, + }); + + const nextExpiresAt = new Date(Math.min( + now.getTime() + AGENT_AUTH_SESSION_TTL_MILLIS, + agent.maxLifetimeEndsAt.getTime(), + agent.absoluteLifetimeEndsAt.getTime(), + )); + await prisma.agent.update({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: agent.id, + }, + }, + data: { + lastUsedAt: now, + expiresAt: nextExpiresAt, + }, + }); + + await logEvent([SystemEventTypes.AgentCapabilityExecuted], { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + actor: { + type: "agent", + agentId: agent.id, + hostId: agent.host.id, + userId: linkedUserId, + }, + agentId: agent.id, + hostId: agent.host.id, + capability: capability.name, + input: normalizedInput, + decision: "allowed", + constraints: grant.constraints, + }, { + billingTeamId: null, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + capability: capability.name, + result, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/agent-auth/capabilities.ts b/apps/backend/src/lib/agent-auth/capabilities.ts new file mode 100644 index 000000000..fd344f3c5 --- /dev/null +++ b/apps/backend/src/lib/agent-auth/capabilities.ts @@ -0,0 +1,235 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT } from "./constants"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { yupNumber, yupObject } from "@hexclave/shared/dist/schema-fields"; +import type { Tenancy } from "@/lib/tenancies"; +import * as yup from "yup"; + +export type AgentCapabilityConstraint = { + min?: number, + max?: number, + in?: unknown[], + not_in?: unknown[], +}; + +export type AgentCapabilityGrantConstraints = Record; + +export type AgentCapabilityDefinition, TResult> = { + name: string, + description: string, + inputSchema: yup.Schema, + handler: (options: { tenancy: Tenancy, prisma: Awaited>, input: TInput }) => Promise, + constrainInput: ((input: TInput, constraints: AgentCapabilityGrantConstraints | null | undefined) => TInput) | undefined, +}; + +function throwConstraintViolation(message: string): never { + throw new StatusError(StatusError.BadRequest, message); +} + +function assertKnownConstraintKeys(constraint: Record) { + for (const key of Object.keys(constraint)) { + if (!["min", "max", "in", "not_in"].includes(key)) { + throwConstraintViolation("constraint_violated"); + } + } +} + +function normalizeConstraintValue(value: unknown): AgentCapabilityConstraint { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throwConstraintViolation("constraint_violated"); + } + assertKnownConstraintKeys(value as Record); + const constraint = value as Record; + const result: AgentCapabilityConstraint = {}; + if (constraint.min != null) { + if (typeof constraint.min !== "number") throwConstraintViolation("constraint_violated"); + result.min = constraint.min; + } + if (constraint.max != null) { + if (typeof constraint.max !== "number") throwConstraintViolation("constraint_violated"); + result.max = constraint.max; + } + if (constraint.in != null) { + if (!Array.isArray(constraint.in)) throwConstraintViolation("constraint_violated"); + result.in = constraint.in; + } + if (constraint.not_in != null) { + if (!Array.isArray(constraint.not_in)) throwConstraintViolation("constraint_violated"); + result.not_in = constraint.not_in; + } + return result; +} + +export function normalizeGrantConstraints(capability: string, constraints: unknown): AgentCapabilityGrantConstraints | null { + if (constraints == null) return null; + if (capability === "get_project_info") { + if (typeof constraints === "object" && !Array.isArray(constraints) && Object.keys(constraints).length > 0) { + throwConstraintViolation("constraint_violated"); + } + return {}; + } + if (typeof constraints !== "object" || Array.isArray(constraints)) { + throwConstraintViolation("constraint_violated"); + } + const result: AgentCapabilityGrantConstraints = {}; + for (const [field, value] of Object.entries(constraints as Record)) { + result[field] = normalizeConstraintValue(value); + } + return result; +} + +function clampNumber(value: number, min?: number, max?: number): number { + let result = value; + if (min != null) result = Math.max(result, min); + if (max != null) result = Math.min(result, max); + return result; +} + +function assertConstraintSatisfied(field: string, value: unknown, constraint: AgentCapabilityConstraint) { + if (constraint.min != null && (typeof value !== "number" || value < constraint.min)) { + throwConstraintViolation("constraint_violated"); + } + if (constraint.max != null && (typeof value !== "number" || value > constraint.max)) { + throwConstraintViolation("constraint_violated"); + } + if (constraint.in != null && !constraint.in.some((candidate) => Object.is(candidate, value))) { + throwConstraintViolation("constraint_violated"); + } + if (constraint.not_in != null && constraint.not_in.some((candidate) => Object.is(candidate, value))) { + throwConstraintViolation("constraint_violated"); + } + if (field !== "limit" && (constraint.min != null || constraint.max != null)) { + throwConstraintViolation("constraint_violated"); + } +} + +function applyConstraintsForListUsers(input: { limit?: number }, constraints: AgentCapabilityGrantConstraints | null | undefined) { + if (constraints == null || Object.keys(constraints).length === 0) { + return { + limit: input.limit ?? AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT, + }; + } + + for (const field of Object.keys(constraints)) { + if (field !== "limit") { + throwConstraintViolation("constraint_violated"); + } + } + + const limitConstraint = constraints.limit; + if (limitConstraint == null) return input; + const resolvedLimit = clampNumber(input.limit ?? AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT, limitConstraint.min, limitConstraint.max); + assertConstraintSatisfied("limit", resolvedLimit, limitConstraint); + return { limit: resolvedLimit }; +} + +export function validateAgentCapabilityInput>( + capability: AgentCapabilityDefinition, + input: unknown, +): TInput { + try { + return capability.inputSchema.validateSync(input, { + abortEarly: false, + strict: true, + }); + } catch (error) { + if (error instanceof yup.ValidationError) { + throw new StatusError(StatusError.BadRequest, "invalid_capability_input"); + } + throw error; + } +} + +export const AGENT_CAPABILITIES = { + list_users: { + name: "list_users", + description: "List real ProjectUsers in the current tenancy.", + inputSchema: yupObject({ + limit: yupNumber().integer().positive().optional(), + }), + constrainInput: applyConstraintsForListUsers, + handler: async ({ tenancy, prisma, input }: { + tenancy: Tenancy, + prisma: Awaited>, + input: { limit?: number }, + }) => { + const users = await prisma.projectUser.findMany({ + where: { tenancyId: tenancy.id }, + orderBy: [ + { signedUpAt: "desc" }, + { projectUserId: "asc" }, + ], + take: input.limit ?? AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT, + }); + const primaryEmailChannels = await prisma.contactChannel.findMany({ + where: { + tenancyId: tenancy.id, + projectUserId: { + in: users.map((user) => user.projectUserId), + }, + type: "EMAIL", + isPrimary: "TRUE", + }, + select: { + projectUserId: true, + value: true, + }, + }); + const primaryEmailByUserId = new Map(primaryEmailChannels.map((channel) => [channel.projectUserId, channel.value])); + + return { + users: users.map((user) => ({ + id: user.projectUserId, + display_name: user.displayName ?? null, + primary_email: primaryEmailByUserId.get(user.projectUserId) ?? null, + signed_up_at: user.signedUpAt.toISOString(), + })), + }; + }, + }, + get_project_info: { + name: "get_project_info", + description: "Read the current project's identifier, display name, and user count.", + inputSchema: yupObject({}), + constrainInput: undefined, + handler: async ({ tenancy, prisma }) => { + const userCount = await prisma.projectUser.count({ + where: { tenancyId: tenancy.id }, + }); + return { + project_id: tenancy.project.id, + display_name: tenancy.project.display_name, + user_count: userCount, + }; + }, + }, +} as const satisfies Record, unknown>>; + +export type AgentCapabilityName = keyof typeof AGENT_CAPABILITIES; + +export function getAgentCapability(name: string) { + const capability = Object.entries(AGENT_CAPABILITIES).find(([capabilityName]) => capabilityName === name)?.[1]; + if (capability == null) { + throwConstraintViolation("capability_not_supported"); + } + return capability; +} + +export async function executeAgentCapability(options: { + tenancy: Tenancy, + capabilityName: string, + input: unknown, + constraints: AgentCapabilityGrantConstraints | null | undefined, +}) { + const capability = getAgentCapability(options.capabilityName); + const prisma = await getPrismaClientForTenancy(options.tenancy); + const validatedInput = validateAgentCapabilityInput(capability, options.input ?? {}); + const constrainedInput = capability.constrainInput + ? capability.constrainInput(validatedInput, options.constraints) + : validatedInput; + return await capability.handler({ + tenancy: options.tenancy, + prisma, + input: constrainedInput, + }); +} diff --git a/apps/backend/src/lib/agent-auth/constants.ts b/apps/backend/src/lib/agent-auth/constants.ts new file mode 100644 index 000000000..5774bf0fa --- /dev/null +++ b/apps/backend/src/lib/agent-auth/constants.ts @@ -0,0 +1,17 @@ +export const AGENT_AUTH_APP_ID = "agent-auth" as const; +export const AGENT_AUTH_PROTOCOL_VERSION = "1.0-draft" as const; +export const AGENT_AUTH_JWS_ALG = "EdDSA" as const; +export const AGENT_AUTH_JWK_CRV = "Ed25519" as const; +export const AGENT_AUTH_HOST_JWT_TYP = "host+jwt" as const; +export const AGENT_AUTH_AGENT_JWT_TYP = "agent+jwt" as const; +export const AGENT_AUTH_MODES = ["delegated"] as const; +export const AGENT_AUTH_CAPABILITY_EXECUTE_PATH = "/api/latest/agent-auth/capabilities/execute" as const; +export const AGENT_AUTH_REGISTER_PATH = "/api/latest/agent-auth/agents/register" as const; +export const AGENT_AUTH_AGENTS_PATH = "/api/latest/agent-auth/agents" as const; +export const AGENT_AUTH_DISCOVERY_PATH = "/.well-known/agent-configuration" as const; + +export const AGENT_AUTH_SESSION_TTL_MILLIS = 30 * 60 * 1000; +export const AGENT_AUTH_MAX_LIFETIME_MILLIS = 24 * 60 * 60 * 1000; +export const AGENT_AUTH_ABSOLUTE_LIFETIME_MILLIS = 7 * 24 * 60 * 60 * 1000; +export const AGENT_AUTH_MAX_AGENT_JWT_SECONDS = 60; +export const AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT = 25; diff --git a/apps/backend/src/lib/agent-auth/jwt.ts b/apps/backend/src/lib/agent-auth/jwt.ts new file mode 100644 index 000000000..88329eeff --- /dev/null +++ b/apps/backend/src/lib/agent-auth/jwt.ts @@ -0,0 +1,192 @@ +import * as jose from "jose"; +import { HexclaveAssertionError, StatusError } from "@hexclave/shared/dist/utils/errors"; +import { + AGENT_AUTH_AGENT_JWT_TYP, + AGENT_AUTH_HOST_JWT_TYP, + AGENT_AUTH_JWS_ALG, + AGENT_AUTH_JWK_CRV, + AGENT_AUTH_MAX_AGENT_JWT_SECONDS, +} from "./constants"; + +export type AgentAuthPublicJwk = jose.JWK & { + alg: typeof AGENT_AUTH_JWS_ALG, + crv: typeof AGENT_AUTH_JWK_CRV, + kid: string, + use?: "sig", +}; + +export type AgentAuthPrivateJwk = jose.JWK & { + alg: typeof AGENT_AUTH_JWS_ALG, + crv: typeof AGENT_AUTH_JWK_CRV, + kid: string, + use?: "sig", + d: string, +}; + +export type AgentAuthKeyPair = { + publicJwk: AgentAuthPublicJwk, + privateJwk: AgentAuthPrivateJwk, + thumbprint: string, + publicKey: CryptoKey, + privateKey: CryptoKey, +}; + +export type VerifiedAgentAuthJwt = { + thumbprint: string, + payload: jose.JWTPayload, +}; + +function getJwtError(message: string): StatusError { + return new StatusError(StatusError.Unauthorized, message); +} + +function ensureEd25519PublicJwk(publicJwk: jose.JWK): AgentAuthPublicJwk { + if (publicJwk.kty !== "OKP" || publicJwk.crv !== AGENT_AUTH_JWK_CRV || typeof publicJwk.x !== "string") { + throw new HexclaveAssertionError("Expected an Ed25519 OKP public JWK"); + } + return { + ...publicJwk, + alg: AGENT_AUTH_JWS_ALG, + crv: AGENT_AUTH_JWK_CRV, + kid: typeof publicJwk.kid === "string" && publicJwk.kid.length > 0 ? publicJwk.kid : "", + use: "sig", + }; +} + +export function normalizeAgentAuthPublicJwk(value: unknown): AgentAuthPublicJwk { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw new HexclaveAssertionError("Expected an Ed25519 OKP public JWK"); + } + return ensureEd25519PublicJwk(value as jose.JWK); +} + +export async function getAgentAuthJwkThumbprint(publicJwk: AgentAuthPublicJwk): Promise { + return await jose.calculateJwkThumbprint(publicJwk); +} + +export async function generateAgentAuthKeyPair(): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(AGENT_AUTH_JWS_ALG, { crv: AGENT_AUTH_JWK_CRV }); + const publicJwk = ensureEd25519PublicJwk(await jose.exportJWK(publicKey)); + const thumbprint = await jose.calculateJwkThumbprint(publicJwk); + const publicJwkWithKid = { ...publicJwk, kid: thumbprint }; + const privateJwk = { + ...await jose.exportJWK(privateKey), + alg: AGENT_AUTH_JWS_ALG, + crv: AGENT_AUTH_JWK_CRV, + kid: thumbprint, + use: "sig" as const, + } as AgentAuthPrivateJwk; + + return { + publicJwk: publicJwkWithKid, + privateJwk, + thumbprint, + publicKey, + privateKey, + }; +} + +export async function signHostJwt(options: { + privateKey: CryptoKey, + publicJwk: AgentAuthPublicJwk, + audience: string, + expiresInSeconds?: number, + additionalClaims?: Record, +}): Promise { + const thumbprint = await jose.calculateJwkThumbprint(options.publicJwk); + const kid = options.publicJwk.kid || thumbprint; + return await new jose.SignJWT(options.additionalClaims ?? {}) + .setProtectedHeader({ alg: AGENT_AUTH_JWS_ALG, typ: AGENT_AUTH_HOST_JWT_TYP, kid }) + .setIssuer(thumbprint) + .setAudience(options.audience) + .setIssuedAt() + .setExpirationTime(`${options.expiresInSeconds ?? 5 * 60}s`) + .sign(options.privateKey); +} + +export async function signAgentJwt(options: { + privateKey: CryptoKey, + publicJwk: AgentAuthPublicJwk, + audience: string, + expiresInSeconds?: number, + additionalClaims?: Record, +}): Promise { + const thumbprint = await jose.calculateJwkThumbprint(options.publicJwk); + const kid = options.publicJwk.kid || thumbprint; + return await new jose.SignJWT(options.additionalClaims ?? {}) + .setProtectedHeader({ alg: AGENT_AUTH_JWS_ALG, typ: AGENT_AUTH_AGENT_JWT_TYP, kid }) + .setIssuer(thumbprint) + .setAudience(options.audience) + .setIssuedAt() + .setExpirationTime(`${options.expiresInSeconds ?? AGENT_AUTH_MAX_AGENT_JWT_SECONDS}s`) + .sign(options.privateKey); +} + +async function verifyJwtAgainstPublicJwk(options: { + jwt: string, + publicJwk: AgentAuthPublicJwk, + expectedTyp: typeof AGENT_AUTH_HOST_JWT_TYP | typeof AGENT_AUTH_AGENT_JWT_TYP, + audience: string, + maxLifetimeSeconds?: number, +}): Promise { + const header = jose.decodeProtectedHeader(options.jwt); + if (header.alg !== AGENT_AUTH_JWS_ALG) { + throw getJwtError("invalid_agent_token"); + } + if (header.typ !== options.expectedTyp) { + throw getJwtError("invalid_agent_token"); + } + + const thumbprint = await jose.calculateJwkThumbprint(options.publicJwk); + const importedKey = await jose.importJWK(options.publicJwk, AGENT_AUTH_JWS_ALG); + const { payload } = await jose.jwtVerify(options.jwt, importedKey, { + audience: options.audience, + issuer: thumbprint, + algorithms: [AGENT_AUTH_JWS_ALG], + }); + + if (payload.aud !== options.audience) { + throw getJwtError("invalid_agent_token"); + } + if (payload.iss !== thumbprint) { + throw getJwtError("invalid_agent_token"); + } + + if (options.maxLifetimeSeconds != null) { + if (typeof payload.iat !== "number" || typeof payload.exp !== "number" || payload.exp - payload.iat > options.maxLifetimeSeconds) { + throw getJwtError("invalid_agent_token"); + } + } + + return { + thumbprint, + payload, + }; +} + +export async function verifyHostJwt(options: { + jwt: string, + publicJwk: AgentAuthPublicJwk, + audience: string, +}): Promise { + return await verifyJwtAgainstPublicJwk({ + jwt: options.jwt, + publicJwk: options.publicJwk, + expectedTyp: AGENT_AUTH_HOST_JWT_TYP, + audience: options.audience, + }); +} + +export async function verifyAgentJwt(options: { + jwt: string, + publicJwk: AgentAuthPublicJwk, + audience: string, +}): Promise { + return await verifyJwtAgainstPublicJwk({ + jwt: options.jwt, + publicJwk: options.publicJwk, + expectedTyp: AGENT_AUTH_AGENT_JWT_TYP, + audience: options.audience, + maxLifetimeSeconds: AGENT_AUTH_MAX_AGENT_JWT_SECONDS, + }); +} diff --git a/apps/backend/src/lib/agent-auth/requests.ts b/apps/backend/src/lib/agent-auth/requests.ts new file mode 100644 index 000000000..2bb856139 --- /dev/null +++ b/apps/backend/src/lib/agent-auth/requests.ts @@ -0,0 +1,33 @@ +import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { AGENT_AUTH_APP_ID } from "./constants"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import type { SmartRequest } from "@/route-handlers/smart-request"; + +export function getHeader(req: SmartRequest, name: string): string | null { + const lowerName = name.toLowerCase(); + return req.headers[lowerName]?.[0] ?? null; +} + +export function getBearerToken(req: SmartRequest): string | null { + const authorization = getHeader(req, "authorization"); + if (!authorization) return null; + const [scheme, ...rest] = authorization.trim().split(/\s+/); + const token = rest.join(" "); + if (scheme.toLowerCase() !== "bearer" || token.length === 0) return null; + return token; +} + +export function getAgentAuthAudience(reqUrl: string, path: string): string { + return new URL(path, new URL(reqUrl).origin).toString(); +} + +export async function getAgentAuthTenancy(projectId: string) { + const tenancy = await getSoleTenancyFromProjectBranch(projectId, "main", true); + if (!tenancy) { + throw new StatusError(StatusError.NotFound, "Agent auth project not found"); + } + if (!tenancy.config.apps.installed[AGENT_AUTH_APP_ID]?.enabled) { + throw new StatusError(StatusError.NotFound, "Not found"); + } + return tenancy; +} diff --git a/apps/backend/src/lib/agent-auth/serialization.ts b/apps/backend/src/lib/agent-auth/serialization.ts new file mode 100644 index 000000000..2ae9a70f8 --- /dev/null +++ b/apps/backend/src/lib/agent-auth/serialization.ts @@ -0,0 +1,78 @@ +export type SerializedAgentCapabilityGrant = { + capability: string, + status: string, + constraints: unknown, + expires_at: string | null, +}; + +export type SerializedAgent = { + id: string, + name: string, + mode: string, + status: string, + agent_thumbprint: string, + linked_user_id: string | null, + host: { + id: string, + name: string, + thumbprint: string, + linked_user_id: string | null, + }, + linked_user: { id: string, display_name: string | null } | null, + capabilities: SerializedAgentCapabilityGrant[], + expires_at: string, + max_lifetime_ends_at: string, + absolute_lifetime_ends_at: string, + created_at: string, + updated_at: string, + last_used_at: string | null, +}; + +export function serializeAgent(agent: { + id: string, + name: string, + mode: string, + status: string, + projectUserId: string | null, + jwkThumbprint: string, + expiresAt: Date, + maxLifetimeEndsAt: Date, + absoluteLifetimeEndsAt: Date, + createdAt: Date, + updatedAt: Date, + lastUsedAt: Date | null, + host: { id: string, name: string, jwkThumbprint: string, projectUserId: string | null }, + projectUser: { projectUserId: string, displayName: string | null } | null, + capabilityGrants: Array<{ capability: string, status: string, constraints: unknown, expiresAt: Date | null }>, +}): SerializedAgent { + return { + id: agent.id, + name: agent.name, + mode: agent.mode === "DELEGATED" ? "delegated" : "autonomous", + status: agent.status.toLowerCase(), + agent_thumbprint: agent.jwkThumbprint, + linked_user_id: agent.projectUserId, + host: { + id: agent.host.id, + name: agent.host.name, + thumbprint: agent.host.jwkThumbprint, + linked_user_id: agent.host.projectUserId, + }, + linked_user: agent.projectUser == null ? null : { + id: agent.projectUser.projectUserId, + display_name: agent.projectUser.displayName, + }, + capabilities: agent.capabilityGrants.map((grant) => ({ + capability: grant.capability, + status: grant.status.toLowerCase(), + constraints: grant.constraints, + expires_at: grant.expiresAt?.toISOString() ?? null, + })), + expires_at: agent.expiresAt.toISOString(), + max_lifetime_ends_at: agent.maxLifetimeEndsAt.toISOString(), + absolute_lifetime_ends_at: agent.absoluteLifetimeEndsAt.toISOString(), + created_at: agent.createdAt.toISOString(), + updated_at: agent.updatedAt.toISOString(), + last_used_at: agent.lastUsedAt?.toISOString() ?? null, + }; +} diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 6c92b2336..d4b54cd6e 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -169,6 +169,48 @@ const SignUpRuleTriggerEventType = { inherits: [], } as const satisfies SystemEventTypeBase; +const AgentRegisteredEventType = { + id: "$agent-registered", + dataSchema: yupObject({ + projectId: yupString().defined(), + branchId: yupString().defined(), + actor: yupObject({ + type: yupString().oneOf(["agent"]).defined(), + agentId: yupString().uuid().defined(), + hostId: yupString().uuid().defined(), + userId: yupString().uuid().defined(), + }).defined(), + hostId: yupString().uuid().defined(), + agentId: yupString().uuid().defined(), + mode: yupString().oneOf(["delegated", "autonomous"]).defined(), + agentName: yupString().defined(), + hostName: yupString().defined(), + requestedCapabilities: yupMixed().defined(), + }), + inherits: [], +} as const satisfies SystemEventTypeBase; + +const AgentCapabilityExecutedEventType = { + id: "$agent-capability-executed", + dataSchema: yupObject({ + projectId: yupString().defined(), + branchId: yupString().defined(), + actor: yupObject({ + type: yupString().oneOf(["agent"]).defined(), + agentId: yupString().uuid().defined(), + hostId: yupString().uuid().defined(), + userId: yupString().uuid().defined(), + }).defined(), + agentId: yupString().uuid().defined(), + hostId: yupString().uuid().defined(), + capability: yupString().defined(), + input: yupMixed().defined(), + decision: yupString().oneOf(["allowed", "denied"]).defined(), + constraints: yupMixed().nullable().defined(), + }), + inherits: [], +} as const satisfies SystemEventTypeBase; + export const SystemEventTypes = stripEventTypeSuffixFromKeys({ ProjectEventType, ProjectActivityEventType, @@ -178,6 +220,8 @@ export const SystemEventTypes = stripEventTypeSuffixFromKeys({ ApiRequestEventType, LegacyApiEventType, SignUpRuleTriggerEventType, + AgentRegisteredEventType, + AgentCapabilityExecutedEventType, } as const); const systemEventTypesById = new Map(Object.values(SystemEventTypes).map(eventType => [eventType.id, eventType])); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page-client.tsx new file mode 100644 index 000000000..684cc1d4a --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page-client.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { DesignAlert, DesignCard } from "@/components/design-components"; +import { Switch, Typography } from "@/components/ui"; +import { useUpdateConfig } from "@/components/config-update"; +import { hexclaveAppInternalsSymbol } from "@hexclave/next"; +import { GearSix, Users } from "@phosphor-icons/react"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import { useEffect, useState } from "react"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +type AgentGrant = { + capability: string, + status: string, + constraints: unknown, + expires_at: string | null, +}; + +type AgentRow = { + id: string, + name: string, + mode: "delegated" | "autonomous", + status: string, + agent_thumbprint: string, + linked_user_id: string | null, + host: { + id: string, + name: string, + thumbprint: string, + linked_user_id: string | null, + }, + linked_user: { + id: string, + display_name: string | null, + } | null, + capabilities: AgentGrant[], + expires_at: string, + max_lifetime_ends_at: string, + absolute_lifetime_ends_at: string, + created_at: string, + updated_at: string, + last_used_at: string | null, +}; + +export default function PageClient() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); + const updateConfig = useUpdateConfig(); + const enabled = config.apps.installed["agent-auth"]?.enabled ?? false; + + const [localEnabled, setLocalEnabled] = useState(undefined); + const [agents, setAgents] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const appEnabled = localEnabled ?? enabled; + useEffect(() => { + if (!appEnabled) { + setAgents([]); + setError(null); + setLoading(false); + return; + } + + let cancelled = false; + async function loadAgents() { + setLoading(true); + setError(null); + try { + const response = await adminApp[hexclaveAppInternalsSymbol].sendRequest( + "/api/latest/agent-auth/agents", + {}, + "admin", + ); + if (!response.ok) { + throw new Error(`Failed to load agents: ${response.status}`); + } + const body = await response.json() as { agents: AgentRow[] }; + if (!cancelled) { + setAgents(body.agents); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : "Failed to load agents"); + setAgents(null); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + loadAgents().catch((loadError) => { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : "Failed to load agents"); + setAgents(null); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [adminApp, appEnabled]); + + const handleToggle = (checked: boolean) => { + runAsynchronouslyWithAlert(async () => { + await updateConfig({ + adminApp, + configUpdate: { + "apps.installed.agent-auth.enabled": checked, + }, + pushable: true, + }); + setLocalEnabled(undefined); + }); + }; + + return ( + + + + + +
+ + Agent Auth enabled + + +
+
+ + + {loading ? ( + Loading agents… + ) : error ? ( + {error} + ) : agents == null || agents.length === 0 ? ( + No agents registered yet. + ) : ( +
+ + + + + + + + + + + + + + + {agents.map((agent) => ( + + + + + + + + + + + ))} + +
NameAgentHostLinked userModeStatusCapabilitiesLast used
{agent.name}{agent.id.slice(0, 8)} +
{agent.host.name}
+
{agent.host.id.slice(0, 8)}
+
+ {agent.linked_user?.display_name ?? agent.linked_user_id ?? "—"} + {agent.mode}{agent.status} + {agent.capabilities.map((capability) => ( +
+ {capability.capability} + {capability.status} +
+ ))} +
{agent.last_used_at ?? "—"}
+
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page.tsx new file mode 100644 index 000000000..84cdebde1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page.tsx @@ -0,0 +1,5 @@ +import PageClient from "./page-client"; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 7f592e49d..20bb63d25 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -120,6 +120,21 @@ export const ALL_APPS_FRONTEND = { ), }, + "agent-auth": { + icon: FingerprintSimpleIcon, + href: "agent-auth-app", + navigationItems: [ + { displayName: "Agents", href: "." }, + ], + screenshots: [], + storeDescription: ( + <> +

Agent Auth gives AI agents their own identity, key material, and scoped capabilities.

+

Use it to register hosts and agents, inspect granted capabilities, and review agent-attributed audit events.

+

It is designed for live, real tenancy data rather than synthetic demos.

+ + ), + }, "fraud-protection": { icon: ShieldCheckIcon, href: "sign-up-rules", diff --git a/apps/e2e/tests/backend/agent-auth.test.ts b/apps/e2e/tests/backend/agent-auth.test.ts new file mode 100644 index 000000000..79bd12e43 --- /dev/null +++ b/apps/e2e/tests/backend/agent-auth.test.ts @@ -0,0 +1,191 @@ +import * as jose from "jose"; +import { Client } from "pg"; +import { describe } from "vitest"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { wait } from "@hexclave/shared/dist/utils/promises"; +import { STACK_BACKEND_BASE_URL, it } from "../helpers"; +import { Auth, Project, niceBackendFetch, withInternalProject } from "./backend-helpers"; + +const AGENT_AUTH_REGISTER_AUDIENCE = new URL("/api/latest/agent-auth/agents/register", STACK_BACKEND_BASE_URL).toString(); +const AGENT_AUTH_EXECUTE_AUDIENCE = new URL("/api/latest/agent-auth/capabilities/execute", STACK_BACKEND_BASE_URL).toString(); + +async function generateEd25519KeyPair() { + const { publicKey, privateKey } = await jose.generateKeyPair("EdDSA", { crv: "Ed25519" }); + const publicJwk = await jose.exportJWK(publicKey); + const thumbprint = await jose.calculateJwkThumbprint(publicJwk); + return { + publicKey, + privateKey, + thumbprint, + publicJwk: { + ...publicJwk, + alg: "EdDSA", + crv: "Ed25519", + kid: thumbprint, + use: "sig", + }, + }; +} + +async function signJwt(options: { + privateKey: jose.KeyLike, + thumbprint: string, + typ: "host+jwt" | "agent+jwt", + audience: string, + expiresInSeconds: number, + claims?: Record, +}) { + return await new jose.SignJWT(options.claims ?? {}) + .setProtectedHeader({ alg: "EdDSA", typ: options.typ, kid: options.thumbprint }) + .setIssuer(options.thumbprint) + .setAudience(options.audience) + .setIssuedAt() + .setExpirationTime(`${options.expiresInSeconds}s`) + .sign(options.privateKey); +} + +describe("agent auth", () => { + it("registers, executes, and audits a real agent", async ({ expect }) => { + await withInternalProject(async () => { + await Project.updateConfig({ + apps: { + installed: { + "agent-auth": { + enabled: true, + }, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + const hostKeys = await generateEd25519KeyPair(); + const agentKeys = await generateEd25519KeyPair(); + + const hostJwt = await signJwt({ + privateKey: hostKeys.privateKey, + thumbprint: hostKeys.thumbprint, + typ: "host+jwt", + audience: AGENT_AUTH_REGISTER_AUDIENCE, + expiresInSeconds: 300, + }); + + const registerResponse = await niceBackendFetch("/api/latest/agent-auth/agents/register", { + accessType: "server", + method: "POST", + headers: { + authorization: `Bearer ${hostJwt}`, + }, + body: { + host_public_jwk: hostKeys.publicJwk, + agent_public_jwk: agentKeys.publicJwk, + host_name: "e2e-host", + agent_name: "e2e-agent", + mode: "delegated", + user_id: userId, + requested_capabilities: [ + { + name: "list_users", + constraints: { + limit: { + max: 2, + }, + }, + }, + { + name: "get_project_info", + constraints: {}, + }, + ], + }, + }); + expect(registerResponse.status).toBe(201); + + const agentJwt = await signJwt({ + privateKey: agentKeys.privateKey, + thumbprint: agentKeys.thumbprint, + typ: "agent+jwt", + audience: AGENT_AUTH_EXECUTE_AUDIENCE, + expiresInSeconds: 60, + }); + + const listUsersResponse = await niceBackendFetch("/api/latest/agent-auth/capabilities/execute", { + accessType: "server", + method: "POST", + headers: { + authorization: `Bearer ${agentJwt}`, + }, + body: { + capability: "list_users", + input: { + limit: 1, + }, + }, + }); + expect(listUsersResponse.status).toBe(200); + expect(listUsersResponse.body.result.users).toHaveLength(1); + + const projectInfoResponse = await niceBackendFetch("/api/latest/agent-auth/capabilities/execute", { + accessType: "server", + method: "POST", + headers: { + authorization: `Bearer ${agentJwt}`, + }, + body: { + capability: "get_project_info", + input: {}, + }, + }); + expect(projectInfoResponse.status).toBe(200); + expect(projectInfoResponse.body.result.project_id).toBe("internal"); + + const agentsResponse = await niceBackendFetch("/api/latest/agent-auth/agents", { + accessType: "admin", + method: "GET", + }); + expect(agentsResponse.status).toBe(200); + expect(agentsResponse.body.agents.some((agent: { name: string }) => agent.name === "e2e-agent")).toBe(true); + + const connectionString = getEnvVariable( + "HEXCLAVE_DATABASE_CONNECTION_STRING", + getEnvVariable("STACK_DATABASE_CONNECTION_STRING", ""), + ); + if (connectionString === "") { + throw new Error("A local database connection string is required for audit verification"); + } + const auditDb = new Client({ connectionString }); + await auditDb.connect(); + let auditEvents: Array<{ + systemEventTypeIds: string[], + data: { actor?: { type?: string } } | null, + }> = []; + for (let attempt = 0; attempt < 20; attempt += 1) { + const auditEventsResult = await auditDb.query<{ + systemEventTypeIds: string[], + data: { actor?: { type?: string } } | null, + }>( + ` + SELECT "systemEventTypeIds", data + FROM "Event" + WHERE "systemEventTypeIds" && ARRAY[$1, $2] + ORDER BY "createdAt" DESC + LIMIT 10 + `, + ["$agent-registered", "$agent-capability-executed"], + ); + auditEvents = auditEventsResult.rows; + if (auditEvents.some((row) => { + const data = row.data ?? {}; + return row.systemEventTypeIds.includes("$agent-capability-executed") && data.actor?.type === "agent"; + })) { + break; + } + await wait(250); + } + await auditDb.end(); + expect(auditEvents.some((row) => { + const data = row.data ?? {}; + return row.systemEventTypeIds.includes("$agent-capability-executed") && data.actor?.type === "agent"; + })).toBe(true); + }); + }); +}); diff --git a/docs-mintlify/openapi/admin.json b/docs-mintlify/openapi/admin.json index 16fa08605..b3b9a7e3c 100644 --- a/docs-mintlify/openapi/admin.json +++ b/docs-mintlify/openapi/admin.json @@ -4110,6 +4110,179 @@ } } }, + "/agent-auth/agents": { + "get": { + "summary": "GET /agent-auth/agents", + "description": "No documentation available for this endpoint.", + "parameters": [], + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/agent-auth/agents/register": { + "post": { + "summary": "POST /agent-auth/agents/register", + "description": "No documentation available for this endpoint.", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "host_public_jwk": { + "type": "object" + }, + "agent_public_jwk": { + "type": "object" + }, + "host_name": { + "type": "string" + }, + "agent_name": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "delegated" + ] + }, + "user_id": { + "type": "string" + }, + "requested_capabilities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "constraints": { + "type": "object" + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "host_name", + "agent_name", + "mode", + "user_id", + "requested_capabilities" + ], + "example": {} + } + } + } + }, + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/register", + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/agent-auth/agents/{agentId}": { + "get": { + "summary": "GET /agent-auth/agents/{agentId}", + "description": "No documentation available for this endpoint.", + "parameters": [ + { + "name": "agentId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/{agentId}", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/agent-auth/capabilities/execute": { + "post": { + "summary": "POST /agent-auth/capabilities/execute", + "description": "No documentation available for this endpoint.", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "input": { + "type": "object" + } + }, + "required": [ + "capability" + ], + "example": {} + } + } + } + }, + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/capabilities/execute", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, "/auth/mfa/sign-in": { "post": { "summary": "MFA sign in", diff --git a/docs-mintlify/openapi/client.json b/docs-mintlify/openapi/client.json index f564e7098..fa4c2e921 100644 --- a/docs-mintlify/openapi/client.json +++ b/docs-mintlify/openapi/client.json @@ -2977,6 +2977,49 @@ } } }, + "/agent-auth/capabilities/execute": { + "post": { + "summary": "POST /agent-auth/capabilities/execute", + "description": "No documentation available for this endpoint.", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "input": { + "type": "object" + } + }, + "required": [ + "capability" + ], + "example": {} + } + } + } + }, + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/capabilities/execute", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, "/auth/mfa/sign-in": { "post": { "summary": "MFA sign in", diff --git a/docs-mintlify/openapi/server.json b/docs-mintlify/openapi/server.json index fc9b7a94c..d0f83467d 100644 --- a/docs-mintlify/openapi/server.json +++ b/docs-mintlify/openapi/server.json @@ -4070,6 +4070,179 @@ } } }, + "/agent-auth/agents": { + "get": { + "summary": "GET /agent-auth/agents", + "description": "No documentation available for this endpoint.", + "parameters": [], + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/agent-auth/agents/register": { + "post": { + "summary": "POST /agent-auth/agents/register", + "description": "No documentation available for this endpoint.", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "host_public_jwk": { + "type": "object" + }, + "agent_public_jwk": { + "type": "object" + }, + "host_name": { + "type": "string" + }, + "agent_name": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "delegated" + ] + }, + "user_id": { + "type": "string" + }, + "requested_capabilities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "constraints": { + "type": "object" + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "host_name", + "agent_name", + "mode", + "user_id", + "requested_capabilities" + ], + "example": {} + } + } + } + }, + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/register", + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/agent-auth/agents/{agentId}": { + "get": { + "summary": "GET /agent-auth/agents/{agentId}", + "description": "No documentation available for this endpoint.", + "parameters": [ + { + "name": "agentId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/{agentId}", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/agent-auth/capabilities/execute": { + "post": { + "summary": "POST /agent-auth/capabilities/execute", + "description": "No documentation available for this endpoint.", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "input": { + "type": "object" + } + }, + "required": [ + "capability" + ], + "example": {} + } + } + } + }, + "tags": [ + "Others" + ], + "x-full-url": "https://api.hexclave.com/api/v1/agent-auth/capabilities/execute", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, "/auth/mfa/sign-in": { "post": { "summary": "MFA sign in", diff --git a/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts b/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts index a878f75e9..0b3eb3dfd 100644 --- a/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts +++ b/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts @@ -245,9 +245,7 @@ const docsJson = { } }, "seo": { - "metatags": { - "robots": "noindex" - } + "indexing": "all" }, "settings": { "customScripts": [ @@ -255,7 +253,12 @@ const docsJson = { "/code-language-labels.js" ] }, - "redirects": [] + "redirects": [ + { + "source": "/guides/going-further/backend-integration", + "destination": "/guides/going-further/local-vs-cloud-dashboard" + } + ] } as const; export default docsJson; diff --git a/packages/shared/src/apps/apps-config.ts b/packages/shared/src/apps/apps-config.ts index a5b85e513..53e9bf643 100644 --- a/packages/shared/src/apps/apps-config.ts +++ b/packages/shared/src/apps/apps-config.ts @@ -57,6 +57,13 @@ export const ALL_APPS = { tags: ["auth", "security"], stage: "stable", }, + "agent-auth": { + displayName: "Agent Auth", + subtitle: "Register, authorize, and audit AI agent identities", + tags: ["auth", "security", "developers"], + stage: "alpha", + parentAppId: "authentication", + }, "fraud-protection": { displayName: "Fraud Protection", subtitle: "Protect your project from fraud and abuse",