From 179ae01c4dd55410ab34a530b8fb83413bd8fa24 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:21:55 +0000 Subject: [PATCH] (janitor/dedupe): consolidate Kilo user JWT auth middleware onto worker-utils wasteland and gastown each carried a near-identical `kiloAuthMiddleware` (~48 lines): the same bearer-token extraction, NEXTAUTH_SECRET resolution, Kilo token verification, Hono context population, and 401/500 error handling. The only real difference was gastown additionally setting `kiloGastownAccess` from the token's `gastownAccess` claim, plus each service's own Env type and structured logger. A change to Kilo JWT claim handling (e.g. a new required claim, a different error contract, or a gastownAccess/orgMemberships semantics fix) would have to be applied to both copies by hand. Both services already depend on @kilocode/worker-utils (which owns verifyKiloToken and extractBearerToken), so the dependency direction is clean: - packages/worker-utils/kilo-auth-middleware: single owner for the middleware core, exported as `createKiloAuthMiddleware`. It takes the service's existing `resolveSecret` (kept service-local so the many existing import sites don't churn) and an optional `onAuthenticated` hook for the service's structured-logger tagging. It always sets the full Kilo auth context (kiloUserId, kiloIsAdmin, kiloApiTokenPepper, kiloGastownAccess, kiloOrgMemberships); gastownAccess is an optional claim so setting it in wasteland is a no-op. - wasteland/gastown: delete the duplicated middleware files and build the middleware from the shared factory with their own Env + logger. The gastown middleware unit test moves to worker-utils alongside the new owner and now also asserts gastownAccess context propagation. No behavior change: the shared core is the union of the two copies, and wasteland simply gains an unused `kiloGastownAccess` context variable. --- packages/worker-utils/package.json | 1 + packages/worker-utils/src/index.ts | 10 ++ .../src/kilo-auth-middleware.test.ts | 31 +++++-- .../worker-utils/src/kilo-auth-middleware.ts | 93 +++++++++++++++++++ services/gastown/src/gastown.worker.ts | 11 ++- .../src/middleware/kilo-auth.middleware.ts | 54 ----------- .../src/middleware/auth.middleware.ts | 1 + .../middleware/kilo-auth.middleware.test.ts | 7 +- .../src/middleware/kilo-auth.middleware.ts | 53 ----------- services/wasteland/src/wasteland.worker.ts | 11 ++- 10 files changed, 155 insertions(+), 117 deletions(-) rename services/gastown/src/middleware/kilo-auth.middleware.test.ts => packages/worker-utils/src/kilo-auth-middleware.test.ts (87%) create mode 100644 packages/worker-utils/src/kilo-auth-middleware.ts delete mode 100644 services/gastown/src/middleware/kilo-auth.middleware.ts delete mode 100644 services/wasteland/src/middleware/kilo-auth.middleware.ts diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json index 4463ca04d2..ab2332bf97 100644 --- a/packages/worker-utils/package.json +++ b/packages/worker-utils/package.json @@ -13,6 +13,7 @@ "./kilo-token-auth": "./src/kilo-token-auth.ts", "./kilo-token": "./src/kilo-token.ts", "./kilo-token-policy": "./src/kilo-token-policy.ts", + "./kilo-auth-middleware": "./src/kilo-auth-middleware.ts", "./sandbox-id": "./src/sandbox-id.ts", "./hostname-label": "./src/hostname-label.ts", "./deployment-slug": "./src/deployment-slug.ts", diff --git a/packages/worker-utils/src/index.ts b/packages/worker-utils/src/index.ts index 7c02caa312..f1fa03f58e 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -160,6 +160,16 @@ export type { VerifiedKiloAuthContext, } from './kilo-token-policy.js'; +export { createKiloAuthMiddleware } from './kilo-auth-middleware.js'; +export type { + KiloAuthEnv, + KiloAuthMiddlewareOptions, + KiloAuthOrgMembership, + KiloAuthVariables, + ResolveSecret, + SecretBinding, +} from './kilo-auth-middleware.js'; + export { SessionMetricsParamsSchema, TerminationReasons } from './session-metrics-schema.js'; export type { SessionMetricsParams, SessionMetricsParamsInput } from './session-metrics-schema.js'; diff --git a/services/gastown/src/middleware/kilo-auth.middleware.test.ts b/packages/worker-utils/src/kilo-auth-middleware.test.ts similarity index 87% rename from services/gastown/src/middleware/kilo-auth.middleware.test.ts rename to packages/worker-utils/src/kilo-auth-middleware.test.ts index 1b770015d8..9ecedb49ab 100644 --- a/services/gastown/src/middleware/kilo-auth.middleware.test.ts +++ b/packages/worker-utils/src/kilo-auth-middleware.test.ts @@ -1,18 +1,35 @@ import { describe, expect, it } from 'vitest'; import { Hono, type Context } from 'hono'; import { SignJWT } from 'jose'; -import { GASTOWN_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; -import { kiloAuthMiddleware } from './kilo-auth.middleware'; -import type { GastownEnv } from '../gastown.worker'; +import { GASTOWN_AUDIENCE } from './internal-service-token-audiences'; +import { createKiloAuthMiddleware } from './kilo-auth-middleware'; const TEST_SECRET = 'test-secret-that-is-long-enough-for-hs256'; +const resolveSecret = async (binding: { get(): Promise } | string) => + typeof binding === 'string' ? binding : await binding.get(); + +type TestEnv = { + Bindings: { NEXTAUTH_SECRET?: string }; + Variables: { + kiloUserId: string; + kiloIsAdmin: boolean; + kiloApiTokenPepper: string | null; + kiloGastownAccess: boolean; + kiloOrgMemberships: { orgId: string; role: 'owner' | 'member' | 'billing_manager' }[]; + }; +}; + function createApp() { let downstreamCalls = 0; - const app = new Hono(); + const app = new Hono(); + const kiloAuthMiddleware = createKiloAuthMiddleware({ + resolveSecret, + audiencePolicy: { audience: GASTOWN_AUDIENCE, mode: 'allow-legacy' }, + }); app.use('/api/*', kiloAuthMiddleware); app.use('/trpc/*', kiloAuthMiddleware); - const handler = (c: Context) => { + const handler = (c: Context) => { downstreamCalls += 1; return c.json({ kiloUserId: c.get('kiloUserId'), @@ -42,7 +59,7 @@ async function signToken( } async function request( - app: Hono, + app: Hono, token: string | undefined, secret: string | { get(): Promise } | null = TEST_SECRET ) { @@ -57,7 +74,7 @@ async function request( ); } -describe('kiloAuthMiddleware', () => { +describe('createKiloAuthMiddleware', () => { it.each([ ['missing authentication', undefined], ['malformed authentication', 'Bearer'], diff --git a/packages/worker-utils/src/kilo-auth-middleware.ts b/packages/worker-utils/src/kilo-auth-middleware.ts new file mode 100644 index 0000000000..0362e83d06 --- /dev/null +++ b/packages/worker-utils/src/kilo-auth-middleware.ts @@ -0,0 +1,93 @@ +import { createMiddleware } from 'hono/factory'; +import type { MiddlewareHandler } from 'hono'; +import { extractBearerToken } from './extract-bearer-token.js'; +import type { KiloTokenPayload } from './kilo-token.js'; +import type { KiloResourceAudiencePolicy } from './kilo-token-policy.js'; +import { verifyKiloTokenForResource } from './kilo-token-policy.js'; +import { resError } from './res.js'; + +/** + * A Cloudflare Secrets Store binding (production) or a plain string + * (test/local env vars). Structural so worker-utils does not need to pull in + * `@cloudflare/workers-types`. + */ +export type SecretBinding = { get(): Promise } | string; + +export type KiloAuthOrgMembership = { + orgId: string; + role: 'owner' | 'member' | 'billing_manager'; +}; + +export type KiloAuthVariables = { + kiloUserId: string; + kiloIsAdmin: boolean; + kiloApiTokenPepper: string | null; + kiloGastownAccess: boolean; + kiloOrgMemberships: KiloAuthOrgMembership[]; +}; + +export type ResolveSecret = (binding: SecretBinding) => Promise; + +export type KiloAuthMiddlewareOptions = { + resolveSecret: ResolveSecret; + audiencePolicy: KiloResourceAudiencePolicy; + onAuthenticated?: (payload: KiloTokenPayload) => void; +}; + +export type KiloAuthEnv = { + Bindings: { NEXTAUTH_SECRET?: SecretBinding | undefined }; + Variables: KiloAuthVariables; +}; + +/** + * Hono middleware that validates Kilo user JWTs (HS256, signed with + * NEXTAUTH_SECRET) for dashboard/user-facing routes. + * + * Sets the `kiloUserId`, `kiloIsAdmin`, `kiloApiTokenPepper`, + * `kiloGastownAccess`, and `kiloOrgMemberships` variables on the Hono context. + * + * The secret is resolved via the injected `resolveSecret` so each service can + * keep its own Secrets Store handling (and test string fallback). The optional + * `onAuthenticated` hook lets a service tag its structured logger with the + * authenticated user id. + */ +export function createKiloAuthMiddleware( + options: KiloAuthMiddlewareOptions +): MiddlewareHandler { + const { resolveSecret, audiencePolicy, onAuthenticated } = options; + return createMiddleware(async (c, next) => { + const token = extractBearerToken(c.req.header('Authorization')); + + if (!token) { + return c.json(resError('Authentication required'), 401); + } + + if (!c.env.NEXTAUTH_SECRET) { + console.error('[kilo-auth] NEXTAUTH_SECRET not configured'); + return c.json(resError('Internal server error'), 500); + } + const secret = await resolveSecret(c.env.NEXTAUTH_SECRET); + if (!secret) { + console.error('[kilo-auth] failed to resolve NEXTAUTH_SECRET from Secrets Store'); + return c.json(resError('Internal server error'), 500); + } + + try { + const payload = await verifyKiloTokenForResource(token, secret, audiencePolicy); + c.set('kiloUserId', payload.kiloUserId); + c.set('kiloIsAdmin', payload.isAdmin === true); + c.set('kiloApiTokenPepper', payload.apiTokenPepper ?? null); + c.set('kiloGastownAccess', payload.gastownAccess === true); + c.set('kiloOrgMemberships', payload.orgMemberships ?? []); + onAuthenticated?.(payload); + } catch (err) { + console.warn( + '[kilo-auth] token verification failed:', + err instanceof Error ? err.message : 'unknown error' + ); + return c.json(resError('Invalid token'), 401); + } + + return next(); + }); +} diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index ce1f3fa066..545feae835 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -17,7 +17,9 @@ import { townIdMiddleware, type AuthVariables, } from './middleware/auth.middleware'; -import { kiloAuthMiddleware } from './middleware/kilo-auth.middleware'; +import { createKiloAuthMiddleware } from '@kilocode/worker-utils/kilo-auth-middleware'; +import { GASTOWN_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; +import { resolveSecret } from './util/secret.util'; import { validateCfAccessRequest } from '@kilocode/worker-utils/cf-access'; import { trpcServer } from '@hono/trpc-server'; @@ -171,6 +173,13 @@ export type GastownEnv = { }; const app = new Hono(); + +const kiloAuthMiddleware = createKiloAuthMiddleware({ + resolveSecret, + audiencePolicy: { audience: GASTOWN_AUDIENCE, mode: 'allow-legacy' }, + onAuthenticated: payload => logger.setTags({ userId: payload.kiloUserId }), +}); + const LOCAL_DEV_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); async function cfAccessDebugMiddleware(c: Context, next: () => Promise) { diff --git a/services/gastown/src/middleware/kilo-auth.middleware.ts b/services/gastown/src/middleware/kilo-auth.middleware.ts deleted file mode 100644 index 12b990d5cc..0000000000 --- a/services/gastown/src/middleware/kilo-auth.middleware.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createMiddleware } from 'hono/factory'; -import { extractBearerToken } from '@kilocode/worker-utils/extract-bearer-token'; -import { GASTOWN_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; -import { verifyKiloTokenForResource } from '@kilocode/worker-utils/kilo-token-policy'; -import { resError } from '../util/res.util'; -import type { GastownEnv } from '../gastown.worker'; -import { resolveSecret } from '../util/secret.util'; -import { logger } from '../util/log.util'; - -/** - * Auth middleware that validates Kilo user JWTs (signed with NEXTAUTH_SECRET). - * Used for dashboard/user-facing routes where the Next.js app sends a - * Bearer token on behalf of the logged-in user. - * - * Sets `kiloUserId` on the Hono context. - */ -export const kiloAuthMiddleware = createMiddleware(async (c, next) => { - const token = extractBearerToken(c.req.header('Authorization')); - - if (!token) { - return c.json(resError('Authentication required'), 401); - } - - if (!c.env.NEXTAUTH_SECRET) { - console.error('[kilo-auth] NEXTAUTH_SECRET not configured'); - return c.json(resError('Internal server error'), 500); - } - const secret = await resolveSecret(c.env.NEXTAUTH_SECRET); - if (!secret) { - console.error('[kilo-auth] failed to resolve NEXTAUTH_SECRET from Secrets Store'); - return c.json(resError('Internal server error'), 500); - } - - try { - const payload = await verifyKiloTokenForResource(token, secret, { - audience: GASTOWN_AUDIENCE, - mode: 'allow-legacy', - }); - c.set('kiloUserId', payload.kiloUserId); - c.set('kiloIsAdmin', payload.isAdmin === true); - c.set('kiloApiTokenPepper', payload.apiTokenPepper ?? null); - c.set('kiloGastownAccess', payload.gastownAccess === true); - c.set('kiloOrgMemberships', payload.orgMemberships ?? []); - logger.setTags({ userId: payload.kiloUserId }); - } catch (err) { - console.warn( - '[kilo-auth] token verification failed:', - err instanceof Error ? err.message : 'unknown error' - ); - return c.json(resError('Invalid token'), 401); - } - - return next(); -}); diff --git a/services/wasteland/src/middleware/auth.middleware.ts b/services/wasteland/src/middleware/auth.middleware.ts index 361414e3f3..330900066b 100644 --- a/services/wasteland/src/middleware/auth.middleware.ts +++ b/services/wasteland/src/middleware/auth.middleware.ts @@ -4,6 +4,7 @@ export type AuthVariables = { kiloUserId: string; kiloIsAdmin: boolean; kiloApiTokenPepper: string | null; + kiloGastownAccess: boolean; kiloOrgMemberships: JwtOrgMembership[]; requestStartTime: number; }; diff --git a/services/wasteland/src/middleware/kilo-auth.middleware.test.ts b/services/wasteland/src/middleware/kilo-auth.middleware.test.ts index e3e265cb7f..a710666ad0 100644 --- a/services/wasteland/src/middleware/kilo-auth.middleware.test.ts +++ b/services/wasteland/src/middleware/kilo-auth.middleware.test.ts @@ -2,14 +2,19 @@ import { describe, expect, it } from 'vitest'; import { Hono, type Context } from 'hono'; import { SignJWT } from 'jose'; import { WASTELAND_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; -import { kiloAuthMiddleware } from './kilo-auth.middleware'; +import { createKiloAuthMiddleware } from '@kilocode/worker-utils/kilo-auth-middleware'; import type { WastelandEnv } from '../wasteland.worker'; +import { resolveSecret } from '../util/secret.util'; const TEST_SECRET = 'test-secret-that-is-long-enough-for-hs256'; function createApp() { let downstreamCalls = 0; const app = new Hono(); + const kiloAuthMiddleware = createKiloAuthMiddleware({ + resolveSecret, + audiencePolicy: { audience: WASTELAND_AUDIENCE, mode: 'allow-legacy' }, + }); app.use('/api/*', kiloAuthMiddleware); app.use('/trpc/*', kiloAuthMiddleware); const handler = (c: Context) => { diff --git a/services/wasteland/src/middleware/kilo-auth.middleware.ts b/services/wasteland/src/middleware/kilo-auth.middleware.ts deleted file mode 100644 index 8517c45620..0000000000 --- a/services/wasteland/src/middleware/kilo-auth.middleware.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createMiddleware } from 'hono/factory'; -import { extractBearerToken } from '@kilocode/worker-utils/extract-bearer-token'; -import { WASTELAND_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; -import { verifyKiloTokenForResource } from '@kilocode/worker-utils/kilo-token-policy'; -import { resError } from '../util/res.util'; -import type { WastelandEnv } from '../wasteland.worker'; -import { resolveSecret } from '../util/secret.util'; -import { logger } from '../util/log.util'; - -/** - * Auth middleware that validates Kilo user JWTs (signed with NEXTAUTH_SECRET). - * Used for dashboard/user-facing routes where the Next.js app sends a - * Bearer token on behalf of the logged-in user. - * - * Sets `kiloUserId` on the Hono context. - */ -export const kiloAuthMiddleware = createMiddleware(async (c, next) => { - const token = extractBearerToken(c.req.header('Authorization')); - - if (!token) { - return c.json(resError('Authentication required'), 401); - } - - if (!c.env.NEXTAUTH_SECRET) { - console.error('[kilo-auth] NEXTAUTH_SECRET not configured'); - return c.json(resError('Internal server error'), 500); - } - const secret = await resolveSecret(c.env.NEXTAUTH_SECRET); - if (!secret) { - console.error('[kilo-auth] failed to resolve NEXTAUTH_SECRET from Secrets Store'); - return c.json(resError('Internal server error'), 500); - } - - try { - const payload = await verifyKiloTokenForResource(token, secret, { - audience: WASTELAND_AUDIENCE, - mode: 'allow-legacy', - }); - c.set('kiloUserId', payload.kiloUserId); - c.set('kiloIsAdmin', payload.isAdmin === true); - c.set('kiloApiTokenPepper', payload.apiTokenPepper ?? null); - c.set('kiloOrgMemberships', payload.orgMemberships ?? []); - logger.setTags({ userId: payload.kiloUserId }); - } catch (err) { - console.warn( - '[kilo-auth] token verification failed:', - err instanceof Error ? err.message : 'unknown error' - ); - return c.json(resError('Invalid token'), 401); - } - - return next(); -}); diff --git a/services/wasteland/src/wasteland.worker.ts b/services/wasteland/src/wasteland.worker.ts index f5a8088cba..bdf4be9bb3 100644 --- a/services/wasteland/src/wasteland.worker.ts +++ b/services/wasteland/src/wasteland.worker.ts @@ -11,7 +11,9 @@ import { logger } from './util/log.util'; import { useWorkersLogger } from 'workers-tagged-logger'; import type { MiddlewareHandler } from 'hono'; import type { AuthVariables } from './middleware/auth.middleware'; -import { kiloAuthMiddleware } from './middleware/kilo-auth.middleware'; +import { createKiloAuthMiddleware } from '@kilocode/worker-utils/kilo-auth-middleware'; +import { WASTELAND_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; +import { resolveSecret } from './util/secret.util'; import { validateCfAccessRequest } from '@kilocode/worker-utils/cf-access'; import { timingMiddleware } from './middleware/analytics.middleware'; import { wrappedWastelandRouter } from './trpc/router'; @@ -37,6 +39,13 @@ export type WastelandEnv = { }; const app = new Hono(); + +const kiloAuthMiddleware = createKiloAuthMiddleware({ + resolveSecret, + audiencePolicy: { audience: WASTELAND_AUDIENCE, mode: 'allow-legacy' }, + onAuthenticated: payload => logger.setTags({ userId: payload.kiloUserId }), +}); + async function cfAccessDebugMiddleware(c: Context, next: () => Promise) { // Bypass CF Access in dev. We can't trust the request hostname for // a localhost check — `wrangler dev` rewrites `request.url` to the