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
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ const tokenApp = new Hono<TenantEnv>();
mountAgentTokens(tokenApp, {
db,
requireGrant: requireGrant("credential:*", "create"),
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 @@ -43,32 +42,33 @@ app.route("/api/tenants/:tenantId", tokenApp);
## Middleware (`requireAgentToken`)

Reads `Authorization: Bearer`, hashes the presented value, and looks up an
unrevoked row. On success it sets `agentToken` — `{ id, tenantId,
definitionId }` — on the context; missing, malformed, unknown and revoked
tokens all get the same bare 401, with no detail that would tell a caller
which it was.
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.

```ts
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.
if (tenantId !== c.req.param("tenantId")) return c.json({ error: "forbidden" }, 403);
const { definitionId } = c.get("agentToken");
...
});
```

`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.
`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`.
`resolveRun` pair. `createAgentTokenVerifier({ db })` is a ready `verify`;
the mount refuses a token whose `tenantId` is not the resolved run's.

## Security

Expand Down
85 changes: 73 additions & 12 deletions src/agent-token.drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ 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 { eq } from "drizzle-orm";
import { Hono, type MiddlewareHandler } from "hono";

import { applyAgentTokenMigrations } from "./schema";
import { hashAgentToken, mintAgentToken, revokeAgentToken, verifyAgentToken } from "./tokens";
Expand All @@ -30,13 +31,28 @@ function dbTargetFromUrl(url: string) {
};
}

/** Stands in for the hub's tenant middleware. */
function tenantFrom<E extends TenantEnv>(
db: ReturnType<typeof createDB>["db"],
tenantId: string,
): MiddlewareHandler<E> {
return async (c, next) => {
const [tenant] = await db.select().from(schema.tenant).where(eq(schema.tenant.id, tenantId));
if (tenant === undefined) return c.json({ error: "not_found" }, 404);
c.set("tenant", tenant);
await next();
return undefined;
};
}

/** Every route runs the host's own middleware; these stand in for it. */
function mountedApp(
db: ReturnType<typeof createDB>["db"],
tenantId: string,
options: { gate?: "allow" | "deny"; owns?: readonly string[] } = {},
) {
const app = new Hono<TenantEnv>();
app.use(tenantFrom(db, tenantId));
const owns = options.owns ?? ["def_artifacts"];
mountAgentTokens(app, {
db,
Expand All @@ -45,7 +61,6 @@ function mountedApp(
await next();
return undefined;
},
resolveTenantId: () => tenantId,
resolveDefinition: (_tenantId, definitionId) => owns.includes(definitionId),
});
return app;
Expand Down Expand Up @@ -158,31 +173,53 @@ describeIfDb("agent tokens", () => {
}
});

test("the middleware admits a live bearer and 401s everything else", async () => {
test("the middleware admits a live bearer on its own tenant and 401s everything else", async () => {
const { db, close } = createDB({ ...target, schema: SCHEMA });
try {
const tenantId = `tnt_mw_${randomUUID().slice(0, 8)}`;
const otherTenantId = `tnt_mw_other_${randomUUID().slice(0, 8)}`;
await seedTenant(db, tenantId);
await seedTenant(db, otherTenantId);
const minted = await mintAgentToken(db, {
tenantId,
definitionId: "def_artifacts",
name: "artifacts",
});

const app = new Hono<TenantEnv>();
app.get("/guarded", requireAgentToken({ db }), (c) =>
app.use("/tenants/:tenantId/*", async (c, next) => {
const [tenant] = await db
.select()
.from(schema.tenant)
.where(eq(schema.tenant.id, c.req.param("tenantId")));
if (tenant === undefined) return c.json({ error: "not_found" }, 404);
c.set("tenant", tenant);
await next();
return undefined;
});
app.get("/tenants/:tenantId/guarded", requireAgentToken({ db }), (c) =>
c.json({ tenantId: c.get("agentToken").tenantId }),
);
app.get("/untenanted", requireAgentToken({ db }), (c) => c.json({ ok: true }));
const bearer = { headers: { authorization: `Bearer ${minted.token}` } };

const ok = await app.request("/guarded", {
headers: { authorization: `Bearer ${minted.token}` },
});
const ok = await app.request(`/tenants/${tenantId}/guarded`, bearer);
expect(ok.status).toBe(200);
expect(await ok.json()).toEqual({ tenantId });

expect((await app.request("/guarded")).status).toBe(401);
const crossTenant = await app.request(`/tenants/${otherTenantId}/guarded`, bearer);
expect(crossTenant.status).toBe(401);

// Mounted without the tenant middleware, a valid token never passes.
expect((await app.request("/untenanted", bearer)).status).toBe(500);

expect((await app.request(`/tenants/${tenantId}/guarded`)).status).toBe(401);
expect(
(await app.request("/guarded", { headers: { authorization: "Bearer nope" } })).status,
(
await app.request(`/tenants/${tenantId}/guarded`, {
headers: { authorization: "Bearer nope" },
})
).status,
).toBe(401);
} finally {
await close();
Expand All @@ -199,11 +236,17 @@ describeIfDb("agent tokens", () => {
name: "artifacts",
});

const otherTenantId = `tnt_ver_other_${randomUUID().slice(0, 8)}`;
await seedTenant(db, otherTenantId);

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", {
app.use("/:tenantId/*", async (c, next) =>
tenantFrom<WorkflowRunScopeEnv>(db, c.req.param("tenantId"))(c, next),
);
app.get("/:tenantId/verify", async (c) => c.json({ identity: (await verify(c)) ?? null }));
const identityFor = async (authorization?: string, onTenant = tenantId) => {
const res = await app.request(`/${onTenant}/verify`, {
headers: authorization === undefined ? {} : { authorization },
});
return ((await res.json()) as { identity: unknown }).identity;
Expand All @@ -214,6 +257,7 @@ describeIfDb("agent tokens", () => {
tenantId,
definitionId: "def_artifacts",
});
expect(await identityFor(`Bearer ${minted.token}`, otherTenantId)).toBeNull();
expect(await identityFor()).toBeNull();
expect(await identityFor("Bearer nope")).toBeNull();
await revokeAgentToken(db, { tenantId, id: minted.id });
Expand All @@ -222,4 +266,21 @@ describeIfDb("agent tokens", () => {
await close();
}
});

test("the verifier rejects, not throws, when the host has no tenant middleware", async () => {
const { db, close } = createDB({ ...target, schema: SCHEMA });
try {
const verify = createAgentTokenVerifier({ db });
const app = new Hono<WorkflowRunScopeEnv>();
app.get("/verify", (c) =>
verify(c).then(
() => c.text("resolved"),
() => c.text("rejected"),
),
);
expect(await (await app.request("/verify")).text()).toBe("rejected");
} finally {
await close();
}
});
});
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export {
mountAgentTokens,
type MountAgentTokensOpts,
type ResolveDefinition,
type ResolveTenantId,
} from "./mount.js";
export {
createAgentTokenVerifier,
Expand Down
17 changes: 11 additions & 6 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.
// context. Anything it cannot resolve to an unrevoked row minted by the
// route's own tenant is a 401.
import type { TenantEnv } from "@intx/hub-api";
import type { Context, MiddlewareHandler } from "hono";

Expand All @@ -20,33 +21,37 @@ 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. */
* themselves rather than run this as middleware. Like the middleware, it only
* resolves tokens minted by the context's own tenant. */
export type AgentTokenVerifier = <E extends TenantEnv>(
c: Context<E>,
) => Promise<AgentTokenContext | undefined>;

async function verifyAuthorization<TSchema extends Record<string, unknown>>(
db: AgentTokenDb<TSchema>,
tenantId: string,
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;
if (identity === undefined || identity.tenantId !== tenantId) 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 (c) => verifyAuthorization(opts.db, c.req.header("authorization"));
// The tenant is read before the header, so a host missing the tenant
// middleware rejects every request, not only valid tokens.
return async (c) => verifyAuthorization(opts.db, c.get("tenant").id, c.req.header("authorization"));
}

export function requireAgentToken<TSchema extends Record<string, unknown>>(
opts: RequireAgentTokenOpts<TSchema>,
): MiddlewareHandler<{ Variables: AgentTokenVariables }> {
): MiddlewareHandler<TenantEnv & { Variables: AgentTokenVariables }> {
return async (c, next) => {
const identity = await verifyAuthorization(opts.db, c.req.header("authorization"));
const identity = await verifyAuthorization(opts.db, c.get("tenant").id, c.req.header("authorization"));
if (identity === undefined) return c.json({ error: "unauthorized" }, 401);
c.set("agentToken", identity);
await next();
Expand Down
13 changes: 4 additions & 9 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ 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<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
* definition the tenant does not own would widen it past the tenant. */
Expand All @@ -30,7 +26,6 @@ export type MountAgentTokensOpts<E extends TenantEnv, TSchema extends Record<str
* interprets.
*/
requireGrant: MiddlewareHandler<E>;
resolveTenantId: ResolveTenantId<E>;
resolveDefinition: ResolveDefinition;
};

Expand All @@ -44,7 +39,7 @@ export function mountAgentTokens<E extends TenantEnv, TSchema extends Record<str
app: Hono<E>,
opts: MountAgentTokensOpts<E, TSchema>,
): Hono<E> {
const { db, requireGrant, resolveTenantId, resolveDefinition } = opts;
const { db, requireGrant, resolveDefinition } = opts;

app.get("/agent-tokens", requireGrant, async (c: Context<E>) => {
const rows = await db
Expand All @@ -57,13 +52,13 @@ export function mountAgentTokens<E extends TenantEnv, TSchema extends Record<str
revokedAt: agentTokenTable.revokedAt,
})
.from(agentTokenTable)
.where(eq(agentTokenTable.tenantId, resolveTenantId(c)))
.where(eq(agentTokenTable.tenantId, c.get("tenant").id))
.orderBy(desc(agentTokenTable.createdAt));
return c.json({ tokens: rows });
});

app.post("/agent-tokens", requireGrant, async (c: Context<E>) => {
const tenantId = resolveTenantId(c);
const tenantId = c.get("tenant").id;
const parsed = MintBody(await c.req.json().catch(() => undefined));
if (parsed instanceof type.errors) {
return c.json({ error: "invalid_body", detail: parsed.summary }, 400);
Expand All @@ -83,7 +78,7 @@ export function mountAgentTokens<E extends TenantEnv, TSchema extends Record<str

app.delete("/agent-tokens/:id", requireGrant, async (c: Context<E, "/agent-tokens/:id">) => {
const revoked = await revokeAgentToken(db, {
tenantId: resolveTenantId(c),
tenantId: c.get("tenant").id,
id: c.req.param("id"),
});
if (!revoked) return c.json({ error: "not_found" }, 404);
Expand Down