Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const tokenApp = new Hono<TenantEnv>();
mountAgentTokens(tokenApp, {
db,
requireGrant: requireGrant("credential:*", "create"),
resolveTenantId: (ctx) => (ctx as { get(k: "tenant"): { id: string } }).get("tenant").id,
resolveTenantId: (c) => c.get("tenant").id,
// 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),
Expand All @@ -49,10 +49,7 @@ tokens all get the same bare 401, with no detail that would tell a caller
which it was.

```ts
type HostEnv = { Variables: AgentTokenVariables };

app.use("/api/tenants/:tenantId/artifacts/*", requireAgentToken({ db }));
app.get("/api/tenants/:tenantId/artifacts/:id", (c) => {
app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) => {
const { tenantId, definitionId } = c.get("agentToken");
// A token is scoped to the tenant that minted it: the consuming mount
// checks it against the route's own tenant param.
Expand All @@ -61,10 +58,18 @@ app.get("/api/tenants/:tenantId/artifacts/:id", (c) => {
});
```

`createAgentTokenVerifier` is the same check as a plain function, for a
`createAgentTokenVerifier` is the same check as a plain function, for a hub
mount that wants to fall back to its own authentication when no bearer is
presented.

## 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`.

## Security

- The token is 32 random bytes from `node:crypto`'s `randomBytes`,
Expand Down
336 changes: 336 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@
"postgres": "^3.4.8",
"@intx/db": "^0.4.0"
},
"peerDependencies": {
"@intx/hub-api": "^0.4.0"
},
"devDependencies": {
"@intx/hub-api": "0.4.0",
"@types/bun": "1.3.9",
"typescript": "5.9.3"
}
Expand Down
47 changes: 42 additions & 5 deletions src/agent-token.drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
import { createDB, dropSchema, runMigrations, schema } from "@intx/db";
import type { TenantEnv } from "@intx/hub-api";
import { Hono } from "hono";

import { applyAgentTokenMigrations } from "./schema";
import { hashAgentToken, mintAgentToken, revokeAgentToken, verifyAgentToken } from "./tokens";
import { mountAgentTokens } from "./mount";
import { requireAgentToken } from "./middleware";
import { createAgentTokenVerifier, requireAgentToken } from "./middleware";
import type { WorkflowRunScopeEnv } from "./workflow-run-scope";

const databaseUrl = process.env.DATABASE_URL;
const describeIfDb = databaseUrl === undefined ? describe.skip : describe;
Expand All @@ -34,7 +36,7 @@ function mountedApp(
tenantId: string,
options: { gate?: "allow" | "deny"; owns?: readonly string[] } = {},
) {
const app = new Hono();
const app = new Hono<TenantEnv>();
const owns = options.owns ?? ["def_artifacts"];
mountAgentTokens(app, {
db,
Expand Down Expand Up @@ -167,9 +169,10 @@ describeIfDb("agent tokens", () => {
name: "artifacts",
});

const app = new Hono<{ Variables: { agentToken: { tenantId: string } } }>();
app.use("/guarded", requireAgentToken({ db }));
app.get("/guarded", (c) => c.json({ tenantId: c.get("agentToken").tenantId }));
const app = new Hono<TenantEnv>();
app.get("/guarded", requireAgentToken({ db }), (c) =>
c.json({ tenantId: c.get("agentToken").tenantId }),
);

const ok = await app.request("/guarded", {
headers: { authorization: `Bearer ${minted.token}` },
Expand All @@ -185,4 +188,38 @@ describeIfDb("agent tokens", () => {
await close();
}
});
test("the verifier resolves a live bearer and nothing else", async () => {
const { db, close } = createDB({ ...target, schema: SCHEMA });
try {
const tenantId = `tnt_ver_${randomUUID().slice(0, 8)}`;
await seedTenant(db, tenantId);
const minted = await mintAgentToken(db, {
tenantId,
definitionId: "def_artifacts",
name: "artifacts",
});

const verify = createAgentTokenVerifier({ db });
const app = new Hono<WorkflowRunScopeEnv>();
app.get("/verify", async (c) => c.json({ identity: (await verify(c)) ?? null }));
const identityFor = async (authorization?: string) => {
const res = await app.request("/verify", {
headers: authorization === undefined ? {} : { authorization },
});
return ((await res.json()) as { identity: unknown }).identity;
};

expect(await identityFor(`Bearer ${minted.token}`)).toEqual({
id: minted.id,
tenantId,
definitionId: "def_artifacts",
});
expect(await identityFor()).toBeNull();
expect(await identityFor("Bearer nope")).toBeNull();
await revokeAgentToken(db, { tenantId, id: minted.id });
expect(await identityFor(`Bearer ${minted.token}`)).toBeNull();
} finally {
await close();
}
});
});
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ export {
type AgentTokenVerifier,
type RequireAgentTokenOpts,
} from "./middleware.js";
export type {
AgentTokenAuth,
ResolvedWorkflowRunScope,
WorkflowRunScopeEnv,
} from "./workflow-run-scope.js";
30 changes: 18 additions & 12 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Hono middleware that turns a presented bearer into an `agentToken` on the
// context. Anything it cannot resolve to an unrevoked row is a 401.
import type { TenantEnv } from "@intx/hub-api";
import type { Context, MiddlewareHandler } from "hono";

import { bearerFromAuthorization, verifyAgentToken, type AgentTokenDb } from "./tokens.js";
Expand All @@ -20,27 +21,32 @@ export type RequireAgentTokenOpts<TSchema extends Record<string, unknown>> = {

/** A host-side verifier, for mounts that want to authenticate a bearer
* themselves rather than run this as middleware. */
export type AgentTokenVerifier = (ctx: unknown) => Promise<AgentTokenContext | undefined>;
export type AgentTokenVerifier = <E extends TenantEnv>(
c: Context<E>,
) => Promise<AgentTokenContext | undefined>;

async function verifyAuthorization<TSchema extends Record<string, unknown>>(
db: AgentTokenDb<TSchema>,
authorization: string | undefined,
): Promise<AgentTokenContext | undefined> {
const token = bearerFromAuthorization(authorization);
if (token === undefined) return undefined;
const identity = await verifyAgentToken(db, token);
if (identity === undefined) return undefined;
return { id: identity.id, tenantId: identity.tenantId, definitionId: identity.definitionId };
}

export function createAgentTokenVerifier<TSchema extends Record<string, unknown>>(
opts: RequireAgentTokenOpts<TSchema>,
): AgentTokenVerifier {
return async (ctx) => {
const c = ctx as Context;
const token = bearerFromAuthorization(c.req.header("authorization"));
if (token === undefined) return undefined;
const identity = await verifyAgentToken(opts.db, token);
if (identity === undefined) return undefined;
return { id: identity.id, tenantId: identity.tenantId, definitionId: identity.definitionId };
};
return (c) => verifyAuthorization(opts.db, c.req.header("authorization"));
}

export function requireAgentToken<TSchema extends Record<string, unknown>>(
opts: RequireAgentTokenOpts<TSchema>,
): MiddlewareHandler {
const verify = createAgentTokenVerifier(opts);
): MiddlewareHandler<{ Variables: AgentTokenVariables }> {
return async (c, next) => {
const identity = await verify(c);
const identity = await verifyAuthorization(opts.db, c.req.header("authorization"));
if (identity === undefined) return c.json({ error: "unauthorized" }, 401);
c.set("agentToken", identity);
await next();
Expand Down
21 changes: 11 additions & 10 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
// host's authenticated context and never from a path parameter.
import { type } from "arktype";
import { desc, eq } from "drizzle-orm";
import type { Env, Hono, MiddlewareHandler } from "hono";
import type { TenantEnv } from "@intx/hub-api";
import type { Context, Hono, MiddlewareHandler } from "hono";

import { agentTokenTable } from "./schema.js";
import { mintAgentToken, revokeAgentToken, type AgentTokenDb } from "./tokens.js";

/** Reads the acting tenant off the host's context. The host owns how its
* tenant is resolved; this package never reimplements that. */
export type ResolveTenantId = (ctx: unknown) => string;
export type ResolveTenantId<E extends TenantEnv> = (c: Context<E>) => string;

/** Answers whether `definitionId` names an agent definition this tenant
* owns. A token is scoped to a definition, so minting one against a
Expand All @@ -20,16 +21,16 @@ export type ResolveDefinition = (
definitionId: string,
) => Promise<boolean> | boolean;

export type MountAgentTokensOpts<TSchema extends Record<string, unknown>> = {
export type MountAgentTokensOpts<E extends TenantEnv, TSchema extends Record<string, unknown>> = {
db: AgentTokenDb<TSchema>;
/**
* 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
* interprets.
*/
requireGrant: MiddlewareHandler;
resolveTenantId: ResolveTenantId;
requireGrant: MiddlewareHandler<E>;
resolveTenantId: ResolveTenantId<E>;
resolveDefinition: ResolveDefinition;
};

Expand All @@ -39,13 +40,13 @@ const MintBody = type({
});

/** Mount `/agent-tokens` onto the host's app, under its tenant prefix. */
export function mountAgentTokens<E extends Env, TSchema extends Record<string, unknown>>(
export function mountAgentTokens<E extends TenantEnv, TSchema extends Record<string, unknown>>(
app: Hono<E>,
opts: MountAgentTokensOpts<TSchema>,
opts: MountAgentTokensOpts<E, TSchema>,
): Hono<E> {
const { db, requireGrant, resolveTenantId, resolveDefinition } = opts;

app.get("/agent-tokens", requireGrant, async (c) => {
app.get("/agent-tokens", requireGrant, async (c: Context<E>) => {
const rows = await db
.select({
id: agentTokenTable.id,
Expand All @@ -61,7 +62,7 @@ export function mountAgentTokens<E extends Env, TSchema extends Record<string, u
return c.json({ tokens: rows });
});

app.post("/agent-tokens", requireGrant, async (c) => {
app.post("/agent-tokens", requireGrant, async (c: Context<E>) => {
const tenantId = resolveTenantId(c);
const parsed = MintBody(await c.req.json().catch(() => undefined));
if (parsed instanceof type.errors) {
Expand All @@ -80,7 +81,7 @@ export function mountAgentTokens<E extends Env, TSchema extends Record<string, u
return c.json({ token: minted }, 201);
});

app.delete("/agent-tokens/:id", requireGrant, async (c) => {
app.delete("/agent-tokens/:id", requireGrant, async (c: Context<E, "/agent-tokens/:id">) => {
const revoked = await revokeAgentToken(db, {
tenantId: resolveTenantId(c),
id: c.req.param("id"),
Expand Down
29 changes: 29 additions & 0 deletions src/workflow-run-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// The run scope a host resolves for a deployed agent's call, shared by every
// run-scoped mount that accepts an agent token.
import type { TenantEnv } from "@intx/hub-api";

import type { AgentTokenVerifier } from "./middleware.js";

/** Who a run authenticates as, and which run it is acting on behalf of. */
export type ResolvedWorkflowRunScope = {
readonly tenantId: string;
readonly principalId: string;
readonly runId: string;
};

export type WorkflowRunScopeEnv = TenantEnv & {
Variables: TenantEnv["Variables"] & { workflowRunScope: ResolvedWorkflowRunScope };
};

/**
* The host's two halves of agent authentication. `verify` reads the
* presented `Authorization` header and returns `undefined` when it is not an
* agent token; `resolveRun` is the host's lookup from a run address to the
* run it names.
*/
export type AgentTokenAuth = {
verify: AgentTokenVerifier;
resolveRun: (
runAddress: string,
) => Promise<ResolvedWorkflowRunScope | null> | ResolvedWorkflowRunScope | null;
};