From 802f6dd89e46698e76adbd998c85f3654d84bfb0 Mon Sep 17 00:00:00 2001 From: Florian Hines Date: Tue, 8 Sep 2026 11:53:45 -0500 Subject: [PATCH 1/2] feat(auth): extract shared token foundation --- ENVIRONMENT.md | 10 + ...resource-delegation.servicecontrol.test.ts | 128 +++++ .../src/lib/auth/resource-delegation.test.ts | 398 +++++++++++++++ apps/web/src/lib/auth/resource-delegation.ts | 465 ++++++++++++++++++ .../lib/config.server.resource-tokens.test.ts | 107 ++++ apps/web/src/lib/config.server.ts | 28 ++ apps/web/src/lib/token.test.ts | 15 + apps/web/src/lib/tokens.ts | 127 ++++- apps/web/src/lib/user/server.test.ts | 84 ++++ apps/web/src/lib/user/server.ts | 39 +- apps/web/src/routers/test-utils.ts | 7 +- packages/worker-utils/package.json | 3 + .../src/kilo-auth-middleware.test.ts | 51 +- .../worker-utils/src/kilo-auth-middleware.ts | 22 +- .../src/kilo-token-policy.test.ts | 50 +- .../worker-utils/src/kilo-token-policy.ts | 56 ++- packages/worker-utils/src/kilo-token.test.ts | 22 + packages/worker-utils/src/kilo-token.ts | 21 +- .../src/runtime-authorization-contract.ts | 82 +++ .../src/runtime-authorization.test.ts | 346 +++++++++++++ .../worker-utils/src/runtime-authorization.ts | 368 ++++++++++++++ .../src/runtime-proxy-attestation.test.ts | 65 +++ .../src/runtime-proxy-attestation.ts | 125 +++++ .../src/middleware/kilo-jwt-auth.test.ts | 88 +++- .../src/middleware/kilo-jwt-auth.ts | 37 +- 25 files changed, 2706 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/lib/auth/resource-delegation.servicecontrol.test.ts create mode 100644 apps/web/src/lib/auth/resource-delegation.test.ts create mode 100644 apps/web/src/lib/auth/resource-delegation.ts create mode 100644 apps/web/src/lib/config.server.resource-tokens.test.ts create mode 100644 packages/worker-utils/src/runtime-authorization-contract.ts create mode 100644 packages/worker-utils/src/runtime-authorization.test.ts create mode 100644 packages/worker-utils/src/runtime-authorization.ts create mode 100644 packages/worker-utils/src/runtime-proxy-attestation.test.ts create mode 100644 packages/worker-utils/src/runtime-proxy-attestation.ts diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 20529b19ce..c7b7aa3e73 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -81,6 +81,16 @@ Manage shared web env var additions and rotations with `pnpm web:env set { + shared.enabled = true; + shared.family = ''; +}); +jest.mock('@/lib/config.server', () => ({ + NEXTAUTH_SECRET: 'service-control-test-secret', + isResourceTokenIssuanceEnabled: (family: string) => + shared.enabled && (!shared.family || shared.family === family), +})); +jest.mock('@/lib/user/server', () => ({ getUserFromSessionForCredentialIssuance: jest.fn() })); + +import { generateCloudAgentWorkflowToken, generateWorkflowGatewayToken } from '@/lib/tokens'; +import { defineTestUser } from '@/tests/helpers/user.helper'; + +describe('workflow service control tokens', () => { + test('uses bounded modern gateway workflow owner claims', () => { + const user = defineTestUser({ api_token_pepper: 'workflow-pepper' }); + const token = generateWorkflowGatewayToken(user, { + organizationId: 'organization-id', + tokenSource: 'reviewer', + }); + const claims = jwt.verify(token, 'service-control-test-secret') as jwt.JwtPayload; + + expect(claims).toMatchObject({ + aud: 'kilo-gateway', + kiloUserId: user.id, + apiTokenPepper: 'workflow-pepper', + organizationId: 'organization-id', + tokenSource: 'reviewer', + tokenPurpose: 'delegated-workload', + credentialExchange: false, + }); + expect(claims).not.toHaveProperty('runtimeAdmission'); + expect(claims.exp! - claims.iat!).toBe(60 * 60); + }); + + test('uses bounded modern automation admission', () => { + const user = defineTestUser({ api_token_pepper: 'workflow-pepper' }); + const token = generateCloudAgentWorkflowToken(user, { + expiresIn: 300, + tokenSource: 'reviewer', + botId: 'reviewer', + }); + const claims = jwt.verify(token, 'service-control-test-secret') as jwt.JwtPayload; + expect(claims).toMatchObject({ + aud: 'cloud-agent-next', + tokenPurpose: 'internal-service', + credentialExchange: false, + runtimeAdmission: { + source: 'automation', + authorizationUserId: user.id, + authorizationPepper: 'workflow-pepper', + }, + }); + expect(claims.exp! - claims.iat!).toBe(300); + }); + + test('caps modern workflow admission to one hour', () => { + const user = defineTestUser({ api_token_pepper: 'workflow-pepper' }); + const token = generateCloudAgentWorkflowToken(user, { + expiresIn: 5 * 365 * 24 * 60 * 60, + tokenSource: 'reviewer', + }); + const claims = jwt.decode(token) as jwt.JwtPayload; + + expect(claims.exp! - claims.iat!).toBe(60 * 60); + }); + + test('requires an authorization pepper for modern workflow admission', () => { + const user = defineTestUser({ api_token_pepper: 'workflow-pepper' }); + const authorizationUser = defineTestUser({ api_token_pepper: null }); + + expect(() => + generateCloudAgentWorkflowToken(user, { + expiresIn: 300, + tokenSource: 'reviewer', + authorizationUser, + }) + ).toThrow('current authorization pepper'); + }); + + test('preserves the legacy workflow token shape when shared issuance is disabled', () => { + shared.enabled = false; + const user = defineTestUser({ api_token_pepper: 'workflow-pepper' }); + const token = generateCloudAgentWorkflowToken(user, { + expiresIn: 300, + tokenSource: 'reviewer', + botId: 'reviewer', + }); + const claims = jwt.verify(token, 'service-control-test-secret') as jwt.JwtPayload; + expect(claims).toMatchObject({ + kiloUserId: user.id, + tokenSource: 'reviewer', + botId: 'reviewer', + }); + expect(claims).not.toHaveProperty('tokenPurpose'); + expect(claims.exp! - claims.iat!).toBe(300); + }); +}); + +test.each(['cloud-agent-next', 'workflow-gateway'])( + 'only enables the selected workflow family %s', + family => { + shared.family = family; + const user = defineTestUser({ api_token_pepper: 'workflow-pepper' }); + const cloud = jwt.decode( + generateCloudAgentWorkflowToken(user, { + expiresIn: 300, + tokenSource: 'reviewer', + }) + ) as jwt.JwtPayload; + const gateway = jwt.decode( + generateWorkflowGatewayToken(user, { + tokenSource: 'reviewer', + }) + ) as jwt.JwtPayload; + expect(cloud.tokenPurpose).toBe(family === 'cloud-agent-next' ? 'internal-service' : undefined); + expect(gateway.tokenPurpose).toBe( + family === 'workflow-gateway' ? 'delegated-workload' : undefined + ); + expect(cloud.exp! - cloud.iat!).toBe(300); + if (family === 'cloud-agent-next') expect(gateway).not.toHaveProperty('aud'); + } +); diff --git a/apps/web/src/lib/auth/resource-delegation.test.ts b/apps/web/src/lib/auth/resource-delegation.test.ts new file mode 100644 index 0000000000..74a90296cd --- /dev/null +++ b/apps/web/src/lib/auth/resource-delegation.test.ts @@ -0,0 +1,398 @@ +import { isResourceTokenIssuanceEnabled } from '@/lib/config.server'; +import { afterEach, describe, expect, test } from '@jest/globals'; +import { + device_sessions, + kilocode_users, + organization_memberships, + organizations, +} from '@kilocode/db/schema'; +import { eq, inArray } from 'drizzle-orm'; +import jwt from 'jsonwebtoken'; +import { buildModernKiloTokenPayload } from '@kilocode/worker-utils/kilo-token-policy'; + +const shared = { enabled: true, family: '' }; +jest.mock('@/lib/config.server', () => ({ + NEXTAUTH_SECRET: 'resource-delegation-test-secret', + isResourceTokenIssuanceEnabled: jest.fn( + (family: string) => shared.enabled && (!shared.family || shared.family === family) + ), +})); +jest.mock('@/lib/user/server', () => ({ + getUserFromSessionForCredentialIssuance: jest.fn(), +})); + +import { + canIssueLegacyOrganizationToken, + createControlTokenForRequest, + createDelegatedResourceToken, + getResourceDelegationAuthority, +} from './resource-delegation'; +import { db } from '@/lib/drizzle'; +import { getUserFromSessionForCredentialIssuance } from '@/lib/user/server'; +import { insertTestUser } from '@/tests/helpers/user.helper'; + +const secret = 'resource-delegation-test-secret'; +const cleanups: string[] = []; + +afterEach(async () => { + if (cleanups.length) { + await db.delete(kilocode_users).where(inArray(kilocode_users.id, cleanups)); + cleanups.length = 0; + } + shared.enabled = true; + shared.family = ''; + jest.clearAllMocks(); +}); + +async function user() { + const row = await insertTestUser({ api_token_pepper: crypto.randomUUID() }); + cleanups.push(row.id); + return row; +} + +async function organizationFor(userId: string) { + const [organization] = await db + .insert(organizations) + .values({ + name: `Resource delegation ${crypto.randomUUID()}`, + created_by_kilo_user_id: userId, + require_seats: false, + }) + .returning(); + return organization; +} + +function bearer(token: string) { + return new Headers({ authorization: `Bearer ${token}` }); +} + +describe('legacy organization token compatibility', () => { + test('allows only requests without an Authorization header', () => { + expect(canIssueLegacyOrganizationToken(new Headers())).toBe(true); + expect(canIssueLegacyOrganizationToken(bearer('restricted-token'))).toBe(false); + expect(canIssueLegacyOrganizationToken(new Headers({ authorization: '' }))).toBe(false); + }); +}); + +function modernToken( + user: { id: string; api_token_pepper: string | null }, + options?: { + purpose?: 'human-api' | 'device-access'; + exchange?: boolean; + deviceSessionId?: string; + } +) { + const now = Math.floor(Date.now() / 1000); + const base = { + userId: user.id, + pepper: user.api_token_pepper, + env: process.env.NODE_ENV, + audience: 'kilo-api', + issuedAt: now, + expiresAt: now + 1800, + extra: options?.deviceSessionId ? { deviceSessionId: options.deviceSessionId } : undefined, + }; + const payload = + options?.purpose === 'device-access' + ? buildModernKiloTokenPayload({ + ...base, + tokenPurpose: 'device-access', + credentialExchange: false, + }) + : buildModernKiloTokenPayload({ + ...base, + tokenPurpose: 'human-api', + credentialExchange: options?.exchange ?? true, + }); + return jwt.sign(payload, secret, { algorithm: 'HS256' }); +} + +describe('resource delegation authority', () => { + test.each([true, false])( + 'accepts direct billing_manager membership when shared issuance is %s', + async enabled => { + shared.enabled = enabled; + const current = await user(); + const organization = await organizationFor(current.id); + await db.insert(organization_memberships).values({ + organization_id: organization.id, + kilo_user_id: current.id, + role: 'billing_manager', + }); + const headers = enabled ? bearer(modernToken(current)) : new Headers(); + if (!enabled) { + jest.mocked(getUserFromSessionForCredentialIssuance).mockResolvedValue({ + user: current, + authFailedResponse: null, + }); + } + + const result = await createControlTokenForRequest(current, 'cloud-agent-next', { + headers, + organizationId: organization.id, + }); + const claims = jwt.decode(result.token) as jwt.JwtPayload; + expect(claims).not.toHaveProperty('organizationRole'); + if (enabled) { + expect(claims.organizationId).toBe(organization.id); + } else { + expect(claims).not.toHaveProperty('organizationId'); + } + } + ); + + test.each([ + [true, 'removed'], + [false, 'removed'], + [true, 'deleted'], + [false, 'deleted'], + ] as const)( + 'denies direct organization access when shared issuance is %s and state is %s', + async (enabled, state) => { + shared.enabled = enabled; + const current = await user(); + const organization = await organizationFor(current.id); + await db.insert(organization_memberships).values({ + organization_id: organization.id, + kilo_user_id: current.id, + role: 'billing_manager', + }); + if (state === 'removed') { + await db + .delete(organization_memberships) + .where(eq(organization_memberships.organization_id, organization.id)); + } else { + await db + .update(organizations) + .set({ deleted_at: new Date().toISOString() }) + .where(eq(organizations.id, organization.id)); + } + + const headers = enabled ? bearer(modernToken(current)) : new Headers(); + if (!enabled) { + jest.mocked(getUserFromSessionForCredentialIssuance).mockResolvedValue({ + user: current, + authFailedResponse: null, + }); + } + + await expect( + createControlTokenForRequest(current, 'cloud-agent-next', { + headers, + organizationId: organization.id, + }) + ).rejects.toThrow('Unauthorized resource delegation request'); + } + ); + + test('accepts an exchangeable modern human API credential and preserves its provenance', async () => { + const current = await user(); + const authority = await getResourceDelegationAuthority(current, { + headers: bearer(modernToken(current)), + }); + + expect(authority).toMatchObject({ + credentialKind: 'human-api', + isModern: true, + runtimeAdmission: { source: 'user', authorizationUserId: current.id }, + }); + }); + + test('requires an active owned device session for a modern device credential', async () => { + const current = await user(); + const [session] = await db + .insert(device_sessions) + .values({ kilo_user_id: current.id, user_agent: 'resource-delegation-test' }) + .returning({ id: device_sessions.id }); + + const authority = await getResourceDelegationAuthority(current, { + headers: bearer( + modernToken(current, { + purpose: 'device-access', + exchange: false, + deviceSessionId: session.id, + }) + ), + }); + expect(authority.credentialKind).toBe('device-access'); + }); + + test('rejects restricted modern principals rather than projecting away their source claim', async () => { + const current = await user(); + const claims = jwt.decode(modernToken(current)); + if (!claims || typeof claims === 'string') throw new Error('Expected JWT claims'); + const token = jwt.sign({ ...claims, tokenSource: 'cloud-agent' }, secret, { + algorithm: 'HS256', + }); + await expect( + getResourceDelegationAuthority(current, { headers: bearer(token) }) + ).rejects.toThrow('Unauthorized resource delegation request'); + }); + + test('does not fall back to ambient cookies for malformed supplied authorization', async () => { + const current = await user(); + jest.mocked(getUserFromSessionForCredentialIssuance).mockResolvedValue({ + user: current, + authFailedResponse: null, + }); + await expect( + getResourceDelegationAuthority(current, { + headers: new Headers({ authorization: 'Basic bad' }), + }) + ).rejects.toThrow('Unauthorized resource delegation request'); + }); + + test('mints a bounded single-audience control token from a verified authority', async () => { + const current = await user(); + const result = await createControlTokenForRequest(current, 'gastown', { + headers: bearer(modernToken(current)), + expiresIn: 7200, + }); + const claims = jwt.verify(result.token, secret) as jwt.JwtPayload; + expect(claims).toMatchObject({ + aud: 'gastown', + tokenPurpose: 'human-api', + credentialExchange: false, + runtimeAdmission: { source: 'user', authorizationUserId: current.id }, + }); + expect(claims.exp! - claims.iat!).toBeLessThanOrEqual(3600); + }); + + test('keeps a legitimate legacy session request on the legacy contract when disabled', async () => { + shared.enabled = false; + const current = await user(); + jest.mocked(getUserFromSessionForCredentialIssuance).mockResolvedValue({ + user: current, + authFailedResponse: null, + }); + const result = await createControlTokenForRequest(current, 'gastown', { + headers: new Headers(), + legacyExpiresIn: 60, + }); + const claims = jwt.verify(result.token, secret) as jwt.JwtPayload; + expect(claims.aud).toBeUndefined(); + expect(claims.exp! - claims.iat!).toBe(60); + }); + + test('returns migration unavailable for a verified modern user principal when disabled', async () => { + shared.enabled = false; + const current = await user(); + await expect( + createControlTokenForRequest(current, 'wasteland', { headers: bearer(modernToken(current)) }) + ).rejects.toMatchObject({ status: 503, delegationCode: 'MIGRATION_UNAVAILABLE' }); + }); + + test('continues bounded control issuance for an active modern device after shared rollout rollback', async () => { + const current = await user(); + const [session] = await db + .insert(device_sessions) + .values({ kilo_user_id: current.id, user_agent: 'resource-delegation-test' }) + .returning({ id: device_sessions.id }); + const headers = bearer( + modernToken(current, { + purpose: 'device-access', + exchange: false, + deviceSessionId: session.id, + }) + ); + + const active = await createControlTokenForRequest(current, 'cloud-agent-next', { headers }); + expect(jwt.verify(active.token, secret)).toMatchObject({ + aud: 'cloud-agent-next', + tokenPurpose: 'device-access', + credentialExchange: false, + deviceSessionId: session.id, + runtimeAdmission: { source: 'user', authorizationUserId: current.id }, + }); + + shared.enabled = false; + const rollback = await createControlTokenForRequest(current, 'gastown', { headers }); + const claims = jwt.verify(rollback.token, secret) as jwt.JwtPayload; + expect(claims).toMatchObject({ + aud: 'gastown', + tokenPurpose: 'device-access', + credentialExchange: false, + deviceSessionId: session.id, + runtimeAdmission: { source: 'user', authorizationUserId: current.id }, + }); + expect(claims.exp! - claims.iat!).toBeLessThanOrEqual(1800); + + const rotatedPepper = crypto.randomUUID(); + await db + .update(kilocode_users) + .set({ api_token_pepper: rotatedPepper }) + .where(eq(kilocode_users.id, current.id)); + await expect(createControlTokenForRequest(current, 'wasteland', { headers })).rejects.toThrow( + 'Unauthorized resource delegation request' + ); + await db + .update(kilocode_users) + .set({ api_token_pepper: current.api_token_pepper }) + .where(eq(kilocode_users.id, current.id)); + + await db + .update(device_sessions) + .set({ revoked_at: new Date().toISOString(), revoked_reason: 'test' }) + .where(eq(device_sessions.id, session.id)); + await expect(createControlTokenForRequest(current, 'wasteland', { headers })).rejects.toThrow( + 'Unauthorized resource delegation request' + ); + }); + + test('mints a non-exchangeable delegated token for exactly the selected audience', async () => { + const current = await user(); + const result = await createDelegatedResourceToken(current, 'gateway', { + headers: bearer(modernToken(current)), + }); + const claims = jwt.verify(result.token, secret) as jwt.JwtPayload; + expect(claims).toMatchObject({ + aud: 'kilo-gateway', + tokenPurpose: 'delegated-workload', + credentialExchange: false, + }); + expect(claims.exp! - claims.iat!).toBeLessThanOrEqual(15 * 60); + }); + + test('caps delegated tokens to the parent credential expiry', async () => { + const current = await user(); + const now = Math.floor(Date.now() / 1000); + const claims = buildModernKiloTokenPayload({ + userId: current.id, + pepper: current.api_token_pepper, + env: process.env.NODE_ENV, + audience: 'kilo-api', + issuedAt: now - 1, + expiresAt: now + 30, + tokenPurpose: 'human-api', + credentialExchange: true, + }); + const parent = jwt.sign(claims, secret, { algorithm: 'HS256' }); + const result = await createDelegatedResourceToken(current, 'api', { headers: bearer(parent) }); + const delegated = jwt.verify(result.token, secret) as jwt.JwtPayload; + expect(delegated.exp! - delegated.iat!).toBeLessThanOrEqual(30); + }); +}); + +test.each(['cloud-agent-next', 'gastown', 'wasteland'] as const)( + 'isolates request control family %s', + async family => { + shared.family = family; + const current = await user(); + jest + .mocked(getUserFromSessionForCredentialIssuance) + .mockResolvedValue({ user: current, authFailedResponse: null }); + for (const resource of ['cloud-agent-next', 'gastown', 'wasteland'] as const) { + const result = await createControlTokenForRequest(current, resource, { + headers: new Headers(), + }); + const claims = jwt.decode(result.token) as jwt.JwtPayload; + expect(isResourceTokenIssuanceEnabled).toHaveBeenLastCalledWith(resource); + expect(claims.aud).toBe(resource === family ? resource : undefined); + expect(claims.tokenPurpose).toBe(resource === family ? 'human-api' : undefined); + } + await expect( + createDelegatedResourceToken(current, 'gateway', { headers: new Headers() }) + ).rejects.toMatchObject({ status: 503, delegationCode: 'MIGRATION_UNAVAILABLE' }); + expect(isResourceTokenIssuanceEnabled).toHaveBeenLastCalledWith('delegated-resource'); + } +); diff --git a/apps/web/src/lib/auth/resource-delegation.ts b/apps/web/src/lib/auth/resource-delegation.ts new file mode 100644 index 0000000000..8833e69f80 --- /dev/null +++ b/apps/web/src/lib/auth/resource-delegation.ts @@ -0,0 +1,465 @@ +import 'server-only'; + +import type { User } from '@kilocode/db/schema'; +import { + device_sessions, + kilocode_users, + organization_memberships, + organizations, +} from '@kilocode/db/schema'; +import { and, eq, isNull, ne } from 'drizzle-orm'; +import jwt from 'jsonwebtoken'; +import { TRPCError } from '@trpc/server'; +import { headers as nextHeaders } from 'next/headers'; +import { + AI_ATTRIBUTION_AUDIENCE, + CLOUD_AGENT_NEXT_AUDIENCE, + GASTOWN_AUDIENCE, + HTML_DEPLOY_AUDIENCE, + KILO_API_AUDIENCE, + KILO_GATEWAY_AUDIENCE, + WASTELAND_AUDIENCE, +} from '@kilocode/worker-utils/internal-service-token-audiences'; +import { + buildModernKiloTokenPayload, + isKiloCredentialExchangeEligible, + verifyKiloTokenForPolicy, +} from '@kilocode/worker-utils/kilo-token-policy'; +import type { RuntimeAdmission } from '@kilocode/worker-utils/runtime-authorization-contract'; +import { isResourceTokenIssuanceEnabled, NEXTAUTH_SECRET } from '@/lib/config.server'; +import { db } from '@/lib/drizzle'; +import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens'; +import { getUserFromSessionForCredentialIssuance } from '@/lib/user/server'; + +const ONE_HOUR_SECONDS = 60 * 60; +const LEGACY_DEVICE_SESSION_SECONDS = ONE_HOUR_SECONDS; + +export type DelegableResource = 'api' | 'gateway' | 'attribution' | 'html-deploy'; +export type ControlResource = 'cloud-agent-next' | 'gastown' | 'wasteland'; +type Resource = DelegableResource | ControlResource; +type CredentialKind = 'human-api' | 'device-access'; + +export type ResourceDelegationAuthority = { + user: User; + credentialKind: CredentialKind; + expiresAt?: number; + organizationId?: string; + audience?: string | string[]; + tokenSource?: string; + deviceSessionId?: string; + isModern: boolean; + runtimeAdmission: RuntimeAdmission; +}; + +export class TypedResourceDelegationError extends TRPCError { + constructor( + public readonly status: 401 | 403 | 503, + public readonly delegationCode: 'UNAUTHORIZED' | 'FORBIDDEN' | 'MIGRATION_UNAVAILABLE', + message: string + ) { + super({ + code: status === 401 ? 'UNAUTHORIZED' : status === 503 ? 'SERVICE_UNAVAILABLE' : 'FORBIDDEN', + message, + }); + } +} + +function unauthorized(message = 'Unauthorized resource delegation request'): never { + throw new TypedResourceDelegationError(401, 'UNAUTHORIZED', message); +} + +function forbidden(message: string): never { + throw new TypedResourceDelegationError(403, 'FORBIDDEN', message); +} + +export type CreateControlTokenOptions = { + headers?: Headers; + organizationId?: string; + tokenSource?: string; + expiresIn?: number; + legacyExpiresIn?: number; + extra?: { + botId?: string; + createdOnPlatform?: string; + isAdmin?: boolean; + gastownAccess?: boolean; + orgMemberships?: Array<{ + orgId: string; + role: 'owner' | 'admin' | 'member' | 'billing_manager'; + }>; + }; +}; + +export type CreateDelegatedResourceTokenOptions = { + headers?: Headers; + organizationId?: string; + organizationRole?: 'owner' | 'admin' | 'member'; + tokenSource?: string; + expiresIn?: number; +}; + +function resourceAudience(resource: Resource): string { + return { + api: KILO_API_AUDIENCE, + gateway: KILO_GATEWAY_AUDIENCE, + attribution: AI_ATTRIBUTION_AUDIENCE, + 'html-deploy': HTML_DEPLOY_AUDIENCE, + 'cloud-agent-next': CLOUD_AGENT_NEXT_AUDIENCE, + gastown: GASTOWN_AUDIENCE, + wasteland: WASTELAND_AUDIENCE, + }[resource]; +} + +export function isDelegableResource(value: unknown): value is DelegableResource { + return ( + value === 'api' || value === 'gateway' || value === 'attribution' || value === 'html-deploy' + ); +} + +export function canIssueLegacyOrganizationToken(requestHeaders: Headers): boolean { + return !requestHeaders.has('authorization'); +} + +function tokenFromHeaders(requestHeaders: Headers): string | null { + const value = requestHeaders.get('authorization'); + if (!value || !value.toLowerCase().startsWith('bearer ')) return null; + const token = value.slice(7).trim(); + return token || null; +} + +function hasUnsafeLegacyClaims(claimNames: readonly string[]): boolean { + const allowed = new Set([ + 'version', + 'kiloUserId', + 'apiTokenPepper', + 'env', + 'iat', + 'exp', + 'deviceAuthRequestCode', + 'deviceSessionId', + ]); + return claimNames.some(name => !allowed.has(name)); +} + +async function currentUser(expectedUser: User): Promise { + const user = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, expectedUser.id), + }); + if (!user || user.blocked_at || user.blocked_reason) unauthorized(); + return user; +} + +async function assertActiveDeviceSession(id: string, userId: string): Promise { + const session = await db.query.device_sessions.findFirst({ + where: and( + eq(device_sessions.id, id), + eq(device_sessions.kilo_user_id, userId), + isNull(device_sessions.revoked_at) + ), + }); + if (!session) unauthorized(); +} + +async function membershipsFor(userId: string) { + return db + .select({ + orgId: organization_memberships.organization_id, + role: organization_memberships.role, + }) + .from(organization_memberships) + .where( + and( + eq(organization_memberships.kilo_user_id, userId), + ne(organization_memberships.role, 'billing_manager') + ) + ); +} + +async function assertCurrentOrganizationMembership( + userId: string, + organizationId?: string +): Promise { + if (!organizationId) return; + const membership = await db + .select({ id: organization_memberships.id }) + .from(organization_memberships) + .innerJoin(organizations, eq(organizations.id, organization_memberships.organization_id)) + .where( + and( + eq(organization_memberships.kilo_user_id, userId), + eq(organization_memberships.organization_id, organizationId), + isNull(organizations.deleted_at) + ) + ) + .limit(1); + if (!membership[0]) unauthorized(); +} + +function hasRestrictedPrincipalClaims(claimNames: readonly string[]): boolean { + const allowed = new Set([ + 'version', + 'kiloUserId', + 'apiTokenPepper', + 'env', + 'aud', + 'iat', + 'exp', + 'tokenPurpose', + 'credentialExchange', + 'deviceSessionId', + ]); + return claimNames.some(name => !allowed.has(name)); +} + +export async function getResourceDelegationAuthority( + expectedUser: User, + options?: { headers?: Headers; organizationId?: string } +): Promise { + const requestHeaders = options?.headers ?? (await nextHeaders()); + const bearer = tokenFromHeaders(requestHeaders); + const authorizationPresent = requestHeaders.has('authorization'); + const user = await currentUser(expectedUser); + await assertCurrentOrganizationMembership(user.id, options?.organizationId); + + if (!bearer) { + if (authorizationPresent) { + unauthorized(); + } + const session = await getUserFromSessionForCredentialIssuance(); + if (!session.user || session.user.id !== expectedUser.id) { + unauthorized(); + } + return { + user, + credentialKind: 'human-api', + isModern: false, + runtimeAdmission: { + source: 'user', + authorizationUserId: user.id, + authorizationPepper: user.api_token_pepper, + }, + }; + } + + let verified: Awaited>; + try { + verified = await verifyKiloTokenForPolicy(bearer, NEXTAUTH_SECRET, { + audience: KILO_API_AUDIENCE, + mode: 'allow-legacy', + }); + } catch { + unauthorized(); + } + const claims = verified.claims; + if ( + verified.userId !== expectedUser.id || + claims.env !== process.env.NODE_ENV || + claims.apiTokenPepper !== user.api_token_pepper + ) { + unauthorized(); + } + + if (claims.tokenPurpose !== undefined) { + if (claims.tokenPurpose !== 'human-api' && claims.tokenPurpose !== 'device-access') { + forbidden('Delegated credentials cannot mint resource control tokens'); + } + if (claims.aud !== KILO_API_AUDIENCE || hasRestrictedPrincipalClaims(verified.claimNames)) { + unauthorized(); + } + if (claims.tokenPurpose === 'device-access') { + if (!claims.deviceSessionId) unauthorized(); + await assertActiveDeviceSession(claims.deviceSessionId, user.id); + } + return { + user, + credentialKind: claims.tokenPurpose, + expiresAt: claims.exp, + organizationId: claims.organizationId, + audience: claims.aud, + tokenSource: claims.tokenSource, + deviceSessionId: claims.deviceSessionId, + isModern: true, + runtimeAdmission: { + source: 'user', + authorizationUserId: user.id, + authorizationPepper: user.api_token_pepper, + }, + }; + } + + if (hasUnsafeLegacyClaims(verified.claimNames)) { + forbidden('Unsupported legacy credential context'); + } + const isLegacyDevice = claims.deviceSessionId !== undefined; + if (isLegacyDevice) { + if (claims.exp - claims.iat !== LEGACY_DEVICE_SESSION_SECONDS) { + forbidden('Unsupported legacy credential context'); + } + await assertActiveDeviceSession(claims.deviceSessionId, user.id); + } else if (!isKiloCredentialExchangeEligible(verified, { legacy: 'five-year-api' })) { + forbidden('Unsupported legacy credential context'); + } + return { + user, + credentialKind: isLegacyDevice ? 'device-access' : 'human-api', + expiresAt: claims.exp, + isModern: false, + runtimeAdmission: { + source: 'user', + authorizationUserId: user.id, + authorizationPepper: user.api_token_pepper, + }, + }; +} + +export async function isModernResourceDelegationRequest(requestHeaders: Headers): Promise { + const bearer = tokenFromHeaders(requestHeaders); + if (!bearer) return false; + const verified = await verifyKiloTokenForPolicy(bearer, NEXTAUTH_SECRET, { + audience: KILO_API_AUDIENCE, + mode: 'allow-legacy', + }); + return verified.claims.tokenPurpose !== undefined; +} + +async function createModernControlToken( + authority: ResourceDelegationAuthority, + resource: ControlResource, + options?: CreateControlTokenOptions +): Promise<{ token: string; expiresAt: string; user: User; tokenSource?: string }> { + const now = Math.floor(Date.now() / 1000); + const requested = options?.expiresIn ?? ONE_HOUR_SECONDS; + const expiresIn = Math.min( + requested, + ONE_HOUR_SECONDS, + authority.expiresAt ? authority.expiresAt - now : requested + ); + if (expiresIn <= 0) unauthorized('Resource delegation authority has expired'); + const memberships = + resource === 'cloud-agent-next' ? undefined : await membershipsFor(authority.user.id); + const payload = buildModernKiloTokenPayload({ + userId: authority.user.id, + pepper: authority.user.api_token_pepper, + env: process.env.NODE_ENV, + audience: resourceAudience(resource), + issuedAt: now, + expiresAt: now + expiresIn, + tokenPurpose: authority.credentialKind, + credentialExchange: false, + extra: { + organizationId: options?.organizationId ?? authority.organizationId, + tokenSource: options?.tokenSource ?? authority.tokenSource, + deviceSessionId: authority.deviceSessionId, + botId: options?.extra?.botId, + createdOnPlatform: options?.extra?.createdOnPlatform, + isAdmin: options?.extra?.isAdmin === true && authority.user.is_admin, + gastownAccess: options?.extra?.gastownAccess, + orgMemberships: memberships, + ...(resource === 'wasteland' ? {} : { runtimeAdmission: authority.runtimeAdmission }), + }, + }); + const token = jwt.sign(payload, NEXTAUTH_SECRET, { algorithm: 'HS256' }); + return { + token, + expiresAt: new Date((now + expiresIn) * 1000).toISOString(), + user: authority.user, + tokenSource: options?.tokenSource ?? authority.tokenSource, + }; +} + +export async function createControlTokenForRequest( + user: User, + resource: ControlResource, + options?: CreateControlTokenOptions +): Promise<{ token: string; expiresAt: string; user: User; tokenSource?: string }> { + const authority = await getResourceDelegationAuthority(user, options); + if (authority.organizationId) { + forbidden('Organization-scoped credentials cannot mint resource control tokens'); + } + if (!isResourceTokenIssuanceEnabled(resource)) { + if (authority.isModern) { + if (authority.credentialKind === 'device-access' && authority.deviceSessionId) { + return await createModernControlToken(authority, resource, options); + } + throw new TypedResourceDelegationError( + 503, + 'MIGRATION_UNAVAILABLE', + 'Shared resource token migration is unavailable' + ); + } + const expiresIn = + options?.legacyExpiresIn ?? + (resource === 'cloud-agent-next' ? TOKEN_EXPIRY.default : ONE_HOUR_SECONDS); + const token = generateApiToken( + authority.user, + { + ...options?.extra, + tokenSource: options?.tokenSource, + ...(resource === 'cloud-agent-next' + ? {} + : { + isAdmin: options?.extra?.isAdmin === true && authority.user.is_admin, + orgMemberships: await membershipsFor(authority.user.id), + }), + }, + { expiresIn } + ); + return { + token, + expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + user: authority.user, + tokenSource: options?.tokenSource, + }; + } + return await createModernControlToken(authority, resource, options); +} + +export async function createDelegatedResourceToken( + user: User, + resource: DelegableResource, + options?: CreateDelegatedResourceTokenOptions +): Promise<{ token: string; expiresAt: string; user: User; tokenSource?: string }> { + const authority = await getResourceDelegationAuthority(user, options); + if (authority.organizationId && authority.organizationId !== options?.organizationId) { + forbidden('Scoped credentials cannot mint tokens for another organization'); + } + if (authority.organizationId && authority.audience !== resourceAudience(resource)) { + forbidden('Scoped credentials cannot broaden their resource audience'); + } + if (!isResourceTokenIssuanceEnabled('delegated-resource')) { + throw new TypedResourceDelegationError( + 503, + 'MIGRATION_UNAVAILABLE', + 'Shared resource token migration is unavailable' + ); + } + const now = Math.floor(Date.now() / 1000); + const requested = options?.expiresIn ?? 15 * 60; + const expiresIn = Math.min( + requested, + 15 * 60, + authority.expiresAt ? authority.expiresAt - now : requested + ); + if (expiresIn <= 0) unauthorized('Resource delegation authority has expired'); + const payload = buildModernKiloTokenPayload({ + userId: authority.user.id, + pepper: authority.user.api_token_pepper, + env: process.env.NODE_ENV, + audience: resourceAudience(resource), + issuedAt: now, + expiresAt: now + expiresIn, + tokenPurpose: 'delegated-workload', + credentialExchange: false, + extra: { + organizationId: options?.organizationId ?? authority.organizationId, + organizationRole: options?.organizationRole, + tokenSource: options?.tokenSource ?? authority.tokenSource, + }, + }); + return { + token: jwt.sign(payload, NEXTAUTH_SECRET, { algorithm: 'HS256' }), + expiresAt: new Date((now + expiresIn) * 1000).toISOString(), + user: authority.user, + tokenSource: options?.tokenSource ?? authority.tokenSource, + }; +} diff --git a/apps/web/src/lib/config.server.resource-tokens.test.ts b/apps/web/src/lib/config.server.resource-tokens.test.ts new file mode 100644 index 0000000000..77d340a9ec --- /dev/null +++ b/apps/web/src/lib/config.server.resource-tokens.test.ts @@ -0,0 +1,107 @@ +import jwt from 'jsonwebtoken'; +import type { User } from '@kilocode/db/schema'; +import { generateCloudAgentWorkflowToken, generateWorkflowGatewayToken } from './tokens'; +import { + isNativeResourceCredentialIssuanceEnabled, + isResourceTokenIssuanceEnabled, + type ResourceTokenFamily, +} from './config.server'; + +const families = { + 'cloud-agent-next': 'CLOUD_AGENT_RESOURCE_TOKENS_ENABLED', + gastown: 'GASTOWN_RESOURCE_TOKENS_ENABLED', + wasteland: 'WASTELAND_RESOURCE_TOKENS_ENABLED', + chat: 'CHAT_RESOURCE_TOKENS_ENABLED', + 'delegated-resource': 'DELEGATED_RESOURCE_TOKENS_ENABLED', + 'workflow-gateway': 'WORKFLOW_GATEWAY_RESOURCE_TOKENS_ENABLED', + benchmark: 'BENCHMARK_RESOURCE_TOKENS_ENABLED', +} satisfies Record; +const entries = Object.entries(families) as [ResourceTokenFamily, string][]; +const master = 'SHARED_RESOURCE_TOKENS_ENABLED'; +const native = 'NATIVE_RESOURCE_TOKENS_ENABLED'; +const keys = [master, native, ...Object.values(families)]; +const saved = new Map(keys.map(key => [key, process.env[key]])); + +beforeEach(() => { + for (const key of keys) delete process.env[key]; +}); +afterEach(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +it('defaults every family and native adoption off', () => { + for (const [family] of entries) expect(isResourceTokenIssuanceEnabled(family)).toBe(false); + expect(isNativeResourceCredentialIssuanceEnabled()).toBe(false); +}); + +it('keeps all families off when only the master is enabled', () => { + process.env[master] = 'true'; + for (const [family] of entries) expect(isResourceTokenIssuanceEnabled(family)).toBe(false); +}); + +it.each([undefined, '', 'false', 'TRUE', '1', ' true', 'true '])( + 'requires exact true on the master (%s)', + value => { + if (value !== undefined) process.env[master] = value; + for (const [, key] of entries) process.env[key] = 'true'; + process.env[native] = 'true'; + for (const [family] of entries) expect(isResourceTokenIssuanceEnabled(family)).toBe(false); + expect(isNativeResourceCredentialIssuanceEnabled()).toBe(false); + } +); + +it.each(entries)('enables only %s and leaves native independent', (family, key) => { + process.env[master] = 'true'; + process.env[key] = 'true'; + for (const [candidate] of entries) { + expect(isResourceTokenIssuanceEnabled(candidate)).toBe(candidate === family); + } + expect(isNativeResourceCredentialIssuanceEnabled()).toBe(false); +}); + +it.each(['', 'false', 'TRUE', '1', ' true', 'true '])( + 'requires exact true on each family and native flag (%s)', + value => { + process.env[master] = 'true'; + for (const [, key] of entries) process.env[key] = value; + process.env[native] = value; + for (const [family] of entries) expect(isResourceTokenIssuanceEnabled(family)).toBe(false); + expect(isNativeResourceCredentialIssuanceEnabled()).toBe(false); + } +); + +it('native adoption needs no producer family enabled and enables none', () => { + process.env[master] = 'true'; + process.env[native] = 'true'; + expect(isNativeResourceCredentialIssuanceEnabled()).toBe(true); + for (const [family] of entries) expect(isResourceTokenIssuanceEnabled(family)).toBe(false); +}); + +it.each([ + ['CLOUD_AGENT_RESOURCE_TOKENS_ENABLED', true, false], + ['WORKFLOW_GATEWAY_RESOURCE_TOKENS_ENABLED', false, true], +] as const)('real issuer integration isolates %s', (flag, cloudModern, gatewayModern) => { + process.env[master] = 'true'; + process.env[flag] = 'true'; + const user = { id: 'oauth/family-test', api_token_pepper: 'family-test-pepper' } as User; + const cloud = jwt.decode( + generateCloudAgentWorkflowToken(user, { + expiresIn: 7200, + tokenSource: 'reviewer', + }) + ) as jwt.JwtPayload; + const gateway = jwt.decode( + generateWorkflowGatewayToken(user, { + tokenSource: 'reviewer', + }) + ) as jwt.JwtPayload; + expect(cloud.aud).toBe(cloudModern ? 'cloud-agent-next' : undefined); + expect(cloud.tokenPurpose).toBe(cloudModern ? 'internal-service' : undefined); + expect(cloud.exp! - cloud.iat!).toBe(cloudModern ? 3600 : 7200); + expect(gateway.aud).toBe(gatewayModern ? 'kilo-gateway' : undefined); + expect(gateway.tokenPurpose).toBe(gatewayModern ? 'delegated-workload' : undefined); + expect(gateway.exp! - gateway.iat!).toBe(gatewayModern ? 3600 : 5 * 365 * 24 * 3600); +}); diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index 27a9a111cc..22f1d97a16 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -63,6 +63,34 @@ export const INTERNAL_API_SECRET = getEnvVariable('INTERNAL_API_SECRET'); export function isBoundedInternalServiceTokenIssuanceEnabled(): boolean { return getEnvVariable('BOUNDED_INTERNAL_SERVICE_TOKENS_ENABLED') === 'true'; } +export function isNativeResourceCredentialIssuanceEnabled(): boolean { + return ( + getEnvVariable('NATIVE_RESOURCE_TOKENS_ENABLED') === 'true' && + isSharedResourceTokenIssuanceEnabled() + ); +} +export function isSharedResourceTokenIssuanceEnabled(): boolean { + return getEnvVariable('SHARED_RESOURCE_TOKENS_ENABLED') === 'true'; +} +const resourceTokenFamilyFlags = { + 'cloud-agent-next': 'CLOUD_AGENT_RESOURCE_TOKENS_ENABLED', + gastown: 'GASTOWN_RESOURCE_TOKENS_ENABLED', + wasteland: 'WASTELAND_RESOURCE_TOKENS_ENABLED', + chat: 'CHAT_RESOURCE_TOKENS_ENABLED', + 'delegated-resource': 'DELEGATED_RESOURCE_TOKENS_ENABLED', + 'workflow-gateway': 'WORKFLOW_GATEWAY_RESOURCE_TOKENS_ENABLED', + benchmark: 'BENCHMARK_RESOURCE_TOKENS_ENABLED', +} as const; + +export type ResourceTokenFamily = keyof typeof resourceTokenFamilyFlags; + +export function isResourceTokenIssuanceEnabled(family: ResourceTokenFamily): boolean { + return ( + isSharedResourceTokenIssuanceEnabled() && + getEnvVariable(resourceTokenFamilyFlags[family]) === 'true' + ); +} + export const USER_DATA_EXPORT_WORKER_URL = getEnvVariable('USER_DATA_EXPORT_WORKER_URL') || (process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8818' : ''); diff --git a/apps/web/src/lib/token.test.ts b/apps/web/src/lib/token.test.ts index fa0e7d37b2..aeff0fa39d 100644 --- a/apps/web/src/lib/token.test.ts +++ b/apps/web/src/lib/token.test.ts @@ -313,6 +313,21 @@ describe('Token Functions', () => { // validateAuthorizationHeader should NOT return organizationId - that's handled at a higher level }); + it('fails closed when a cloud-agent-next runtime bearer is used directly', () => { + const token = signedToken({ + aud: KILO_API_AUDIENCE, + runtimeAuthorization: { + id: '11111111-1111-4111-8111-111111111111', + resourceKind: 'cloud-agent-next', + resourceId: 'agent_123', + }, + }); + + expect( + validateAuthorizationHeader(new Headers({ authorization: `Bearer ${token}` })).error + ).toMatch(/^Invalid token \([a-f0-9-]+\)$/); + }); + it('should return error when authorization header is missing', () => { const headers = new Headers(); diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts index a331acf211..e5d031c0d5 100644 --- a/apps/web/src/lib/tokens.ts +++ b/apps/web/src/lib/tokens.ts @@ -8,6 +8,7 @@ import { GITHUB_USER_AUTHORIZATION_DISCONNECT_AUDIENCE, GITLAB_CREDENTIAL_BROKER_AUDIENCE, KILO_API_AUDIENCE, + KILO_GATEWAY_AUDIENCE, SESSION_INGEST_AUDIENCE, SESSION_INGEST_USER_DELETION_AUDIENCE, USER_DATA_EXPORT_AUDIENCE, @@ -16,10 +17,15 @@ import { buildModernKiloTokenPayload, isKiloResourceAudienceAllowed, } from '@kilocode/worker-utils/kilo-token-policy'; +import { CloudAgentNextRuntimeAuthorizationClaimSchema } from '@kilocode/worker-utils/runtime-proxy-attestation'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import jwt from 'jsonwebtoken'; import { warnExceptInTest } from '@/lib/utils.server'; -import { isBoundedInternalServiceTokenIssuanceEnabled, NEXTAUTH_SECRET } from '@/lib/config.server'; +import { + isBoundedInternalServiceTokenIssuanceEnabled, + isResourceTokenIssuanceEnabled, + NEXTAUTH_SECRET, +} from '@/lib/config.server'; export { BITBUCKET_REPOSITORY_LIST_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; @@ -193,6 +199,7 @@ export type JWTTokenPayload = { kiloUserId: string; version: number; apiTokenPepper?: string; + runtimeAuthorization?: unknown; } & JWTTokenExtraPayload; function tryJwtVerify(token: string) { @@ -209,7 +216,7 @@ function tryJwtVerify(token: string) { export function validateAuthorizationHeader( headers: Headers, - options?: { expectedAudience?: string } + options?: { expectedAudience?: string; runtimeProxyAttestationVerified?: boolean } ) { const traceability_logging_id = crypto.randomUUID(); const authHeader = headers.get('authorization'); @@ -244,6 +251,21 @@ export function validateAuthorizationHeader( return { error: `Token version outdated, please re-authenticate (${traceability_logging_id})` }; } + if ( + typeof payload.runtimeAuthorization === 'object' && + payload.runtimeAuthorization !== null && + 'resourceKind' in payload.runtimeAuthorization && + payload.runtimeAuthorization.resourceKind === 'cloud-agent-next' + ) { + const runtimeAuthorization = CloudAgentNextRuntimeAuthorizationClaimSchema.safeParse( + payload.runtimeAuthorization + ); + if (!runtimeAuthorization.success || !options?.runtimeProxyAttestationVerified) { + warnExceptInTest(`Invalid token (${traceability_logging_id})`); + return { error: `Invalid token (${traceability_logging_id})` }; + } + } + return { kiloUserId: payload.kiloUserId, apiTokenPepper: payload.apiTokenPepper, @@ -260,3 +282,104 @@ export function validateAuthorizationHeader( export function generateCloudAgentToken(user: User) { return generateApiToken(user, { tokenSource: 'cloud-agent' }); } + +export function generateCloudAgentWorkflowToken( + user: User, + options: { + organizationId?: string; + tokenSource: string; + botId?: string; + createdOnPlatform?: string; + expiresIn: number; + authorizationUser?: User; + } +): string { + if (!isResourceTokenIssuanceEnabled('cloud-agent-next')) { + return generateApiToken( + user, + { + organizationId: options.organizationId, + tokenSource: options.tokenSource, + botId: options.botId, + createdOnPlatform: options.createdOnPlatform, + }, + { expiresIn: options.expiresIn } + ); + } + if (!user.api_token_pepper) { + throw new Error('Workflow control tokens require a current user pepper'); + } + const expiresIn = Math.min(options.expiresIn, 60 * 60); + if (expiresIn <= 0) { + throw new Error('Workflow control token expiry must be positive'); + } + const authorizationUser = options.authorizationUser ?? user; + if (!authorizationUser.api_token_pepper) { + throw new Error('Workflow control tokens require a current authorization pepper'); + } + const issuedAt = Math.floor(Date.now() / 1000); + const payload = buildModernKiloTokenPayload({ + userId: user.id, + pepper: user.api_token_pepper, + env: process.env.NODE_ENV, + audience: 'cloud-agent-next', + issuedAt, + expiresAt: issuedAt + expiresIn, + tokenPurpose: 'internal-service', + credentialExchange: false, + extra: { + organizationId: options.organizationId, + tokenSource: options.tokenSource, + botId: options.botId, + createdOnPlatform: options.createdOnPlatform, + runtimeAdmission: { + source: 'automation', + authorizationUserId: authorizationUser.id, + authorizationPepper: authorizationUser.api_token_pepper, + }, + }, + }); + return jwt.sign(payload, NEXTAUTH_SECRET, { algorithm: jwtSigningAlgorithm }); +} + +/** + * Generates a gateway credential for a server-side workflow. Unlike resource + * delegation from an HTTP request, queued workflows have no browser bearer + * token to delegate from. The workflow owner is therefore the authority and + * the token is deliberately limited to the gateway and one hour. + */ +export function generateWorkflowGatewayToken( + user: User, + options: { + organizationId?: string; + tokenSource: string; + expiresIn?: number; + } +): string { + if (!isResourceTokenIssuanceEnabled('workflow-gateway')) { + return generateApiToken(user, { tokenSource: options.tokenSource }); + } + if (!user.api_token_pepper) { + throw new Error('Workflow gateway tokens require a current user pepper'); + } + const expiresIn = Math.min(options.expiresIn ?? ONE_HOUR_IN_SECONDS, ONE_HOUR_IN_SECONDS); + if (expiresIn <= 0) { + throw new Error('Workflow gateway token expiry must be positive'); + } + const issuedAt = Math.floor(Date.now() / 1000); + const payload = buildModernKiloTokenPayload({ + userId: user.id, + pepper: user.api_token_pepper, + env: process.env.NODE_ENV, + audience: KILO_GATEWAY_AUDIENCE, + issuedAt, + expiresAt: issuedAt + expiresIn, + tokenPurpose: 'delegated-workload', + credentialExchange: false, + extra: { + organizationId: options.organizationId, + tokenSource: options.tokenSource, + }, + }); + return jwt.sign(payload, NEXTAUTH_SECRET, { algorithm: jwtSigningAlgorithm }); +} diff --git a/apps/web/src/lib/user/server.test.ts b/apps/web/src/lib/user/server.test.ts index c0da277cf9..80f6351b40 100644 --- a/apps/web/src/lib/user/server.test.ts +++ b/apps/web/src/lib/user/server.test.ts @@ -51,6 +51,7 @@ import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createCallerForUser } from '@/routers/test-utils'; import { generateApiToken, JWT_TOKEN_VERSION } from '@/lib/tokens'; +import { ORGANIZATION_ID_HEADER } from '@/lib/constants'; import { eq } from 'drizzle-orm'; import { v5 as uuidv5 } from 'uuid'; import jwt from 'jsonwebtoken'; @@ -611,6 +612,89 @@ describe('getUserFromAuth', () => { expect(apiResult.authFailedResponse?.status).toBe(401); }); + test('uses the signed organization instead of a conflicting request header', async () => { + const user = await insertTestUser({ api_token_pepper: 'signed-organization-pepper' }); + const signedOrganization = await createTestOrganization( + `Signed Organization ${crypto.randomUUID()}`, + user.id, + 0 + ); + const headerOrganization = await createTestOrganization( + `Header Organization ${crypto.randomUUID()}`, + user.id, + 0 + ); + const token = signPolicyClaims({ + version: JWT_TOKEN_VERSION, + kiloUserId: user.id, + apiTokenPepper: user.api_token_pepper, + env: process.env.NODE_ENV, + aud: KILO_GATEWAY_AUDIENCE, + organizationId: signedOrganization.id, + }); + mockHeaders.mockResolvedValue( + new Headers({ + Authorization: `Bearer ${token}`, + [ORGANIZATION_ID_HEADER]: headerOrganization.id, + }) + ); + + const result = await getUserFromAuth({ + adminOnly: false, + expectedAudience: KILO_GATEWAY_AUDIENCE, + }); + + expect(result.authFailedResponse).toBeNull(); + expect(result.organizationId).toBe(signedOrganization.id); + }); + + test('uses the signed organization when no request header is supplied', async () => { + const user = await insertTestUser({ api_token_pepper: 'signed-organization-no-header-pepper' }); + const organization = await createTestOrganization( + `Signed Organization ${crypto.randomUUID()}`, + user.id, + 0 + ); + const token = signPolicyClaims({ + version: JWT_TOKEN_VERSION, + kiloUserId: user.id, + apiTokenPepper: user.api_token_pepper, + env: process.env.NODE_ENV, + aud: KILO_GATEWAY_AUDIENCE, + organizationId: organization.id, + }); + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${token}` })); + + const result = await getUserFromAuth({ + adminOnly: false, + expectedAudience: KILO_GATEWAY_AUDIENCE, + }); + + expect(result.authFailedResponse).toBeNull(); + expect(result.organizationId).toBe(organization.id); + }); + + test('uses the request header for a token without a signed organization', async () => { + const user = await insertTestUser({ api_token_pepper: 'unbound-organization-pepper' }); + const organization = await createTestOrganization( + `Header Organization ${crypto.randomUUID()}`, + user.id, + 0 + ); + const token = generateApiToken(user); + mockHeaders.mockResolvedValue( + new Headers({ + Authorization: `Bearer ${token}`, + [ORGANIZATION_ID_HEADER]: organization.id, + }) + ); + + const result = await getUserFromAuth({ adminOnly: false }); + + expect(result.authFailedResponse).toBeNull(); + expect(result.organizationId).toBe(organization.id); + }); + test('allows API-token authentication for users from SSO-protected domains', async () => { const ssoDomain = `${crypto.randomUUID()}.example.com`; const user = await insertTestUser({ diff --git a/apps/web/src/lib/user/server.ts b/apps/web/src/lib/user/server.ts index e78aefcb0e..640d68c76f 100644 --- a/apps/web/src/lib/user/server.ts +++ b/apps/web/src/lib/user/server.ts @@ -1,6 +1,11 @@ import { getEnvVariable } from '@/lib/dotenvx'; import 'server-only'; import { validateAuthorizationHeader, JWT_TOKEN_VERSION } from '@/lib/tokens'; +import { + CloudAgentNextRuntimeAuthorizationClaimSchema, + RuntimeProxyAttestationAudienceSchema, + verifyRuntimeProxyAttestation, +} from '@kilocode/worker-utils/runtime-proxy-attestation'; import { NextResponse } from 'next/server'; import { cookies, headers } from 'next/headers'; @@ -1219,8 +1224,35 @@ async function resolveUserFromAuth( // all calls from the extension including the openrouter proxy call use this auth method // also val.town and other blessed API users who are given their own custom JWTs use this path if (headersList.get('Authorization')) { + const rawAuthorization = headersList.get('Authorization'); + const bearer = rawAuthorization?.match(/^Bearer (.+)$/i)?.[1]; + const decoded = bearer ? jwt.decode(bearer) : null; + const decodedPayload = decoded !== null && typeof decoded !== 'string' ? decoded : null; + const decodedRuntimeAuthorization = decodedPayload?.runtimeAuthorization; + const runtimeAuthorization = CloudAgentNextRuntimeAuthorizationClaimSchema.safeParse( + decodedRuntimeAuthorization + ); + const attestationAudience = RuntimeProxyAttestationAudienceSchema.safeParse( + opts.expectedAudience ?? KILO_API_AUDIENCE + ); + const runtimeProxyAttestationVerified = + bearer !== undefined && + runtimeAuthorization.success && + attestationAudience.success && + typeof decodedPayload?.kiloUserId === 'string' + ? await verifyRuntimeProxyAttestation({ + value: headersList.get('X-Kilo-Runtime-Proxy-Attestation'), + secret: NEXTAUTH_SECRET, + audience: attestationAudience.data, + userId: decodedPayload.kiloUserId, + authorizationId: runtimeAuthorization.data.id, + resourceId: runtimeAuthorization.data.resourceId, + bearer, + }) + : false; const authorizationValidationResult = validateAuthorizationHeader(headersList, { expectedAudience: opts.expectedAudience, + runtimeProxyAttestationVerified, }); if (authorizationValidationResult.error != undefined) { return authError(401, authorizationValidationResult.error, '?'); @@ -1234,7 +1266,12 @@ async function resolveUserFromAuth( ) { return authError(401, 'Invalid API token', user.id); } - const organizationId = headersList.get(ORGANIZATION_ID_HEADER) || undefined; + // A token-bound organization is signed; the request header is mutable. + // Legacy and personal tokens intentionally continue to use the header. + const organizationId = + authorizationValidationResult.organizationId ?? + headersList.get(ORGANIZATION_ID_HEADER) ?? + undefined; const internalApiUse = authorizationValidationResult.internalApiUse; const botId = authorizationValidationResult.botId; const tokenSource = authorizationValidationResult.tokenSource; diff --git a/apps/web/src/routers/test-utils.ts b/apps/web/src/routers/test-utils.ts index 3e90d770a7..e9bc3134a7 100644 --- a/apps/web/src/routers/test-utils.ts +++ b/apps/web/src/routers/test-utils.ts @@ -1,6 +1,7 @@ import { createCallerFactory } from '@/lib/trpc/init'; import { findUserById } from '@/lib/user'; import { rootRouter } from '@/routers/root-router'; +import { generateApiToken } from '@/lib/tokens'; const createCaller = createCallerFactory(rootRouter); @@ -10,5 +11,9 @@ export async function createCallerForUser(userId: string, opts?: { deviceSession if (!user) { throw new Error(`Test user not found: ${userId}`); } - return createCaller({ user, deviceSessionId: opts?.deviceSessionId }); + return createCaller({ + user, + deviceSessionId: opts?.deviceSessionId, + headersList: new Headers({ Authorization: `Bearer ${generateApiToken(user)}` }), + }); } diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json index ab2332bf97..ef2afd39f7 100644 --- a/packages/worker-utils/package.json +++ b/packages/worker-utils/package.json @@ -13,6 +13,9 @@ "./kilo-token-auth": "./src/kilo-token-auth.ts", "./kilo-token": "./src/kilo-token.ts", "./kilo-token-policy": "./src/kilo-token-policy.ts", + "./runtime-authorization": "./src/runtime-authorization.ts", + "./runtime-authorization-contract": "./src/runtime-authorization-contract.ts", + "./runtime-proxy-attestation": "./src/runtime-proxy-attestation.ts", "./kilo-auth-middleware": "./src/kilo-auth-middleware.ts", "./sandbox-id": "./src/sandbox-id.ts", "./hostname-label": "./src/hostname-label.ts", diff --git a/packages/worker-utils/src/kilo-auth-middleware.test.ts b/packages/worker-utils/src/kilo-auth-middleware.test.ts index 9ecedb49ab..5116ae3f8b 100644 --- a/packages/worker-utils/src/kilo-auth-middleware.test.ts +++ b/packages/worker-utils/src/kilo-auth-middleware.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { Hono, type Context } from 'hono'; import { SignJWT } from 'jose'; import { GASTOWN_AUDIENCE } from './internal-service-token-audiences'; -import { createKiloAuthMiddleware } from './kilo-auth-middleware'; +import { createKiloAuthMiddleware, type KiloAuthVariables } from './kilo-auth-middleware'; const TEST_SECRET = 'test-secret-that-is-long-enough-for-hs256'; @@ -11,17 +11,12 @@ const resolveSecret = async (binding: { get(): Promise } | string) => type TestEnv = { Bindings: { NEXTAUTH_SECRET?: string }; - Variables: { - kiloUserId: string; - kiloIsAdmin: boolean; - kiloApiTokenPepper: string | null; - kiloGastownAccess: boolean; - kiloOrgMemberships: { orgId: string; role: 'owner' | 'member' | 'billing_manager' }[]; - }; + Variables: KiloAuthVariables; }; function createApp() { let downstreamCalls = 0; + const authenticated: KiloAuthVariables[] = []; const app = new Hono(); const kiloAuthMiddleware = createKiloAuthMiddleware({ resolveSecret, @@ -31,6 +26,7 @@ function createApp() { app.use('/trpc/*', kiloAuthMiddleware); const handler = (c: Context) => { downstreamCalls += 1; + authenticated.push(c.var); return c.json({ kiloUserId: c.get('kiloUserId'), isAdmin: c.get('kiloIsAdmin'), @@ -41,7 +37,7 @@ function createApp() { }; app.get('/api/whoami', handler); app.get('/trpc/whoami', handler); - return { app, downstreamCalls: () => downstreamCalls }; + return { app, downstreamCalls: () => downstreamCalls, authenticated }; } async function signToken( @@ -95,6 +91,43 @@ describe('createKiloAuthMiddleware', () => { expect(downstreamCalls()).toBe(0); }); + it('captures verified raw claims and control token for authorization', async () => { + const { app, authenticated } = createApp(); + const token = await signToken({ + aud: GASTOWN_AUDIENCE, + tokenPurpose: 'human-api', + credentialExchange: false, + apiTokenPepper: 'current-pepper', + customRestriction: 'retain-for-authorization', + orgMemberships: [{ orgId: 'org-a', role: 'admin' }], + }); + const responses = await request(app, token); + expect(responses.map(response => response.status)).toEqual([200, 200]); + expect(authenticated).toHaveLength(2); + for (const variables of authenticated) { + expect(variables.kiloControlToken).toBe(token); + expect(variables.kiloUsesModernToken).toBe(true); + expect(variables.kiloTokenClaims).toMatchObject({ + tokenPurpose: 'human-api', + customRestriction: 'retain-for-authorization', + }); + expect(variables.kiloOrgMemberships).toEqual([{ orgId: 'org-a', role: 'admin' }]); + } + }); + + it.each([ + { tokenPurpose: 'human-api', credentialExchange: false }, + { aud: GASTOWN_AUDIENCE, tokenPurpose: 'human-api' }, + { aud: GASTOWN_AUDIENCE, tokenPurpose: 'unknown', credentialExchange: false }, + { aud: GASTOWN_AUDIENCE, tokenPurpose: 'delegated-workload', credentialExchange: true }, + ])('rejects invalid modern claims before authorization: %j', async claims => { + const { app, downstreamCalls, authenticated } = createApp(); + const responses = await request(app, await signToken(claims)); + expect(responses.map(response => response.status)).toEqual([401, 401]); + expect(downstreamCalls()).toBe(0); + expect(authenticated).toEqual([]); + }); + it('accepts legacy tokens without an audience or dates', async () => { const { app, downstreamCalls } = createApp(); const responses = await request(app, await signToken({}, { dates: false })); diff --git a/packages/worker-utils/src/kilo-auth-middleware.ts b/packages/worker-utils/src/kilo-auth-middleware.ts index 0362e83d06..6eddfa0eeb 100644 --- a/packages/worker-utils/src/kilo-auth-middleware.ts +++ b/packages/worker-utils/src/kilo-auth-middleware.ts @@ -1,9 +1,10 @@ +import { decodeJwt, type JWTPayload } from 'jose'; 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 { verifyKiloTokenForPolicy, verifyKiloTokenForResource } from './kilo-token-policy.js'; import { resError } from './res.js'; /** @@ -13,12 +14,12 @@ import { resError } from './res.js'; */ export type SecretBinding = { get(): Promise } | string; -export type KiloAuthOrgMembership = { - orgId: string; - role: 'owner' | 'member' | 'billing_manager'; -}; +export type KiloAuthOrgMembership = NonNullable[number]; export type KiloAuthVariables = { + kiloControlToken: string; + kiloUsesModernToken: boolean; + kiloTokenClaims: JWTPayload; kiloUserId: string; kiloIsAdmin: boolean; kiloApiTokenPepper: string | null; @@ -74,6 +75,17 @@ export function createKiloAuthMiddleware( try { const payload = await verifyKiloTokenForResource(token, secret, audiencePolicy); + // Preserve claims stripped by the legacy payload schema for downstream authorization. + // Decode only after signature and resource audience verification succeeds. + const claims = decodeJwt(token); + const usesModernToken = + claims.tokenPurpose !== undefined || claims.credentialExchange !== undefined; + if (usesModernToken) { + await verifyKiloTokenForPolicy(token, secret, audiencePolicy); + } + c.set('kiloControlToken', token); + c.set('kiloUsesModernToken', usesModernToken); + c.set('kiloTokenClaims', claims); c.set('kiloUserId', payload.kiloUserId); c.set('kiloIsAdmin', payload.isAdmin === true); c.set('kiloApiTokenPepper', payload.apiTokenPepper ?? null); diff --git a/packages/worker-utils/src/kilo-token-policy.test.ts b/packages/worker-utils/src/kilo-token-policy.test.ts index d0b8a9d032..0a8d623b31 100644 --- a/packages/worker-utils/src/kilo-token-policy.test.ts +++ b/packages/worker-utils/src/kilo-token-policy.test.ts @@ -22,6 +22,8 @@ import { GITHUB_USER_ACCESS_TOKEN_AUDIENCE, USER_DATA_EXPORT_AUDIENCE, SESSION_INGEST_USER_DELETION_AUDIENCE, + KILO_API_AUDIENCE, + KILO_GATEWAY_AUDIENCE, } from './internal-service-token-audiences.js'; const SECRET = 'synthetic-policy-test-secret-at-least-32-chars'; @@ -855,7 +857,7 @@ describe('buildModernKiloTokenPayload and compatibility', () => { }; type ExpectedReadonlyOrganizationMemberships = readonly { readonly orgId: string; - readonly role: 'owner' | 'member' | 'billing_manager'; + readonly role: 'owner' | 'admin' | 'member' | 'billing_manager'; }[]; expectTypeOf().toEqualTypeOf(); @@ -881,8 +883,52 @@ describe('buildModernKiloTokenPayload and compatibility', () => { ).resolves.toMatchObject({ userId: 'synthetic-user' }); }); + it('supports unique audience arrays only for non-exchangeable modern tokens', () => { + expect( + buildModernKiloTokenPayload({ + userId: 'synthetic-user', + audience: ['kilo-api', 'kilo-gateway'], + issuedAt: NOW_SECONDS, + expiresAt: NOW_SECONDS + 60, + tokenPurpose: 'delegated-workload', + credentialExchange: false, + }).aud + ).toEqual(['kilo-api', 'kilo-gateway']); + expect(() => + buildModernKiloTokenPayload({ + userId: 'synthetic-user', + pepper: 'synthetic-pepper', + audience: ['kilo-api', 'kilo-gateway'], + issuedAt: NOW_SECONDS, + expiresAt: NOW_SECONDS + 60, + tokenPurpose: 'human-api', + credentialExchange: true, + }) + ).toThrow(); + }); + + it('preserves the ordered API and gateway audience set for delegated workloads', () => { + const payload = buildModernKiloTokenPayload({ + userId: 'synthetic-user', + pepper: 'synthetic-pepper', + audience: [KILO_API_AUDIENCE, KILO_GATEWAY_AUDIENCE], + issuedAt: NOW_SECONDS, + expiresAt: NOW_SECONDS + 60, + tokenPurpose: 'delegated-workload', + credentialExchange: false, + }); + + expect(payload.aud).toEqual([KILO_API_AUDIENCE, KILO_GATEWAY_AUDIENCE]); + expect(isKiloResourceAudienceAllowed(payload.aud, API_POLICY)).toBe(true); + expect( + isKiloResourceAudienceAllowed(payload.aud, { + audience: 'unrelated-audience', + mode: 'required', + }) + ).toBe(false); + }); + it.each([ - { audience: ['kilo-api'] }, { issuedAt: -1 }, { expiresAt: NOW_SECONDS }, { tokenPurpose: 'device-access', credentialExchange: true }, diff --git a/packages/worker-utils/src/kilo-token-policy.ts b/packages/worker-utils/src/kilo-token-policy.ts index 34079040b2..4af9b45672 100644 --- a/packages/worker-utils/src/kilo-token-policy.ts +++ b/packages/worker-utils/src/kilo-token-policy.ts @@ -47,14 +47,41 @@ const policyClaims = kiloTokenPayload message: 'Modern tokens require audience, purpose and exchange eligibility', }); } - if (claims.credentialExchange === true && claims.tokenPurpose !== 'human-api') { - ctx.addIssue({ - code: 'custom', - message: 'Only human API tokens may permit credential exchange', - path: ['credentialExchange'], - }); + } + if (claims.runtimeAdmission !== undefined && claims.runtimeAuthorization !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Runtime admission and runtime authorization cannot coexist', + }); + } + if (claims.runtimeAdmission !== undefined) { + const permittedPurpose = + (claims.runtimeAdmission.source === 'user' && + (claims.tokenPurpose === 'human-api' || claims.tokenPurpose === 'device-access')) || + (claims.runtimeAdmission.source === 'automation' && + claims.tokenPurpose === 'internal-service'); + if ( + !permittedPurpose || + claims.credentialExchange !== false || + claims.apiTokenPepper === undefined || + claims.tokenPurpose === 'delegated-workload' + ) { + ctx.addIssue({ code: 'custom', message: 'Invalid runtime admission' }); } } + if ( + claims.runtimeAuthorization !== undefined && + (claims.tokenPurpose !== 'delegated-workload' || claims.credentialExchange !== false) + ) { + ctx.addIssue({ code: 'custom', message: 'Invalid runtime authorization reference' }); + } + if (claims.credentialExchange === true && claims.tokenPurpose !== 'human-api') { + ctx.addIssue({ + code: 'custom', + message: 'Only human API tokens may permit credential exchange', + path: ['credentialExchange'], + }); + } }); export type KiloTokenPolicyClaims = z.infer; @@ -242,7 +269,7 @@ export type ModernKiloTokenPurpose = z.infer; const modernClaims = policyClaims .safeExtend({ - aud: audienceName, + aud: audienceClaim, tokenPurpose: purposeClaim, credentialExchange: z.boolean(), }) @@ -253,7 +280,9 @@ const modernClaims = policyClaims .filter(([, value]) => value !== undefined) .map(([name]) => name); if ( - claims.aud !== KILO_API_AUDIENCE || + (typeof claims.aud === 'string' + ? claims.aud !== KILO_API_AUDIENCE + : claims.aud.length !== 1 || claims.aud[0] !== KILO_API_AUDIENCE) || claims.apiTokenPepper === undefined || !hasOnlyExchangeSafeClaims(serializedClaimNames) ) { @@ -282,8 +311,9 @@ export type SignModernKiloTokenParams = { expiresInSeconds: number; pepper?: string | null; env?: string; - audience: string; + audience: string | string[]; extra?: z.infer; + now?: Date; } & ModernKiloTokenSigningPurpose; export function buildModernKiloTokenPayload( @@ -291,14 +321,14 @@ export function buildModernKiloTokenPayload( userId: string; pepper?: string | null; env?: string; - audience: string; + audience: string | string[]; issuedAt: number; expiresAt: number; extra?: z.infer; } & ModernKiloTokenPurpose ): ModernKiloTokenClaims { const extra = modernTokenExtra.parse(params.extra ?? {}); - const audience = audienceName.parse(params.audience); + const audience = audienceClaim.parse(params.audience); const claims = { ...extra, version: KILO_TOKEN_VERSION, @@ -318,7 +348,9 @@ export async function signModernKiloToken( params: SignModernKiloTokenParams ): Promise<{ token: string; expiresAt: string }> { const expiresInSeconds = positiveSafeInteger.parse(params.expiresInSeconds); - const issuedAt = Math.floor(Date.now() / 1000); + const now = params.now ?? new Date(); + if (!Number.isFinite(now.getTime())) throw new Error('Invalid token issuance time'); + const issuedAt = Math.floor(now.getTime() / 1000); const expiresAt = issuedAt + expiresInSeconds; if (!Number.isSafeInteger(expiresAt)) { throw new Error('Token expiration exceeds the safe integer range'); diff --git a/packages/worker-utils/src/kilo-token.test.ts b/packages/worker-utils/src/kilo-token.test.ts index daf6525638..ee57f98bd4 100644 --- a/packages/worker-utils/src/kilo-token.test.ts +++ b/packages/worker-utils/src/kilo-token.test.ts @@ -144,6 +144,28 @@ describe('signKiloToken', () => { expect(kiloTokenPayload.parse(payload)).toEqual(payload); }); + + it('accepts admin organization roles and runtime claims', async () => { + const { token } = await signKiloToken({ + userId: 'user-runtime', + pepper: 'pepper-runtime', + secret: SECRET, + expiresInSeconds: 60, + extra: { + organizationRole: 'admin', + runtimeAdmission: { + source: 'user', + authorizationUserId: 'user-runtime', + authorizationPepper: 'pepper-runtime', + }, + }, + }); + + await expect(verifyKiloToken(token, SECRET)).resolves.toMatchObject({ + organizationRole: 'admin', + runtimeAdmission: { source: 'user' }, + }); + }); }); describe('verifyKiloToken', () => { diff --git a/packages/worker-utils/src/kilo-token.ts b/packages/worker-utils/src/kilo-token.ts index c4c030e21f..71e9b163d2 100644 --- a/packages/worker-utils/src/kilo-token.ts +++ b/packages/worker-utils/src/kilo-token.ts @@ -2,6 +2,17 @@ import { SignJWT, jwtVerify } from 'jose'; import { z } from 'zod'; export const KILO_TOKEN_VERSION = 3; +const organizationRole = z.enum(['owner', 'admin', 'member', 'billing_manager']); +const runtimeAdmission = z.object({ + source: z.enum(['user', 'automation']), + authorizationUserId: z.string().min(1), + authorizationPepper: z.string().nullable(), +}); +const runtimeAuthorization = z.object({ + id: z.string().uuid(), + resourceKind: z.enum(['cloud-agent-next', 'gastown']), + resourceId: z.string().min(1), +}); /** * All known fields that can appear in a Kilo user JWT, sourced from @@ -20,16 +31,16 @@ export const kiloTokenPayload = z.object({ gastownAccess: z.boolean().optional(), botId: z.string().optional(), organizationId: z.string().optional(), - organizationRole: z.enum(['owner', 'member', 'billing_manager']).optional(), + organizationRole: organizationRole.optional(), internalApiUse: z.boolean().optional(), createdOnPlatform: z.string().optional(), tokenSource: z.string().optional(), deviceAuthRequestCode: z.string().optional(), deviceSessionId: z.string().optional(), // Org memberships (baked into gastown tokens to avoid DB lookups) - orgMemberships: z - .array(z.object({ orgId: z.string(), role: z.enum(['owner', 'member', 'billing_manager']) })) - .optional(), + orgMemberships: z.array(z.object({ orgId: z.string(), role: organizationRole })).optional(), + runtimeAdmission: runtimeAdmission.optional(), + runtimeAuthorization: runtimeAuthorization.optional(), // Standard JWT claims iat: z.number().optional(), exp: z.number().optional(), @@ -55,6 +66,8 @@ export type SignKiloTokenExtra = Pick< | 'deviceAuthRequestCode' | 'deviceSessionId' | 'orgMemberships' + | 'runtimeAdmission' + | 'runtimeAuthorization' >; export async function signKiloToken(params: { diff --git a/packages/worker-utils/src/runtime-authorization-contract.ts b/packages/worker-utils/src/runtime-authorization-contract.ts new file mode 100644 index 0000000000..3648b70c47 --- /dev/null +++ b/packages/worker-utils/src/runtime-authorization-contract.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +export const RuntimeResourceKindSchema = z.enum(['cloud-agent-next', 'gastown']); +export type RuntimeResourceKind = z.infer; + +export function runtimeAuthorizationMaximumLifetimeMs(resourceKind: RuntimeResourceKind): number { + switch (resourceKind) { + case 'cloud-agent-next': + return 24 * 60 * 60_000; + case 'gastown': + return 30 * 24 * 60 * 60_000; + default: { + const exhaustive: never = resourceKind; + return exhaustive; + } + } +} + +export const RuntimeAdmissionSchema = z.object({ + source: z.enum(['user', 'automation']), + authorizationUserId: z.string().min(1), + authorizationPepper: z.string().nullable(), +}); +export type RuntimeAdmission = z.infer; + +const pepperDigest = z.string().regex(/^[a-f0-9]{64}$/); +const nullablePepperDigest = z.union([pepperDigest, z.literal('null')]); + +export const RuntimeAuthorizationSchema = z + .object({ + version: z.literal(1), + id: z.string().uuid(), + resourceKind: RuntimeResourceKindSchema, + resourceId: z.string().min(1), + userId: z.string().min(1), + authorizationUserId: z.string().min(1), + organizationId: z.string().min(1).optional(), + issuedAt: z.string().datetime(), + delegationExpiresAt: z.string().datetime(), + state: z.enum(['active', 'revoked']), + bindings: z + .object({ + userPepperDigest: nullablePepperDigest, + authorizationPepperDigest: nullablePepperDigest, + userMembershipId: z.string().min(1).optional(), + authorizationUserMembershipId: z.string().min(1).optional(), + }) + .strict(), + source: z + .object({ + tokenSource: z.string().optional(), + botId: z.string().optional(), + createdOnPlatform: z.string().optional(), + admissionSource: z.enum(['user', 'automation']), + }) + .strict(), + env: z.string().optional(), + }) + .strict() + .superRefine((authorization, ctx) => { + const issuedAt = Date.parse(authorization.issuedAt); + const delegationExpiresAt = Date.parse(authorization.delegationExpiresAt); + if (delegationExpiresAt <= issuedAt) { + ctx.addIssue({ + code: 'custom', + message: 'Delegation expiration must follow issuance', + path: ['delegationExpiresAt'], + }); + } + if ( + delegationExpiresAt > + issuedAt + runtimeAuthorizationMaximumLifetimeMs(authorization.resourceKind) + ) { + ctx.addIssue({ + code: 'custom', + message: 'Delegation expiration exceeds the resource maximum lifetime', + path: ['delegationExpiresAt'], + }); + } + }); + +export type RuntimeAuthorization = z.infer; diff --git a/packages/worker-utils/src/runtime-authorization.test.ts b/packages/worker-utils/src/runtime-authorization.test.ts new file mode 100644 index 0000000000..42b213c076 --- /dev/null +++ b/packages/worker-utils/src/runtime-authorization.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { decodeJwt } from 'jose'; +import { signModernKiloToken } from './kilo-token-policy.js'; +import { + createRuntimeAuthorization, + renewRuntimeAuthorization, + sealRuntimeAuthorization, + unsealRuntimeAuthorization, + RuntimeAuthorizationExpiredError, + RuntimeAuthorizationRevokedError, +} from './runtime-authorization.js'; +import type { + RuntimeAuthorizationAdapters, + RuntimeAuthorizationPrincipal, +} from './runtime-authorization.js'; + +const secret = 'runtime-authorization-test-secret-at-least-32'; +const principals = new Map([ + [ + 'user', + { + id: 'user', + apiTokenPepper: 'user-pepper', + blockedAt: null, + blockedReason: null, + isBot: false, + }, + ], + [ + 'bot', + { id: 'bot', apiTokenPepper: 'bot-pepper', blockedAt: null, blockedReason: null, isBot: true }, + ], +]); +const memberships = new Map([ + ['org:user', { id: 'membership-user', role: 'admin', organizationDeletedAt: null }], + ['org:bot', { id: 'membership-bot', role: 'member', organizationDeletedAt: null }], +]); + +function adapters(): RuntimeAuthorizationAdapters { + return { + getPrincipal: vi.fn(async ({ userId }) => principals.get(userId) ?? null), + getMembership: vi.fn( + async ({ organizationId, userId }) => memberships.get(`${organizationId}:${userId}`) ?? null + ), + }; +} + +async function admission( + options: { + userId?: string; + source?: 'user' | 'automation'; + audience?: string | string[]; + expiresInSeconds?: number; + } = {} +) { + const source = options.source ?? 'user'; + const userId = options.userId ?? 'user'; + return signModernKiloToken({ + userId, + pepper: principals.get(userId)?.apiTokenPepper ?? null, + secret, + expiresInSeconds: options.expiresInSeconds ?? 300, + audience: options.audience ?? 'cloud-agent-next', + tokenPurpose: source === 'user' ? 'human-api' : 'internal-service', + credentialExchange: false, + extra: { + organizationId: 'org', + runtimeAdmission: { + source, + authorizationUserId: 'user', + authorizationPepper: 'user-pepper', + }, + }, + }); +} + +describe('runtime authorization', () => { + beforeEach(() => { + principals.set('user', { + id: 'user', + apiTokenPepper: 'user-pepper', + blockedAt: null, + blockedReason: null, + isBot: false, + }); + principals.set('bot', { + id: 'bot', + apiTokenPepper: 'bot-pepper', + blockedAt: null, + blockedReason: null, + isBot: true, + }); + memberships.set('org:user', { + id: 'membership-user', + role: 'admin', + organizationDeletedAt: null, + }); + memberships.set('org:bot', { + id: 'membership-bot', + role: 'member', + organizationDeletedAt: null, + }); + }); + afterEach(() => vi.useRealTimers()); + it('creates a bound authorization and issues a runtime-only audience token', async () => { + const control = await admission(); + const result = await createRuntimeAuthorization({ + token: control.token, + secret, + connectionString: 'postgres://unused', + resourceKind: 'cloud-agent-next', + resourceId: 'session', + organizationId: 'org', + adapters: adapters(), + }); + + expect(result.authorization.bindings.userPepperDigest).not.toContain('user-pepper'); + expect(decodeJwt(result.token)).toMatchObject({ + aud: ['kilo-api', 'kilo-gateway', 'session-ingest'], + tokenPurpose: 'delegated-workload', + credentialExchange: false, + runtimeAuthorization: { id: result.authorization.id, resourceId: 'session' }, + }); + }); + + it('sets a fixed resource-specific delegation deadline independently of control expiry', async () => { + vi.useFakeTimers(); + const now = new Date('2026-01-01T00:00:00.000Z'); + vi.setSystemTime(now); + const cloudAgent = await createRuntimeAuthorization({ + token: (await admission({ expiresInSeconds: 60 })).token, + secret, + connectionString: 'postgres://unused', + resourceKind: 'cloud-agent-next', + resourceId: 'session', + organizationId: 'org', + adapters: adapters(), + now, + }); + const gastown = await createRuntimeAuthorization({ + token: (await admission({ audience: 'gastown', expiresInSeconds: 60 })).token, + secret, + connectionString: 'postgres://unused', + resourceKind: 'gastown', + resourceId: 'town', + organizationId: 'org', + adapters: adapters(), + now, + }); + + expect(cloudAgent.authorization.delegationExpiresAt).toBe('2026-01-02T00:00:00.000Z'); + expect(gastown.authorization.delegationExpiresAt).toBe('2026-01-31T00:00:00.000Z'); + expect(decodeJwt(cloudAgent.token).exp).toBe(Date.UTC(2026, 0, 1, 1) / 1000); + }); + + it('caps runtime-token expiration to the positive duration remaining before the deadline', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const created = await createRuntimeAuthorization({ + token: (await admission()).token, + secret, + connectionString: 'postgres://unused', + resourceKind: 'cloud-agent-next', + resourceId: 'session', + organizationId: 'org', + adapters: adapters(), + }); + const renewalTime = new Date('2026-01-01T23:40:00.000Z'); + vi.setSystemTime(renewalTime); + + const renewed = await renewRuntimeAuthorization({ + authorization: created.authorization, + secret, + connectionString: 'postgres://unused', + adapters: adapters(), + now: renewalTime, + }); + + expect(decodeJwt(renewed.token).exp).toBe(Date.UTC(2026, 0, 2) / 1000); + expect(renewed.expiresAt).toBe('2026-01-02T00:00:00.000Z'); + }); + + it('rejects renewal at and after the fixed delegation deadline', async () => { + vi.useFakeTimers(); + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + vi.setSystemTime(createdAt); + const created = await createRuntimeAuthorization({ + token: (await admission()).token, + secret, + connectionString: 'postgres://unused', + resourceKind: 'cloud-agent-next', + resourceId: 'session', + organizationId: 'org', + adapters: adapters(), + now: createdAt, + }); + const deadline = new Date(created.authorization.delegationExpiresAt); + + await expect( + renewRuntimeAuthorization({ + authorization: created.authorization, + secret, + connectionString: 'postgres://unused', + adapters: adapters(), + now: deadline, + }) + ).rejects.toBeInstanceOf(RuntimeAuthorizationExpiredError); + await expect( + renewRuntimeAuthorization({ + authorization: created.authorization, + secret, + connectionString: 'postgres://unused', + adapters: adapters(), + now: new Date(deadline.getTime() + 1), + }) + ).rejects.toBeInstanceOf(RuntimeAuthorizationExpiredError); + }); + + it('rejects normal, multi-audience, expired, and mismatched control tokens', async () => { + const valid = await admission(); + const common = { + secret, + connectionString: 'postgres://unused', + resourceKind: 'cloud-agent-next' as const, + resourceId: 'session', + organizationId: 'org', + adapters: adapters(), + }; + await expect( + createRuntimeAuthorization({ ...common, token: valid.token.replace(/[^.]+$/, 'tampered') }) + ).rejects.toThrow(); + await expect( + createRuntimeAuthorization({ + ...common, + token: (await admission({ audience: ['cloud-agent-next', 'kilo-api'] })).token, + }) + ).rejects.toThrow(); + const ordinary = await signModernKiloToken({ + userId: 'user', + pepper: 'user-pepper', + secret, + expiresInSeconds: 300, + audience: 'cloud-agent-next', + tokenPurpose: 'human-api', + credentialExchange: false, + }); + await expect( + createRuntimeAuthorization({ ...common, token: ordinary.token }) + ).rejects.toThrow(); + await expect( + createRuntimeAuthorization({ + ...common, + token: (await admission({ audience: 'gastown' })).token, + }) + ).rejects.toThrow(); + const expiring = await admission({ expiresInSeconds: 60 }); + vi.setSystemTime(new Date(Date.now() + 61_000)); + await expect( + createRuntimeAuthorization({ ...common, token: expiring.token }) + ).rejects.toThrow(); + }); + + it('binds renewals to current peppers, blocks, and membership identities', async () => { + const result = await createRuntimeAuthorization({ + token: (await admission({ userId: 'bot', source: 'automation' })).token, + secret, + connectionString: 'postgres://unused', + resourceKind: 'cloud-agent-next', + resourceId: 'session', + organizationId: 'org', + adapters: adapters(), + }); + principals.set('bot', { + id: 'bot', + apiTokenPepper: 'rotated', + blockedAt: null, + blockedReason: null, + isBot: true, + }); + await expect( + renewRuntimeAuthorization({ + authorization: result.authorization, + secret, + connectionString: 'postgres://unused', + adapters: adapters(), + }) + ).rejects.toBeInstanceOf(RuntimeAuthorizationRevokedError); + principals.set('bot', { + id: 'bot', + apiTokenPepper: 'bot-pepper', + blockedAt: null, + blockedReason: null, + isBot: true, + }); + memberships.set('org:bot', { + id: 're-added-membership', + role: 'member', + organizationDeletedAt: null, + }); + await expect( + renewRuntimeAuthorization({ + authorization: result.authorization, + secret, + connectionString: 'postgres://unused', + adapters: adapters(), + }) + ).rejects.toBeInstanceOf(RuntimeAuthorizationRevokedError); + }); + + it('seals only the exact intended runtime record', async () => { + const result = await createRuntimeAuthorization({ + token: (await admission({ audience: 'gastown' })).token, + secret, + connectionString: 'postgres://unused', + resourceKind: 'gastown', + resourceId: 'town', + organizationId: 'org', + adapters: adapters(), + }); + const sealed = await sealRuntimeAuthorization(result.authorization, secret); + await expect( + unsealRuntimeAuthorization(sealed, secret, { + resourceKind: 'gastown', + resourceId: 'town', + userId: 'user', + organizationId: 'org', + }) + ).resolves.toEqual(result.authorization); + await expect( + unsealRuntimeAuthorization(sealed, secret, { + resourceKind: 'gastown', + resourceId: 'other', + userId: 'user', + organizationId: 'org', + }) + ).rejects.toThrow(); + await expect( + unsealRuntimeAuthorization(`${sealed}x`, secret, { + resourceKind: 'gastown', + resourceId: 'town', + userId: 'user', + organizationId: 'org', + }) + ).rejects.toThrow(); + }); +}); diff --git a/packages/worker-utils/src/runtime-authorization.ts b/packages/worker-utils/src/runtime-authorization.ts new file mode 100644 index 0000000000..a1ed3aafb8 --- /dev/null +++ b/packages/worker-utils/src/runtime-authorization.ts @@ -0,0 +1,368 @@ +import { getWorkerDb, kilocode_users, organization_memberships, organizations } from '@kilocode/db'; +import { and, eq } from 'drizzle-orm'; +import { jwtVerify, SignJWT } from 'jose'; +import { z } from 'zod'; +import { signModernKiloToken, verifyKiloTokenForPolicy } from './kilo-token-policy.js'; +import type { RuntimeAuthorization } from './runtime-authorization-contract.js'; +import { + RuntimeAuthorizationSchema, + runtimeAuthorizationMaximumLifetimeMs, + RuntimeResourceKindSchema, +} from './runtime-authorization-contract.js'; +export { RuntimeAuthorizationSchema }; +export type { RuntimeAuthorization }; + +const membershipRole = z.enum(['owner', 'admin', 'member']); +const principalSchema = z.object({ + id: z.string().min(1), + apiTokenPepper: z.string().nullable(), + blockedAt: z.string().nullable(), + blockedReason: z.string().nullable(), + isBot: z.boolean(), +}); +const membershipSchema = z.object({ + id: z.string().min(1), + role: z.string(), + organizationDeletedAt: z.string().nullable(), +}); + +export type RuntimeAuthorizationPrincipal = z.infer; +export type RuntimeAuthorizationMembership = z.infer; +export type RuntimeAuthorizationAdapters = { + getPrincipal?: (input: { + connectionString: string; + userId: string; + }) => Promise; + getMembership?: (input: { + connectionString: string; + userId: string; + organizationId: string; + }) => Promise; +}; + +type CommonInput = { + secret: string; + connectionString: string; + adapters?: RuntimeAuthorizationAdapters; +}; + +export class RuntimeAuthorizationRevokedError extends Error { + constructor() { + super('Runtime authorization has been revoked'); + this.name = 'RuntimeAuthorizationRevokedError'; + } +} + +export class RuntimeAuthorizationExpiredError extends Error { + constructor() { + super('Runtime authorization delegation has expired'); + this.name = 'RuntimeAuthorizationExpiredError'; + } +} + +function currentDate(now?: Date): Date { + const value = now ?? new Date(); + if (!Number.isFinite(value.getTime())) throw new Error('Invalid runtime authorization time'); + return new Date(value.getTime()); +} + +async function digest(value: string | null): Promise { + if (value === null) return 'null'; + const bytes = new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)) + ); + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function isBlocked(principal: RuntimeAuthorizationPrincipal): boolean { + return principal.blockedAt !== null || principal.blockedReason !== null; +} + +async function getPrincipal( + input: CommonInput, + userId: string +): Promise { + if (input.adapters?.getPrincipal) { + const principal = await input.adapters.getPrincipal({ + connectionString: input.connectionString, + userId, + }); + return principal === null ? null : principalSchema.parse(principal); + } + const db = getWorkerDb(input.connectionString); + const [row] = await db + .select({ + id: kilocode_users.id, + apiTokenPepper: kilocode_users.api_token_pepper, + blockedAt: kilocode_users.blocked_at, + blockedReason: kilocode_users.blocked_reason, + isBot: kilocode_users.is_bot, + }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)); + return row ? principalSchema.parse(row) : null; +} + +async function getMembership( + input: CommonInput, + userId: string, + organizationId: string +): Promise { + if (input.adapters?.getMembership) { + const membership = await input.adapters.getMembership({ + connectionString: input.connectionString, + userId, + organizationId, + }); + return membership === null ? null : membershipSchema.parse(membership); + } + const db = getWorkerDb(input.connectionString); + const [row] = await db + .select({ + id: organization_memberships.id, + role: organization_memberships.role, + organizationDeletedAt: organizations.deleted_at, + }) + .from(organization_memberships) + .innerJoin(organizations, eq(organization_memberships.organization_id, organizations.id)) + .where( + and( + eq(organization_memberships.kilo_user_id, userId), + eq(organization_memberships.organization_id, organizationId) + ) + ); + return row ? membershipSchema.parse(row) : null; +} + +async function requireBindings( + input: CommonInput, + record: RuntimeAuthorization +): Promise<{ + user: RuntimeAuthorizationPrincipal; + authorizationUser: RuntimeAuthorizationPrincipal; +}> { + const user = await getPrincipal(input, record.userId); + const authorizationUser = await getPrincipal(input, record.authorizationUserId); + if ( + user === null || + authorizationUser === null || + isBlocked(user) || + isBlocked(authorizationUser) || + (await digest(user.apiTokenPepper)) !== record.bindings.userPepperDigest || + (await digest(authorizationUser.apiTokenPepper)) !== record.bindings.authorizationPepperDigest + ) + throw new RuntimeAuthorizationRevokedError(); + if (record.organizationId) { + const userMembership = await getMembership(input, user.id, record.organizationId); + const authorizationMembership = await getMembership( + input, + authorizationUser.id, + record.organizationId + ); + if ( + userMembership === null || + authorizationMembership === null || + userMembership.organizationDeletedAt !== null || + authorizationMembership.organizationDeletedAt !== null || + !membershipRole.safeParse(userMembership.role).success || + !membershipRole.safeParse(authorizationMembership.role).success || + userMembership.id !== record.bindings.userMembershipId || + authorizationMembership.id !== record.bindings.authorizationUserMembershipId + ) + throw new RuntimeAuthorizationRevokedError(); + } + return { user, authorizationUser }; +} + +export async function createRuntimeAuthorization( + input: CommonInput & { + token: string; + resourceKind: RuntimeAuthorization['resourceKind']; + resourceId: string; + organizationId?: string; + now?: Date; + } +): Promise<{ authorization: RuntimeAuthorization; token: string; expiresAt: string }> { + const resourceKind = RuntimeResourceKindSchema.parse(input.resourceKind); + const resourceId = z.string().min(1).parse(input.resourceId); + const auth = await verifyKiloTokenForPolicy(input.token, input.secret, { + audience: resourceKind, + mode: 'allow-legacy', + }); + const claims = auth.claims; + const admission = claims.runtimeAdmission; + const soleAudience = + typeof claims.aud === 'string' + ? claims.aud + : claims.aud?.length === 1 + ? claims.aud[0] + : undefined; + if ( + admission === undefined || + claims.runtimeAuthorization !== undefined || + soleAudience !== resourceKind || + claims.credentialExchange !== false || + claims.apiTokenPepper === undefined || + (admission.source === 'user' && + claims.tokenPurpose !== 'human-api' && + claims.tokenPurpose !== 'device-access') || + (admission.source === 'automation' && claims.tokenPurpose !== 'internal-service') || + claims.tokenPurpose === 'delegated-workload' || + claims.kiloUserId.length === 0 || + input.organizationId !== claims.organizationId + ) + throw new Error('Invalid runtime admission'); + const user = await getPrincipal(input, claims.kiloUserId); + const authorizationUser = await getPrincipal(input, admission.authorizationUserId); + if ( + user === null || + authorizationUser === null || + isBlocked(user) || + isBlocked(authorizationUser) || + user.apiTokenPepper !== claims.apiTokenPepper || + authorizationUser.apiTokenPepper !== admission.authorizationPepper || + (user.id !== authorizationUser.id && (admission.source !== 'automation' || !user.isBot)) + ) + throw new Error('Invalid runtime admission'); + let userMembershipId: string | undefined; + let authorizationUserMembershipId: string | undefined; + if (input.organizationId) { + const userMembership = await getMembership(input, user.id, input.organizationId); + const authorizationMembership = await getMembership( + input, + authorizationUser.id, + input.organizationId + ); + if ( + userMembership === null || + authorizationMembership === null || + userMembership.organizationDeletedAt !== null || + authorizationMembership.organizationDeletedAt !== null || + !membershipRole.safeParse(userMembership.role).success || + !membershipRole.safeParse(authorizationMembership.role).success + ) + throw new Error('Invalid runtime admission'); + userMembershipId = userMembership.id; + authorizationUserMembershipId = authorizationMembership.id; + } + const now = currentDate(input.now); + const authorization = RuntimeAuthorizationSchema.parse({ + version: 1, + id: crypto.randomUUID(), + resourceKind, + resourceId, + userId: user.id, + authorizationUserId: authorizationUser.id, + organizationId: input.organizationId, + issuedAt: now.toISOString(), + delegationExpiresAt: new Date( + now.getTime() + runtimeAuthorizationMaximumLifetimeMs(resourceKind) + ).toISOString(), + state: 'active', + bindings: { + userPepperDigest: await digest(user.apiTokenPepper), + authorizationPepperDigest: await digest(authorizationUser.apiTokenPepper), + userMembershipId, + authorizationUserMembershipId, + }, + source: { + tokenSource: claims.tokenSource, + botId: claims.botId, + createdOnPlatform: claims.createdOnPlatform, + admissionSource: admission.source, + }, + env: claims.env, + }); + const signed = await issueRuntimeToken(authorization, user.apiTokenPepper, input.secret, now); + return { authorization, ...signed }; +} + +async function issueRuntimeToken( + authorization: RuntimeAuthorization, + pepper: string | null, + secret: string, + now: Date +): Promise<{ token: string; expiresAt: string }> { + const maximumExpiration = Math.floor(Date.parse(authorization.delegationExpiresAt) / 1000); + const issuedAt = Math.floor(now.getTime() / 1000); + const expiresInSeconds = Math.min(60 * 60, maximumExpiration - issuedAt); + if (expiresInSeconds <= 0) throw new RuntimeAuthorizationExpiredError(); + const audiences = + authorization.resourceKind === 'cloud-agent-next' + ? ['kilo-api', 'kilo-gateway', 'session-ingest'] + : ['kilo-api', 'kilo-gateway']; + return signModernKiloToken({ + userId: authorization.userId, + pepper, + secret, + expiresInSeconds, + now, + audience: audiences, + tokenPurpose: 'delegated-workload', + credentialExchange: false, + env: authorization.env, + extra: { + organizationId: authorization.organizationId, + tokenSource: authorization.source.tokenSource, + botId: authorization.source.botId, + createdOnPlatform: authorization.source.createdOnPlatform, + runtimeAuthorization: { + id: authorization.id, + resourceKind: authorization.resourceKind, + resourceId: authorization.resourceId, + }, + }, + }); +} + +export async function renewRuntimeAuthorization( + input: CommonInput & { authorization: RuntimeAuthorization; now?: Date } +): Promise<{ token: string; expiresAt: string }> { + const authorization = RuntimeAuthorizationSchema.parse(input.authorization); + if (authorization.state !== 'active') throw new RuntimeAuthorizationRevokedError(); + const now = currentDate(input.now); + if (now.getTime() >= Date.parse(authorization.delegationExpiresAt)) { + throw new RuntimeAuthorizationExpiredError(); + } + const { user } = await requireBindings(input, authorization); + return issueRuntimeToken(authorization, user.apiTokenPepper, input.secret, now); +} + +const sealedRecord = z.object({ + type: z.literal('runtime-authorization-record'), + authorization: RuntimeAuthorizationSchema, +}); + +export async function sealRuntimeAuthorization( + record: RuntimeAuthorization, + secret: string +): Promise { + const authorization = RuntimeAuthorizationSchema.parse(record); + return new SignJWT({ type: 'runtime-authorization-record', authorization }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setAudience(`${authorization.resourceKind}:runtime-authorization-record`) + .setIssuedAt() + .setExpirationTime('5m') + .sign(new TextEncoder().encode(secret)); +} + +export async function unsealRuntimeAuthorization( + sealed: string, + secret: string, + expected: Pick +): Promise { + const { payload } = await jwtVerify(sealed, new TextEncoder().encode(secret), { + algorithms: ['HS256'], + }); + const parsed = sealedRecord.parse(payload); + const record = parsed.authorization; + if ( + record.resourceKind !== expected.resourceKind || + record.resourceId !== expected.resourceId || + record.userId !== expected.userId || + record.organizationId !== expected.organizationId || + payload.aud !== `${expected.resourceKind}:runtime-authorization-record` + ) + throw new Error('Runtime authorization seal binding mismatch'); + return record; +} diff --git a/packages/worker-utils/src/runtime-proxy-attestation.test.ts b/packages/worker-utils/src/runtime-proxy-attestation.test.ts new file mode 100644 index 0000000000..a3859c9e41 --- /dev/null +++ b/packages/worker-utils/src/runtime-proxy-attestation.test.ts @@ -0,0 +1,65 @@ +import { SignJWT } from 'jose'; +import { describe, expect, it } from 'vitest'; +import { + issueRuntimeProxyAttestation, + verifyRuntimeProxyAttestation, +} from './runtime-proxy-attestation.js'; + +const secret = 'runtime-proxy-attestation-secret'; +const now = new Date('2026-01-01T00:00:00.000Z'); +const input = { + secret, + audience: 'kilo-api' as const, + userId: 'usr_123', + authorizationId: '11111111-1111-4111-8111-111111111111', + resourceId: 'agent_123', + bearer: 'exact-runtime-bearer', + now, +}; + +describe('runtime proxy attestation', () => { + it('binds a short-lived proof to the exact bearer and runtime identity', async () => { + const proof = await issueRuntimeProxyAttestation(input); + await expect(verifyRuntimeProxyAttestation({ ...input, value: proof, now })).resolves.toBe( + true + ); + }); + + it.each([ + ['wrong bearer', { bearer: 'other-bearer' }], + ['wrong user', { userId: 'usr_other' }], + ['wrong authorization', { authorizationId: '22222222-2222-4222-8222-222222222222' }], + ['wrong resource', { resourceId: 'agent_other' }], + ['wrong audience', { audience: 'kilo-gateway' as const }], + ])('rejects a proof with a %s binding', async (_name, changed) => { + const proof = await issueRuntimeProxyAttestation(input); + await expect( + verifyRuntimeProxyAttestation({ ...input, ...changed, value: proof, now }) + ).resolves.toBe(false); + }); + + it('rejects forged and expired proofs', async () => { + const forged = await new SignJWT({ + ...input, + version: 1, + type: 'cloud-agent-next-runtime-proxy', + }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuer('kilocode-runtime-proxy') + .setAudience('kilo-api') + .setIssuedAt(Math.floor(now.getTime() / 1000)) + .setExpirationTime(Math.floor(now.getTime() / 1000) + 30) + .sign(new TextEncoder().encode('wrong-secret')); + const proof = await issueRuntimeProxyAttestation(input); + await expect(verifyRuntimeProxyAttestation({ ...input, value: forged, now })).resolves.toBe( + false + ); + await expect( + verifyRuntimeProxyAttestation({ + ...input, + value: proof, + now: new Date(now.getTime() + 31_000), + }) + ).resolves.toBe(false); + }); +}); diff --git a/packages/worker-utils/src/runtime-proxy-attestation.ts b/packages/worker-utils/src/runtime-proxy-attestation.ts new file mode 100644 index 0000000000..f6c075666e --- /dev/null +++ b/packages/worker-utils/src/runtime-proxy-attestation.ts @@ -0,0 +1,125 @@ +import { jwtVerify, SignJWT } from 'jose'; +import { z } from 'zod'; + +export const RUNTIME_PROXY_ATTESTATION_HEADER = 'X-Kilo-Runtime-Proxy-Attestation'; +export const RUNTIME_PROXY_ATTESTATION_ISSUER = 'kilocode-runtime-proxy'; +export const RUNTIME_PROXY_ATTESTATION_TYPE = 'cloud-agent-next-runtime-proxy'; +export const RUNTIME_PROXY_ATTESTATION_VERSION = 1; +export const RUNTIME_PROXY_ATTESTATION_MAX_AGE_SECONDS = 30; + +export const RuntimeProxyAttestationAudienceSchema = z.enum([ + 'kilo-api', + 'kilo-gateway', + 'session-ingest', +]); +export type RuntimeProxyAttestationAudience = z.infer; + +export const CloudAgentNextRuntimeAuthorizationClaimSchema = z + .object({ + id: z.string().uuid(), + resourceKind: z.literal('cloud-agent-next'), + resourceId: z.string().min(1), + }) + .strict(); +export type CloudAgentNextRuntimeAuthorizationClaim = z.infer< + typeof CloudAgentNextRuntimeAuthorizationClaimSchema +>; + +const runtimeProxyAttestationClaims = z + .object({ + iss: z.literal(RUNTIME_PROXY_ATTESTATION_ISSUER), + type: z.literal(RUNTIME_PROXY_ATTESTATION_TYPE), + version: z.literal(RUNTIME_PROXY_ATTESTATION_VERSION), + aud: RuntimeProxyAttestationAudienceSchema, + userId: z.string().min(1), + authorizationId: z.string().uuid(), + resourceId: z.string().min(1), + bearerDigest: z.string().regex(/^[a-f0-9]{64}$/), + iat: z.number().int().nonnegative(), + exp: z.number().int().positive(), + }) + .strict() + .superRefine((claims, ctx) => { + if ( + claims.exp <= claims.iat || + claims.exp - claims.iat > RUNTIME_PROXY_ATTESTATION_MAX_AGE_SECONDS + ) { + ctx.addIssue({ code: 'custom', message: 'Invalid runtime proxy attestation lifetime' }); + } + }); + +export type RuntimeProxyAttestationClaims = z.infer; + +async function bearerDigest(bearer: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(bearer)); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function signingKey(secret: string): Uint8Array { + return new TextEncoder().encode(secret); +} + +export async function issueRuntimeProxyAttestation(input: { + secret: string; + audience: RuntimeProxyAttestationAudience; + userId: string; + authorizationId: string; + resourceId: string; + bearer: string; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const issuedAt = Math.floor(now.getTime() / 1000); + if (!Number.isSafeInteger(issuedAt) || issuedAt < 0) + throw new Error('Invalid attestation issuance time'); + const claims = runtimeProxyAttestationClaims.parse({ + iss: RUNTIME_PROXY_ATTESTATION_ISSUER, + type: RUNTIME_PROXY_ATTESTATION_TYPE, + version: RUNTIME_PROXY_ATTESTATION_VERSION, + aud: RuntimeProxyAttestationAudienceSchema.parse(input.audience), + userId: input.userId, + authorizationId: input.authorizationId, + resourceId: input.resourceId, + bearerDigest: await bearerDigest(input.bearer), + iat: issuedAt, + exp: issuedAt + RUNTIME_PROXY_ATTESTATION_MAX_AGE_SECONDS, + }); + return new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt(claims.iat) + .setExpirationTime(claims.exp) + .setIssuer(claims.iss) + .setAudience(claims.aud) + .sign(signingKey(input.secret)); +} + +export async function verifyRuntimeProxyAttestation(input: { + value: string | null | undefined; + secret: string; + audience: RuntimeProxyAttestationAudience; + userId: string; + authorizationId: string; + resourceId: string; + bearer: string; + now?: Date; +}): Promise { + if (!input.value || input.value.length > 4096) return false; + try { + const { payload } = await jwtVerify(input.value, signingKey(input.secret), { + algorithms: ['HS256'], + issuer: RUNTIME_PROXY_ATTESTATION_ISSUER, + audience: input.audience, + currentDate: input.now, + }); + const claims = runtimeProxyAttestationClaims.parse(payload); + return ( + claims.aud === input.audience && + claims.userId === input.userId && + claims.authorizationId === input.authorizationId && + claims.resourceId === input.resourceId && + claims.bearerDigest === (await bearerDigest(input.bearer)) + ); + } catch { + return false; + } +} diff --git a/services/session-ingest/src/middleware/kilo-jwt-auth.test.ts b/services/session-ingest/src/middleware/kilo-jwt-auth.test.ts index 3daa1a1244..9ba0d10b96 100644 --- a/services/session-ingest/src/middleware/kilo-jwt-auth.test.ts +++ b/services/session-ingest/src/middleware/kilo-jwt-auth.test.ts @@ -10,6 +10,10 @@ import { SESSION_INGEST_AUDIENCE, SESSION_INGEST_USER_DELETION_AUDIENCE, } from '@kilocode/worker-utils/internal-service-token-audiences'; +import { + issueRuntimeProxyAttestation, + RUNTIME_PROXY_ATTESTATION_HEADER, +} from '@kilocode/worker-utils/runtime-proxy-attestation'; import { kiloJwtAuthMiddleware, type KiloJwtAuthVariables } from './kilo-jwt-auth'; @@ -149,6 +153,26 @@ async function signModernInternalToken(audience: string): Promise { return token; } +async function signCloudAgentRuntimeToken(): Promise { + const { token } = await signModernKiloToken({ + userId: 'usr_123', + pepper: 'pepper-current', + secret: TEST_JWT_SECRET, + expiresInSeconds: 3600, + audience: SESSION_INGEST_AUDIENCE, + tokenPurpose: 'delegated-workload', + credentialExchange: false, + extra: { + runtimeAuthorization: { + id: '11111111-1111-4111-8111-111111111111', + resourceKind: 'cloud-agent-next', + resourceId: 'agent_123', + }, + }, + }); + return token; +} + async function signClaims( claims: Record, secret = TEST_JWT_SECRET @@ -183,17 +207,26 @@ function request( query?: string; ticketStore?: TicketStore; secret?: string | null; + runtimeProxyAttestation?: string; + env?: TestEnv; } = {} ) { const app = makeApp(); const url = `http://local${options.path ?? '/api/me'}${options.query ?? ''}`; const headers = new Headers(); if (token !== null) headers.set('Authorization', `Bearer ${token}`); + if (options.runtimeProxyAttestation) { + headers.set(RUNTIME_PROXY_ATTESTATION_HEADER, options.runtimeProxyAttestation); + } if (options.websocket) headers.set('Upgrade', 'websocket'); return { response: app.fetch( new Request(url, { method: options.method, headers }), - makeEnv('secret' in options ? (options.secret ?? null) : TEST_JWT_SECRET, options.ticketStore) + options.env ?? + makeEnv( + 'secret' in options ? (options.secret ?? null) : TEST_JWT_SECRET, + options.ticketStore + ) ), }; } @@ -255,6 +288,59 @@ describe('kiloJwtAuthMiddleware', () => { expect(await res.json()).toEqual({ user_id: 'usr_123' }); }); + it('requires a proxy attestation for a cloud-agent-next runtime bearer after pepper validation', async () => { + userRowByUserId.set('usr_123', { pepper: 'pepper-current', blockedReason: null }); + const token = await signCloudAgentRuntimeToken(); + + const missingProof = await request(token).response; + expect(missingProof.status).toBe(401); + await expect(missingProof.json()).resolves.toEqual({ + success: false, + error: 'Invalid runtime proxy attestation', + }); + const proof = await issueRuntimeProxyAttestation({ + secret: TEST_JWT_SECRET, + audience: SESSION_INGEST_AUDIENCE, + userId: 'usr_123', + authorizationId: '11111111-1111-4111-8111-111111111111', + resourceId: 'agent_123', + bearer: token, + }); + expect((await request(token, { runtimeProxyAttestation: proof }).response).status).toBe(200); + }); + + it.each([ + [ + 'rejects', + async (env: TestEnv) => { + env.NEXTAUTH_SECRET_PROD.get = async () => { + throw new Error('secrets store unavailable'); + }; + }, + ], + [ + 'returns no secret', + async (env: TestEnv) => { + env.NEXTAUTH_SECRET_PROD.get = async () => null; + }, + ], + ])( + 'returns sanitized retryable 503 when the runtime attestation secret lookup %s', + async (_name, alter) => { + userRowByUserId.set('usr_123', { pepper: 'pepper-current', blockedReason: null }); + const env = makeEnv(TEST_JWT_SECRET); + await alter(env); + + const response = await request(await signCloudAgentRuntimeToken(), { env }).response; + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Service temporarily unavailable', + }); + } + ); + it('rejects a stale pepper even when the user exists', async () => { userRowByUserId.set('usr_123', { pepper: 'pepper-current', blockedReason: null }); const token = await signUserToken('pepper-stale'); diff --git a/services/session-ingest/src/middleware/kilo-jwt-auth.ts b/services/session-ingest/src/middleware/kilo-jwt-auth.ts index 9399b1c23d..84292af233 100644 --- a/services/session-ingest/src/middleware/kilo-jwt-auth.ts +++ b/services/session-ingest/src/middleware/kilo-jwt-auth.ts @@ -1,10 +1,16 @@ import { createMiddleware } from 'hono/factory'; -import { extractBearerToken } from '@kilocode/worker-utils'; +import { extractBearerToken, getCachedSecret } from '@kilocode/worker-utils'; import { SESSION_INGEST_AUDIENCE, SESSION_INGEST_USER_DELETION_AUDIENCE, } from '@kilocode/worker-utils/internal-service-token-audiences'; import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; +import { + CloudAgentNextRuntimeAuthorizationClaimSchema, + RUNTIME_PROXY_ATTESTATION_HEADER, + verifyRuntimeProxyAttestation, +} from '@kilocode/worker-utils/runtime-proxy-attestation'; +import { decodeJwt } from 'jose'; import type { Env } from '../env'; @@ -94,6 +100,35 @@ export const kiloJwtAuthMiddleware = createMiddleware<{ return c.json({ success: false, error: 'Invalid or expired token' }, 401); } + const runtimeAuthorization = CloudAgentNextRuntimeAuthorizationClaimSchema.safeParse( + decodeJwt(token).runtimeAuthorization + ); + if (runtimeAuthorization.success) { + let attested: boolean; + try { + const secret = await getCachedSecret(c.env.NEXTAUTH_SECRET_PROD, 'NEXTAUTH_SECRET'); + attested = await verifyRuntimeProxyAttestation({ + value: c.req.header(RUNTIME_PROXY_ATTESTATION_HEADER), + secret, + audience: SESSION_INGEST_AUDIENCE, + userId: auth.userId, + authorizationId: runtimeAuthorization.data.id, + resourceId: runtimeAuthorization.data.resourceId, + bearer: token, + }); + } catch (error) { + console.error('Auth infrastructure failure', { + operation: 'runtime-proxy-attestation-verify', + errorClass: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error), + }); + return c.json({ success: false, error: 'Service temporarily unavailable' }, 503); + } + if (!attested) { + return c.json({ success: false, error: 'Invalid runtime proxy attestation' }, 401); + } + } + c.set('user_id', auth.userId); c.set('deletionAudience', deletionAudience); return next(); From 1bca2576df24eb756f3ed3dae058ed3b9e58288c Mon Sep 17 00:00:00 2001 From: Florian Hines Date: Tue, 8 Sep 2026 12:09:23 -0500 Subject: [PATCH 2/2] fix(auth): include existing consumer type adapters --- services/gastown/src/middleware/auth.middleware.ts | 8 ++++++-- services/wasteland/src/middleware/auth.middleware.ts | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/services/gastown/src/middleware/auth.middleware.ts b/services/gastown/src/middleware/auth.middleware.ts index fa8b694074..8d0e2accea 100644 --- a/services/gastown/src/middleware/auth.middleware.ts +++ b/services/gastown/src/middleware/auth.middleware.ts @@ -1,13 +1,15 @@ +import type { KiloAuthVariables } from '@kilocode/worker-utils/kilo-auth-middleware'; import type { Context } from 'hono'; import { createMiddleware } from 'hono/factory'; import { extractBearerToken } from '@kilocode/worker-utils'; +import type { KiloTokenPayload } from '@kilocode/worker-utils/kilo-token'; import { verifyAgentJWT, verifyContainerJWT, type AgentJWTPayload } from '../util/jwt.util'; import { resError } from '../util/res.util'; import type { GastownEnv } from '../gastown.worker'; -export type JwtOrgMembership = { orgId: string; role: 'owner' | 'member' | 'billing_manager' }; +export type JwtOrgMembership = NonNullable[number]; -export type AuthVariables = { +export type AuthVariables = KiloAuthVariables & { agentJWT: AgentJWTPayload; townId: string; kiloUserId: string; @@ -15,6 +17,8 @@ export type AuthVariables = { kiloApiTokenPepper: string | null; kiloGastownAccess: boolean; kiloOrgMemberships: JwtOrgMembership[]; + kiloControlToken: string; + kiloUsesModernToken: boolean; requestStartTime: number; orgId?: string; orgRole?: string; diff --git a/services/wasteland/src/middleware/auth.middleware.ts b/services/wasteland/src/middleware/auth.middleware.ts index 330900066b..5b27127a71 100644 --- a/services/wasteland/src/middleware/auth.middleware.ts +++ b/services/wasteland/src/middleware/auth.middleware.ts @@ -1,6 +1,9 @@ -export type JwtOrgMembership = { orgId: string; role: 'owner' | 'member' | 'billing_manager' }; +import type { KiloAuthVariables } from '@kilocode/worker-utils/kilo-auth-middleware'; +import type { KiloTokenPayload } from '@kilocode/worker-utils/kilo-token'; -export type AuthVariables = { +export type JwtOrgMembership = NonNullable[number]; + +export type AuthVariables = KiloAuthVariables & { kiloUserId: string; kiloIsAdmin: boolean; kiloApiTokenPepper: string | null;