From cdde7ce48abd9874f18b0d5e8e6306fca9a7f053 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 20 Oct 2026 14:05:04 -0700 Subject: [PATCH] fix: uniform 401 for every rejected bearer on run routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rejection path — missing bearer, invalid token, unbound token, wrong run, wrong tenant, and missing tenant middleware — now returns the same 401 { error: unauthorized }. Previously, a missing tenant middleware caused a 500, which revealed that the route existed. Changes: - requireAgentToken catches host-side errors and returns 401 - requireRunBoundToken catches host-side errors and returns 401 - Updated existing test expectation from 500 to 401 for missing tenant - Added test for requireRunBoundToken without tenant middleware Closes CL-10972 --- e2e/agent-token.drizzle.test.ts | 30 ++++++++++++++++++++++++++++-- src/middleware.ts | 29 +++++++++++++++++++++++------ src/run-bound.ts | 23 ++++++++++++++++------- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/e2e/agent-token.drizzle.test.ts b/e2e/agent-token.drizzle.test.ts index 302a627..3347ab7 100644 --- a/e2e/agent-token.drizzle.test.ts +++ b/e2e/agent-token.drizzle.test.ts @@ -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, @@ -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(); + // 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(); + } + }); }); diff --git a/src/middleware.ts b/src/middleware.ts index 2b037ee..d77466f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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>( opts: RequireAgentTokenOpts, ): MiddlewareHandler { + 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; diff --git a/src/run-bound.ts b/src/run-bound.ts index 235cdbe..49710e5 100644 --- a/src/run-bound.ts +++ b/src/run-bound.ts @@ -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>( opts: RequireRunBoundTokenOpts, ): MiddlewareHandler { 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);