diff --git a/e2e/agent-token.drizzle.test.ts b/e2e/agent-token.drizzle.test.ts index 1d75a4b..302a627 100644 --- a/e2e/agent-token.drizzle.test.ts +++ b/e2e/agent-token.drizzle.test.ts @@ -21,6 +21,10 @@ import { import { mountAgentTokens } from "../src/mount"; import { mountRunScopedTokens } from "../src/mount-run-scoped"; import { createAgentTokenVerifier, requireAgentToken } from "../src/middleware"; +import { + createRunBoundTokenVerifier, + requireRunBoundToken, +} from "../src/run-bound"; import type { WorkflowRunScopeEnv } from "../src/workflow-run-scope"; const databaseUrl = process.env.DATABASE_URL; @@ -539,4 +543,194 @@ describeIfDb("agent tokens", () => { await close(); } }); + + test("requireRunBoundToken admits a run-bound bearer and sets workflowRunScope from the token", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_rb_${randomUUID().slice(0, 8)}`; + await seedTenant(db, tenantId); + + const minted = await mintRunScopedToken(db, { + tenantId, + definitionId: "def_artifacts", + name: "workflow", + runId: "run_bound_1", + }); + + const scope = { + tenantId, + principalId: "principal_1", + runId: "run_bound_1", + }; + + const app = new Hono(); + app.use(tenantFrom(db, tenantId)); + app.get( + "/guarded", + requireRunBoundToken({ + db, + resolveRunById: (runId) => (runId === "run_bound_1" ? scope : null), + }), + (c) => + c.json({ + tokenRunId: c.get("agentToken").runId, + scopeRunId: c.get("workflowRunScope").runId, + principalId: c.get("workflowRunScope").principalId, + }), + ); + + const ok = await app.request("/guarded", { + headers: { authorization: `Bearer ${minted.token}` }, + }); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ + tokenRunId: "run_bound_1", + scopeRunId: "run_bound_1", + principalId: "principal_1", + }); + } finally { + await close(); + } + }); + + test("requireRunBoundToken returns identical 401 for every rejection case", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_rb401_${randomUUID().slice(0, 8)}`; + const otherTenantId = `tnt_rb401_o_${randomUUID().slice(0, 8)}`; + await seedTenant(db, tenantId); + await seedTenant(db, otherTenantId); + + // A run-bound token. + const bound = await mintRunScopedToken(db, { + tenantId, + definitionId: "def_artifacts", + name: "workflow", + runId: "run_401", + }); + + // A definition-scoped token (no runId). + const unbound = await mintAgentToken(db, { + tenantId, + definitionId: "def_artifacts", + name: "artifacts", + }); + + // A token from a different tenant. + const foreign = await mintRunScopedToken(db, { + tenantId: otherTenantId, + definitionId: "def_artifacts", + name: "workflow", + runId: "run_foreign", + }); + + const app = new Hono(); + app.use(tenantFrom(db, tenantId)); + app.get( + "/guarded", + requireRunBoundToken({ + db, + resolveRunById: (runId) => + runId === "run_401" ? { tenantId, principalId: "p1", runId } : null, + }), + (c) => c.json({ ok: true }), + ); + + const expected = { error: "unauthorized" }; + + // No bearer. + const noBear = await app.request("/guarded"); + expect(noBear.status).toBe(401); + expect(await noBear.json()).toEqual(expected); + + // Fabricated bearer. + const fake = await app.request("/guarded", { + headers: { authorization: "Bearer invented" }, + }); + expect(fake.status).toBe(401); + expect(await fake.json()).toEqual(expected); + + // Valid token but no runId (definition-scoped). + const noRun = await app.request("/guarded", { + headers: { authorization: `Bearer ${unbound.token}` }, + }); + expect(noRun.status).toBe(401); + expect(await noRun.json()).toEqual(expected); + + // Valid token from another tenant. + const cross = await app.request("/guarded", { + headers: { authorization: `Bearer ${foreign.token}` }, + }); + expect(cross.status).toBe(401); + expect(await cross.json()).toEqual(expected); + + // Valid run-bound token (positive control). + const ok = await app.request("/guarded", { + headers: { authorization: `Bearer ${bound.token}` }, + }); + expect(ok.status).toBe(200); + } finally { + await close(); + } + }); + + test("createRunBoundTokenVerifier resolves run-bound tokens and rejects everything else", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_rbv_${randomUUID().slice(0, 8)}`; + await seedTenant(db, tenantId); + + const bound = await mintRunScopedToken(db, { + tenantId, + definitionId: "def_artifacts", + name: "workflow", + runId: "run_verifier", + }); + + const unbound = await mintAgentToken(db, { + tenantId, + definitionId: "def_artifacts", + name: "artifacts", + }); + + const verify = createRunBoundTokenVerifier({ + db, + resolveRunById: (runId) => + runId === "run_verifier" + ? { tenantId, principalId: "p1", runId } + : null, + }); + + const app = new Hono(); + app.use("/:tenantId/*", async (c, next) => + tenantFrom(db, c.req.param("tenantId"))(c, next), + ); + app.get("/:tenantId/check", async (c) => { + const result = await verify(c); + return c.json({ result: result ?? null }); + }); + + const ok = await app.request(`/${tenantId}/check`, { + headers: { authorization: `Bearer ${bound.token}` }, + }); + expect(ok.status).toBe(200); + const body = (await ok.json()) as { + result: { workflowRunScope: { runId: string } } | null; + }; + expect(body.result).not.toBeNull(); + expect(body.result!.workflowRunScope.runId).toBe("run_verifier"); + + // Unbound token returns null. + const noRun = await app.request(`/${tenantId}/check`, { + headers: { authorization: `Bearer ${unbound.token}` }, + }); + expect(((await noRun.json()) as { result: unknown }).result).toBeNull(); + + // No bearer returns null. + const noBear = await app.request(`/${tenantId}/check`); + expect(((await noBear.json()) as { result: unknown }).result).toBeNull(); + } finally { + await close(); + } + }); }); diff --git a/src/index.ts b/src/index.ts index 5bd0ac8..642eb9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,14 @@ export { type ResolveRunForToken, type RunScopeForToken, } from "./mount-run-scoped.js"; +export { + createRunBoundTokenVerifier, + requireRunBoundToken, + type ResolveRunById, + type RequireRunBoundTokenOpts, + type RunBoundTokenVariables, + type RunBoundTokenVerifier, +} from "./run-bound.js"; export { createAgentTokenVerifier, requireAgentToken, diff --git a/src/run-bound.ts b/src/run-bound.ts new file mode 100644 index 0000000..235cdbe --- /dev/null +++ b/src/run-bound.ts @@ -0,0 +1,104 @@ +// Middleware and verifier that bind an agent token to the specific run it +// was issued for. A token with a `runId` is only valid for requests to that +// run; requests for any other run get the same 401 as an unknown bearer. +// The run identity comes from the *token*, not from a request header, so +// the consumer can trust it without a second header-based lookup. +import type { TenantEnv } from "@intx/hub-api"; +import type { Context, MiddlewareHandler } from "hono"; + +import { + type AgentTokenContext, + type AgentTokenVariables, + createAgentTokenVerifier, + type RequireAgentTokenOpts, +} from "./middleware.js"; +import type { + ResolvedWorkflowRunScope, + WorkflowRunScopeEnv, +} from "./workflow-run-scope.js"; + +/** `(runId) => scope | null`: the host's lookup from a token's bound run ID + * to the full run scope it names, or `null` for unknown runs. Unlike + * `resolveRun(runAddress)` in `AgentTokenAuth`, this resolves by the stored + * run ID, not by a user-supplied address. */ +export type ResolveRunById = ( + runId: string, +) => Promise | ResolvedWorkflowRunScope | null; + +export type RequireRunBoundTokenOpts> = + RequireAgentTokenOpts & { + resolveRunById: ResolveRunById; + }; + +/** Context variables set by `requireRunBoundToken`: the verified token + * *and* the resolved workflow run scope. */ +export type RunBoundTokenVariables = AgentTokenVariables & { + workflowRunScope: ResolvedWorkflowRunScope; +}; + +/** Middleware that verifies a bearer, asserts it is bound to a run, resolves + * that run, and sets both `agentToken` and `workflowRunScope` on the + * context. Every rejection — missing bearer, invalid token, unbound token, + * unknown run, wrong tenant — returns the same `401 { "error": + * "unauthorized" }` so a response never reveals whether a token is live. + * + * Must run behind the hub's tenant middleware. */ +export function requireRunBoundToken>( + opts: RequireRunBoundTokenOpts, +): MiddlewareHandler { + const verify = createAgentTokenVerifier(opts); + + return async (c, next) => { + const identity = await verify(c); + + // Token missing, invalid, revoked or wrong tenant. + if (identity === undefined) { + return c.json({ error: "unauthorized" }, 401); + } + + // Token not bound to a run (definition-scoped only). + if (identity.runId === null) { + return c.json({ error: "unauthorized" }, 401); + } + + const scope = await opts.resolveRunById(identity.runId); + + // Run unknown or belongs to a different tenant. + if (scope === null || scope.tenantId !== identity.tenantId) { + return c.json({ error: "unauthorized" }, 401); + } + + c.set("agentToken", identity); + c.set("workflowRunScope", scope); + await next(); + return undefined; + }; +} + +/** The same check as a function, for a route that falls back to its own + * authentication when no run-bound agent token is presented. */ +export type RunBoundTokenVerifier = ( + c: Context, +) => Promise< + | { + agentToken: AgentTokenContext; + workflowRunScope: ResolvedWorkflowRunScope; + } + | undefined +>; + +export function createRunBoundTokenVerifier< + TSchema extends Record, +>(opts: RequireRunBoundTokenOpts): RunBoundTokenVerifier { + const verify = createAgentTokenVerifier(opts); + + return async (c) => { + const identity = await verify(c); + if (identity === undefined || identity.runId === null) return undefined; + const scope = await opts.resolveRunById(identity.runId); + if (scope === null || scope.tenantId !== identity.tenantId) { + return undefined; + } + return { agentToken: identity, workflowRunScope: scope }; + }; +}