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
194 changes: 194 additions & 0 deletions e2e/agent-token.drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<TenantEnv>();
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<TenantEnv>();
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<WorkflowRunScopeEnv>();
app.use("/:tenantId/*", async (c, next) =>
tenantFrom<WorkflowRunScopeEnv>(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();
}
});
});
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
104 changes: 104 additions & 0 deletions src/run-bound.ts
Original file line number Diff line number Diff line change
@@ -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> | ResolvedWorkflowRunScope | null;

export type RequireRunBoundTokenOpts<TSchema extends Record<string, unknown>> =
RequireAgentTokenOpts<TSchema> & {
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<TSchema extends Record<string, unknown>>(
opts: RequireRunBoundTokenOpts<TSchema>,
): MiddlewareHandler<TenantEnv & { Variables: RunBoundTokenVariables }> {
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 = <E extends WorkflowRunScopeEnv>(
c: Context<E>,
) => Promise<
| {
agentToken: AgentTokenContext;
workflowRunScope: ResolvedWorkflowRunScope;
}
| undefined
>;

export function createRunBoundTokenVerifier<
TSchema extends Record<string, unknown>,
>(opts: RequireRunBoundTokenOpts<TSchema>): 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 };
};
}
Loading