diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json index 29ef44db00..9597850586 100644 --- a/packages/worker-utils/package.json +++ b/packages/worker-utils/package.json @@ -12,6 +12,7 @@ "./instance-id": "./src/instance-id.ts", "./kilo-token-auth": "./src/kilo-token-auth.ts", "./kilo-token": "./src/kilo-token.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 a88199959b..5e8eb2c828 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -127,6 +127,16 @@ export { } from './kilo-token.js'; export type { KiloTokenPayload, SignKiloTokenExtra } from './kilo-token.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 59% rename from services/gastown/src/middleware/kilo-auth.middleware.test.ts rename to packages/worker-utils/src/kilo-auth-middleware.test.ts index 1e4991c41f..e3bd5dba56 100644 --- a/services/gastown/src/middleware/kilo-auth.middleware.test.ts +++ b/packages/worker-utils/src/kilo-auth-middleware.test.ts @@ -1,16 +1,32 @@ -import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { SignJWT } from 'jose'; -import { kiloAuthMiddleware } from './kilo-auth.middleware'; -import type { GastownEnv } from '../gastown.worker'; +import { describe, expect, it } from 'vitest'; +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() { - const app = new Hono(); - app.use('/api/*', kiloAuthMiddleware); + const app = new Hono(); + app.use('/api/*', createKiloAuthMiddleware({ resolveSecret })); app.get('/api/whoami', c => { - return c.json({ kiloUserId: c.get('kiloUserId') }); + return c.json({ + kiloUserId: c.get('kiloUserId'), + kiloGastownAccess: c.get('kiloGastownAccess'), + }); }); return app; } @@ -24,7 +40,7 @@ async function signToken(payload: Record) { .sign(new TextEncoder().encode(TEST_SECRET)); } -describe('kiloAuthMiddleware', () => { +describe('createKiloAuthMiddleware', () => { it('rejects when no token is provided', async () => { const app = createApp(); const res = await app.request('/api/whoami', {}, { @@ -33,11 +49,12 @@ describe('kiloAuthMiddleware', () => { expect(res.status).toBe(401); }); - it('accepts a well-formed Kilo token', async () => { + it('accepts a well-formed Kilo token and sets the auth context', async () => { const app = createApp(); const token = await signToken({ version: 3, kiloUserId: 'user-abc', + gastownAccess: true, env: 'development', }); @@ -47,13 +64,12 @@ describe('kiloAuthMiddleware', () => { { NEXTAUTH_SECRET: TEST_SECRET } as never ); expect(res.status).toBe(200); - const body = (await res.json()) as { kiloUserId: string }; + const body = (await res.json()) as { kiloUserId: string; kiloGastownAccess: boolean }; expect(body.kiloUserId).toBe('user-abc'); + expect(body.kiloGastownAccess).toBe(true); }); -}); -describe('C15 deviceSessionId compatibility', () => { - it('accepts a token carrying deviceSessionId claim', async () => { + it('accepts a token carrying a deviceSessionId claim', async () => { const app = createApp(); const token = await signToken({ version: 3, 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..2560c65178 --- /dev/null +++ b/packages/worker-utils/src/kilo-auth-middleware.ts @@ -0,0 +1,90 @@ +import { createMiddleware } from 'hono/factory'; +import type { MiddlewareHandler } from 'hono'; +import { extractBearerToken } from './extract-bearer-token.js'; +import { verifyKiloToken, type KiloTokenPayload } from './kilo-token.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; + 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, 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 verifyKiloToken(token, secret); + 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..19e91abdfb 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -17,7 +17,8 @@ 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 { resolveSecret } from './util/secret.util'; import { validateCfAccessRequest } from '@kilocode/worker-utils/cf-access'; import { trpcServer } from '@hono/trpc-server'; @@ -171,6 +172,12 @@ export type GastownEnv = { }; const app = new Hono(); + +const kiloAuthMiddleware = createKiloAuthMiddleware({ + resolveSecret, + 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 93f490db3f..0000000000 --- a/services/gastown/src/middleware/kilo-auth.middleware.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createMiddleware } from 'hono/factory'; -import { verifyKiloToken, extractBearerToken } from '@kilocode/worker-utils'; -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 verifyKiloToken(token, secret); - 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.ts b/services/wasteland/src/middleware/kilo-auth.middleware.ts deleted file mode 100644 index ed3e15796f..0000000000 --- a/services/wasteland/src/middleware/kilo-auth.middleware.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createMiddleware } from 'hono/factory'; -import { verifyKiloToken, extractBearerToken } from '@kilocode/worker-utils'; -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 verifyKiloToken(token, secret); - 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..3cbd8389bd 100644 --- a/services/wasteland/src/wasteland.worker.ts +++ b/services/wasteland/src/wasteland.worker.ts @@ -11,7 +11,8 @@ 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 { 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 +38,12 @@ export type WastelandEnv = { }; const app = new Hono(); + +const kiloAuthMiddleware = createKiloAuthMiddleware({ + resolveSecret, + 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