From e0ae5e14a4f0d0f240af8c0eab4cedb1b80d5e6e Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 21 Oct 2026 14:13:04 +0200 Subject: [PATCH 1/3] feat(cloud-agent): add CLI credential negotiation --- .../api/cloud-agent-next/token/route.test.ts | 408 ++++++++++++++++++ .../app/api/cloud-agent-next/token/route.ts | 75 ++++ 2 files changed, 483 insertions(+) create mode 100644 apps/web/src/app/api/cloud-agent-next/token/route.test.ts create mode 100644 apps/web/src/app/api/cloud-agent-next/token/route.ts diff --git a/apps/web/src/app/api/cloud-agent-next/token/route.test.ts b/apps/web/src/app/api/cloud-agent-next/token/route.test.ts new file mode 100644 index 00000000000..aaf78e05538 --- /dev/null +++ b/apps/web/src/app/api/cloud-agent-next/token/route.test.ts @@ -0,0 +1,408 @@ +import jwt from 'jsonwebtoken'; +import { NextRequest, NextResponse } from 'next/server'; +import { eq, inArray } from 'drizzle-orm'; +import type { User } from '@kilocode/db/schema'; +import { + device_sessions, + kilocode_users, + organization_memberships, + organizations, +} from '@kilocode/db/schema'; +import { db } from '@kilocode/web-shared/lib/drizzle'; +import { isResourceTokenIssuanceEnabled } from '@kilocode/web-shared/lib/config.server'; +import { + getUserFromAuth, + getUserFromSessionForCredentialIssuance, +} from '@kilocode/web-shared/lib/user/server'; +import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper'; +import { createRuntimeAuthorization } from '@kilocode/worker-utils/runtime-authorization'; +import { POST } from './route'; + +// Exercise the real control issuer, JWT policy and PostgreSQL authorization state. +// Substitute the outer auth resolver and secret; the issuer revalidates the bearer. +jest.mock('@kilocode/web-shared/lib/config.server', () => ({ + NEXTAUTH_SECRET: 'cloud-agent-exchange-test-secret', + isResourceTokenIssuanceEnabled: jest.fn(() => true), +})); +jest.mock('@kilocode/web-shared/lib/user/server', () => ({ + getUserFromAuth: jest.fn(), + getUserFromSessionForCredentialIssuance: jest.fn(), +})); +jest.mock('@/lib/organizations/organization-auth', () => ({ + getAuthorizedOrgContext: jest.fn(), +})); + +const secret = 'cloud-agent-exchange-test-secret'; +const cleanupUsers: string[] = []; +let user: User; + +async function createUser() { + const created = await insertTestUser({ api_token_pepper: crypto.randomUUID() }); + cleanupUsers.push(created.id); + return created; +} + +beforeEach(async () => { + jest.clearAllMocks(); + jest.mocked(isResourceTokenIssuanceEnabled).mockReturnValue(true); + user = await createUser(); + jest.mocked(getUserFromAuth).mockResolvedValue({ user, authFailedResponse: null }); +}); + +afterEach(async () => { + await db.delete(kilocode_users).where(inArray(kilocode_users.id, cleanupUsers)); + cleanupUsers.length = 0; +}); + +function sourceToken(claims: Record = {}, signingSecret = secret) { + const now = Math.floor(Date.now() / 1000); + return jwt.sign( + { + version: 3, + env: process.env.NODE_ENV, + kiloUserId: user.id, + apiTokenPepper: user.api_token_pepper, + deviceAuthRequestCode: 'synthetic-cli-pairing-code', + iat: now, + exp: now + 157_680_000, + ...claims, + }, + signingSecret, + { algorithm: 'HS256' } + ); +} + +function modernSourceToken(claims: Record = {}) { + return sourceToken({ + deviceAuthRequestCode: undefined, + aud: 'kilo-api', + tokenPurpose: 'human-api', + credentialExchange: true, + exp: Math.floor(Date.now() / 1000) + 1800, + ...claims, + }); +} + +function request(token = sourceToken(), body: unknown = {}) { + return new NextRequest('https://example.test/api/cloud-agent-next/token', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function verifyToken(token: unknown) { + if (typeof token !== 'string') throw new Error('Expected token response'); + const claims = jwt.verify(token, secret, { algorithms: ['HS256'] }); + if (typeof claims === 'string') throw new Error('Expected JWT claims'); + if (typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { + throw new Error('Expected bounded token lifetime'); + } + return { ...claims, exp: claims.exp, iat: claims.iat }; +} + +async function organizationForCurrentUser() { + const [organization] = await db + .insert(organizations) + .values({ + name: `Cloud Agent exchange ${crypto.randomUUID()}`, + created_by_kilo_user_id: user.id, + require_seats: false, + }) + .returning(); + await db.insert(organization_memberships).values({ + kilo_user_id: user.id, + organization_id: organization.id, + role: 'member', + }); + return organization; +} + +it('exchanges a legacy CLI credential for a bounded modern Cloud Agent control token', async () => { + const response = await POST(request()); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + const body = await response.json(); + expect(Object.keys(body).sort()).toEqual(['expiresAt', 'token']); + const claims = verifyToken(body.token); + expect(claims).toMatchObject({ + kiloUserId: user.id, + apiTokenPepper: user.api_token_pepper, + aud: 'cloud-agent-next', + tokenPurpose: 'human-api', + credentialExchange: false, + tokenSource: 'cloud-agent', + runtimeAdmission: { + source: 'user', + authorizationUserId: user.id, + authorizationPepper: user.api_token_pepper, + }, + }); + expect(claims.exp - claims.iat).toBe(900); + expect(body.expiresAt).toBe(new Date(claims.exp * 1000).toISOString()); + expect(claims).not.toHaveProperty('organizationId'); + expect(claims).not.toHaveProperty('runtimeAuthorization'); + expect(claims).not.toHaveProperty('deviceAuthRequestCode'); +}); + +it('returns a token the Worker runtime-admission consumer accepts', async () => { + const response = await POST(request()); + expect(response.status).toBe(200); + const body = await response.json(); + const result = await createRuntimeAuthorization({ + token: body.token, + secret, + connectionString: 'unused-test-adapter', + resourceKind: 'cloud-agent-next', + resourceId: 'workspace_token-exchange-test', + adapters: { + getPrincipal: async ({ userId }) => + userId === user.id + ? { + id: user.id, + apiTokenPepper: user.api_token_pepper, + blockedAt: null, + blockedReason: null, + isBot: false, + } + : null, + }, + }); + expect(result.authorization).toMatchObject({ + userId: user.id, + resourceKind: 'cloud-agent-next', + resourceId: 'workspace_token-exchange-test', + state: 'active', + }); +}); + +it('does not exchange the returned control token again', async () => { + const issued = await POST(request()); + expect(issued.status).toBe(200); + const body = await issued.json(); + const repeated = await POST(request(body.token)); + expect(repeated.status).toBe(401); + expect(await repeated.json()).not.toHaveProperty('token'); +}); + +it('rejects a short-lived legacy token without a device session', async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const response = await POST(request(sourceToken({ exp }))); + expect(response.status).toBe(403); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it('bounds the control token by the source credential expiry', async () => { + const exp = Math.floor(Date.now() / 1000) + 45; + const response = await POST( + request(modernSourceToken({ exp, deviceAuthRequestCode: undefined })) + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(verifyToken(body.token).exp).toBe(exp); +}); + +it('scopes organization issuance to the authenticated member', async () => { + const organization = await organizationForCurrentUser(); + const response = await POST(request(sourceToken(), { organizationId: organization.id })); + expect(response.status).toBe(200); + const body = await response.json(); + expect(verifyToken(body.token)).toMatchObject({ + kiloUserId: user.id, + organizationId: organization.id, + aud: 'cloud-agent-next', + }); +}); + +it.each(['removed membership', 'deleted organization'])( + 'rejects organization issuance after %s', + async state => { + const organization = await organizationForCurrentUser(); + if (state === 'removed membership') { + 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 response = await POST(request(sourceToken(), { organizationId: organization.id })); + expect(response.status).toBe(401); + expect(await response.json()).not.toHaveProperty('token'); + } +); + +it('accepts an active modern device credential and preserves its device identity', async () => { + const [device] = await db.insert(device_sessions).values({ kilo_user_id: user.id }).returning(); + const response = await POST( + request( + modernSourceToken({ + tokenPurpose: 'device-access', + credentialExchange: false, + deviceSessionId: device.id, + deviceAuthRequestCode: undefined, + }) + ) + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(verifyToken(body.token)).toMatchObject({ + tokenPurpose: 'device-access', + deviceSessionId: device.id, + }); +}); + +it.each(['revoked', 'another user'])( + 'rejects a device credential whose session is %s', + async state => { + const owner = state === 'another user' ? await createUser() : user; + const [device] = await db + .insert(device_sessions) + .values({ + kilo_user_id: owner.id, + ...(state === 'revoked' ? { revoked_at: new Date().toISOString() } : {}), + }) + .returning(); + const response = await POST( + request( + modernSourceToken({ + tokenPurpose: 'device-access', + credentialExchange: false, + deviceSessionId: device.id, + deviceAuthRequestCode: undefined, + }) + ) + ); + expect(response.status).toBe(401); + expect(await response.json()).not.toHaveProperty('token'); + } +); + +it.each([ + { name: 'expired', claims: { exp: 1 } }, + { name: 'wrong environment', claims: { env: 'another-environment' } }, + { name: 'stale pepper', claims: { apiTokenPepper: 'stale' } }, + { name: 'wrong audience', claims: { aud: 'kilo-gateway' } }, +])('rejects a source with $name', async ({ claims }) => { + const response = await POST(request(sourceToken(claims))); + expect(response.status).toBe(401); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it('rejects an invalid signature', async () => { + const response = await POST(request(sourceToken({}, 'untrusted-signing-key'))); + expect(response.status).toBe(401); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it('rejects a source belonging to another user', async () => { + const other = await createUser(); + const response = await POST( + request(sourceToken({ kiloUserId: other.id, apiTokenPepper: other.api_token_pepper })) + ); + expect(response.status).toBe(401); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it('rechecks the current user state before issuance', async () => { + await db + .update(kilocode_users) + .set({ blocked_at: new Date().toISOString() }) + .where(eq(kilocode_users.id, user.id)); + const response = await POST(request()); + expect(response.status).toBe(401); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it('rejects delegated workload credentials', async () => { + const response = await POST( + request( + modernSourceToken({ + tokenPurpose: 'delegated-workload', + credentialExchange: false, + deviceAuthRequestCode: undefined, + }) + ) + ); + expect(response.status).toBe(403); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it.each([{ organizationId: crypto.randomUUID() }, { botId: 'bot-test' }, { isAdmin: true }])( + 'rejects restricted legacy credentials: %j', + async claims => { + const response = await POST(request(sourceToken(claims))); + expect(response.status).toBe(403); + expect(await response.json()).not.toHaveProperty('token'); + } +); + +it.each([undefined, '', 'Basic synthetic', 'Bearer '])( + 'requires bearer authentication without cookie fallback: %s', + async authorization => { + const headers = new Headers({ cookie: 'synthetic-session-cookie=value' }); + if (authorization !== undefined) headers.set('authorization', authorization); + const response = await POST( + new NextRequest('https://example.test/api/cloud-agent-next/token', { + method: 'POST', + headers, + body: '{}', + }) + ); + expect(response.status).toBe(401); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(getUserFromAuth).not.toHaveBeenCalled(); + expect(getUserFromSessionForCredentialIssuance).not.toHaveBeenCalled(); + } +); + +it('preserves authentication failures without caching them', async () => { + jest.mocked(getUserFromAuth).mockResolvedValue({ + user: null, + authFailedResponse: NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ), + }); + const response = await POST(request()); + expect(response.status).toBe(401); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it.each([ + null, + [], + { organizationId: 'invalid-id' }, + { audience: 'kilo-api' }, + { expiresIn: 157_680_000 }, + { userId: 'someone-else' }, + { runtimeAdmission: { source: 'automation' } }, +])('rejects invalid or extra request fields: %j', async body => { + const response = await POST(request(sourceToken(), body)); + expect(response.status).toBe(400); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it('rejects malformed JSON', async () => { + const response = await POST( + new NextRequest('https://example.test/api/cloud-agent-next/token', { + method: 'POST', + headers: { authorization: `Bearer ${sourceToken()}` }, + body: '{', + }) + ); + expect(response.status).toBe(400); + expect(await response.json()).not.toHaveProperty('token'); +}); + +it.each(['legacy', 'modern'])('never falls back to legacy issuance for a %s source', async kind => { + jest.mocked(isResourceTokenIssuanceEnabled).mockReturnValue(false); + const token = kind === 'legacy' ? sourceToken() : modernSourceToken(); + const response = await POST(request(token)); + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).not.toHaveProperty('token'); +}); diff --git a/apps/web/src/app/api/cloud-agent-next/token/route.ts b/apps/web/src/app/api/cloud-agent-next/token/route.ts new file mode 100644 index 00000000000..99d87943031 --- /dev/null +++ b/apps/web/src/app/api/cloud-agent-next/token/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { extractBearerToken } from '@kilocode/worker-utils/extract-bearer-token'; +import { isResourceTokenIssuanceEnabled } from '@kilocode/web-shared/lib/config.server'; +import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server'; +import { + createControlTokenForRequest, + TypedResourceDelegationError, +} from '@/lib/auth/resource-delegation'; + +const requestSchema = z.object({ organizationId: z.uuid().optional() }).strict(); +const responseHeaders = { 'Cache-Control': 'no-store' }; + +/** + * Exchange a CLI/API bearer for a Cloud Agent control token. + * POST JSON: {} for personal scope, or { organizationId } for organization scope. + * The token authorizes the Cloud Agent service, including environments that share auth. + */ +export async function POST(request: Request) { + if (!extractBearerToken(request.headers.get('authorization'))) { + return NextResponse.json( + { error: 'Bearer authentication is required' }, + { status: 401, headers: responseHeaders } + ); + } + + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (authFailedResponse) { + authFailedResponse.headers.set('Cache-Control', 'no-store'); + return authFailedResponse; + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: 'Invalid JSON body' }, + { status: 400, headers: responseHeaders } + ); + } + const input = requestSchema.safeParse(body); + if (!input.success) { + return NextResponse.json( + { error: 'Expected an object with an optional organizationId' }, + { status: 400, headers: responseHeaders } + ); + } + + // This endpoint must never return the control issuer's legacy compatibility token. + if (!isResourceTokenIssuanceEnabled('cloud-agent-next')) { + return NextResponse.json( + { error: 'Cloud Agent token issuance is unavailable' }, + { status: 503, headers: responseHeaders } + ); + } + + try { + const { token, expiresAt } = await createControlTokenForRequest(user, 'cloud-agent-next', { + headers: request.headers, + organizationId: input.data.organizationId, + tokenSource: 'cloud-agent', + expiresIn: 15 * 60, + }); + return NextResponse.json({ token, expiresAt }, { headers: responseHeaders }); + } catch (error) { + if (error instanceof TypedResourceDelegationError) { + return NextResponse.json( + { error: error.message }, + { status: error.status, headers: responseHeaders } + ); + } + throw error; + } +} From e99d1095c83720ab2efb687cbcf92c3bb200b0ff Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 21 Oct 2026 17:17:57 +0200 Subject: [PATCH 2/3] fix(cloud-agent): address token exchange review remarks - Refuse only legacy sources when the issuance gate is off, so active modern device credentials keep the issuer's bounded rollback issuance. - Use the organization header when the body omits organizationId, and reject a body/header mismatch with 400. - Return { success: false, error } on every error path. - Wrap the handler in withRestTiming. - Document the status codes, the token audience and the personal-source requirement. - Remove a dead mock and cover rollback, header scope, non-member organizations and no-store on 403. --- .../api/cloud-agent-next/token/route.test.ts | 85 +++++++++++++------ .../app/api/cloud-agent-next/token/route.ts | 72 ++++++++++------ 2 files changed, 107 insertions(+), 50 deletions(-) diff --git a/apps/web/src/app/api/cloud-agent-next/token/route.test.ts b/apps/web/src/app/api/cloud-agent-next/token/route.test.ts index aaf78e05538..98ddba05bec 100644 --- a/apps/web/src/app/api/cloud-agent-next/token/route.test.ts +++ b/apps/web/src/app/api/cloud-agent-next/token/route.test.ts @@ -28,9 +28,6 @@ jest.mock('@kilocode/web-shared/lib/user/server', () => ({ getUserFromAuth: jest.fn(), getUserFromSessionForCredentialIssuance: jest.fn(), })); -jest.mock('@/lib/organizations/organization-auth', () => ({ - getAuthorizedOrgContext: jest.fn(), -})); const secret = 'cloud-agent-exchange-test-secret'; const cleanupUsers: string[] = []; @@ -83,6 +80,14 @@ function modernSourceToken(claims: Record = {}) { }); } +function deviceSourceToken(deviceSessionId: string) { + return modernSourceToken({ + tokenPurpose: 'device-access', + credentialExchange: false, + deviceSessionId, + }); +} + function request(token = sourceToken(), body: unknown = {}) { return new NextRequest('https://example.test/api/cloud-agent-next/token', { method: 'POST', @@ -214,6 +219,41 @@ it('scopes organization issuance to the authenticated member', async () => { }); }); +it('uses the organization header when the body omits organizationId', async () => { + const organization = await organizationForCurrentUser(); + // getUserFromAuth resolves and validates the header; this covers the route wiring only. + jest.mocked(getUserFromAuth).mockResolvedValue({ + user, + authFailedResponse: null, + organizationId: organization.id, + }); + const response = await POST(request()); + expect(response.status).toBe(200); + const body = await response.json(); + expect(verifyToken(body.token)).toMatchObject({ organizationId: organization.id }); +}); + +it('rejects a body organization that differs from the organization header', async () => { + const organization = await organizationForCurrentUser(); + jest.mocked(getUserFromAuth).mockResolvedValue({ + user, + authFailedResponse: null, + organizationId: organization.id, + }); + const response = await POST(request(sourceToken(), { organizationId: crypto.randomUUID() })); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + success: false, + error: 'organizationId does not match the organization header', + }); +}); + +it('rejects organization issuance for an organization the user does not belong to', async () => { + const response = await POST(request(sourceToken(), { organizationId: crypto.randomUUID() })); + expect(response.status).toBe(401); + expect(await response.json()).not.toHaveProperty('token'); +}); + it.each(['removed membership', 'deleted organization'])( 'rejects organization issuance after %s', async state => { @@ -236,16 +276,7 @@ it.each(['removed membership', 'deleted organization'])( it('accepts an active modern device credential and preserves its device identity', async () => { const [device] = await db.insert(device_sessions).values({ kilo_user_id: user.id }).returning(); - const response = await POST( - request( - modernSourceToken({ - tokenPurpose: 'device-access', - credentialExchange: false, - deviceSessionId: device.id, - deviceAuthRequestCode: undefined, - }) - ) - ); + const response = await POST(request(deviceSourceToken(device.id))); expect(response.status).toBe(200); const body = await response.json(); expect(verifyToken(body.token)).toMatchObject({ @@ -265,16 +296,7 @@ it.each(['revoked', 'another user'])( ...(state === 'revoked' ? { revoked_at: new Date().toISOString() } : {}), }) .returning(); - const response = await POST( - request( - modernSourceToken({ - tokenPurpose: 'device-access', - credentialExchange: false, - deviceSessionId: device.id, - deviceAuthRequestCode: undefined, - }) - ) - ); + const response = await POST(request(deviceSourceToken(device.id))); expect(response.status).toBe(401); expect(await response.json()).not.toHaveProperty('token'); } @@ -327,6 +349,7 @@ it('rejects delegated workload credentials', async () => { ) ); expect(response.status).toBe(403); + expect(response.headers.get('cache-control')).toBe('no-store'); expect(await response.json()).not.toHaveProperty('token'); }); @@ -395,7 +418,7 @@ it('rejects malformed JSON', async () => { }) ); expect(response.status).toBe(400); - expect(await response.json()).not.toHaveProperty('token'); + expect(await response.json()).toEqual({ success: false, error: 'Invalid JSON body' }); }); it.each(['legacy', 'modern'])('never falls back to legacy issuance for a %s source', async kind => { @@ -406,3 +429,17 @@ it.each(['legacy', 'modern'])('never falls back to legacy issuance for a %s sour expect(response.headers.get('cache-control')).toBe('no-store'); expect(await response.json()).not.toHaveProperty('token'); }); + +it('keeps bounded issuance for an active modern device credential after rollback', async () => { + jest.mocked(isResourceTokenIssuanceEnabled).mockReturnValue(false); + const [device] = await db.insert(device_sessions).values({ kilo_user_id: user.id }).returning(); + const response = await POST(request(deviceSourceToken(device.id))); + expect(response.status).toBe(200); + const claims = verifyToken((await response.json()).token); + expect(claims).toMatchObject({ + aud: 'cloud-agent-next', + tokenPurpose: 'device-access', + deviceSessionId: device.id, + }); + expect(claims.exp - claims.iat).toBe(900); +}); diff --git a/apps/web/src/app/api/cloud-agent-next/token/route.ts b/apps/web/src/app/api/cloud-agent-next/token/route.ts index 99d87943031..af949067ad9 100644 --- a/apps/web/src/app/api/cloud-agent-next/token/route.ts +++ b/apps/web/src/app/api/cloud-agent-next/token/route.ts @@ -2,29 +2,55 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { extractBearerToken } from '@kilocode/worker-utils/extract-bearer-token'; import { isResourceTokenIssuanceEnabled } from '@kilocode/web-shared/lib/config.server'; +import { failureResult } from '@kilocode/web-shared/lib/maybe-result'; +import { withRestTiming } from '@kilocode/web-shared/lib/observability/request-timing'; import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server'; import { createControlTokenForRequest, + isModernResourceDelegationRequest, TypedResourceDelegationError, } from '@/lib/auth/resource-delegation'; const requestSchema = z.object({ organizationId: z.uuid().optional() }).strict(); const responseHeaders = { 'Cache-Control': 'no-store' }; +function errorResponse(status: number, error: string) { + return NextResponse.json(failureResult(error), { status, headers: responseHeaders }); +} + +async function isLegacySource(request: Request): Promise { + try { + return !(await isModernResourceDelegationRequest(request.headers)); + } catch { + // An unverifiable bearer is not a legacy source; the issuer rejects it with 401. + return false; + } +} + /** - * Exchange a CLI/API bearer for a Cloud Agent control token. + * Exchange a personal CLI/API bearer for a Cloud Agent control token. * POST JSON: {} for personal scope, or { organizationId } for organization scope. - * The token authorizes the Cloud Agent service, including environments that share auth. + * Without a body organizationId, the x-kilocode-organizationid header selects the scope. + * + * The token has the cloud-agent-next audience and lasts at most 15 minutes, capped by the + * source credential's expiry. The Cloud Agent Worker and /api/cloud-agent-next/balance accept + * it; /api/trpc/* and /api/cloud-agent-next/sessions/stream-ticket do not, so the client keeps + * the source credential for those. Organization-bound source credentials are rejected. + * + * 200 { token, expiresAt }; errors are { success: false, error }: + * - 400 invalid JSON, unknown fields, or body/header organization mismatch + * - 401 missing, invalid, expired, revoked, or exchanged bearer; body organization non-member + * - 403 delegated, org-bound, or restricted legacy credential; header organization non-member + * - 503 issuance disabled for this credential (rollout gate off) */ -export async function POST(request: Request) { +async function postTokenHandler(request: Request) { if (!extractBearerToken(request.headers.get('authorization'))) { - return NextResponse.json( - { error: 'Bearer authentication is required' }, - { status: 401, headers: responseHeaders } - ); + return errorResponse(401, 'Bearer authentication is required'); } - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, authFailedResponse, organizationId } = await getUserFromAuth({ + adminOnly: false, + }); if (authFailedResponse) { authFailedResponse.headers.set('Cache-Control', 'no-store'); return authFailedResponse; @@ -34,42 +60,36 @@ export async function POST(request: Request) { try { body = await request.json(); } catch { - return NextResponse.json( - { error: 'Invalid JSON body' }, - { status: 400, headers: responseHeaders } - ); + return errorResponse(400, 'Invalid JSON body'); } const input = requestSchema.safeParse(body); if (!input.success) { - return NextResponse.json( - { error: 'Expected an object with an optional organizationId' }, - { status: 400, headers: responseHeaders } - ); + return errorResponse(400, 'Expected an object with an optional organizationId'); + } + if (organizationId && input.data.organizationId && input.data.organizationId !== organizationId) { + return errorResponse(400, 'organizationId does not match the organization header'); } // This endpoint must never return the control issuer's legacy compatibility token. - if (!isResourceTokenIssuanceEnabled('cloud-agent-next')) { - return NextResponse.json( - { error: 'Cloud Agent token issuance is unavailable' }, - { status: 503, headers: responseHeaders } - ); + // Modern sources reach the issuer, which keeps its rollback rules for them. + if (!isResourceTokenIssuanceEnabled('cloud-agent-next') && (await isLegacySource(request))) { + return errorResponse(503, 'Cloud Agent token issuance is unavailable'); } try { const { token, expiresAt } = await createControlTokenForRequest(user, 'cloud-agent-next', { headers: request.headers, - organizationId: input.data.organizationId, + organizationId: input.data.organizationId ?? organizationId, tokenSource: 'cloud-agent', expiresIn: 15 * 60, }); return NextResponse.json({ token, expiresAt }, { headers: responseHeaders }); } catch (error) { if (error instanceof TypedResourceDelegationError) { - return NextResponse.json( - { error: error.message }, - { status: error.status, headers: responseHeaders } - ); + return errorResponse(error.status, error.message); } throw error; } } + +export const POST = withRestTiming('/api/cloud-agent-next/token', postTokenHandler); From 1b3a095a61081b637dc4155f3a8400783cd7e006 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 21 Oct 2026 17:19:52 +0200 Subject: [PATCH 3/3] docs(cloud-agent): correct org-bound status codes in token exchange contract --- apps/web/src/app/api/cloud-agent-next/token/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/api/cloud-agent-next/token/route.ts b/apps/web/src/app/api/cloud-agent-next/token/route.ts index af949067ad9..7013ee6a5ea 100644 --- a/apps/web/src/app/api/cloud-agent-next/token/route.ts +++ b/apps/web/src/app/api/cloud-agent-next/token/route.ts @@ -39,8 +39,10 @@ async function isLegacySource(request: Request): Promise { * * 200 { token, expiresAt }; errors are { success: false, error }: * - 400 invalid JSON, unknown fields, or body/header organization mismatch - * - 401 missing, invalid, expired, revoked, or exchanged bearer; body organization non-member - * - 403 delegated, org-bound, or restricted legacy credential; header organization non-member + * - 401 missing, invalid, expired, revoked, exchanged, or modern org-bound bearer; body + * organization non-member + * - 403 delegated or restricted legacy (including org-bound) credential; header organization + * non-member * - 503 issuance disabled for this credential (rollout gate off) */ async function postTokenHandler(request: Request) {