mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Add agent auth MVP: first-class agent identity with scoped capabilities
Co-Authored-By: madison@stack-auth.com <madison.w.kennedy@gmail.com>
This commit is contained in:
parent
105f03fb94
commit
5e6e1165c3
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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<ReturnType<typeof preMigration>>) => {
|
||||
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);
|
||||
};
|
||||
@ -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
|
||||
|
||||
237
apps/backend/scripts/agent-auth-demo.ts
Normal file
237
apps/backend/scripts/agent-auth-demo.ts
Normal file
@ -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<ReturnType<typeof generateKeyPair>>["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<string, unknown>) => {
|
||||
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;
|
||||
});
|
||||
@ -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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
42
apps/backend/src/app/api/latest/agent-auth/agents/route.ts
Normal file
42
apps/backend/src/app/api/latest/agent-auth/agents/route.ts
Normal file
@ -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),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
235
apps/backend/src/lib/agent-auth/capabilities.ts
Normal file
235
apps/backend/src/lib/agent-auth/capabilities.ts
Normal file
@ -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<string, AgentCapabilityConstraint | undefined>;
|
||||
|
||||
export type AgentCapabilityDefinition<TInput extends Record<string, unknown>, TResult> = {
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: yup.Schema<TInput>,
|
||||
handler: (options: { tenancy: Tenancy, prisma: Awaited<ReturnType<typeof getPrismaClientForTenancy>>, input: TInput }) => Promise<TResult>,
|
||||
constrainInput: ((input: TInput, constraints: AgentCapabilityGrantConstraints | null | undefined) => TInput) | undefined,
|
||||
};
|
||||
|
||||
function throwConstraintViolation(message: string): never {
|
||||
throw new StatusError(StatusError.BadRequest, message);
|
||||
}
|
||||
|
||||
function assertKnownConstraintKeys(constraint: Record<string, unknown>) {
|
||||
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<string, unknown>);
|
||||
const constraint = value as Record<string, unknown>;
|
||||
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<string, unknown>)) {
|
||||
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<TInput extends Record<string, unknown>>(
|
||||
capability: AgentCapabilityDefinition<TInput, unknown>,
|
||||
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<ReturnType<typeof getPrismaClientForTenancy>>,
|
||||
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<string, AgentCapabilityDefinition<Record<string, unknown>, 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,
|
||||
});
|
||||
}
|
||||
17
apps/backend/src/lib/agent-auth/constants.ts
Normal file
17
apps/backend/src/lib/agent-auth/constants.ts
Normal file
@ -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;
|
||||
192
apps/backend/src/lib/agent-auth/jwt.ts
Normal file
192
apps/backend/src/lib/agent-auth/jwt.ts
Normal file
@ -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<string> {
|
||||
return await jose.calculateJwkThumbprint(publicJwk);
|
||||
}
|
||||
|
||||
export async function generateAgentAuthKeyPair(): Promise<AgentAuthKeyPair> {
|
||||
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<string, unknown>,
|
||||
}): Promise<string> {
|
||||
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<string, unknown>,
|
||||
}): Promise<string> {
|
||||
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<VerifiedAgentAuthJwt> {
|
||||
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<VerifiedAgentAuthJwt> {
|
||||
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<VerifiedAgentAuthJwt> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
33
apps/backend/src/lib/agent-auth/requests.ts
Normal file
33
apps/backend/src/lib/agent-auth/requests.ts
Normal file
@ -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;
|
||||
}
|
||||
78
apps/backend/src/lib/agent-auth/serialization.ts
Normal file
78
apps/backend/src/lib/agent-auth/serialization.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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]));
|
||||
|
||||
|
||||
@ -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<boolean | undefined>(undefined);
|
||||
const [agents, setAgents] = useState<AgentRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<AppEnabledGuard appId="agent-auth">
|
||||
<PageLayout
|
||||
title="Agent Auth"
|
||||
description="Review real agent identities, hosts, and capability grants."
|
||||
>
|
||||
<DesignAlert
|
||||
variant="info"
|
||||
title="Agent identities"
|
||||
description="This page reads the live backend Agents API for the current project."
|
||||
/>
|
||||
|
||||
<DesignCard title="App State" subtitle="Enable or disable Agent Auth for this project" icon={GearSix}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Typography className="text-sm font-medium">
|
||||
Agent Auth enabled
|
||||
</Typography>
|
||||
<Switch checked={appEnabled} onCheckedChange={handleToggle} />
|
||||
</div>
|
||||
</DesignCard>
|
||||
|
||||
<DesignCard title="Agents" subtitle="Live agents registered in this project" icon={Users}>
|
||||
{loading ? (
|
||||
<Typography className="text-sm text-muted-foreground">Loading agents…</Typography>
|
||||
) : error ? (
|
||||
<Typography className="text-sm text-destructive">{error}</Typography>
|
||||
) : agents == null || agents.length === 0 ? (
|
||||
<Typography className="text-sm text-muted-foreground">No agents registered yet.</Typography>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="py-2 pr-4">Name</th>
|
||||
<th className="py-2 pr-4">Agent</th>
|
||||
<th className="py-2 pr-4">Host</th>
|
||||
<th className="py-2 pr-4">Linked user</th>
|
||||
<th className="py-2 pr-4">Mode</th>
|
||||
<th className="py-2 pr-4">Status</th>
|
||||
<th className="py-2 pr-4">Capabilities</th>
|
||||
<th className="py-2 pr-4">Last used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map((agent) => (
|
||||
<tr key={agent.id} className="border-b last:border-b-0 align-top">
|
||||
<td className="py-3 pr-4 font-medium">{agent.name}</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs">{agent.id.slice(0, 8)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium">{agent.host.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{agent.host.id.slice(0, 8)}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{agent.linked_user?.display_name ?? agent.linked_user_id ?? "—"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{agent.mode}</td>
|
||||
<td className="py-3 pr-4">{agent.status}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{agent.capabilities.map((capability) => (
|
||||
<div key={`${agent.id}-${capability.capability}`} className="mb-1">
|
||||
<span className="font-medium">{capability.capability}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{capability.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{agent.last_used_at ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</DesignCard>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -120,6 +120,21 @@ export const ALL_APPS_FRONTEND = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
"agent-auth": {
|
||||
icon: FingerprintSimpleIcon,
|
||||
href: "agent-auth-app",
|
||||
navigationItems: [
|
||||
{ displayName: "Agents", href: "." },
|
||||
],
|
||||
screenshots: [],
|
||||
storeDescription: (
|
||||
<>
|
||||
<p>Agent Auth gives AI agents their own identity, key material, and scoped capabilities.</p>
|
||||
<p>Use it to register hosts and agents, inspect granted capabilities, and review agent-attributed audit events.</p>
|
||||
<p>It is designed for live, real tenancy data rather than synthetic demos.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
"fraud-protection": {
|
||||
icon: ShieldCheckIcon,
|
||||
href: "sign-up-rules",
|
||||
|
||||
191
apps/e2e/tests/backend/agent-auth.test.ts
Normal file
191
apps/e2e/tests/backend/agent-auth.test.ts
Normal file
@ -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<string, unknown>,
|
||||
}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user