From 471313ff41ba3ee04b70322358fd11e7c551f924 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 22:56:50 -0700 Subject: [PATCH 1/2] fix(middleware): refuse agent tokens minted by another tenant Leaving the tenant comparison to each consuming route made forgetting it a cross-tenant hole. --- README.md | 25 +++++++++++----------- src/agent-token.drizzle.test.ts | 37 ++++++++++++++++++++++++++------- src/middleware.ts | 11 +++++++--- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 3188789..6786202 100644 --- a/README.md +++ b/README.md @@ -43,24 +43,24 @@ 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. It does not compare tenants: the caller checks the returned +`tenantId` against whatever it scopes the request to. ## Run-scoped mounts @@ -68,7 +68,8 @@ 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 diff --git a/src/agent-token.drizzle.test.ts b/src/agent-token.drizzle.test.ts index 6753299..e85637c 100644 --- a/src/agent-token.drizzle.test.ts +++ b/src/agent-token.drizzle.test.ts @@ -6,6 +6,7 @@ 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 { eq } from "drizzle-orm"; import { Hono } from "hono"; import { applyAgentTokenMigrations } from "./schema"; @@ -158,11 +159,13 @@ 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", @@ -170,19 +173,39 @@ describeIfDb("agent tokens", () => { }); const app = new Hono(); - 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(); diff --git a/src/middleware.ts b/src/middleware.ts index 8b7f98f..c7688dc 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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"; @@ -44,10 +45,14 @@ export function createAgentTokenVerifier export function requireAgentToken>( opts: RequireAgentTokenOpts, -): MiddlewareHandler<{ Variables: AgentTokenVariables }> { +): MiddlewareHandler { return async (c, next) => { + // Read first so a host missing the tenant middleware fails every request. + const tenantId = c.get("tenant").id; const identity = await verifyAuthorization(opts.db, c.req.header("authorization")); - if (identity === undefined) return c.json({ error: "unauthorized" }, 401); + if (identity === undefined || identity.tenantId !== tenantId) { + return c.json({ error: "unauthorized" }, 401); + } c.set("agentToken", identity); await next(); return undefined; From b50eb061c406a2df792c4a830ce1a109650078d5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 11:16:01 -0700 Subject: [PATCH 2/2] fix(verifier)!: check the tenant and take it from TenantEnv in the mount createAgentTokenVerifier now refuses another tenant's token like requireAgentToken does, and mountAgentTokens reads the tenant from TenantEnv instead of a resolveTenantId option that could disagree. --- README.md | 5 ++-- src/agent-token.drizzle.test.ts | 48 +++++++++++++++++++++++++++++---- src/index.ts | 1 - src/middleware.ts | 18 ++++++------- src/mount.ts | 13 +++------ 5 files changed, 58 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 6786202..ba5fa1a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ const tokenApp = new Hono(); 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), @@ -59,8 +58,8 @@ app.get("/api/tenants/:tenantId/artifacts/:id", requireAgentToken({ db }), (c) = `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. It does not compare tenants: the caller checks the returned -`tenantId` against whatever it scopes the request to. +is presented. Like the middleware, it refuses a token minted by another +tenant. ## Run-scoped mounts diff --git a/src/agent-token.drizzle.test.ts b/src/agent-token.drizzle.test.ts index e85637c..f0607e1 100644 --- a/src/agent-token.drizzle.test.ts +++ b/src/agent-token.drizzle.test.ts @@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto"; import { createDB, dropSchema, runMigrations, schema } from "@intx/db"; import type { TenantEnv } from "@intx/hub-api"; import { eq } from "drizzle-orm"; -import { Hono } from "hono"; +import { Hono, type MiddlewareHandler } from "hono"; import { applyAgentTokenMigrations } from "./schema"; import { hashAgentToken, mintAgentToken, revokeAgentToken, verifyAgentToken } from "./tokens"; @@ -31,6 +31,20 @@ function dbTargetFromUrl(url: string) { }; } +/** Stands in for the hub's tenant middleware. */ +function tenantFrom( + db: ReturnType["db"], + tenantId: string, +): MiddlewareHandler { + 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["db"], @@ -38,6 +52,7 @@ function mountedApp( options: { gate?: "allow" | "deny"; owns?: readonly string[] } = {}, ) { const app = new Hono(); + app.use(tenantFrom(db, tenantId)); const owns = options.owns ?? ["def_artifacts"]; mountAgentTokens(app, { db, @@ -46,7 +61,6 @@ function mountedApp( await next(); return undefined; }, - resolveTenantId: () => tenantId, resolveDefinition: (_tenantId, definitionId) => owns.includes(definitionId), }); return app; @@ -222,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(); - 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(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; @@ -237,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 }); @@ -245,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(); + 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(); + } + }); }); diff --git a/src/index.ts b/src/index.ts index ceee902..e84470d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,6 @@ export { mountAgentTokens, type MountAgentTokensOpts, type ResolveDefinition, - type ResolveTenantId, } from "./mount.js"; export { createAgentTokenVerifier, diff --git a/src/middleware.ts b/src/middleware.ts index c7688dc..d64530b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -21,38 +21,38 @@ export type RequireAgentTokenOpts> = { }; /** 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 = ( c: Context, ) => Promise; async function verifyAuthorization>( db: AgentTokenDb, + tenantId: string, authorization: string | undefined, ): Promise { 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>( opts: RequireAgentTokenOpts, ): 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>( opts: RequireAgentTokenOpts, ): MiddlewareHandler { return async (c, next) => { - // Read first so a host missing the tenant middleware fails every request. - const tenantId = c.get("tenant").id; - const identity = await verifyAuthorization(opts.db, c.req.header("authorization")); - if (identity === undefined || identity.tenantId !== tenantId) { - return c.json({ error: "unauthorized" }, 401); - } + 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(); return undefined; diff --git a/src/mount.ts b/src/mount.ts index 3c09e20..3c243f9 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -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 = (c: Context) => 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. */ @@ -30,7 +26,6 @@ export type MountAgentTokensOpts; - resolveTenantId: ResolveTenantId; resolveDefinition: ResolveDefinition; }; @@ -44,7 +39,7 @@ export function mountAgentTokens, opts: MountAgentTokensOpts, ): Hono { - const { db, requireGrant, resolveTenantId, resolveDefinition } = opts; + const { db, requireGrant, resolveDefinition } = opts; app.get("/agent-tokens", requireGrant, async (c: Context) => { const rows = await db @@ -57,13 +52,13 @@ export function mountAgentTokens) => { - 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); @@ -83,7 +78,7 @@ export function mountAgentTokens) => { 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);