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
30 changes: 28 additions & 2 deletions e2e/agent-token.drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,10 @@ describeIfDb("agent tokens", () => {
);
expect(crossTenant.status).toBe(401);

// Mounted without the tenant middleware, a valid token never passes.
expect((await app.request("/untenanted", bearer)).status).toBe(500);
// Mounted without the tenant middleware, a valid token gets 401.
const untenanted = await app.request("/untenanted", bearer);
expect(untenanted.status).toBe(401);
expect(await untenanted.json()).toEqual({ error: "unauthorized" });

expect((await app.request(`/tenants/${tenantId}/guarded`)).status).toBe(
401,
Expand Down Expand Up @@ -733,4 +735,28 @@ describeIfDb("agent tokens", () => {
await close();
}
});

test("requireRunBoundToken returns 401 (not 500) when the host has no tenant middleware", async () => {
const { db, close } = createDB({ ...target, schema: SCHEMA });
try {
const app = new Hono<TenantEnv>();
// No tenant middleware registered.
app.get(
"/guarded",
requireRunBoundToken({
db,
resolveRunById: () => null,
}),
(c) => c.json({ ok: true }),
);

const res = await app.request("/guarded", {
headers: { authorization: "Bearer any-value" },
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "unauthorized" });
} finally {
await close();
}
});
});
29 changes: 23 additions & 6 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,33 @@ export function createAgentTokenVerifier<
);
}

/** Middleware that verifies a bearer and sets `agentToken` on the context.
* Every rejection — missing bearer, invalid token, revoked token, wrong
* tenant, or missing tenant middleware — returns the same
* `401 { "error": "unauthorized" }` so a response never reveals whether a
* token is live or even whether the route exists.
*
* Must run behind the hub's tenant middleware; without it every request is
* a 401 (not a 500). */
export function requireAgentToken<TSchema extends Record<string, unknown>>(
opts: RequireAgentTokenOpts<TSchema>,
): MiddlewareHandler<TenantEnv & { Variables: AgentTokenVariables }> {
const unauthorized = { error: "unauthorized" } as const;

return async (c, next) => {
const identity = await verifyAuthorization(
opts.db,
c.get("tenant").id,
c.req.header("authorization"),
);
if (identity === undefined) return c.json({ error: "unauthorized" }, 401);
let identity: AgentTokenContext | undefined;
try {
identity = await verifyAuthorization(
opts.db,
c.get("tenant").id,
c.req.header("authorization"),
);
} catch {
// Missing tenant middleware or other host-side error — same 401 so
// the response never reveals whether the route exists.
return c.json(unauthorized, 401);
}
if (identity === undefined) return c.json(unauthorized, 401);
c.set("agentToken", identity);
await next();
return undefined;
Expand Down
23 changes: 16 additions & 7 deletions src/run-bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,33 +39,42 @@ export type RunBoundTokenVariables = AgentTokenVariables & {
/** 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.
* unknown run, wrong tenant, or missing tenant middleware — returns the same
* `401 { "error": "unauthorized" }` so a response never reveals whether a
* token is live or even whether the route exists.
*
* Must run behind the hub's tenant middleware. */
* Must run behind the hub's tenant middleware; without it every request is
* a 401 (not a 500). */
export function requireRunBoundToken<TSchema extends Record<string, unknown>>(
opts: RequireRunBoundTokenOpts<TSchema>,
): MiddlewareHandler<TenantEnv & { Variables: RunBoundTokenVariables }> {
const verify = createAgentTokenVerifier(opts);
const unauthorized = { error: "unauthorized" } as const;

return async (c, next) => {
const identity = await verify(c);
let identity: AgentTokenContext | undefined;
try {
identity = await verify(c);
} catch {
// Missing tenant middleware or other host-side error — same 401.
return c.json(unauthorized, 401);
}

// Token missing, invalid, revoked or wrong tenant.
if (identity === undefined) {
return c.json({ error: "unauthorized" }, 401);
return c.json(unauthorized, 401);
}

// Token not bound to a run (definition-scoped only).
if (identity.runId === null) {
return c.json({ error: "unauthorized" }, 401);
return c.json(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);
return c.json(unauthorized, 401);
}

c.set("agentToken", identity);
Expand Down
Loading