diff --git a/apps/web/src/lib/auth/cloud-agent-workflow-compat.test.ts b/apps/web/src/lib/auth/cloud-agent-workflow-compat.test.ts new file mode 100644 index 0000000000..7e028c5b34 --- /dev/null +++ b/apps/web/src/lib/auth/cloud-agent-workflow-compat.test.ts @@ -0,0 +1,115 @@ +import jwt from 'jsonwebtoken'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/drizzle'; +import { kilocode_users } from '@kilocode/db/schema'; +import { NEXTAUTH_SECRET } from '@/lib/config.server'; +import { GET as getCloudAgentBalance } from '@/app/api/cloud-agent-next/balance/route'; +import { GET as getBalance } from '@/app/api/profile/balance/route'; +import { getUserFromAuth } from '@/lib/user/server'; +import { + generateApiToken, + generateWorkflowGatewayToken, + generateCloudAgentWorkflowToken, +} from '@/lib/tokens'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { prepareCloudAgentWorkflowUser } from './cloud-agent-workflow-user'; + +// Request lifecycle scheduling is supplied by Next.js in production. +jest.mock('next/server', () => ({ + ...jest.requireActual('next/server'), + after: jest.fn(), +})); + +const mockHeaders = jest.fn, []>(); +jest.mock('next/headers', () => ({ + headers: () => mockHeaders(), + cookies: jest.fn(), +})); +jest.mock('@/lib/config.server', () => ({ + ...jest.requireActual('@/lib/config.server'), + isResourceTokenIssuanceEnabled: () => true, +})); + +test('preparing modern workflows preserves authentication for an existing null-pepper CLI token', async () => { + const user = await insertTestUser({ api_token_pepper: null }); + const token = generateApiToken(user, { createdOnPlatform: 'cli' }); + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${token}` })); + + const before = await getUserFromAuth({ adminOnly: false }); + expect(before.authFailedResponse).toBeNull(); + expect(before.user?.id).toBe(user.id); + + await prepareCloudAgentWorkflowUser(user); + + const after = await getUserFromAuth({ adminOnly: false }); + expect({ + userId: after.user?.id, + status: after.authFailedResponse?.status ?? null, + body: after.authFailedResponse ? await after.authFailedResponse.json() : null, + }).toEqual({ userId: user.id, status: null, body: null }); +}); + +test('null-pepper modern credentials enforce audiences and genuine rotation revokes every old credential', async () => { + const user = await insertTestUser({ api_token_pepper: null }); + const cliToken = generateApiToken(user, { createdOnPlatform: 'cli' }); + const prepared = await prepareCloudAgentWorkflowUser(user); + expect(prepared.api_token_pepper).toBeNull(); + const gatewayToken = generateWorkflowGatewayToken(prepared, { tokenSource: 'reviewer' }); + const controlToken = generateCloudAgentWorkflowToken(prepared, { + tokenSource: 'reviewer', + expiresIn: 3600, + }); + + for (const [token, audience, purpose] of [ + [gatewayToken, 'kilo-gateway', 'delegated-workload'], + [controlToken, 'cloud-agent-next', 'internal-service'], + ]) { + const claims = jwt.verify(token, NEXTAUTH_SECRET); + expect(claims).toMatchObject({ + aud: audience, + apiTokenPepper: null, + tokenPurpose: purpose, + credentialExchange: false, + }); + if (audience === 'cloud-agent-next') { + expect(claims).toMatchObject({ runtimeAdmission: { authorizationPepper: null } }); + } + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${token}` })); + const accepted = await getUserFromAuth({ adminOnly: false, expectedAudience: audience }); + expect(accepted.authFailedResponse).toBeNull(); + expect(accepted.user?.id).toBe(user.id); + const wrongAudience = await getUserFromAuth({ adminOnly: false }); + expect(wrongAudience.authFailedResponse?.status).toBe(401); + } + + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${controlToken}` })); + expect((await getCloudAgentBalance()).status).toBe(200); + expect((await getBalance()).status).toBe(401); + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${gatewayToken}` })); + expect((await getCloudAgentBalance()).status).toBe(401); + + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${cliToken}` })); + const balance = await getBalance(); + expect(balance.status).toBe(200); + expect(await balance.json()).toEqual({ balance: 0, isDepleted: true }); + + await db + .update(kilocode_users) + .set({ api_token_pepper: 'explicit-test-rotation' }) + .where(eq(kilocode_users.id, user.id)); + + for (const [token, expectedAudience] of [ + [cliToken, 'kilo-api'], + [gatewayToken, 'kilo-gateway'], + [controlToken, 'cloud-agent-next'], + ]) { + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${token}` })); + const revoked = await getUserFromAuth({ adminOnly: false, expectedAudience }); + expect(revoked.user).toBeNull(); + expect(revoked.authFailedResponse?.status).toBe(401); + } + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${cliToken}` })); + expect((await getBalance()).status).toBe(401); + mockHeaders.mockResolvedValue(new Headers({ Authorization: `Bearer ${controlToken}` })); + expect((await getCloudAgentBalance()).status).toBe(401); +}); diff --git a/apps/web/src/lib/auth/cloud-agent-workflow-user.test.ts b/apps/web/src/lib/auth/cloud-agent-workflow-user.test.ts index 6f7712716d..3d25d58ece 100644 --- a/apps/web/src/lib/auth/cloud-agent-workflow-user.test.ts +++ b/apps/web/src/lib/auth/cloud-agent-workflow-user.test.ts @@ -16,14 +16,11 @@ beforeEach(() => { issuance.enabled = true; }); -test('initializes a null pepper and issues modern review admission with the persisted value', async () => { +test('preserves a null pepper and issues modern review admission with the persisted value', async () => { const user = await insertTestUser({ api_token_pepper: null }); - expect(() => - generateCloudAgentWorkflowToken(user, { tokenSource: 'code-review', expiresIn: 3600 }) - ).toThrow('current user pepper'); const prepared = await prepareCloudAgentWorkflowUser(user); const [persisted] = await db.select().from(kilocode_users).where(eq(kilocode_users.id, user.id)); - expect(prepared.api_token_pepper).toEqual(expect.any(String)); + expect(prepared.api_token_pepper).toBeNull(); expect(prepared.api_token_pepper).toBe(persisted.api_token_pepper); const claims = jwt.decode( generateCloudAgentWorkflowToken(prepared, { tokenSource: 'code-review', expiresIn: 3600 }) @@ -41,13 +38,13 @@ test('initializes a null pepper and issues modern review admission with the pers }); }); -test('concurrent initializers return the same persisted pepper', async () => { +test('concurrent preparations preserve the persisted null pepper', async () => { const user = await insertTestUser({ api_token_pepper: null }); const results = await Promise.all( Array.from({ length: 8 }, () => prepareCloudAgentWorkflowUser(user)) ); expect(new Set(results.map(result => result.api_token_pepper)).size).toBe(1); - expect(results[0].api_token_pepper).toEqual(expect.any(String)); + expect(results[0].api_token_pepper).toBeNull(); }); test('preserves a pepper assigned after the user snapshot was loaded', async () => { diff --git a/apps/web/src/lib/auth/cloud-agent-workflow-user.ts b/apps/web/src/lib/auth/cloud-agent-workflow-user.ts index ad18b69078..149ad5c4ca 100644 --- a/apps/web/src/lib/auth/cloud-agent-workflow-user.ts +++ b/apps/web/src/lib/auth/cloud-agent-workflow-user.ts @@ -1,5 +1,4 @@ -import { randomUUID } from 'node:crypto'; -import { eq, sql } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { kilocode_users, type User } from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { isResourceTokenIssuanceEnabled, type ResourceTokenFamily } from '@/lib/config.server'; @@ -12,13 +11,12 @@ export async function prepareCloudAgentWorkflowUser( return user; } - // Use the primary's persisted value: another issuer or revocation may have - // assigned a pepper since this user was loaded. Never overwrite that value. + // Reload null snapshots from the primary without revoking existing credentials. + // Another issuer or revocation may have assigned a pepper since this user was loaded. const [currentUser] = await db - .update(kilocode_users) - .set({ api_token_pepper: sql`COALESCE(${kilocode_users.api_token_pepper}, ${randomUUID()})` }) - .where(eq(kilocode_users.id, user.id)) - .returning(); + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, user.id)); if (!currentUser) throw new Error(`User ${user.id} not found`); return currentUser; } diff --git a/apps/web/src/lib/auth/resource-delegation.servicecontrol.test.ts b/apps/web/src/lib/auth/resource-delegation.servicecontrol.test.ts index 41486cc4e5..d5fee86862 100644 --- a/apps/web/src/lib/auth/resource-delegation.servicecontrol.test.ts +++ b/apps/web/src/lib/auth/resource-delegation.servicecontrol.test.ts @@ -70,18 +70,21 @@ describe('workflow service control tokens', () => { 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 }); + test.each([undefined, ''])( + 'rejects an absent or empty authorization pepper: %s', + api_token_pepper => { + const user = defineTestUser({ api_token_pepper: 'workflow-pepper' }); + const authorizationUser = defineTestUser({ api_token_pepper }); - expect(() => - generateCloudAgentWorkflowToken(user, { - expiresIn: 300, - tokenSource: 'reviewer', - authorizationUser, - }) - ).toThrow('current authorization pepper'); - }); + 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; @@ -126,3 +129,16 @@ test.each(['cloud-agent-next', 'workflow-gateway'])( if (family === 'cloud-agent-next') expect(gateway).not.toHaveProperty('aud'); } ); + +test.each([undefined, ''])( + 'rejects absent or empty workflow user peppers: %s', + api_token_pepper => { + const user = defineTestUser({ api_token_pepper }); + expect(() => generateWorkflowGatewayToken(user, { tokenSource: 'reviewer' })).toThrow( + 'current user pepper' + ); + expect(() => + generateCloudAgentWorkflowToken(user, { tokenSource: 'reviewer', expiresIn: 300 }) + ).toThrow('current user pepper'); + } +); diff --git a/apps/web/src/lib/auto-fix/triggers/prepare-fix-payload.test.ts b/apps/web/src/lib/auto-fix/triggers/prepare-fix-payload.test.ts index 4b3ddf04da..766379c07f 100644 --- a/apps/web/src/lib/auto-fix/triggers/prepare-fix-payload.test.ts +++ b/apps/web/src/lib/auto-fix/triggers/prepare-fix-payload.test.ts @@ -63,7 +63,7 @@ describe('prepareFixPayload workflow token ownership', () => { .select() .from(kilocode_users) .where(eq(kilocode_users.id, 'user-1')); - expect(persisted.api_token_pepper).toEqual(expect.any(String)); + expect(persisted.api_token_pepper).toBe(null); expect(mockGenerateCloudAgentWorkflowToken).toHaveBeenCalledWith( expect.objectContaining({ id: 'user-1', api_token_pepper: persisted.api_token_pepper }), expect.objectContaining({ organizationId: expectedOrganizationId }) diff --git a/apps/web/src/lib/auto-triage/triggers/prepare-triage-payload.test.ts b/apps/web/src/lib/auto-triage/triggers/prepare-triage-payload.test.ts index b42540d1af..f7110817e3 100644 --- a/apps/web/src/lib/auto-triage/triggers/prepare-triage-payload.test.ts +++ b/apps/web/src/lib/auto-triage/triggers/prepare-triage-payload.test.ts @@ -62,7 +62,7 @@ describe('prepareTriagePayload workflow token ownership', () => { .select() .from(kilocode_users) .where(eq(kilocode_users.id, 'user-1')); - expect(persisted.api_token_pepper).toEqual(expect.any(String)); + expect(persisted.api_token_pepper).toBe(null); expect(mockGenerateCloudAgentWorkflowToken).toHaveBeenCalledWith( expect.objectContaining({ id: 'user-1', api_token_pepper: persisted.api_token_pepper }), expect.objectContaining({ organizationId: expectedOrganizationId }) diff --git a/apps/web/src/lib/security-agent/services/analysis-service.resource-tokens.test.ts b/apps/web/src/lib/security-agent/services/analysis-service.resource-tokens.test.ts index 5283bab66f..de649e7fb0 100644 --- a/apps/web/src/lib/security-agent/services/analysis-service.resource-tokens.test.ts +++ b/apps/web/src/lib/security-agent/services/analysis-service.resource-tokens.test.ts @@ -4,7 +4,6 @@ import { db } from '@/lib/drizzle'; import { kilocode_users, type SecurityFinding } from '@kilocode/db/schema'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { NEXTAUTH_SECRET } from '@/lib/config.server'; -import { generateWorkflowGatewayToken } from '@/lib/tokens'; import { prepareCloudAgentWorkflowUser } from '@/lib/auth/cloud-agent-workflow-user'; import { getSecurityFindingById } from '../db/security-findings'; import { triageSecurityFinding } from './triage-service'; @@ -58,17 +57,13 @@ it.each([ [false, true], [false, false], [true, false], + [true, true], ])( 'starts security analysis with cloud=%s gateway=%s and a null pepper', async (cloud, gateway) => { process.env.CLOUD_AGENT_RESOURCE_TOKENS_ENABLED = String(cloud); process.env.WORKFLOW_GATEWAY_RESOURCE_TOKENS_ENABLED = String(gateway); const user = await insertTestUser({ api_token_pepper: null }); - if (gateway) { - expect(() => generateWorkflowGatewayToken(user, { tokenSource: 'security-agent' })).toThrow( - 'Workflow gateway tokens require a current user pepper' - ); - } jest.mocked(getSecurityFindingById).mockResolvedValue({ id: 'finding', owned_by_user_id: user.id, @@ -127,7 +122,7 @@ it.each([ .select() .from(kilocode_users) .where(eq(kilocode_users.id, user.id)); - expect(persisted.api_token_pepper).toEqual(cloud || gateway ? expect.any(String) : null); + expect(persisted.api_token_pepper).toBeNull(); const gatewayClaims = jwt.verify( jest.mocked(triageSecurityFinding).mock.calls[0][0].authToken, NEXTAUTH_SECRET diff --git a/apps/web/src/lib/security-agent/services/analysis-service.token-source.test.ts b/apps/web/src/lib/security-agent/services/analysis-service.token-source.test.ts index 63569f7528..e32fcfaa34 100644 --- a/apps/web/src/lib/security-agent/services/analysis-service.token-source.test.ts +++ b/apps/web/src/lib/security-agent/services/analysis-service.token-source.test.ts @@ -189,8 +189,7 @@ describe('startSecurityAnalysis token source', () => { .select() .from(kilocode_users) .where(eq(kilocode_users.id, user.id)); - expect(persisted.api_token_pepper).toEqual(expect.any(String)); - if (api_token_pepper !== null) expect(persisted.api_token_pepper).toBe(api_token_pepper); + expect(persisted.api_token_pepper).toBe(api_token_pepper); const triageInput = mockTriageSecurityFinding.mock.calls[0]?.[0]; const cloudAgentToken = mockCreateCloudAgentNextClient.mock.calls[0]?.[0]; if (!triageInput) throw new Error('Expected triage to receive an input'); diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts index e5d031c0d5..97af92a23c 100644 --- a/apps/web/src/lib/tokens.ts +++ b/apps/web/src/lib/tokens.ts @@ -306,7 +306,7 @@ export function generateCloudAgentWorkflowToken( { expiresIn: options.expiresIn } ); } - if (!user.api_token_pepper) { + if (user.api_token_pepper !== null && !user.api_token_pepper) { throw new Error('Workflow control tokens require a current user pepper'); } const expiresIn = Math.min(options.expiresIn, 60 * 60); @@ -314,7 +314,7 @@ export function generateCloudAgentWorkflowToken( throw new Error('Workflow control token expiry must be positive'); } const authorizationUser = options.authorizationUser ?? user; - if (!authorizationUser.api_token_pepper) { + if (authorizationUser.api_token_pepper !== null && !authorizationUser.api_token_pepper) { throw new Error('Workflow control tokens require a current authorization pepper'); } const issuedAt = Math.floor(Date.now() / 1000); @@ -359,7 +359,7 @@ export function generateWorkflowGatewayToken( if (!isResourceTokenIssuanceEnabled('workflow-gateway')) { return generateApiToken(user, { tokenSource: options.tokenSource }); } - if (!user.api_token_pepper) { + if (user.api_token_pepper !== null && !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); diff --git a/docs/token-issuance-policy.md b/docs/token-issuance-policy.md index 50aac3eab4..dec1b5650f 100644 --- a/docs/token-issuance-policy.md +++ b/docs/token-issuance-policy.md @@ -182,3 +182,5 @@ The disconnect reader accepts its dedicated operation audience or legacy audienc Gastown/Wasteland control issuers are deferred with their delegation adapters: current paths discard bearer expiry/signed restrictions into a bare user record, and Gastown can derive and renew broader 30-day runtime credentials. A source/audience stamp alone would not close that path. Modern-builder organization roles also require a deliberate compatibility decision for web `admin` memberships; do not cast or promote them to owner. The generic organization-token issuer is not proven attribution-only and is likewise deferred. User/native credentials, Chat fan-out, Cloud Agent/App Builder/automation runtime forwarding, Gastown renewal, and other shared credentials remain PR 5.2 work. The separate Worker-local `services/security-auto-analysis/src/token.ts` snapshot assertion is not one of these 16 web callsites; migrate it with that Worker's other token paths and rollout configuration in PR 5.2. KiloClaw stays minimal for its October EOL. These reallocations keep Phase 5 at exactly two PRs, rather than claiming unsafe control-plane migrations are bounded. This PR does not retire legacy native exchange, shorten user credentials, change global pepper/session semantics, or remove ordinary legacy resource access. + +Workflow issuance reads the existing primary user pepper, including explicit `null`, without initializing or rotating it. Modern workflow tokens retain their audience, purpose, exchange restrictions, and bounded lifetime. This requires no migration and must not reset already initialized peppers; genuine pepper rotation continues to revoke previously issued credentials. diff --git a/packages/worker-utils/src/runtime-authorization.test.ts b/packages/worker-utils/src/runtime-authorization.test.ts index 42b213c076..82ab0ba462 100644 --- a/packages/worker-utils/src/runtime-authorization.test.ts +++ b/packages/worker-utils/src/runtime-authorization.test.ts @@ -123,6 +123,94 @@ describe('runtime authorization', () => { }); }); + it.each([ + { userId: 'user', rotatedUserId: 'user' }, + { userId: 'bot', rotatedUserId: 'bot' }, + { userId: 'bot', rotatedUserId: 'user' }, + ])( + 'preserves explicit null automation peppers for $userId and revokes after $rotatedUserId rotates', + async ({ userId, rotatedUserId }) => { + for (const id of ['user', 'bot']) { + principals.set(id, { + id, + apiTokenPepper: null, + blockedAt: null, + blockedReason: null, + isBot: id === 'bot', + }); + } + const control = await signModernKiloToken({ + userId, + pepper: null, + secret, + expiresInSeconds: 300, + audience: 'cloud-agent-next', + tokenPurpose: 'internal-service', + credentialExchange: false, + extra: { + organizationId: 'org', + runtimeAdmission: { + source: 'automation', + authorizationUserId: 'user', + authorizationPepper: null, + }, + }, + }); + expect(decodeJwt(control.token)).toMatchObject({ + apiTokenPepper: null, + tokenPurpose: 'internal-service', + runtimeAdmission: { source: 'automation', authorizationPepper: null }, + }); + const common = { + secret, + connectionString: 'postgres://unused', + adapters: adapters(), + }; + const creation = { + ...common, + token: control.token, + resourceKind: 'cloud-agent-next' as const, + resourceId: 'session', + organizationId: 'org', + }; + const created = await createRuntimeAuthorization(creation); + expect(created.authorization).toMatchObject({ + userId, + authorizationUserId: 'user', + bindings: { userPepperDigest: 'null', authorizationPepperDigest: 'null' }, + source: { admissionSource: 'automation' }, + }); + const persisted = await unsealRuntimeAuthorization( + await sealRuntimeAuthorization(created.authorization, secret), + secret, + { resourceKind: 'cloud-agent-next', resourceId: 'session', userId, organizationId: 'org' } + ); + expect(persisted).toEqual(created.authorization); + const renewed = await renewRuntimeAuthorization({ ...common, authorization: persisted }); + for (const token of [created.token, renewed.token]) { + expect(decodeJwt(token)).toMatchObject({ + apiTokenPepper: null, + tokenPurpose: 'delegated-workload', + runtimeAuthorization: { id: persisted.id, resourceId: 'session' }, + }); + } + + principals.set(rotatedUserId, { + id: rotatedUserId, + apiTokenPepper: 'rotated-pepper', + blockedAt: null, + blockedReason: null, + isBot: rotatedUserId === 'bot', + }); + await expect(createRuntimeAuthorization(creation)).rejects.toThrow( + 'Invalid runtime admission' + ); + await expect( + renewRuntimeAuthorization({ ...common, authorization: persisted }) + ).rejects.toBeInstanceOf(RuntimeAuthorizationRevokedError); + } + ); + 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');