Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/worker-utils/src/kilo-token.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 8 additions & 2 deletions packages/worker-utils/src/kilo-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
23 changes: 9 additions & 14 deletions services/gastown/src/util/kilo-token.util.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
111 changes: 19 additions & 92 deletions services/security-auto-analysis/src/token.ts
Original file line number Diff line number Diff line change
@@ -1,115 +1,42 @@
type JwtPayload = Record<string, unknown>;
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<string> {
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(
user: TokenUser,
secret: string,
environment: string
): Promise<string> {
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<string> {
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<ArrayBuffer> {
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;
}
25 changes: 11 additions & 14 deletions services/webhook-agent-ingest/src/services/token-minting-service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { signKiloToken } from '@kilocode/worker-utils';
import {
getWorkerDb,
findUserForToken,
organizationExists,
ensureBotUserForOrg,
type WorkerDb,
} from '../db/queries.js';
import { signJwt } from '../util/jwt.js';
import { logger } from '../util/logger.js';

/**
Expand Down Expand Up @@ -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;
Expand All @@ -146,23 +146,20 @@ export class TokenMintingService {
internalApiUse: boolean;
createdOnPlatform: string;
}): Promise<string> {
// 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;
}
}
90 changes: 0 additions & 90 deletions services/webhook-agent-ingest/src/util/jwt.ts

This file was deleted.