|
1 | 1 | # @corbits/agent-token |
2 | 2 |
|
3 | | -Bearer tokens that let agents authenticate inbound calls to your |
4 | | -Interchange hub. Use it when a deployed agent calls back into the hub that |
5 | | -deployed it: a tenant mints a token for one of its agent definitions, the |
6 | | -plaintext is returned once and only its sha256 digest is stored, and a |
7 | | -guarded route reads the tenant and definition straight off the verified |
8 | | -token. |
| 3 | +[](https://www.npmjs.com/package/@corbits/agent-token) [](https://github.com/corbitsdev/corbits-agent-token/blob/main/LICENSE) |
9 | 4 |
|
10 | | -## Install |
| 5 | +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`. |
11 | 6 |
|
12 | | -``` |
13 | | -bun add @corbits/agent-token |
14 | | -``` |
| 7 | +## Why @corbits/agent-token? |
15 | 8 |
|
16 | | -The host supplies the Interchange stack as peers: `@intx/db`, |
17 | | -`@intx/hub-api`, `drizzle-orm`, `hono` and `postgres`. |
| 9 | +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. |
| 10 | +2. **The plaintext is shown once.** Only `sha256(token)` is stored. The mint response is the only place the plaintext appears. |
| 11 | +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. |
| 12 | + |
| 13 | +It authenticates agents calling the hub. It does not hold outbound credentials an agent uses to call other services. |
| 14 | + |
| 15 | +## Install |
18 | 16 |
|
19 | | -## Mount (`mountAgentTokens`) |
| 17 | +```bash |
| 18 | +npm add @corbits/agent-token @intx/db @intx/hub-api drizzle-orm hono postgres |
| 19 | +``` |
20 | 20 |
|
21 | | -Routes are relative and go under the host's own tenant prefix, so the acting |
22 | | -tenant comes from the host's authenticated context and never from a path |
23 | | -parameter. The host supplies its own grant middleware: minting a token is |
24 | | -minting a credential, so the host gates it exactly as it gates credential |
25 | | -creation. |
| 21 | +## Quickstart |
26 | 22 |
|
27 | 23 | ```ts |
28 | | -import { mountAgentTokens, type AgentTokenDb } from "@corbits/agent-token"; |
| 24 | +import { createDB, type DBConfig } from "@intx/db"; |
29 | 25 | import type { RequireGrant, TenantEnv } from "@intx/hub-api"; |
30 | 26 | import { Hono } from "hono"; |
| 27 | +import { mountAgentTokens, requireAgentToken } from "@corbits/agent-token"; |
| 28 | +import { runAgentTokenMigrations } from "@corbits/agent-token/migrations"; |
31 | 29 |
|
32 | | -export function mountTokens( |
33 | | - app: Hono<TenantEnv>, |
34 | | - db: AgentTokenDb, |
35 | | - requireGrant: RequireGrant, |
36 | | - tenantOwnsDefinition: (tenantId: string, definitionId: string) => Promise<boolean>, |
37 | | -) { |
38 | | - const tokenApp = new Hono<TenantEnv>(); |
39 | | - mountAgentTokens(tokenApp, { |
40 | | - db, |
41 | | - requireGrant: requireGrant("credential:*", "create"), |
42 | | - resolveTenantId: (c) => c.get("tenant").id, |
43 | | - // A token is scoped to a definition, so the host confirms this tenant |
44 | | - // owns it; an unknown definition answers 404 with no detail. |
45 | | - resolveDefinition: tenantOwnsDefinition, |
46 | | - }); |
47 | | - app.route("/api/tenants/:tenantId", tokenApp); |
48 | | -} |
| 30 | +declare const app: Hono<TenantEnv>; |
| 31 | +declare const dbConfig: DBConfig; |
| 32 | +declare const requireGrant: RequireGrant; |
| 33 | +declare const tenantOwnsDefinition: ( |
| 34 | + tenantId: string, |
| 35 | + id: string, |
| 36 | +) => Promise<boolean>; |
| 37 | + |
| 38 | +await runAgentTokenMigrations(dbConfig, { schema: "public" }); |
| 39 | +const { db } = createDB(dbConfig); |
| 40 | + |
| 41 | +const tokens = new Hono<TenantEnv>(); |
| 42 | +mountAgentTokens(tokens, { |
| 43 | + db, |
| 44 | + requireGrant: requireGrant("credential:*", "create"), |
| 45 | + resolveDefinition: tenantOwnsDefinition, |
| 46 | +}); |
| 47 | +app.route("/api/tenants/:tenantId", tokens); |
| 48 | + |
| 49 | +app.get("/api/tenants/:tenantId/whoami", requireAgentToken({ db }), (c) => |
| 50 | + c.json(c.get("agentToken")), |
| 51 | +); |
49 | 52 | ``` |
50 | 53 |
|
51 | | -| Route | | |
52 | | -|---|---| |
53 | | -| `GET /agent-tokens` | List the tenant's tokens (never the plaintext or its digest) | |
54 | | -| `POST /agent-tokens` | Mint a token (`definitionId`, `name`); the plaintext is in the 201 response and nowhere else | |
55 | | -| `DELETE /agent-tokens/:id` | Revoke a token; revocation is final | |
| 54 | +`POST /api/tenants/<id>/agent-tokens` with `{ "definitionId": "...", "name": "..." }` returns `{ "token": { "id", "token", ... } }`. Calling `GET /api/tenants/<id>/whoami` with `Authorization: Bearer <token>` returns `{ "id", "tenantId", "definitionId" }`. |
56 | 55 |
|
57 | | -## Middleware (`requireAgentToken`) |
| 56 | +## Where it fits |
58 | 57 |
|
59 | | -Reads `Authorization: Bearer`, hashes the presented value, and looks up an |
60 | | -unrevoked row minted by the route's own tenant (`c.get("tenant")`). On |
61 | | -success it sets `agentToken` — `{ id, tenantId, definitionId }` — on the |
62 | | -context; missing, malformed, unknown, revoked and other-tenant tokens all |
63 | | -get the same bare 401, with no detail that would tell a caller which it was. |
64 | | -It must run behind the hub's tenant middleware, which sets `tenant`; mounted |
65 | | -without it, a valid token fails the request with a 500 rather than passing. |
| 58 | +[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. |
66 | 59 |
|
67 | | -```ts |
68 | | -import { requireAgentToken, type AgentTokenDb } from "@corbits/agent-token"; |
69 | | -import type { TenantEnv } from "@intx/hub-api"; |
70 | | -import type { Hono } from "hono"; |
71 | | - |
72 | | -export function mountArtifacts(app: Hono<TenantEnv>, db: AgentTokenDb) { |
73 | | - app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) => { |
74 | | - const { definitionId } = c.get("agentToken"); |
75 | | - return c.json({ id: c.req.param("id"), definitionId }); |
76 | | - }); |
77 | | -} |
78 | | -``` |
| 60 | +- **Runs in:** the hub, as routes and middleware on its Hono app and one table in its Postgres (`agent_token` schema). |
| 61 | +- **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). |
| 62 | +- **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. |
79 | 63 |
|
80 | | -`createAgentTokenVerifier` is the bearer lookup as a plain function, for a |
81 | | -hub mount that wants to fall back to its own authentication when no bearer |
82 | | -is presented. It does not compare tenants: the caller checks the returned |
83 | | -`tenantId` against whatever it scopes the request to. |
| 64 | +## Reference |
84 | 65 |
|
85 | | -## Run-scoped mounts |
| 66 | +### `mountAgentTokens(app, opts)` |
86 | 67 |
|
87 | | -A mount that serves a deployed agent's run, rather than a browser session, |
88 | | -imports its types from here: `ResolvedWorkflowRunScope` (`{ tenantId, |
89 | | -principalId, runId }`), `WorkflowRunScopeEnv` (the hub's `TenantEnv` plus a |
90 | | -`workflowRunScope` variable) and `AgentTokenAuth`, the host's `verify` and |
91 | | -`resolveRun` pair. `createAgentTokenVerifier({ db })` is a ready `verify`; |
92 | | -the mount refuses a token whose `tenantId` is not the resolved run's. |
| 68 | +Mounts relative routes on a `Hono<TenantEnv>`. Put it under the hub's tenant prefix. |
93 | 69 |
|
94 | | -## Security |
| 70 | +| `opts` | Type | What the host provides | |
| 71 | +| ------------------- | ------------------- | -------------------------------------------------------------------------------------- | |
| 72 | +| `db` | `AgentTokenDb` | The hub's drizzle handle, for example from `createDB`. | |
| 73 | +| `requireGrant` | `MiddlewareHandler` | The grant check for minting a credential, run on every route. | |
| 74 | +| `resolveDefinition` | `ResolveDefinition` | `(tenantId, definitionId) => boolean \| Promise<boolean>`: whether the tenant owns it. | |
95 | 75 |
|
96 | | -- The token is 32 random bytes from `node:crypto`'s `randomBytes`, |
97 | | - base64url-encoded. |
98 | | -- Only `sha256(token)` is stored; lookups are by digest, and the digest |
99 | | - comparison is constant-time. |
100 | | -- Scope — the tenant and the definition — travels with the row, not with |
101 | | - the caller's claim. The tenant is read off the host's context and the |
102 | | - definition is confirmed to belong to it before a token is minted. |
103 | | -- Every route runs the host's grant middleware, so authority is checked |
104 | | - once and by the host. |
105 | | -- Revoke sets `revoked_at`; a revoked row never verifies again and cannot |
106 | | - be un-revoked. |
| 76 | +| Route | Result | |
| 77 | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 78 | +| `GET /agent-tokens` | `200 { tokens }`, without plaintext or digest. | |
| 79 | +| `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. | |
| 80 | +| `DELETE /agent-tokens/:id` | Revokes: `200 { ok: true }`, or `404 not_found`. Revocation is final. | |
107 | 81 |
|
108 | | -## Migrations |
| 82 | +### `requireAgentToken({ db })` |
109 | 83 |
|
110 | | -The package ships its SQL. Run it after Interchange's own migrations, with |
111 | | -the same config and schema; it is idempotent and advisory-locked, so |
112 | | -concurrent hub replicas cannot race it: |
| 84 | +Middleware that reads `Authorization: Bearer <token>` 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. |
113 | 85 |
|
114 | | -```ts |
115 | | -import { runAgentTokenMigrations } from "@corbits/agent-token/migrations"; |
116 | | -import { runMigrations, type DBConfig } from "@intx/db"; |
| 86 | +### `createAgentTokenVerifier({ db })` |
117 | 87 |
|
118 | | -export async function migrate(dbConfig: DBConfig) { |
119 | | - await runMigrations(dbConfig, { schema: "public" }); |
120 | | - await runAgentTokenMigrations(dbConfig, { schema: "public" }); |
121 | | -} |
122 | | -``` |
| 88 | +The same check as a function, `(c) => Promise<AgentTokenContext | undefined>`, for a route that falls back to its own authentication when no agent token is presented. It also refuses other-tenant tokens. |
| 89 | + |
| 90 | +### Run-scoped types |
| 91 | + |
| 92 | +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. |
| 93 | + |
| 94 | +### Functions |
| 95 | + |
| 96 | +`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. |
| 97 | + |
| 98 | +### `runAgentTokenMigrations(dbConfig, { schema })` |
| 99 | + |
| 100 | +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. |
| 101 | + |
| 102 | +## Using with Interchange |
| 103 | + |
| 104 | +1. Run `runAgentTokenMigrations` at hub start, after `runMigrations`. |
| 105 | +2. Mount `mountAgentTokens` under the tenant prefix, gated with `requireGrant("credential:*", "create")`, since minting a token creates a credential. |
| 106 | +3. Give the minted token to the deployed agent, for example as a secret in its environment. |
| 107 | +4. Put `requireAgentToken` (or `createAgentTokenVerifier`) on each hub route the agent calls. |
| 108 | + |
| 109 | +## Upgrading from 0.1 |
| 110 | + |
| 111 | +- Existing databases and tokens keep working: the migration is unchanged and re-runs cleanly, and tokens minted by 0.1.0 still verify. |
| 112 | +- `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. |
| 113 | +- `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `postgres` are now peer dependencies. |
| 114 | +- The root no longer exports `agentTokenSchema`, `agentTokenTable`, `generateAgentToken`, `hashAgentToken`, `agentTokenHashEquals` or `bearerFromAuthorization`. |
| 115 | +- `mountAgentTokens` drops `resolveTenantId` and reads the tenant from `TenantEnv`. |
| 116 | +- `requireAgentToken` and `createAgentTokenVerifier` now refuse tokens minted by another tenant, and need the hub's tenant middleware in front of them. |
123 | 117 |
|
124 | 118 | ## License |
125 | 119 |
|
126 | | -LGPL-2.1 |
| 120 | +[LGPL-2.1](https://github.com/corbitsdev/corbits-agent-token/blob/main/LICENSE) |
0 commit comments