diff --git a/packages/worker-utils/src/kilo-token.test.ts b/packages/worker-utils/src/kilo-token.test.ts index ea81c678fd..519aebb8d6 100644 --- a/packages/worker-utils/src/kilo-token.test.ts +++ b/packages/worker-utils/src/kilo-token.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { SignJWT } from 'jose'; +import { SignJWT, decodeProtectedHeader } from 'jose'; import { kiloTokenPayload, KILO_TOKEN_VERSION, @@ -102,6 +102,31 @@ describe('signKiloToken', () => { expect(payloadWithoutEnv.env).toBeUndefined(); }); + it('omits the apiTokenPepper claim entirely when pepper is not provided', async () => { + const { token } = await signKiloToken({ + userId: 'user-internal', + secret: SECRET, + expiresInSeconds: 60, + }); + + const payload = await verifyKiloToken(token, SECRET); + + expect('apiTokenPepper' in payload).toBe(false); + }); + + it('sets typ: JWT on the protected header', async () => { + const { token } = await signKiloToken({ + userId: 'user-header', + pepper: 'pepper-header', + secret: SECRET, + expiresInSeconds: 60, + }); + + const header = decodeProtectedHeader(token); + + expect(header).toEqual({ alg: 'HS256', typ: 'JWT' }); + }); + it('produces payloads accepted by the closed schema', async () => { const { token } = await signKiloToken({ userId: 'user-schema', diff --git a/packages/worker-utils/src/kilo-token.ts b/packages/worker-utils/src/kilo-token.ts index c54d3e5973..c4c030e21f 100644 --- a/packages/worker-utils/src/kilo-token.ts +++ b/packages/worker-utils/src/kilo-token.ts @@ -59,7 +59,13 @@ export type SignKiloTokenExtra = Pick< export async function signKiloToken(params: { userId: string; - pepper: string | null; + /** + * Omit (or pass `undefined`) to mint an internal-service token with no + * `apiTokenPepper` claim at all — verifiers treat an absent claim as + * "skip pepper comparison", unlike an explicit `null`, which is compared + * against the account's current pepper. + */ + pepper?: string | null; secret: string; expiresInSeconds: number; audience?: string; @@ -83,7 +89,7 @@ export async function signKiloToken(params: { const validatedPayload = signKiloTokenPayload.parse(payload); let signer = new SignJWT(validatedPayload) - .setProtectedHeader({ alg: 'HS256' }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) .setIssuedAt(now) .setExpirationTime(exp); if (params.audience) signer = signer.setAudience(params.audience); diff --git a/services/gastown/src/util/kilo-token.util.ts b/services/gastown/src/util/kilo-token.util.ts index 302e1ac488..dd8f939a73 100644 --- a/services/gastown/src/util/kilo-token.util.ts +++ b/services/gastown/src/util/kilo-token.util.ts @@ -1,28 +1,23 @@ -import { SignJWT } from 'jose'; +import { signKiloToken } from '@kilocode/worker-utils'; -const JWT_TOKEN_VERSION = 3; const THIRTY_DAYS_SECONDS = 30 * 24 * 60 * 60; /** * Generate a Kilo API token for a user. Used to create kilocode_tokens * for agents to authenticate with the Kilo LLM gateway. * - * This is the CF Worker equivalent of generateApiToken() from src/lib/tokens.ts, - * using jose (Web Crypto) instead of jsonwebtoken (Node.js). + * This is the CF Worker equivalent of generateApiToken() from src/lib/tokens.ts. */ export async function generateKiloApiToken( user: { id: string; api_token_pepper: string | null }, secret: string, expiresInSeconds: number = THIRTY_DAYS_SECONDS ): Promise { - const now = Math.floor(Date.now() / 1000); - return new SignJWT({ - kiloUserId: user.id, - apiTokenPepper: user.api_token_pepper, - version: JWT_TOKEN_VERSION, - }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt(now) - .setExpirationTime(now + expiresInSeconds) - .sign(new TextEncoder().encode(secret)); + const { token } = await signKiloToken({ + userId: user.id, + pepper: user.api_token_pepper, + secret, + expiresInSeconds, + }); + return token; } diff --git a/services/security-auto-analysis/src/token.ts b/services/security-auto-analysis/src/token.ts index 27ce5ce186..f4a4792459 100644 --- a/services/security-auto-analysis/src/token.ts +++ b/services/security-auto-analysis/src/token.ts @@ -1,27 +1,25 @@ -type JwtPayload = Record; -type JwtOptions = { - expiresIn: string; -}; +import { signKiloToken } from '@kilocode/worker-utils'; type TokenUser = { id: string; api_token_pepper: string | null; }; -const JWT_TOKEN_VERSION = 3; +const ONE_HOUR_SECONDS = 60 * 60; export async function generateInternalServiceToken( userId: string, secret: string ): Promise { - return signJwt( - { - kiloUserId: userId, - version: JWT_TOKEN_VERSION, - }, + // No `pepper` field: verifiers treat an absent apiTokenPepper claim as + // "skip pepper comparison" for internal-service tokens (see + // verifyKiloBearerAgainstCurrentPepper in @kilocode/worker-utils). + const { token } = await signKiloToken({ + userId, secret, - { expiresIn: '1h' } - ); + expiresInSeconds: ONE_HOUR_SECONDS, + }); + return token; } export async function generateApiToken( @@ -29,87 +27,16 @@ export async function generateApiToken( secret: string, environment: string ): Promise { - return signJwt( - { - env: environment, - kiloUserId: user.id, - apiTokenPepper: user.api_token_pepper, - version: JWT_TOKEN_VERSION, + const { token } = await signKiloToken({ + userId: user.id, + pepper: user.api_token_pepper, + secret, + expiresInSeconds: ONE_HOUR_SECONDS, + env: environment, + extra: { internalApiUse: true, createdOnPlatform: 'security-agent', }, - secret, - { expiresIn: '1h' } - ); -} - -async function signJwt(payload: JwtPayload, secret: string, options: JwtOptions): Promise { - const header = { - alg: 'HS256', - typ: 'JWT', - }; - - const now = Math.floor(Date.now() / 1000); - const exp = now + parseExpiresIn(options.expiresIn); - - const encodedHeader = base64UrlEncode(JSON.stringify(header)); - const encodedPayload = base64UrlEncode( - JSON.stringify({ - ...payload, - iat: now, - exp, - }) - ); - - const signature = await hmacSha256(`${encodedHeader}.${encodedPayload}`, secret); - const encodedSignature = base64UrlEncodeBytes(new Uint8Array(signature)); - - return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; -} - -function parseExpiresIn(expiresIn: string): number { - const match = expiresIn.match(/^(\d+)([smhd])$/); - if (!match) { - throw new Error(`Invalid expiresIn format: ${expiresIn}`); - } - - const value = Number.parseInt(match[1], 10); - const unit = match[2]; - - if (unit === 's') { - return value; - } - if (unit === 'm') { - return value * 60; - } - if (unit === 'h') { - return value * 60 * 60; - } - - return value * 60 * 60 * 24; -} - -function base64UrlEncodeBytes(bytes: Uint8Array): string { - const base64 = btoa(bytes.reduce((s, b) => s + String.fromCharCode(b), '')); - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function base64UrlEncode(value: string): string { - return base64UrlEncodeBytes(new TextEncoder().encode(value)); -} - -async function hmacSha256(message: string, secret: string): Promise { - const encoder = new TextEncoder(); - const keyData = encoder.encode(secret); - const messageData = encoder.encode(message); - - const key = await crypto.subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - - return crypto.subtle.sign('HMAC', key, messageData); + }); + return token; } diff --git a/services/webhook-agent-ingest/src/services/token-minting-service.ts b/services/webhook-agent-ingest/src/services/token-minting-service.ts index f0084f0b30..359c4fe808 100644 --- a/services/webhook-agent-ingest/src/services/token-minting-service.ts +++ b/services/webhook-agent-ingest/src/services/token-minting-service.ts @@ -1,3 +1,4 @@ +import { signKiloToken } from '@kilocode/worker-utils'; import { getWorkerDb, findUserForToken, @@ -5,7 +6,6 @@ import { ensureBotUserForOrg, type WorkerDb, } from '../db/queries.js'; -import { signJwt } from '../util/jwt.js'; import { logger } from '../util/logger.js'; /** @@ -137,7 +137,7 @@ export class TokenMintingService { } /** - * Sign a JWT token with the given payload. + * Sign a Kilo API token (v3) with the given payload. */ private async signToken(payload: { kiloUserId: string; @@ -146,23 +146,20 @@ export class TokenMintingService { internalApiUse: boolean; createdOnPlatform: string; }): Promise { - // JWT_TOKEN_VERSION must match kilocode-backend's version (src/lib/tokens.ts) - const JWT_TOKEN_VERSION = 3; const jwtSecret = await this.getJwtSecret(); - // signJwt is async (uses Web Crypto API) - return await signJwt( - { - env: this.env.ENVIRONMENT === 'production' ? 'production' : 'development', - kiloUserId: payload.kiloUserId, - apiTokenPepper: payload.apiTokenPepper, - version: JWT_TOKEN_VERSION, + const { token } = await signKiloToken({ + userId: payload.kiloUserId, + pepper: payload.apiTokenPepper, + secret: jwtSecret, + expiresInSeconds: 60 * 60, + env: this.env.ENVIRONMENT === 'production' ? 'production' : 'development', + extra: { botId: payload.botId, internalApiUse: payload.internalApiUse, createdOnPlatform: payload.createdOnPlatform, }, - jwtSecret, - { expiresIn: '1h' } - ); + }); + return token; } } diff --git a/services/webhook-agent-ingest/src/util/jwt.ts b/services/webhook-agent-ingest/src/util/jwt.ts deleted file mode 100644 index ad9bfb063e..0000000000 --- a/services/webhook-agent-ingest/src/util/jwt.ts +++ /dev/null @@ -1,90 +0,0 @@ -type JwtPayload = Record; -type JwtOptions = { - expiresIn: string; // e.g., '1h', '15m' -}; - -/** - * Sign a JWT token using HS256 algorithm. - * This is an async implementation using Web Crypto API for Workers environment. - */ -export async function signJwt( - payload: JwtPayload, - secret: string, - options: JwtOptions -): Promise { - const header = { - alg: 'HS256', - typ: 'JWT', - }; - - // Calculate expiration - const now = Math.floor(Date.now() / 1000); - const expiresIn = parseExpiresIn(options.expiresIn); - const exp = now + expiresIn; - - const fullPayload = { - ...payload, - iat: now, - exp, - }; - - const encodedHeader = base64UrlEncode(JSON.stringify(header)); - const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload)); - - // IMPORTANT: hmacSha256 is async, must await - const signature = await hmacSha256(`${encodedHeader}.${encodedPayload}`, secret); - const encodedSignature = base64UrlEncodeBuffer(signature); - - return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; -} - -function parseExpiresIn(expiresIn: string): number { - const match = expiresIn.match(/^(\d+)([smhd])$/); - if (!match) { - throw new Error(`Invalid expiresIn format: ${expiresIn}`); - } - - const value = parseInt(match[1], 10); - const unit = match[2]; - - switch (unit) { - case 's': - return value; - case 'm': - return value * 60; - case 'h': - return value * 60 * 60; - case 'd': - return value * 60 * 60 * 24; - default: - throw new Error(`Unknown time unit: ${unit}`); - } -} - -function base64UrlEncode(str: string): string { - const bytes = new TextEncoder().encode(str); - const base64 = btoa(String.fromCharCode(...bytes)); - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function base64UrlEncodeBuffer(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - const base64 = btoa(String.fromCharCode(...bytes)); - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -async function hmacSha256(message: string, secret: string): Promise { - const encoder = new TextEncoder(); - const keyData = encoder.encode(secret); - const messageData = encoder.encode(message); - - const key = await crypto.subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - - return crypto.subtle.sign('HMAC', key, messageData); -}