diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac22897 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +## Development + +```sh +git clone https://github.com/corbitsdev/corbits-agent-token.git +cd corbits-agent-token +bun install +createdb agent_token_dev +export DATABASE_URL=postgres://localhost:5432/agent_token_dev + +bun run typecheck +bun run test +bun run build +``` + +The suites in `e2e/` run against a real Postgres and skip when `DATABASE_URL` is unset. CI sets it. + +- `e2e/agent-token.drizzle.test.ts` runs Interchange's migrations into a scratch schema and exercises the routes, the middleware and the verifier. +- `e2e/upgrade-from-0.1.0.test.ts` creates its own database, builds it with the published 0.1.0 (the `agent-token-0.1.0` dev dependency), then upgrades it with this build. + +## Migrations + +`migrations/*.sql` ship in the package. `runAgentTokenMigrations` replays every file on every boot, in one transaction under an advisory lock, with no record of what already ran. Every statement must be idempotent (`IF NOT EXISTS`, guarded `ALTER`s, re-runnable backfills). `"public".` in a file is rewritten to the host schema passed as `schema`. diff --git a/README.md b/README.md index fa755c0..aa3f4cd 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,144 @@ # @corbits/agent-token -Bearer tokens that let a deployed agent definition call back into the -Interchange hub that deployed it. A tenant mints a token for one of its -definitions; the plaintext is returned once and only its sha256 digest is -stored. A mount that wants to accept an agent as a caller runs the -middleware and reads the tenant and definition straight off the verified -token. +[![npm](https://img.shields.io/npm/v/@corbits/agent-token.svg)](https://www.npmjs.com/package/@corbits/agent-token) [![License: LGPL-2.1](https://img.shields.io/badge/license-LGPL--2.1-green.svg)](https://github.com/corbitsdev/corbits-agent-token/blob/main/LICENSE) + +Bearer tokens that a deployed agent presents when it calls back into its Interchange hub (the multi-tenant control plane): mint, list and revoke routes, SHA-256 digests in Postgres, and a Hono middleware that verifies the bearer against the route's tenant. A Corbits hub module that mounts on `@intx/hub-api` and `@intx/db`. + +## Why @corbits/agent-token? + +1. **Scoped to a tenant and an agent definition.** A token carries the tenant that minted it and the agent definition (the hub's record of a deployable agent) it was minted for. The middleware refuses a token on any other tenant's routes. +2. **The plaintext is shown once.** Only `sha256(token)` is stored. The mint response is the only place the plaintext appears. +3. **The hub stays in charge.** Every route runs the host's `requireGrant` (the hub's permission check), and the tenant comes from the hub's context, never from the request. + +It authenticates agents calling the hub. It does not hold outbound credentials an agent uses to call other services. ## Install -``` -bun add @corbits/agent-token +```bash +bun add @corbits/agent-token @intx/authz @intx/db @intx/hub-api drizzle-orm hono postgres ``` -The host supplies the Interchange stack as peers: `@intx/db`, -`@intx/hub-api`, `drizzle-orm`, `hono` and `postgres`. +## Quickstart -## Mount (`mountAgentTokens`) - -Routes are relative and go under the host's own tenant prefix, so the acting -tenant comes from the host's authenticated context and never from a path -parameter. The host supplies its own grant middleware: minting a token is -minting a credential, so it is gated the way the host gates credential -creation — once, its way. +With `DATABASE_URL` pointing at a hub database that has run `runAgentTokenMigrations` (see [Using with Interchange](#using-with-interchange)): ```ts -const tokenApp = new Hono(); -mountAgentTokens(tokenApp, { - db, - requireGrant: requireGrant("credential:*", "create"), - // A token is scoped to a definition, so the host confirms this tenant - // owns it; an unknown definition answers 404 with no detail. - resolveDefinition: (tenantId, definitionId) => hub.tenantOwnsDefinition(tenantId, definitionId), +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { mintAgentToken, verifyAgentToken } from "@corbits/agent-token"; + +const client = postgres(process.env.DATABASE_URL!); +const db = drizzle(client); + +const minted = await mintAgentToken(db, { + tenantId: "acme", + definitionId: "support-agent", + name: "demo", }); -app.route("/api/tenants/:tenantId", tokenApp); +console.log(await verifyAgentToken(db, minted.token)); +await client.end(); ``` -| Route | | -|---|---| -| `GET /agent-tokens` | List the tenant's tokens (never the plaintext or its digest) | -| `POST /agent-tokens` | Mint a token (`definitionId`, `name`); the plaintext is in the 201 response and nowhere else | -| `DELETE /agent-tokens/:id` | Revoke a token; revocation is final | +`acme` must be a tenant in the hub. It prints `{ id, tenantId: "acme", definitionId: "support-agent" }`. -## Middleware (`requireAgentToken`) +## Where it fits -Reads `Authorization: Bearer`, hashes the presented value, and looks up an -unrevoked row minted by the route's own tenant (`c.get("tenant")`). On -success it sets `agentToken` — `{ id, tenantId, definitionId }` — on the -context; missing, malformed, unknown, revoked and other-tenant tokens all -get the same bare 401, with no detail that would tell a caller which it was. -It must run behind the hub's tenant middleware, which sets `tenant`; mounted -without it, a valid token fails the request with a 500 rather than passing. +[Interchange](https://github.com/faremeter/interchange) runs AI agents as principals: accounts with their own identity, permissions and credentials. Its hub is the multi-tenant control plane that holds tenants, principals and grants (permissions a principal holds on a resource); its sidecar is the agent runtime. -```ts -app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) => { - const { definitionId } = c.get("agentToken"); - ... -}); -``` +- **Runs in:** the hub, as routes and middleware on its Hono app and one table in its Postgres (`agent_token` schema). +- **Plugs into:** [`@intx/hub-api`](https://github.com/faremeter/interchange/tree/main/packages/hub-api) (`TenantEnv`, `requireGrant`) and [`@intx/db`](https://github.com/faremeter/interchange/tree/main/packages/db) (its `DBConfig`, and its `tenant` table as the FK target). +- **Pairs with:** the run-scoped routes of [`@corbits/memory`](https://github.com/corbitsdev/corbits-memory) and [`@corbits/artifacts`](https://github.com/corbitsdev/corbits-artifacts), which accept these tokens from agents. + +## Reference + +### `mountAgentTokens(app, opts)` + +Mounts relative routes on a `Hono`. Put it under the hub's tenant prefix. + +| `opts` | Type | What the host provides | +| ------------------- | ------------------- | -------------------------------------------------------------------------------------- | +| `db` | `AgentTokenDb` | The hub's drizzle handle, for example from `createDB`. | +| `requireGrant` | `MiddlewareHandler` | The grant check for minting a credential, run on every route. | +| `resolveDefinition` | `ResolveDefinition` | `(tenantId, definitionId) => boolean \| Promise`: whether the tenant owns it. | + +| Route | Result | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /agent-tokens` | `200 { tokens }`, without plaintext or digest. | +| `POST /agent-tokens` | Mints for `{ definitionId, name }`: `201 { token }` with the plaintext. `400 invalid_body` on a bad body, `404 not_found` for a definition the tenant does not own. | +| `DELETE /agent-tokens/:id` | Revokes: `200 { ok: true }`, or `404 not_found`. Revocation is final. | + +### `requireAgentToken({ db })` + +Middleware that reads `Authorization: Bearer ` and sets `agentToken` (`{ id, tenantId, definitionId }`) on the context. Missing, unknown, revoked and other-tenant tokens all get the same `401 { "error": "unauthorized" }`. It must run behind the hub's tenant middleware; without `tenant` on the context every request fails with a 500. -`createAgentTokenVerifier` is the bearer lookup as a plain function, for a -hub mount that wants to fall back to its own authentication when no bearer -is presented. Like the middleware, it refuses a token minted by another -tenant. - -## Run-scoped mounts - -A mount that serves a deployed agent's run, rather than a browser session, -imports its types from here: `ResolvedWorkflowRunScope` (`{ tenantId, -principalId, runId }`), `WorkflowRunScopeEnv` (the hub's `TenantEnv` plus a -`workflowRunScope` variable) and `AgentTokenAuth`, the host's `verify` and -`resolveRun` pair. `createAgentTokenVerifier({ db })` is a ready `verify`; -the mount refuses a token whose `tenantId` is not the resolved run's. - -## Security - -- The token is 32 random bytes from `node:crypto`'s `randomBytes`, - base64url-encoded. -- Only `sha256(token)` is stored; lookups are by digest, and the digest - comparison is constant-time. -- Scope — the tenant and the definition — travels with the row, not with - the caller's claim. The tenant is read off the host's context and the - definition is confirmed to belong to it before a token is minted. -- Every route runs the host's grant middleware, so authority is checked - once and by the host. -- Revoke sets `revoked_at`; a revoked row never verifies again and cannot - be un-revoked. - -## Schema / migrations - -One table, on its own Postgres schema (`agent_token.token`), FK'd back to -Interchange's `tenant` table: - -| Column | | -|---|---| -| `id` | primary key | -| `tenant_id` | FK → `tenant.id`, cascades on delete | -| `definition_id` | the agent definition this token acts as | -| `name` | what the minting caller called it | -| `token_hash` | sha256 of the plaintext, unique | -| `created_at` | mint time | -| `revoked_at` | set once, on revoke | - -Applied idempotently, inside one advisory-locked transaction so concurrent -hub replicas cannot race the same DDL: +### `createAgentTokenVerifier({ db })` + +The same check as a function, `(c) => Promise`, for a route that falls back to its own authentication when no agent token is presented. It also refuses other-tenant tokens. + +### Run-scoped types + +For a route an agent calls on behalf of one workflow run (a single execution of a deployed agent): `ResolvedWorkflowRunScope` (`{ tenantId, principalId, runId }`), `WorkflowRunScopeEnv` (`TenantEnv` plus `workflowRunScope`), and `AgentTokenAuth`: `{ verify, resolveRun }`, where `verify` is a `createAgentTokenVerifier` and `resolveRun(runAddress)` is the host's lookup from the run address in the request to a `ResolvedWorkflowRunScope`, or `null`. `AgentTokenVariables` types `c.get("agentToken")` on a host's own Env. + +### Functions + +`mintAgentToken(db, { tenantId, definitionId, name })` returns a `MintedAgentToken`, `verifyAgentToken(db, token)` an `AgentTokenIdentity` or `undefined`, and `revokeAgentToken(db, { tenantId, id })` whether a live token was revoked. + +### `runAgentTokenMigrations(dbConfig, { schema })` + +From `@corbits/agent-token/migrations`. Run it after Interchange's `runMigrations`, with the same config and the same `schema`: the host schema that holds the `tenant` table. The `token` table always lives in the `agent_token` schema. It is idempotent and takes an advisory lock, so several replicas can start at once. + +## Using with Interchange + +1. Run `runAgentTokenMigrations` at hub start, after `runMigrations`. +2. Mount `mountAgentTokens` under the tenant prefix, gated with `requireGrant("credential:*", "create")`, since minting a token creates a credential. +3. Give the minted token to the deployed agent, for example as a secret in its environment. +4. Put `requireAgentToken` (or `createAgentTokenVerifier`) on each hub route the agent calls. ```ts -import { runMigrations } from "@intx/db"; +import { timeWindowEvaluator } from "@intx/authz"; +import { createDB, createGrantStore, runMigrations } from "@intx/db"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; +import { Hono } from "hono"; +import { requireAgentToken } from "@corbits/agent-token"; import { runAgentTokenMigrations } from "@corbits/agent-token/migrations"; +const dbConfig = { + host: "localhost", + port: 5432, + user: "postgres", + password: "postgres", + database: "interchange", +}; + await runMigrations(dbConfig, { schema: "public" }); await runAgentTokenMigrations(dbConfig, { schema: "public" }); + +const { db } = createDB(dbConfig); +export const requireGrant = createRequireGrant({ + grantStore: createGrantStore(db), + conditionRegistry: { time_window: timeWindowEvaluator }, +}); + +export const whoami = new Hono().get( + "/whoami", + requireAgentToken({ db }), + (c) => c.json(c.get("agentToken")), +); ``` +Mount the token routes with `mountAgentTokens(tokens, { db, requireGrant: requireGrant("credential:*", "create"), resolveDefinition })` on a `Hono`. `resolveDefinition` is the host's check that the tenant owns the agent definition, answered from its own definition records. Mount `tokens` and `whoami` on the hub app under `/api/tenants/:tenantId`, behind the hub's auth and tenant middleware. + +`POST /api/tenants//agent-tokens` with `{ "definitionId": "...", "name": "..." }` returns `{ "token": { "id", "token", ... } }`. Calling `GET /api/tenants//whoami` with `Authorization: Bearer ` returns `{ "id", "tenantId", "definitionId" }`. + +## Upgrading from 0.1 + +- Existing databases and tokens keep working: the migration is unchanged and re-runs cleanly, and tokens minted by 0.1.0 still verify. +- `applyAgentTokenMigrations(databaseUrl, { tenantSchema })` is replaced by `runAgentTokenMigrations(dbConfig, { schema })` from `@corbits/agent-token/migrations`. A 0.1.0 table keeps its FK to the schema it was first created against. +- `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `postgres` are now peer dependencies. +- The root no longer exports `agentTokenSchema`, `agentTokenTable`, `generateAgentToken`, `hashAgentToken`, `agentTokenHashEquals` or `bearerFromAuthorization`. +- `mountAgentTokens` drops `resolveTenantId` and reads the tenant from `TenantEnv`. +- `requireAgentToken` and `createAgentTokenVerifier` now refuse tokens minted by another tenant, and need the hub's tenant middleware in front of them. + ## License -LGPL-2.1 +[LGPL-2.1](https://github.com/corbitsdev/corbits-agent-token/blob/main/LICENSE) diff --git a/src/mount.ts b/src/mount.ts index 3c243f9..c4b7ce8 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -21,8 +21,8 @@ export type MountAgentTokensOpts; /** * The host's own authority check, run as middleware on every route. Minting - * a token is minting a credential, so it is gated the way the host gates - * credential creation — once, its way, not by a boolean this package + * a token is minting a credential, so the host gates it exactly as it gates + * credential creation, rather than through a boolean this package * interprets. */ requireGrant: MiddlewareHandler;