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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions apps/web/src/lib/auth/cloud-agent-workflow-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<Headers>, []>();
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);
});
11 changes: 4 additions & 7 deletions apps/web/src/lib/auth/cloud-agent-workflow-user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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 () => {
Expand Down
14 changes: 6 additions & 8 deletions apps/web/src/lib/auth/cloud-agent-workflow-user.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}
38 changes: 27 additions & 11 deletions apps/web/src/lib/auth/resource-delegation.servicecontrol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lib/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,15 +306,15 @@ 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);
if (expiresIn <= 0) {
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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions docs/token-issuance-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading