diff --git a/services/event-service/src/__tests__/auth.test.ts b/services/event-service/src/__tests__/auth.test.ts index f512a4cc66..9f8aeeca67 100644 --- a/services/event-service/src/__tests__/auth.test.ts +++ b/services/event-service/src/__tests__/auth.test.ts @@ -1,5 +1,9 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { clearSecretCacheForTest, signKiloToken } from '@kilocode/worker-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearSecretCacheForTest, + EVENT_SERVICE_AUDIENCE, + signKiloToken, +} from '@kilocode/worker-utils'; import { type KiloUserPepperResult } from '@kilocode/worker-utils/kilo-token-auth'; import { type AuthEnv, authenticateToken } from '../auth'; @@ -25,6 +29,45 @@ function authenticateTestToken(token: string | null) { return authenticateToken(token, makeEnv(), { getUserPepper }); } +function signEventServiceToken(params: { pepper?: string | null; env?: string } = {}) { + return signKiloToken({ + userId: 'user-xyz-789', + pepper: params.pepper, + secret: TEST_JWT_SECRET, + expiresInSeconds: 3600, + env: params.env, + audience: EVENT_SERVICE_AUDIENCE, + }); +} + +async function signWithAudience(aud: unknown): Promise { + const now = Math.floor(Date.now() / 1000); + const encode = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + const json = (value: unknown) => encode(new TextEncoder().encode(JSON.stringify(value))); + const input = `${json({ alg: 'HS256', typ: 'JWT' })}.${json({ + version: 3, + kiloUserId: 'user-xyz-789', + apiTokenPepper: 'pepper-current', + env: 'production', + aud, + iat: now, + exp: now + 3600, + })}`; + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(TEST_JWT_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(input)); + return `${input}.${encode(new Uint8Array(signature))}`; +} + describe('authenticateToken', () => { beforeEach(() => { clearSecretCacheForTest(); @@ -32,7 +75,7 @@ describe('authenticateToken', () => { currentPepperByUserId.set('user-xyz-789', { pepper: 'pepper-current', blockedReason: null }); }); - it('authenticates a kilo-chat token with the current pepper', async () => { + it('authenticates a legacy one-hour kilo-chat token without an audience', async () => { const { token } = await signKiloToken({ userId: 'user-xyz-789', pepper: 'pepper-current', @@ -60,6 +103,49 @@ describe('authenticateToken', () => { }); }); + it('authenticates event-service audience claims as strings and arrays', async () => { + const arrayAudienceToken = await signWithAudience(['another-service', EVENT_SERVICE_AUDIENCE]); + const stringAudienceToken = await signEventServiceToken({ + pepper: 'pepper-current', + env: 'production', + }); + + await expect(authenticateTestToken(stringAudienceToken.token)).resolves.toEqual({ + userId: 'user-xyz-789', + }); + await expect(authenticateTestToken(arrayAudienceToken)).resolves.toEqual({ + userId: 'user-xyz-789', + }); + }); + + it('rejects wrong or malformed event-service audiences before pepper lookup', async () => { + const malformedAudienceToken = await signWithAudience([ + EVENT_SERVICE_AUDIENCE, + EVENT_SERVICE_AUDIENCE, + ]); + const wrongAudienceToken = await signKiloToken({ + userId: 'user-xyz-789', + pepper: 'pepper-current', + secret: TEST_JWT_SECRET, + expiresInSeconds: 3600, + env: 'production', + audience: 'another-service', + }); + let lookupCount = 0; + const lookup = async (...args: Parameters) => { + lookupCount++; + return getUserPepper(...args); + }; + + await expect( + authenticateToken(wrongAudienceToken.token, makeEnv(), { getUserPepper: lookup }) + ).resolves.toBeNull(); + await expect( + authenticateToken(malformedAudienceToken, makeEnv(), { getUserPepper: lookup }) + ).resolves.toBeNull(); + expect(lookupCount).toBe(0); + }); + it('rejects a valid kilo-chat JWT with a stale pepper', async () => { const { token } = await signKiloToken({ userId: 'user-xyz-789', @@ -86,6 +172,16 @@ describe('authenticateToken', () => { await expect(authenticateTestToken(token)).resolves.toBeNull(); }); + it('rejects event-service tokens with missing or mismatched environments', async () => { + const [missingEnvironment, mismatchedEnvironment] = await Promise.all([ + signEventServiceToken({ pepper: 'pepper-current' }), + signEventServiceToken({ pepper: 'pepper-current', env: 'development' }), + ]); + + await expect(authenticateTestToken(missingEnvironment.token)).resolves.toBeNull(); + await expect(authenticateTestToken(mismatchedEnvironment.token)).resolves.toBeNull(); + }); + it('rejects a token for a blocked user even when pepper matches', async () => { currentPepperByUserId.set('user-xyz-789', { pepper: 'pepper-current', @@ -102,4 +198,55 @@ describe('authenticateToken', () => { await expect(authenticateTestToken(token)).resolves.toBeNull(); }); + + it('rejects missing users, stale peppers, and a null pepper against a stored pepper', async () => { + const [stalePepper, nullPepper] = await Promise.all([ + signEventServiceToken({ pepper: 'pepper-stale', env: 'production' }), + signEventServiceToken({ pepper: null, env: 'production' }), + ]); + + await expect(authenticateTestToken(stalePepper.token)).resolves.toBeNull(); + await expect(authenticateTestToken(nullPepper.token)).resolves.toBeNull(); + const validToken = await signEventServiceToken({ pepper: 'pepper-current', env: 'production' }); + await expect(authenticateTestToken(validToken.token)).resolves.toEqual({ + userId: 'user-xyz-789', + }); + currentPepperByUserId.clear(); + const lookup = vi.fn(getUserPepper); + await expect( + authenticateToken(validToken.token, makeEnv(), { getUserPepper: lookup }) + ).resolves.toBeNull(); + expect(lookup).toHaveBeenCalledWith('postgres://test', 'user-xyz-789'); + }); + + it('allows absent peppers but rejects explicit null peppers for a user with a current pepper', async () => { + const absentPepper = await signEventServiceToken({ env: 'production' }); + const nullPepper = await signEventServiceToken({ pepper: null, env: 'production' }); + + await expect(authenticateTestToken(absentPepper.token)).resolves.toEqual({ + userId: 'user-xyz-789', + }); + await expect(authenticateTestToken(nullPepper.token)).resolves.toBeNull(); + }); + + it('propagates secret and pepper lookup failures', async () => { + const { token } = await signEventServiceToken({ pepper: 'pepper-current', env: 'production' }); + + await expect( + authenticateToken( + token, + { + ...makeEnv(), + NEXTAUTH_SECRET: { get: async () => Promise.reject(new Error('secret unavailable')) }, + }, + { getUserPepper } + ) + ).rejects.toThrow('secret unavailable'); + clearSecretCacheForTest(); + await expect( + authenticateToken(token, makeEnv(), { + getUserPepper: async () => Promise.reject(new Error('lookup unavailable')), + }) + ).rejects.toThrow('lookup unavailable'); + }); }); diff --git a/services/event-service/src/auth.ts b/services/event-service/src/auth.ts index c9623bc1a7..1991eeb1e3 100644 --- a/services/event-service/src/auth.ts +++ b/services/event-service/src/auth.ts @@ -1,3 +1,4 @@ +import { EVENT_SERVICE_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; import { type GetKiloUserPepper, verifyKiloBearerAgainstCurrentPepper, @@ -21,6 +22,7 @@ export async function authenticateToken( nextAuthSecret: env.NEXTAUTH_SECRET, workerEnv: env.WORKER_ENV, connectionString: env.HYPERDRIVE.connectionString, + resourceAudience: { audience: EVENT_SERVICE_AUDIENCE, mode: 'allow-legacy' }, ...(options.getUserPepper ? { getUserPepper: options.getUserPepper } : {}), }); } diff --git a/services/kilo-chat/src/__tests__/auth.test.ts b/services/kilo-chat/src/__tests__/auth.test.ts index aae4b2fc5f..700d5b4e9c 100644 --- a/services/kilo-chat/src/__tests__/auth.test.ts +++ b/services/kilo-chat/src/__tests__/auth.test.ts @@ -1,8 +1,14 @@ -import { beforeEach, describe, it, expect, vi } from 'vitest'; import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { signKiloToken } from '@kilocode/worker-utils'; -import { authMiddleware } from '../auth'; -import type { AuthContext } from '../auth'; +import { getWorkerDb } from '@kilocode/db/client'; +import { + EVENT_SERVICE_AUDIENCE, + KILO_CHAT_AUDIENCE, + KILO_GATEWAY_AUDIENCE, + NOTIFICATIONS_AUDIENCE, +} from '@kilocode/worker-utils/internal-service-token-audiences'; +import { authMiddleware, type AuthContext } from '../auth'; type MockEnv = { NEXTAUTH_SECRET: { get: () => Promise }; @@ -11,151 +17,225 @@ type MockEnv = { }; const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256'; -const currentPepperByUserId = vi.hoisted(() => new Map()); +const dbState = vi.hoisted(() => ({ lookupCount: 0, fails: false })); +const userRow = vi.hoisted(() => ({ + pepper: 'pepper-current' as string | null, + blockedReason: null as string | null, +})); vi.mock('@kilocode/db/client', () => ({ - getWorkerDb: () => ({ + getWorkerDb: vi.fn(() => ({ select: () => ({ from: () => ({ where: () => ({ - limit: async () => [ - { - api_token_pepper: currentPepperByUserId.get('user-xyz-789'), - blocked_reason: null, - }, - ], + limit: async () => { + dbState.lookupCount++; + if (dbState.fails) throw new Error('connection refused'); + return [{ api_token_pepper: userRow.pepper, blocked_reason: userRow.blockedReason }]; + }, }), }), }), - }), + })), })); -function makeApp(_env: MockEnv) { - const app = new Hono<{ Bindings: MockEnv; Variables: AuthContext }>(); - app.use('*', authMiddleware); - app.get('/test', c => c.json({ callerId: c.get('callerId'), callerKind: c.get('callerKind') })); - return app; -} - const defaultEnv: MockEnv = { NEXTAUTH_SECRET: { get: async () => TEST_JWT_SECRET }, HYPERDRIVE: { connectionString: 'postgres://test' }, WORKER_ENV: 'production', }; +function makeApp() { + let downstreamCalls = 0; + const app = new Hono<{ Bindings: MockEnv; Variables: AuthContext }>(); + app.use('*', authMiddleware); + app.get('/test', c => { + downstreamCalls++; + return c.json({ callerId: c.get('callerId'), callerKind: c.get('callerKind') }); + }); + return { app, downstreamCalls: () => downstreamCalls }; +} + +async function signToken( + params: { + audience?: string; + secret?: string; + expiresInSeconds?: number; + pepper?: string | null; + env?: string; + extra?: { tokenSource?: string; botId?: string; deviceSessionId?: string }; + } = {} +) { + return ( + await signKiloToken({ + userId: 'user-xyz-789', + pepper: 'pepper' in params ? params.pepper : 'pepper-current', + secret: params.secret ?? TEST_JWT_SECRET, + expiresInSeconds: params.expiresInSeconds ?? 3600, + env: 'env' in params ? params.env : 'production', + audience: params.audience, + extra: params.extra, + }) + ).token; +} + +async function signWithAudience(aud: unknown): Promise { + const now = Math.floor(Date.now() / 1000); + const encode = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + const json = (value: unknown) => encode(new TextEncoder().encode(JSON.stringify(value))); + const input = `${json({ alg: 'HS256', typ: 'JWT' })}.${json({ + version: 3, + kiloUserId: 'user-xyz-789', + apiTokenPepper: 'pepper-current', + env: 'production', + aud, + iat: now, + exp: now + 3600, + })}`; + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(TEST_JWT_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(input)); + return `${input}.${encode(new Uint8Array(signature))}`; +} + +async function request(token: string, env = defaultEnv) { + const testApp = makeApp(); + const response = await testApp.app.request( + '/test', + { headers: { authorization: `Bearer ${token}` } }, + env + ); + return { response, ...testApp }; +} + describe('authMiddleware', () => { beforeEach(() => { - currentPepperByUserId.set('user-xyz-789', 'pepper-current'); + vi.mocked(getWorkerDb).mockClear(); + dbState.lookupCount = 0; + dbState.fails = false; + userRow.pepper = 'pepper-current'; + userRow.blockedReason = null; }); it('returns 401 with no authorization header', async () => { - const res = await makeApp(defaultEnv).request('/test', {}, defaultEnv); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'Unauthorized' }); + const testApp = makeApp(); + const response = await testApp.app.request('/test', {}, defaultEnv); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); }); - it('authenticates with a valid JWT and sets user identity', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-current', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'production', - extra: { tokenSource: 'kilo-chat' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - callerId: 'user-xyz-789', - callerKind: 'user', - }); + it.each([ + ['a matching string audience', () => signToken({ audience: KILO_CHAT_AUDIENCE })], + [ + 'a legacy token from another source', + () => signToken({ extra: { tokenSource: 'cloud-agent' } }), + ], + ['a matching array audience', () => signWithAudience([KILO_CHAT_AUDIENCE, 'other-service'])], + [ + 'a legacy kilo-chat token with bot and device-session claims', + () => + signToken({ + extra: { + tokenSource: 'kilo-chat', + botId: 'bot-123', + deviceSessionId: 'device-123', + }, + }), + ], + ])('authenticates %s', async (_name, createToken) => { + const { response } = await request(await createToken()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ callerId: 'user-xyz-789', callerKind: 'user' }); }); - it('authenticates a valid JWT from another token source', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-current', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'production', - extra: { tokenSource: 'cloud-agent' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - callerId: 'user-xyz-789', - callerKind: 'user', - }); + it.each([ + ['an expired JWT', () => signToken({ audience: KILO_CHAT_AUDIENCE, expiresInSeconds: -60 })], + ['a malformed bearer', () => 'not-a-jwt'], + [ + 'a JWT signed with the wrong secret', + () => + signToken({ + audience: KILO_CHAT_AUDIENCE, + secret: 'wrong-test-secret-at-least-32-characters', + }), + ], + ])('returns 401 for %s before database or downstream access', async (_name, createToken) => { + const { response, downstreamCalls } = await request(await createToken()); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(getWorkerDb).not.toHaveBeenCalled(); + expect(dbState.lookupCount).toBe(0); + expect(downstreamCalls()).toBe(0); }); - it('returns 401 when the chat JWT has a stale pepper', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-stale', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'production', - extra: { tokenSource: 'kilo-chat' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(401); + it.each([ + ['another batch', NOTIFICATIONS_AUDIENCE], + ['the gateway', KILO_GATEWAY_AUDIENCE], + ['another service', EVENT_SERVICE_AUDIENCE], + ])('rejects an audience for %s before database access', async (_name, audience) => { + const { response, downstreamCalls } = await request(await signToken({ audience })); + expect(response.status).toBe(401); + expect(dbState.lookupCount).toBe(0); + expect(downstreamCalls()).toBe(0); }); - it('returns 401 when the chat JWT was minted for a different environment', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-current', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'development', - extra: { tokenSource: 'kilo-chat' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(401); + it.each([null, '', ['kilo-chat', 'kilo-chat'], ['kilo-chat', 1], []])( + 'rejects malformed audience claims before database access', + async audience => { + const { response, downstreamCalls } = await request(await signWithAudience(audience)); + expect(response.status).toBe(401); + expect(dbState.lookupCount).toBe(0); + expect(downstreamCalls()).toBe(0); + } + ); + + it.each([ + ['a stale string pepper', 'pepper-stale', 'pepper-current', 401], + ['a null claim against a non-null pepper', null, 'pepper-current', 401], + ['a null claim against a null pepper', null, null, 200], + ])('preserves pepper semantics for %s', async (_name, pepper, currentPepper, status) => { + userRow.pepper = currentPepper; + const { response } = await request(await signToken({ audience: KILO_CHAT_AUDIENCE, pepper })); + expect(response.status).toBe(status); }); - it('returns 401 with an expired JWT', async () => { + it('preserves absent pepper claim semantics', async () => { const { token } = await signKiloToken({ userId: 'user-xyz-789', - pepper: null, secret: TEST_JWT_SECRET, - expiresInSeconds: -1, + expiresInSeconds: 3600, env: 'production', - extra: { tokenSource: 'kilo-chat' }, + audience: KILO_CHAT_AUDIENCE, }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'Unauthorized' }); + const { response } = await request(token); + expect(response.status).toBe(200); }); - it('does not accept arbitrary bearers as bots — there is no HTTP bot surface', async () => { - // Bots reach kilo-chat via service-binding RPC only; no HTTP path grants - // bot identity. Any non-JWT bearer must fail closed. - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: 'Bearer not-a-jwt' } }, - defaultEnv + it.each([ + ['a blocked account', () => (userRow.blockedReason = 'manual block'), 'production'], + ['a missing env claim', () => undefined, undefined], + ['a mismatched environment', () => undefined, 'development'], + ])('returns 401 for %s', async (_name, arrange, tokenEnv) => { + arrange(); + const { response } = await request( + await signToken({ audience: KILO_CHAT_AUDIENCE, env: tokenEnv }) ); - expect(res.status).toBe(401); + expect(response.status).toBe(401); + }); + + it('maps dependency failures to 401', async () => { + dbState.fails = true; + const { response } = await request(await signToken({ audience: KILO_CHAT_AUDIENCE })); + expect(response.status).toBe(401); }); }); diff --git a/services/kilo-chat/src/auth.ts b/services/kilo-chat/src/auth.ts index 1be48bc694..c6fcf183ef 100644 --- a/services/kilo-chat/src/auth.ts +++ b/services/kilo-chat/src/auth.ts @@ -1,6 +1,7 @@ import { createMiddleware } from 'hono/factory'; import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; import { extractBearerToken } from '@kilocode/worker-utils'; +import { KILO_CHAT_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; import { logger } from './util/logger'; export type AuthContext = { @@ -31,6 +32,7 @@ export const authMiddleware = createMiddleware<{ nextAuthSecret: c.env.NEXTAUTH_SECRET, workerEnv: c.env.WORKER_ENV, connectionString: c.env.HYPERDRIVE.connectionString, + resourceAudience: { audience: KILO_CHAT_AUDIENCE, mode: 'allow-legacy' }, }); if (!auth) { return c.json({ error: 'Unauthorized' }, 401); diff --git a/services/kiloclaw/src/auth/jwt.test.ts b/services/kiloclaw/src/auth/jwt.test.ts index 17f200e18a..0af8f0203c 100644 --- a/services/kiloclaw/src/auth/jwt.test.ts +++ b/services/kiloclaw/src/auth/jwt.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SignJWT } from 'jose'; import { validateKiloToken } from './jwt'; import { KILO_TOKEN_VERSION } from '../config'; +import { KILOCLAW_AUDIENCE } from '@kilocode/worker-utils'; const TEST_SECRET = 'test-secret-for-jwt-verification'; @@ -37,6 +38,56 @@ describe('validateKiloToken', () => { }); }); + it('accepts the KiloClaw audience as a string or array member', async () => { + for (const aud of [KILOCLAW_AUDIENCE, ['another-resource', KILOCLAW_AUDIENCE]]) { + const token = await signToken({ + kiloUserId: 'user_123', + apiTokenPepper: 'pepper_abc', + version: KILO_TOKEN_VERSION, + aud, + }); + + await expect(validateKiloToken(token, TEST_SECRET, undefined)).resolves.toMatchObject({ + success: true, + userId: 'user_123', + token, + pepper: 'pepper_abc', + }); + } + }); + + it('rejects wrong and malformed audiences', async () => { + for (const aud of ['another-resource', [], [' kiloclaw']]) { + const token = await signToken({ + kiloUserId: 'user_123', + apiTokenPepper: 'pepper_abc', + version: KILO_TOKEN_VERSION, + aud, + }); + + await expect(validateKiloToken(token, TEST_SECRET, undefined)).resolves.toMatchObject({ + success: false, + }); + } + }); + + it('preserves legacy tokens without an audience or date claims', async () => { + const token = await new SignJWT({ + kiloUserId: 'user_123', + apiTokenPepper: 'pepper_abc', + version: KILO_TOKEN_VERSION, + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode(TEST_SECRET)); + + await expect(validateKiloToken(token, TEST_SECRET, undefined)).resolves.toEqual({ + success: true, + userId: 'user_123', + token, + pepper: 'pepper_abc', + }); + }); + it('rejects wrong token version', async () => { const token = await signToken({ kiloUserId: 'user_123', diff --git a/services/kiloclaw/src/auth/jwt.ts b/services/kiloclaw/src/auth/jwt.ts index 392a5f782f..fee7f10346 100644 --- a/services/kiloclaw/src/auth/jwt.ts +++ b/services/kiloclaw/src/auth/jwt.ts @@ -1,5 +1,6 @@ import { SignJWT } from 'jose'; -import { verifyKiloToken } from '@kilocode/worker-utils'; +import { KILOCLAW_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; +import { verifyKiloTokenForResource } from '@kilocode/worker-utils/kilo-token-policy'; import { KILO_TOKEN_VERSION, KILOCLAW_AUTH_COOKIE_MAX_AGE } from '../config'; export type ValidateResult = @@ -10,6 +11,7 @@ export type ValidateResult = * Verify a Kilo JWT using HS256 symmetric secret. * * Checks: signature, expiration (built into jose), version === 3 (via shared schema), + * KILOCLAW_AUDIENCE when an audience is present (legacy audience-less tokens are accepted), * and optional env match against the worker's WORKER_ENV. */ export async function validateKiloToken( @@ -17,9 +19,12 @@ export async function validateKiloToken( secret: string, expectedEnv: string | undefined ): Promise { - let payload: Awaited>; + let payload: Awaited>; try { - payload = await verifyKiloToken(token, secret); + payload = await verifyKiloTokenForResource(token, secret, { + audience: KILOCLAW_AUDIENCE, + mode: 'allow-legacy', + }); } catch (err) { const message = err instanceof Error ? err.message : 'JWT verification failed'; return { success: false, error: message }; diff --git a/services/kiloclaw/src/auth/middleware.test.ts b/services/kiloclaw/src/auth/middleware.test.ts index ccc23b5e31..ad715fee12 100644 --- a/services/kiloclaw/src/auth/middleware.test.ts +++ b/services/kiloclaw/src/auth/middleware.test.ts @@ -4,12 +4,16 @@ import { SignJWT } from 'jose'; import type { AppEnv } from '../types'; import { authMiddleware, internalApiMiddleware } from './middleware'; import { KILO_TOKEN_VERSION, KILOCLAW_AUTH_COOKIE } from '../config'; +import { KILOCLAW_AUDIENCE } from '@kilocode/worker-utils'; +import { findPepperByUserId, getWorkerDb } from '../db'; + +let downstreamExecutions = 0; vi.mock('../db', () => ({ getWorkerDb: vi.fn(() => ({})), findPepperByUserId: vi.fn(async (_db: unknown, userId: string) => ({ id: userId, - api_token_pepper: `pepper_for_${userId}`, + api_token_pepper: userId === 'pepperless_user' ? null : `pepper_for_${userId}`, blocked_reason: userId === 'blocked_user' ? 'abuse' : null, })), })); @@ -36,6 +40,7 @@ function createTestApp() { // Auth-protected route app.use('/protected/*', authMiddleware); app.get('/protected/whoami', c => { + downstreamExecutions += 1; return c.json({ userId: c.get('userId'), authToken: c.get('authToken') }); }); @@ -62,6 +67,8 @@ describe('authMiddleware', () => { let app: ReturnType; beforeEach(() => { + vi.clearAllMocks(); + downstreamExecutions = 0; app = createTestApp(); }); @@ -131,6 +138,98 @@ describe('authMiddleware', () => { expect(body.userId).toBe('user_cookie'); }); + it.each([ + { tokenPepper: 'absent', storedPepper: null, expectedStatus: 200 }, + { tokenPepper: null, storedPepper: null, expectedStatus: 200 }, + { tokenPepper: 'absent', storedPepper: 'rotated_pepper', expectedStatus: 401 }, + { tokenPepper: null, storedPepper: 'rotated_pepper', expectedStatus: 401 }, + ])( + 'validates $tokenPepper token pepper against $storedPepper stored pepper', + async ({ tokenPepper, storedPepper, expectedStatus }) => { + const token = await signToken({ + kiloUserId: 'pepperless_user', + ...(tokenPepper === null ? { apiTokenPepper: null } : {}), + version: KILO_TOKEN_VERSION, + aud: KILOCLAW_AUDIENCE, + }); + const lookup = vi.mocked(findPepperByUserId); + lookup.mockResolvedValueOnce({ + id: 'pepperless_user', + api_token_pepper: storedPepper, + blocked_reason: null, + }); + + const res = await app.request( + '/protected/whoami', + { headers: { Authorization: `Bearer ${token}` } }, + ENV_WITH_HYPERDRIVE + ); + + expect(res.status).toBe(expectedStatus); + expect(lookup).toHaveBeenCalledOnce(); + if (storedPepper === null) { + expect(await jsonBody(res)).toEqual({ userId: 'pepperless_user', authToken: token }); + } else { + expect(await jsonBody(res)).toEqual({ error: 'Token revoked' }); + } + } + ); + + it('authenticates correct-audience Bearer and cookie tokens', async () => { + for (const headers of [ + { Authorization: 'Bearer TOKEN' }, + { Cookie: `${KILOCLAW_AUTH_COOKIE}=TOKEN` }, + ]) { + const token = await signToken({ + kiloUserId: 'user_123', + apiTokenPepper: pepperFor('user_123'), + version: KILO_TOKEN_VERSION, + aud: KILOCLAW_AUDIENCE, + }); + const resolvedHeaders = Object.fromEntries( + Object.entries(headers).map(([name, value]) => [name, value.replace('TOKEN', token)]) + ); + + const res = await app.request( + '/protected/whoami', + { headers: resolvedHeaders }, + ENV_WITH_HYPERDRIVE + ); + expect(res.status).toBe(200); + expect(await jsonBody(res)).toEqual({ userId: 'user_123', authToken: token }); + } + }); + + it('rejects wrong-audience Bearer and cookie tokens before database lookup', async () => { + const lookup = vi.mocked(findPepperByUserId); + const workerDb = vi.mocked(getWorkerDb); + for (const headers of [ + { Authorization: 'Bearer TOKEN' }, + { Cookie: `${KILOCLAW_AUTH_COOKIE}=TOKEN` }, + ]) { + const token = await signToken({ + kiloUserId: 'user_123', + apiTokenPepper: pepperFor('user_123'), + version: KILO_TOKEN_VERSION, + aud: 'another-resource', + }); + const resolvedHeaders = Object.fromEntries( + Object.entries(headers).map(([name, value]) => [name, value.replace('TOKEN', token)]) + ); + lookup.mockClear(); + + const res = await app.request( + '/protected/whoami', + { headers: resolvedHeaders }, + ENV_WITH_HYPERDRIVE + ); + expect(res.status).toBe(401); + expect(lookup).not.toHaveBeenCalled(); + expect(workerDb).not.toHaveBeenCalled(); + expect(downstreamExecutions).toBe(0); + } + }); + it('prefers Bearer header over cookie', async () => { const bearerToken = await signToken({ kiloUserId: 'user_bearer', @@ -158,6 +257,53 @@ describe('authMiddleware', () => { expect(body.userId).toBe('user_bearer'); }); + it('does not fall back to a valid cookie when the Bearer token has the wrong audience', async () => { + const bearerToken = await signToken({ + kiloUserId: 'user_bearer', + apiTokenPepper: pepperFor('user_bearer'), + version: KILO_TOKEN_VERSION, + aud: 'another-resource', + }); + const cookieToken = await signToken({ + kiloUserId: 'user_cookie', + apiTokenPepper: pepperFor('user_cookie'), + version: KILO_TOKEN_VERSION, + aud: KILOCLAW_AUDIENCE, + }); + + const res = await app.request( + '/protected/whoami', + { + headers: { + Authorization: `Bearer ${bearerToken}`, + Cookie: `${KILOCLAW_AUTH_COOKIE}=${cookieToken}`, + }, + }, + ENV_WITH_HYPERDRIVE + ); + expect(res.status).toBe(401); + expect(vi.mocked(findPepperByUserId)).not.toHaveBeenCalled(); + expect(vi.mocked(getWorkerDb)).not.toHaveBeenCalled(); + expect(downstreamExecutions).toBe(0); + }); + + it('rejects a correct-audience token with a stale pepper', async () => { + const token = await signToken({ + kiloUserId: 'user_123', + apiTokenPepper: 'stale_pepper', + version: KILO_TOKEN_VERSION, + aud: KILOCLAW_AUDIENCE, + }); + + const res = await app.request( + '/protected/whoami', + { headers: { Authorization: `Bearer ${token}` } }, + ENV_WITH_HYPERDRIVE + ); + expect(res.status).toBe(401); + expect((await jsonBody(res)).error).toContain('revoked'); + }); + it('rejects when pepper does not match', async () => { const token = await signToken({ kiloUserId: 'user_123', @@ -226,6 +372,8 @@ describe('blocked users', () => { let app: ReturnType; beforeEach(() => { + vi.clearAllMocks(); + downstreamExecutions = 0; app = createTestApp(); }); @@ -234,6 +382,7 @@ describe('blocked users', () => { kiloUserId: 'blocked_user', apiTokenPepper: pepperFor('blocked_user'), version: KILO_TOKEN_VERSION, + aud: KILOCLAW_AUDIENCE, }); const res = await app.request( @@ -249,6 +398,8 @@ describe('C15 deviceSessionId compatibility', () => { let app: ReturnType; beforeEach(() => { + vi.clearAllMocks(); + downstreamExecutions = 0; app = createTestApp(); }); @@ -276,6 +427,8 @@ describe('internalApiMiddleware', () => { let app: ReturnType; beforeEach(() => { + vi.clearAllMocks(); + downstreamExecutions = 0; app = createTestApp(); }); diff --git a/services/kiloclaw/src/routes/access-gateway.test.ts b/services/kiloclaw/src/routes/access-gateway.test.ts index 22fc9a1eea..4ae5fc1ed5 100644 --- a/services/kiloclaw/src/routes/access-gateway.test.ts +++ b/services/kiloclaw/src/routes/access-gateway.test.ts @@ -1,12 +1,28 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Hono } from 'hono'; import type { AppEnv } from '../types'; +import type * as dbModule from '../db'; + +vi.mock('../db', async importOriginal => ({ + ...(await importOriginal()), + getWorkerDb: vi.fn(() => ({})), + findPepperByUserId: vi.fn(), + validateAndRedeemAccessCode: vi.fn(), +})); + import { accessGatewayRoutes } from './access-gateway'; import { signKiloToken } from '../auth/jwt'; import { deriveGatewayToken } from '../auth/gateway-token'; import { sandboxIdFromUserId } from '../auth/sandbox-id'; import { sandboxIdFromInstanceId } from '@kilocode/worker-utils/instance-id'; -import { KILOCLAW_AUTH_COOKIE, KILOCLAW_ACTIVE_INSTANCE_COOKIE } from '../config'; +import { + KILO_TOKEN_VERSION, + KILOCLAW_AUTH_COOKIE, + KILOCLAW_ACTIVE_INSTANCE_COOKIE, +} from '../config'; +import { KILOCLAW_AUDIENCE } from '@kilocode/worker-utils'; +import { SignJWT } from 'jose'; +import { findPepperByUserId, getWorkerDb, validateAndRedeemAccessCode } from '../db'; const NEXTAUTH_SECRET = 'test-nextauth-secret'; const GATEWAY_TOKEN_SECRET = 'test-gateway-secret'; @@ -50,6 +66,19 @@ async function signedAuthCookie(): Promise { }); } +async function signedAudienceAuthCookie(aud: string | string[]): Promise { + return new SignJWT({ + kiloUserId: USER_ID, + apiTokenPepper: null, + version: KILO_TOKEN_VERSION, + aud, + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(new TextEncoder().encode(NEXTAUTH_SECRET)); +} + function parseSetCookies(response: Response): Record { const cookies: Record = {}; for (const line of response.headers.getSetCookie?.() ?? []) { @@ -66,6 +95,7 @@ function envBindings(overrides: Record = {}) { NEXTAUTH_SECRET, GATEWAY_TOKEN_SECRET, WORKER_ENV: 'test', + HYPERDRIVE: { connectionString: 'postgresql://fake' }, KILOCLAW_INSTANCE: buildInstanceBinding(USER_ID), KILOCLAW_INSTANCE_HOST_SUFFIX: '.kiloclaw.ai', KILOCLAW_INSTANCE_URL_SCHEME: 'https', @@ -74,6 +104,48 @@ function envBindings(overrides: Record = {}) { } describe('access-gateway cookie scoping', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects a wrong-audience cookie without an access-code lookup', async () => { + const app = buildApp(); + const token = await signedAudienceAuthCookie('another-resource'); + + const response = await app.fetch( + new Request(`https://claw.kilosessions.ai/kilo-access-gateway?userId=${USER_ID}`, { + headers: { Cookie: `${KILOCLAW_AUTH_COOKIE}=${token}` }, + }), + envBindings() + ); + + expect(response.status).toBe(200); + expect(await response.text()).toContain('Enter the access code'); + expect(vi.mocked(getWorkerDb)).not.toHaveBeenCalled(); + expect(vi.mocked(findPepperByUserId)).not.toHaveBeenCalled(); + expect(vi.mocked(validateAndRedeemAccessCode)).not.toHaveBeenCalled(); + }); + + it('accepts a correct-audience cookie without a pepper database lookup', async () => { + const app = buildApp(); + const token = await signedAudienceAuthCookie([KILOCLAW_AUDIENCE]); + + const response = await app.fetch( + new Request(`https://claw.kilosessions.ai/kilo-access-gateway?userId=${USER_ID}`, { + headers: { Cookie: `${KILOCLAW_AUTH_COOKIE}=${token}` }, + }), + envBindings() + ); + + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe( + `/#token=${await deriveGatewayToken(sandboxIdFromUserId(USER_ID), GATEWAY_TOKEN_SECRET)}` + ); + expect(vi.mocked(getWorkerDb)).not.toHaveBeenCalled(); + expect(vi.mocked(findPepperByUserId)).not.toHaveBeenCalled(); + expect(vi.mocked(validateAndRedeemAccessCode)).not.toHaveBeenCalled(); + }); + it('sets KILOCLAW_ACTIVE_INSTANCE_COOKIE on legacy host (claw.kilosessions.ai)', async () => { const app = buildApp(); const token = await signedAuthCookie(); diff --git a/services/notifications/src/__tests__/auth.test.ts b/services/notifications/src/__tests__/auth.test.ts index c8e847526f..bcf2402069 100644 --- a/services/notifications/src/__tests__/auth.test.ts +++ b/services/notifications/src/__tests__/auth.test.ts @@ -1,8 +1,14 @@ -import { beforeEach, describe, it, expect, vi } from 'vitest'; import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { signKiloToken } from '@kilocode/worker-utils'; -import { authMiddleware } from '../auth'; -import type { AuthContext } from '../auth'; +import { getWorkerDb } from '@kilocode/db/client'; +import { + EVENT_SERVICE_AUDIENCE, + KILO_CHAT_AUDIENCE, + KILO_GATEWAY_AUDIENCE, + NOTIFICATIONS_AUDIENCE, +} from '@kilocode/worker-utils/internal-service-token-audiences'; +import { authMiddleware, type AuthContext } from '../auth'; type MockEnv = { NEXTAUTH_SECRET: { get: () => Promise }; @@ -11,164 +17,234 @@ type MockEnv = { }; const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256'; -const currentPepperByUserId = vi.hoisted(() => new Map()); +const dbState = vi.hoisted(() => ({ lookupCount: 0, fails: false })); +const userRow = vi.hoisted(() => ({ + pepper: 'pepper-current' as string | null, + blockedReason: null as string | null, +})); vi.mock('@kilocode/db/client', () => ({ - getWorkerDb: () => ({ + getWorkerDb: vi.fn(() => ({ select: () => ({ from: () => ({ where: () => ({ - limit: async () => [ - { - api_token_pepper: currentPepperByUserId.get('user-xyz-789'), - blocked_reason: null, - }, - ], + limit: async () => { + dbState.lookupCount++; + if (dbState.fails) throw new Error('connection refused'); + return [{ api_token_pepper: userRow.pepper, blocked_reason: userRow.blockedReason }]; + }, }), }), }), - }), + })), })); -function makeApp(_env: MockEnv) { - const app = new Hono<{ Bindings: MockEnv; Variables: AuthContext }>(); - app.use('*', authMiddleware); - app.get('/test', c => c.json({ callerId: c.get('callerId'), callerKind: c.get('callerKind') })); - return app; -} - const defaultEnv: MockEnv = { NEXTAUTH_SECRET: { get: async () => TEST_JWT_SECRET }, HYPERDRIVE: { connectionString: 'postgres://test' }, WORKER_ENV: 'production', }; +function makeApp() { + let downstreamCalls = 0; + const app = new Hono<{ Bindings: MockEnv; Variables: AuthContext }>(); + app.use('*', authMiddleware); + app.get('/test', c => { + downstreamCalls++; + return c.json({ callerId: c.get('callerId'), callerKind: c.get('callerKind') }); + }); + return { app, downstreamCalls: () => downstreamCalls }; +} + +async function signToken( + params: { + audience?: string; + secret?: string; + expiresInSeconds?: number; + pepper?: string | null; + env?: string; + extra?: { tokenSource?: string; botId?: string; deviceSessionId?: string }; + } = {} +) { + return ( + await signKiloToken({ + userId: 'user-xyz-789', + pepper: 'pepper' in params ? params.pepper : 'pepper-current', + secret: params.secret ?? TEST_JWT_SECRET, + expiresInSeconds: params.expiresInSeconds ?? 3600, + env: 'env' in params ? params.env : 'production', + audience: params.audience, + extra: params.extra ?? { tokenSource: 'kilo-chat' }, + }) + ).token; +} + +async function signWithAudience(aud: unknown): Promise { + const now = Math.floor(Date.now() / 1000); + const encode = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + const json = (value: unknown) => encode(new TextEncoder().encode(JSON.stringify(value))); + const input = `${json({ alg: 'HS256', typ: 'JWT' })}.${json({ + version: 3, + kiloUserId: 'user-xyz-789', + apiTokenPepper: 'pepper-current', + env: 'production', + aud, + iat: now, + exp: now + 3600, + })}`; + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(TEST_JWT_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(input)); + return `${input}.${encode(new Uint8Array(signature))}`; +} + +async function request(token: string, env = defaultEnv) { + const testApp = makeApp(); + const response = await testApp.app.request( + '/test', + { headers: { authorization: `Bearer ${token}` } }, + env + ); + return { response, ...testApp }; +} + describe('authMiddleware', () => { beforeEach(() => { - currentPepperByUserId.set('user-xyz-789', 'pepper-current'); + vi.mocked(getWorkerDb).mockClear(); + dbState.lookupCount = 0; + dbState.fails = false; + userRow.pepper = 'pepper-current'; + userRow.blockedReason = null; }); it('returns 401 with no authorization header', async () => { - const res = await makeApp(defaultEnv).request('/test', {}, defaultEnv); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'Unauthorized' }); + const testApp = makeApp(); + const response = await testApp.app.request('/test', {}, defaultEnv); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); }); - it('authenticates with a valid JWT and sets user identity', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-current', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'production', - extra: { tokenSource: 'kilo-chat' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - callerId: 'user-xyz-789', - callerKind: 'user', - }); + it.each([ + ['a matching string audience', () => signToken({ audience: NOTIFICATIONS_AUDIENCE })], + [ + 'a legacy token from another source', + () => signToken({ extra: { tokenSource: 'cloud-agent' } }), + ], + [ + 'a matching array audience', + () => signWithAudience([NOTIFICATIONS_AUDIENCE, 'other-service']), + ], + [ + 'a one-hour legacy kilo-chat token with bot and device-session claims', + () => + signToken({ + extra: { + tokenSource: 'kilo-chat', + botId: 'bot-123', + deviceSessionId: 'device-123', + }, + }), + ], + ])('authenticates %s', async (_name, createToken) => { + const { response } = await request(await createToken()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ callerId: 'user-xyz-789', callerKind: 'user' }); }); - it('authenticates a valid JWT from another token source', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-current', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'production', - extra: { tokenSource: 'cloud-agent' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - callerId: 'user-xyz-789', - callerKind: 'user', - }); + it.each([ + [ + 'an expired JWT', + () => signToken({ audience: NOTIFICATIONS_AUDIENCE, expiresInSeconds: -60 }), + ], + ['a malformed bearer', () => 'not-a-jwt'], + [ + 'a JWT signed with the wrong secret', + () => + signToken({ + audience: NOTIFICATIONS_AUDIENCE, + secret: 'wrong-test-secret-at-least-32-characters', + }), + ], + ])('returns 401 for %s before database or downstream access', async (_name, createToken) => { + const { response, downstreamCalls } = await request(await createToken()); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(getWorkerDb).not.toHaveBeenCalled(); + expect(dbState.lookupCount).toBe(0); + expect(downstreamCalls()).toBe(0); }); - it('returns 401 when the chat JWT has a stale pepper', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-stale', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'production', - extra: { tokenSource: 'kilo-chat' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(401); + it.each([ + ['another batch', KILO_CHAT_AUDIENCE], + ['the gateway', KILO_GATEWAY_AUDIENCE], + ['another service', EVENT_SERVICE_AUDIENCE], + ])('rejects an audience for %s before database access', async (_name, audience) => { + const { response, downstreamCalls } = await request(await signToken({ audience })); + expect(response.status).toBe(401); + expect(dbState.lookupCount).toBe(0); + expect(downstreamCalls()).toBe(0); }); - it('returns 401 when the chat JWT was minted for a different environment', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: 'pepper-current', - secret: TEST_JWT_SECRET, - expiresInSeconds: 3600, - env: 'development', - extra: { tokenSource: 'kilo-chat' }, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv + it.each([null, '', ['notifications', 'notifications'], ['notifications', 1], []])( + 'rejects malformed audience claims before database access', + async audience => { + const { response, downstreamCalls } = await request(await signWithAudience(audience)); + expect(response.status).toBe(401); + expect(dbState.lookupCount).toBe(0); + expect(downstreamCalls()).toBe(0); + } + ); + + it.each([ + ['a stale string pepper', 'pepper-stale', 'pepper-current', 401], + ['a null claim against a non-null pepper', null, 'pepper-current', 401], + ['a null claim against a null pepper', null, null, 200], + ])('preserves pepper semantics for %s', async (_name, pepper, currentPepper, status) => { + userRow.pepper = currentPepper; + const { response } = await request( + await signToken({ audience: NOTIFICATIONS_AUDIENCE, pepper }) ); - expect(res.status).toBe(401); + expect(response.status).toBe(status); }); - it('returns 401 with an expired JWT', async () => { + it('preserves absent pepper claim semantics', async () => { const { token } = await signKiloToken({ userId: 'user-xyz-789', - pepper: null, secret: TEST_JWT_SECRET, - expiresInSeconds: -1, + expiresInSeconds: 3600, env: 'production', - extra: { tokenSource: 'kilo-chat' }, + audience: NOTIFICATIONS_AUDIENCE, }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'Unauthorized' }); + const { response } = await request(token); + expect(response.status).toBe(200); }); - it('returns 401 for an arbitrary non-JWT bearer', async () => { - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: 'Bearer not-a-jwt' } }, - defaultEnv + it.each([ + ['a blocked account', () => (userRow.blockedReason = 'manual block'), defaultEnv, 'production'], + ['a missing env claim', () => undefined, defaultEnv, undefined], + ['a mismatched environment', () => undefined, defaultEnv, 'development'], + ])('returns 401 for %s', async (_name, arrange, env, tokenEnv) => { + arrange(); + const { response } = await request( + await signToken({ audience: NOTIFICATIONS_AUDIENCE, env: tokenEnv }), + env ); - expect(res.status).toBe(401); + expect(response.status).toBe(401); }); - it('returns 401 when the JWT is signed with a different secret', async () => { - const { token } = await signKiloToken({ - userId: 'user-xyz-789', - pepper: null, - secret: 'a-completely-different-secret-of-correct-length', - expiresInSeconds: 3600, - }); - const res = await makeApp(defaultEnv).request( - '/test', - { headers: { authorization: `Bearer ${token}` } }, - defaultEnv - ); - expect(res.status).toBe(401); + it('maps dependency failures to 401', async () => { + dbState.fails = true; + const { response } = await request(await signToken({ audience: NOTIFICATIONS_AUDIENCE })); + expect(response.status).toBe(401); }); }); diff --git a/services/notifications/src/auth.ts b/services/notifications/src/auth.ts index 5abd95b1a0..acb6d14738 100644 --- a/services/notifications/src/auth.ts +++ b/services/notifications/src/auth.ts @@ -1,6 +1,7 @@ import { createMiddleware } from 'hono/factory'; import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; import { extractBearerToken } from '@kilocode/worker-utils'; +import { NOTIFICATIONS_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; import { logger } from './util/logger'; export type AuthContext = { @@ -30,6 +31,7 @@ export const authMiddleware = createMiddleware<{ nextAuthSecret: c.env.NEXTAUTH_SECRET, workerEnv: c.env.WORKER_ENV, connectionString: c.env.HYPERDRIVE.connectionString, + resourceAudience: { audience: NOTIFICATIONS_AUDIENCE, mode: 'allow-legacy' }, }); if (!auth) { return c.json({ error: 'Unauthorized' }, 401);