diff --git a/apps/web/src/app/api/auth/resource-token/route.test.ts b/apps/web/src/app/api/auth/resource-token/route.test.ts new file mode 100644 index 0000000000..02c7083b7d --- /dev/null +++ b/apps/web/src/app/api/auth/resource-token/route.test.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server'; +import { createDelegatedResourceToken } from '@/lib/auth/resource-delegation'; +import { POST } from './route'; + +jest.mock('@/lib/user/server', () => ({ + getUserFromAuth: jest.fn(async () => ({ user: { id: 'oauth/test-user' } })), +})); +jest.mock('@/lib/auth/resource-delegation', () => ({ + isDelegableResource: (value: string) => + ['api', 'gateway', 'attribution', 'html-deploy'].includes(value), + createDelegatedResourceToken: jest.fn(async () => ({ token: 'delegated', expiresAt: 'expiry' })), +})); + +beforeEach(() => jest.clearAllMocks()); + +it('rejects personal attribution issuance because the reader requires organization claims', async () => { + const response = await POST( + new NextRequest('https://example.test/api/auth/resource-token', { + method: 'POST', + headers: { origin: 'https://example.test' }, + body: JSON.stringify({ resource: 'attribution' }), + }) + ); + expect(response.status).toBe(403); + expect(createDelegatedResourceToken).not.toHaveBeenCalled(); +}); + +it.each(['api', 'gateway', 'html-deploy'])('retains personal %s negotiation', async resource => { + const response = await POST( + new NextRequest('https://example.test/api/auth/resource-token', { + method: 'POST', + headers: { origin: 'https://example.test' }, + body: JSON.stringify({ resource }), + }) + ); + expect(response.status).toBe(200); + expect(createDelegatedResourceToken).toHaveBeenCalledWith( + { id: 'oauth/test-user' }, + resource, + expect.objectContaining({ headers: expect.any(Headers) }) + ); +}); diff --git a/apps/web/src/app/api/auth/resource-token/route.ts b/apps/web/src/app/api/auth/resource-token/route.ts new file mode 100644 index 0000000000..b2247c0b7b --- /dev/null +++ b/apps/web/src/app/api/auth/resource-token/route.ts @@ -0,0 +1,59 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getUserFromAuth } from '@/lib/user/server'; +import { + createDelegatedResourceToken, + isDelegableResource, + TypedResourceDelegationError, +} from '@/lib/auth/resource-delegation'; + +function isSameOriginRequest(request: NextRequest): boolean { + const origin = request.headers.get('origin'); + return origin !== null && origin === request.nextUrl.origin; +} + +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + const resource = + body && typeof body === 'object' && 'resource' in body ? body.resource : undefined; + if (!isDelegableResource(resource)) { + return NextResponse.json({ error: 'Unsupported resource' }, { status: 400 }); + } + if (!request.headers.has('authorization') && !isSameOriginRequest(request)) { + return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 }); + } + const { user, authFailedResponse, organizationId, tokenSource } = await getUserFromAuth({ + adminOnly: false, + }); + if (authFailedResponse) return authFailedResponse; + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (organizationId) { + return NextResponse.json( + { error: 'Organization credentials are not supported' }, + { status: 403 } + ); + } + if (resource === 'attribution') { + return NextResponse.json( + { error: 'Attribution requires an organization resource token' }, + { status: 403 } + ); + } + try { + const result = await createDelegatedResourceToken(user, resource, { + headers: request.headers, + tokenSource, + }); + return NextResponse.json({ token: result.token, expiresAt: result.expiresAt }); + } catch (error) { + if (error instanceof TypedResourceDelegationError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + throw error; + } +} diff --git a/apps/web/src/app/api/organizations/[id]/user-tokens/route.auth.test.ts b/apps/web/src/app/api/organizations/[id]/user-tokens/route.auth.test.ts new file mode 100644 index 0000000000..918accbb3b --- /dev/null +++ b/apps/web/src/app/api/organizations/[id]/user-tokens/route.auth.test.ts @@ -0,0 +1,507 @@ +const mockHeaders = jest.fn(); +const mockSession = jest.fn(); +jest.mock('next/headers', () => ({ headers: () => mockHeaders(), cookies: jest.fn() })); +jest.mock('next-auth', () => ({ + __esModule: true, + ...jest.requireActual('next-auth'), + getServerSession: (...args: unknown[]) => mockSession(...args), +})); + +import { NextRequest } from 'next/server'; +import jwt from 'jsonwebtoken'; +import { verifyKiloTokenForPolicy } from '@kilocode/worker-utils/kilo-token-policy'; +import { db } from '@/lib/drizzle'; +import { + device_sessions, + organizations, + organization_memberships, + organization_audit_logs, + kilocode_users, + type User, +} from '@kilocode/db/schema'; +import { eq, inArray } from 'drizzle-orm'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { generateApiToken } from '@/lib/tokens'; +import { NEXTAUTH_SECRET } from '@/lib/config.server'; +import { + canIssueLegacyOrganizationToken, + createControlTokenForRequest, +} from '@/lib/auth/resource-delegation'; +import { POST } from './route'; +jest.mock('../../../../../../../../services/ai-attribution/src/util/logger', () => ({ + logger: {}, +})); +const { validateKiloToken } = jest.requireActual<{ + validateKiloToken: (header: string, secret: string) => Promise<{ success: boolean }>; +}>('../../../../../../../../services/ai-attribution/src/util/auth'); + +let user: User; +let orgId: string; +const userIds: string[] = []; +const parentIds: string[] = []; +const originalShared = process.env.SHARED_RESOURCE_TOKENS_ENABLED; +const originalFamily = process.env.DELEGATED_RESOURCE_TOKENS_ENABLED; + +beforeEach(async () => { + user = await insertTestUser({ + api_token_pepper: crypto.randomUUID(), + web_session_pepper: crypto.randomUUID(), + }); + userIds.push(user.id); + const [org] = await db + .insert(organizations) + .values({ + name: 'Legacy token compatibility', + created_by_kilo_user_id: user.id, + require_seats: false, + }) + .returning(); + orgId = org.id; + await db.insert(organization_memberships).values({ + organization_id: orgId, + kilo_user_id: user.id, + role: 'member', + }); + mockSession.mockResolvedValue({ kiloUserId: user.id, webSessionPepper: user.web_session_pepper }); +}); + +afterEach(async () => { + await db + .delete(organization_audit_logs) + .where(eq(organization_audit_logs.organization_id, orgId)); + await db + .delete(organization_memberships) + .where(eq(organization_memberships.organization_id, orgId)); + await db.delete(organizations).where(eq(organizations.id, orgId)); + if (parentIds.length) { + await db + .delete(organization_memberships) + .where(inArray(organization_memberships.organization_id, parentIds)); + await db.delete(organizations).where(inArray(organizations.id, parentIds)); + parentIds.length = 0; + } + await db.delete(kilocode_users).where(inArray(kilocode_users.id, userIds)); + userIds.length = 0; + if (originalShared === undefined) delete process.env.SHARED_RESOURCE_TOKENS_ENABLED; + else process.env.SHARED_RESOURCE_TOKENS_ENABLED = originalShared; + if (originalFamily === undefined) delete process.env.DELEGATED_RESOURCE_TOKENS_ENABLED; + else process.env.DELEGATED_RESOURCE_TOKENS_ENABLED = originalFamily; + jest.clearAllMocks(); +}); + +function setIssuance(enabled: boolean) { + process.env.SHARED_RESOURCE_TOKENS_ENABLED = String(enabled); + process.env.DELEGATED_RESOURCE_TOKENS_ENABLED = String(enabled); +} + +function request(headers: Headers, body?: string) { + mockHeaders.mockResolvedValue(headers); + return POST( + new NextRequest(`https://example.test/api/organizations/${orgId}/user-tokens`, { + method: 'POST', + headers, + ...(body === undefined ? {} : { body }), + }), + { params: Promise.resolve({ id: orgId }) } + ); +} + +function bearer(token: string) { + return new Headers({ authorization: `Bearer ${token}` }); +} + +function signedClaims(extra: Record, secret = NEXTAUTH_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, + iat: now, + exp: now + 3600, + ...extra, + }, + secret, + { algorithm: 'HS256' } + ); +} + +async function auditLogs() { + return db + .select() + .from(organization_audit_logs) + .where(eq(organization_audit_logs.organization_id, orgId)); +} + +for (const enabled of [false, true]) { + describe(`legacy organization issuance with resource flags ${enabled}`, () => { + beforeEach(() => setIssuance(enabled)); + + test.each(['cookie', 'empty-header-cookie', 'legacy-1h', 'legacy-5y'] as const)( + 'preserves %s for absent, empty, malformed and empty-object bodies', + async auth => { + const headers = auth.startsWith('legacy') + ? bearer( + generateApiToken( + user, + undefined, + auth === 'legacy-1h' ? { expiresIn: 3600 } : undefined + ) + ) + : new Headers({ cookie: 'next-auth.session-token=test-session' }); + if (auth === 'empty-header-cookie') headers.set('authorization', ''); + for (const body of [undefined, '', '{', '{}']) { + const response = await request(headers, body); + expect(response.status).toBe(200); + const result = await response.json(); + const claims = jwt.verify(result.token, NEXTAUTH_SECRET) as jwt.JwtPayload; + expect(claims).toMatchObject({ + kiloUserId: user.id, + organizationId: orgId, + organizationRole: 'member', + }); + expect(claims.exp! - claims.iat!).toBe(900); + expect(claims).not.toHaveProperty('aud'); + expect(claims).not.toHaveProperty('tokenPurpose'); + expect(result.organizationId).toBe(orgId); + expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now()); + } + const logs = await auditLogs(); + expect(logs).toHaveLength(4); + expect(logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: 'organization.token.generate', + actor_id: user.id, + }), + ]) + ); + } + ); + + test.each([900, 3600, 7200])( + 'preserves legacy human tokens with deviceAuthRequestCode and %s-second lifetime', + async expiresIn => { + const response = await request( + bearer(generateApiToken(user, { deviceAuthRequestCode: 'legacy-login' }, { expiresIn })) + ); + expect(response.status).toBe(200); + expect(jwt.verify((await response.json()).token, NEXTAUTH_SECRET)).toMatchObject({ + organizationId: orgId, + }); + } + ); + + test.each(['active', 'revoked', 'foreign'] as const)( + 'checks %s legacy native device sessions', + async state => { + let ownerId = user.id; + if (state === 'foreign') { + const other = await insertTestUser(); + userIds.push(other.id); + ownerId = other.id; + } + const [session] = await db + .insert(device_sessions) + .values({ + kilo_user_id: ownerId, + user_agent: 'legacy-native-test', + ...(state === 'revoked' ? { revoked_at: new Date().toISOString() } : {}), + }) + .returning(); + const headers = bearer( + generateApiToken(user, { deviceSessionId: session.id }, { expiresIn: 3600 }) + ); + await expect(canIssueLegacyOrganizationToken(headers, user)).resolves.toBe( + state === 'active' + ); + const response = await request(headers); + expect(response.status).toBe(state === 'active' ? 200 : 403); + if (state !== 'active') expect(await auditLogs()).toEqual([]); + } + ); + + test('does not initialize an absent API pepper', async () => { + await db + .update(kilocode_users) + .set({ api_token_pepper: null }) + .where(eq(kilocode_users.id, user.id)); + user = { ...user, api_token_pepper: null }; + const response = await request( + bearer(generateApiToken(user, undefined, { expiresIn: 3600 })) + ); + expect(response.status).toBe(200); + const stored = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, user.id), + }); + expect(stored?.api_token_pepper).toBeNull(); + }); + + test.each(['owner', 'admin', 'billing_manager'] as const)( + 'retains legacy %s role', + async role => { + await db + .update(organization_memberships) + .set({ role }) + .where(eq(organization_memberships.organization_id, orgId)); + const response = await request( + bearer(generateApiToken(user, undefined, { expiresIn: 3600 })) + ); + expect(response.status).toBe(200); + const claims = jwt.verify((await response.json()).token, NEXTAUTH_SECRET); + expect(claims).toMatchObject({ organizationRole: role }); + } + ); + + test.each([ + 'audience-only', + 'modern', + 'scoped-modern', + 'exchange-only', + 'organization', + 'service', + 'runtime', + 'device', + 'admin', + 'internal', + 'unknown-claim', + ])('rejects %s claims without minting or auditing', async kind => { + const modern = { aud: 'kilo-api', tokenPurpose: 'human-api', credentialExchange: false }; + const extras: Record> = { + 'audience-only': { aud: 'kilo-api' }, + modern, + 'scoped-modern': { ...modern, organizationId: orgId, organizationRole: 'member' }, + 'exchange-only': { credentialExchange: false }, + organization: { organizationId: orgId, organizationRole: 'member' }, + service: { tokenSource: 'cloud-agent' }, + admin: { isAdmin: true }, + internal: { internalApiUse: true }, + runtime: { + aud: 'kilo-api', + tokenPurpose: 'delegated-workload', + credentialExchange: false, + runtimeAuthorization: { + id: crypto.randomUUID(), + resourceKind: 'cloud-agent-next', + resourceId: 'restricted-runtime', + }, + }, + device: { deviceSessionId: crypto.randomUUID() }, + 'unknown-claim': { futureScope: 'restricted' }, + }; + const token = signedClaims(extras[kind]); + if (kind !== 'exchange-only') { + await expect( + verifyKiloTokenForPolicy(token, NEXTAUTH_SECRET, { + audience: 'kilo-api', + mode: 'allow-legacy', + }) + ).resolves.toMatchObject({ userId: user.id }); + } + const headers = bearer(token); + await expect(canIssueLegacyOrganizationToken(headers, user)).resolves.toBe(false); + const response = await request(headers); + expect([401, 403]).toContain(response.status); + expect(await response.json()).not.toHaveProperty('token'); + expect(await auditLogs()).toEqual([]); + }); + + test.each([ + 'invalid-signature', + 'expired', + 'wrong-user', + 'revoked-pepper', + 'wrong-env', + 'malformed-header', + ])('fails closed for %s even with a valid ambient session', async kind => { + let token = signedClaims({}); + if (kind === 'invalid-signature') token = signedClaims({}, 'wrong-signing-secret'); + if (kind === 'expired') token = signedClaims({ iat: 1, exp: 2 }); + if (kind === 'wrong-env') token = signedClaims({ env: 'different-environment' }); + if (kind === 'wrong-user') { + const other = await insertTestUser({ api_token_pepper: crypto.randomUUID() }); + userIds.push(other.id); + token = generateApiToken(other); + } + if (kind === 'revoked-pepper') { + const pepper = crypto.randomUUID(); + await db + .update(kilocode_users) + .set({ api_token_pepper: pepper }) + .where(eq(kilocode_users.id, user.id)); + user = { ...user, api_token_pepper: pepper }; + } + const headers = + kind === 'malformed-header' + ? new Headers({ authorization: 'Basic invalid' }) + : bearer(token); + await expect(canIssueLegacyOrganizationToken(headers, user)).resolves.toBe(false); + const response = await request(headers); + expect([401, 403, 404]).toContain(response.status); + expect(await response.json()).not.toHaveProperty('token'); + expect(await auditLogs()).toEqual([]); + }); + + test.each(['{"resource":null}', '{"resource":"unknown"}'])( + 'rejects unsupported resource %s', + async body => { + expect((await request(bearer(generateApiToken(user)), body)).status).toBe(400); + expect(await auditLogs()).toEqual([]); + } + ); + + test('keeps explicit delegation gated and separate from legacy issuance', async () => { + const response = await request(bearer(generateApiToken(user)), '{"resource":"api"}'); + expect(response.status).toBe(enabled ? 200 : 503); + const result = await response.json(); + if (enabled) { + expect(jwt.verify(result.token, NEXTAUTH_SECRET)).toMatchObject({ + aud: 'kilo-api', + tokenPurpose: 'delegated-workload', + credentialExchange: false, + organizationId: orgId, + }); + } else { + expect(result).not.toHaveProperty('token'); + expect(await auditLogs()).toEqual([]); + } + }); + }); +} + +describe('explicit organization access', () => { + beforeEach(() => setIssuance(true)); + + async function inherited(role: 'owner' | 'admin' | 'member' | 'billing_manager') { + const [parent] = await db + .insert(organizations) + .values({ name: 'Delegation parent', require_seats: false }) + .returning(); + parentIds.push(parent.id); + await db + .update(organizations) + .set({ parent_organization_id: parent.id }) + .where(eq(organizations.id, orgId)); + await db + .delete(organization_memberships) + .where(eq(organization_memberships.organization_id, orgId)); + await db + .insert(organization_memberships) + .values({ organization_id: parent.id, kilo_user_id: user.id, role }); + } + + test.each(['owner', 'admin'] as const)( + 'issues child organization tokens for inherited %s', + async role => { + await inherited(role); + for (const headers of [new Headers(), bearer(generateApiToken(user))]) { + const response = await request(headers, '{"resource":"api"}'); + expect(response.status).toBe(200); + const claims = jwt.verify((await response.json()).token, NEXTAUTH_SECRET) as jwt.JwtPayload; + expect(claims).toMatchObject({ + organizationId: orgId, + organizationRole: role, + aud: 'kilo-api', + tokenPurpose: 'delegated-workload', + credentialExchange: false, + }); + expect(claims.exp! - claims.iat!).toBe(900); + } + expect(await auditLogs()).toHaveLength(2); + } + ); + + test.each(['member', 'billing_manager'] as const)( + 'denies inherited %s explicit issuance', + async role => { + await inherited(role); + const response = await request(new Headers(), '{"resource":"api"}'); + expect(response.status).toBe(role === 'member' ? 404 : 403); + expect(await auditLogs()).toEqual([]); + } + ); + + test('denies unrelated organizations', async () => { + await db + .delete(organization_memberships) + .where(eq(organization_memberships.organization_id, orgId)); + expect((await request(bearer(generateApiToken(user)), '{"resource":"api"}')).status).toBe(404); + expect(await auditLogs()).toEqual([]); + }); + + test.each([false, true])('denies deleted organizations for global admin %s', async isAdmin => { + await inherited('owner'); + await db + .update(kilocode_users) + .set({ is_admin: isAdmin }) + .where(eq(kilocode_users.id, user.id)); + await db + .update(organizations) + .set({ deleted_at: new Date().toISOString() }) + .where(eq(organizations.id, orgId)); + expect((await request(new Headers(), '{"resource":"api"}')).status).toBe(404); + expect(await auditLogs()).toEqual([]); + }); + + test('retains global-admin explicit issuance without membership', async () => { + await db + .delete(organization_memberships) + .where(eq(organization_memberships.organization_id, orgId)); + await db.update(kilocode_users).set({ is_admin: true }).where(eq(kilocode_users.id, user.id)); + for (const headers of [new Headers(), bearer(generateApiToken(user))]) { + const response = await request(headers, '{"resource":"api"}'); + expect(response.status).toBe(200); + expect(jwt.verify((await response.json()).token, NEXTAUTH_SECRET)).toMatchObject({ + organizationId: orgId, + organizationRole: 'owner', + }); + } + }); + + test.each(['owner', 'admin'] as const)( + 'preserves inherited %s attribution policy', + async role => { + await inherited(role); + const response = await request(new Headers(), '{"resource":"attribution"}'); + expect(response.status).toBe(role === 'owner' ? 200 : 403); + if (role === 'owner') { + const { token } = await response.json(); + expect(jwt.verify(token, NEXTAUTH_SECRET)).toMatchObject({ + organizationRole: 'owner', + aud: 'ai-attribution', + }); + expect(await validateKiloToken(`Bearer ${token}`, NEXTAUTH_SECRET)).toMatchObject({ + success: true, + organizationId: orgId, + organizationRole: 'owner', + }); + } + } + ); + + test('does not widen runtime control-token organization authorization', async () => { + await inherited('owner'); + await expect( + createControlTokenForRequest(user, 'cloud-agent-next', { + headers: bearer(generateApiToken(user)), + organizationId: orgId, + }) + ).rejects.toThrow('Unauthorized resource delegation request'); + }); + + test('still rejects scoped credentials for inherited access', async () => { + await inherited('owner'); + const headers = bearer( + signedClaims({ + aud: 'kilo-api', + tokenPurpose: 'human-api', + credentialExchange: false, + organizationId: orgId, + }) + ); + const response = await request(headers, '{"resource":"api"}'); + expect([401, 403]).toContain(response.status); + expect(await auditLogs()).toEqual([]); + }); +}); diff --git a/apps/web/src/app/api/organizations/[id]/user-tokens/route.test.ts b/apps/web/src/app/api/organizations/[id]/user-tokens/route.test.ts new file mode 100644 index 0000000000..a03b5ef4df --- /dev/null +++ b/apps/web/src/app/api/organizations/[id]/user-tokens/route.test.ts @@ -0,0 +1,63 @@ +import { NextRequest } from 'next/server'; +import { isResourceTokenIssuanceEnabled } from '@/lib/config.server'; +import { createDelegatedResourceToken } from '@/lib/auth/resource-delegation'; +import { generateOrganizationApiToken } from '@/lib/tokens'; +import { POST } from './route'; + +jest.mock('@/lib/config.server', () => ({ isResourceTokenIssuanceEnabled: jest.fn() })); +jest.mock('@/lib/organizations/organization-auth', () => ({ + getAuthorizedOrgContext: jest.fn(async () => ({ + success: true, + data: { user: { id: 'oauth/test-user', role: 'member' }, organization: { name: 'Test' } }, + })), +})); +jest.mock('@/lib/organizations/organization-audit-logs', () => ({ createAuditLog: jest.fn() })); +jest.mock('@/lib/auth/resource-delegation', () => ({ + isDelegableResource: (value: string) => + ['api', 'gateway', 'attribution', 'html-deploy'].includes(value), + canIssueLegacyOrganizationToken: jest.fn(async () => true), + createDelegatedResourceToken: jest.fn(async () => ({ token: 'delegated', expiresAt: 'expiry' })), +})); +jest.mock('@/lib/tokens', () => ({ + generateOrganizationApiToken: jest.fn(() => ({ token: 'legacy', expiresAt: 'expiry' })), +})); + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(isResourceTokenIssuanceEnabled).mockReturnValue(false); +}); + +it.each(['api', 'gateway', 'attribution', 'html-deploy'])( + 'requires the delegated-resource family for explicit %s issuance', + async resource => { + const request = () => + new NextRequest('http://localhost/api/organizations/org/user-tokens', { + method: 'POST', + body: JSON.stringify({ resource }), + }); + const params = Promise.resolve({ id: 'org' }); + expect((await POST(request(), { params })).status).toBe(503); + expect(isResourceTokenIssuanceEnabled).toHaveBeenCalledWith('delegated-resource'); + expect(createDelegatedResourceToken).not.toHaveBeenCalled(); + expect(generateOrganizationApiToken).not.toHaveBeenCalled(); + jest.mocked(isResourceTokenIssuanceEnabled).mockReturnValue(true); + expect((await POST(request(), { params })).status).toBe(200); + expect(createDelegatedResourceToken).toHaveBeenCalledWith( + expect.objectContaining({ id: 'oauth/test-user' }), + resource, + expect.objectContaining({ organizationId: 'org', organizationRole: 'member' }) + ); + expect(generateOrganizationApiToken).not.toHaveBeenCalled(); + } +); + +it('retains legacy session issuance while the family gate is false', async () => { + const request = new NextRequest('http://localhost/api/organizations/org/user-tokens', { + method: 'POST', + body: '{}', + }); + const response = await POST(request, { params: Promise.resolve({ id: 'org' }) }); + expect(response.status).toBe(200); + expect(generateOrganizationApiToken).toHaveBeenCalled(); + expect(createDelegatedResourceToken).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts b/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts index a64d1a617f..0e0713f2c0 100644 --- a/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts +++ b/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts @@ -3,6 +3,13 @@ import { NextResponse } from 'next/server'; import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; import { generateOrganizationApiToken } from '@/lib/tokens'; import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; +import { + canIssueLegacyOrganizationToken, + createDelegatedResourceToken, + isDelegableResource, + TypedResourceDelegationError, +} from '@/lib/auth/resource-delegation'; +import { isResourceTokenIssuanceEnabled } from '@/lib/config.server'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const organizationId = (await params).id; @@ -16,7 +23,65 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { user, organization } = result.data; - // Generate the organization-scoped JWT token (15 minute expiration) + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + const resource = + body && typeof body === 'object' ? (body as { resource?: unknown }).resource : undefined; + if (resource !== undefined && !isDelegableResource(resource)) { + return NextResponse.json({ error: 'Unsupported resource' }, { status: 400 }); + } + if (resource !== undefined) { + if (!isResourceTokenIssuanceEnabled('delegated-resource')) { + return NextResponse.json( + { error: 'Shared resource token migration is unavailable' }, + { status: 503 } + ); + } + if (user.role === 'billing_manager' || (resource === 'attribution' && user.role === 'admin')) { + return NextResponse.json( + { error: 'Organization role cannot issue this resource token' }, + { status: 403 } + ); + } + const organizationRole = user.role; + try { + const delegated = await createDelegatedResourceToken(user, resource, { + headers: request.headers, + organizationRole, + organizationId, + }); + await createAuditLog({ + organization_id: organizationId, + action: 'organization.token.generate', + actor_name: user.google_user_name, + actor_email: user.google_user_email, + actor_id: user.id, + message: `Resource token generated for organization ${organization.name}`, + }); + return NextResponse.json({ + token: delegated.token, + expiresAt: delegated.expiresAt, + organizationId, + }); + } catch (error) { + if (error instanceof TypedResourceDelegationError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + throw error; + } + } + + if (!(await canIssueLegacyOrganizationToken(request.headers, user))) { + return NextResponse.json( + { error: 'This credential cannot issue a legacy organization token' }, + { status: 403 } + ); + } + const { token, expiresAt } = generateOrganizationApiToken(user, organizationId, user.role); // Log the token generation for audit purposes diff --git a/apps/web/src/lib/auth/resource-delegation.test.ts b/apps/web/src/lib/auth/resource-delegation.test.ts index 74a90296cd..e5451a19c7 100644 --- a/apps/web/src/lib/auth/resource-delegation.test.ts +++ b/apps/web/src/lib/auth/resource-delegation.test.ts @@ -9,6 +9,10 @@ import { import { eq, inArray } from 'drizzle-orm'; import jwt from 'jsonwebtoken'; import { buildModernKiloTokenPayload } from '@kilocode/worker-utils/kilo-token-policy'; +import { NextRequest, NextResponse } from 'next/server'; +import { POST as personalResourcePost } from '@/app/api/auth/resource-token/route'; +import { POST as organizationResourcePost } from '@/app/api/organizations/[id]/user-tokens/route'; +import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; const shared = { enabled: true, family: '' }; jest.mock('@/lib/config.server', () => ({ @@ -19,7 +23,11 @@ jest.mock('@/lib/config.server', () => ({ })); jest.mock('@/lib/user/server', () => ({ getUserFromSessionForCredentialIssuance: jest.fn(), + getUserFromAuth: jest.fn(), })); +jest.mock('@/lib/organizations/organization-auth', () => ({ getAuthorizedOrgContext: jest.fn() })); +jest.mock('@/lib/organizations/organization-audit-logs', () => ({ createAuditLog: jest.fn() })); +jest.mock('../../../../../services/ai-attribution/src/util/logger', () => ({ logger: {} })); import { canIssueLegacyOrganizationToken, @@ -28,11 +36,73 @@ import { getResourceDelegationAuthority, } from './resource-delegation'; import { db } from '@/lib/drizzle'; -import { getUserFromSessionForCredentialIssuance } from '@/lib/user/server'; +import { getUserFromAuth, getUserFromSessionForCredentialIssuance } from '@/lib/user/server'; import { insertTestUser } from '@/tests/helpers/user.helper'; const secret = 'resource-delegation-test-secret'; const cleanups: string[] = []; +const { validateKiloToken } = jest.requireActual<{ + validateKiloToken: (header: string, secret: string) => Promise<{ success: boolean }>; +}>('../../../../../services/ai-attribution/src/util/auth'); + +test.each(['owner', 'member', 'admin', 'billing_manager'] as const)( + 'real attribution route and reader agree for organization role %s', + async role => { + 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 }); + jest.mocked(getUserFromAuth).mockResolvedValue({ user: current, authFailedResponse: null }); + jest + .mocked(getUserFromSessionForCredentialIssuance) + .mockResolvedValue({ user: current, authFailedResponse: null }); + jest + .mocked(getAuthorizedOrgContext) + .mockResolvedValue({ success: true, data: { user: { ...current, role }, organization } }); + const request = () => + new NextRequest('https://example.test/api/auth/resource-token', { + method: 'POST', + headers: { origin: 'https://example.test' }, + body: JSON.stringify({ resource: 'attribution' }), + }); + const personal = await personalResourcePost(request()); + expect(personal.status).toBe(403); + expect(await personal.json()).not.toHaveProperty('token'); + const response = await organizationResourcePost(request(), { + params: Promise.resolve({ id: organization.id }), + }); + if (role === 'admin' || role === 'billing_manager') { + expect(response.status).toBe(403); + expect(await response.json()).not.toHaveProperty('token'); + return; + } + expect(response.status).toBe(200); + const body = await response.json(); + expect(await validateKiloToken(`Bearer ${body.token}`, secret)).toMatchObject({ + success: true, + organizationId: organization.id, + organizationRole: role, + kiloUserId: current.id, + }); + const claims = jwt.verify(body.token, secret) as jwt.JwtPayload; + expect(claims).toMatchObject({ + aud: 'ai-attribution', + apiTokenPepper: current.api_token_pepper, + tokenPurpose: 'delegated-workload', + credentialExchange: false, + }); + expect(claims.exp! - claims.iat!).toBe(900); + shared.enabled = false; + expect( + ( + await organizationResourcePost(request(), { + params: Promise.resolve({ id: organization.id }), + }) + ).status + ).toBe(503); + } +); afterEach(async () => { if (cleanups.length) { @@ -67,10 +137,31 @@ function bearer(token: string) { } 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); + test('allows authenticated sessions with absent or empty Authorization and rejects invalid bearers', async () => { + const current = await user(); + await expect(canIssueLegacyOrganizationToken(new Headers(), current)).resolves.toBe(true); + await expect( + canIssueLegacyOrganizationToken(bearer('restricted-token'), current) + ).resolves.toBe(false); + await expect( + canIssueLegacyOrganizationToken(new Headers({ authorization: '' }), current) + ).resolves.toBe(true); + }); + + test('rejects a verified legacy bearer belonging to a different authenticated user', async () => { + const current = await user(); + const other = await user(); + const token = jwt.sign( + { + version: 3, + env: process.env.NODE_ENV, + kiloUserId: other.id, + apiTokenPepper: other.api_token_pepper, + }, + secret, + { algorithm: 'HS256', expiresIn: 3600 } + ); + await expect(canIssueLegacyOrganizationToken(bearer(token), current)).resolves.toBe(false); }); }); @@ -396,3 +487,40 @@ test.each(['cloud-agent-next', 'gastown', 'wasteland'] as const)( expect(isResourceTokenIssuanceEnabled).toHaveBeenLastCalledWith('delegated-resource'); } ); + +describe('explicit delegated organization authority', () => { + test.each(['missing', 'different-user', 'different-role'] as const)( + 'fails closed for %s organization authority', + async state => { + const current = await user(); + const organization = await organizationFor(current.id); + const other = await user(); + jest.mocked(getUserFromSessionForCredentialIssuance).mockResolvedValue({ + user: current, + authFailedResponse: null, + }); + jest.mocked(getAuthorizedOrgContext).mockResolvedValue( + state === 'missing' + ? { + success: false, + nextResponse: NextResponse.json({ error: 'Organization not found' }, { status: 404 }), + } + : { + success: true, + data: { + user: { ...(state === 'different-user' ? other : current), role: 'member' }, + organization, + }, + } + ); + await expect( + createDelegatedResourceToken(current, 'api', { + headers: new Headers(), + organizationId: organization.id, + organizationRole: state === 'different-role' ? 'owner' : 'member', + }) + ).rejects.toMatchObject({ status: 403, delegationCode: 'FORBIDDEN' }); + expect(getAuthorizedOrgContext).toHaveBeenCalledWith(organization.id); + } + ); +}); diff --git a/apps/web/src/lib/auth/resource-delegation.ts b/apps/web/src/lib/auth/resource-delegation.ts index 8833e69f80..1c1b4d2b53 100644 --- a/apps/web/src/lib/auth/resource-delegation.ts +++ b/apps/web/src/lib/auth/resource-delegation.ts @@ -30,6 +30,7 @@ import { isResourceTokenIssuanceEnabled, NEXTAUTH_SECRET } from '@/lib/config.se import { db } from '@/lib/drizzle'; import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens'; import { getUserFromSessionForCredentialIssuance } from '@/lib/user/server'; +import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; const ONE_HOUR_SECONDS = 60 * 60; const LEGACY_DEVICE_SESSION_SECONDS = ONE_HOUR_SECONDS; @@ -116,8 +117,34 @@ export function isDelegableResource(value: unknown): value is DelegableResource ); } -export function canIssueLegacyOrganizationToken(requestHeaders: Headers): boolean { - return !requestHeaders.has('authorization'); +export async function canIssueLegacyOrganizationToken( + requestHeaders: Headers, + user: Pick +): Promise { + // Organization authorization has already authenticated the session or bearer. + if (!requestHeaders.get('authorization')) return true; + const bearer = tokenFromHeaders(requestHeaders); + if (!bearer) return false; + try { + const verified = await verifyKiloTokenForPolicy(bearer, NEXTAUTH_SECRET, { + audience: KILO_API_AUDIENCE, + mode: 'allow-legacy', + }); + if ( + verified.userId !== user.id || + verified.claims.env !== process.env.NODE_ENV || + verified.claims.apiTokenPepper !== user.api_token_pepper || + hasUnsafeLegacyClaims(verified.claimNames) + ) { + return false; + } + if (verified.claims.deviceSessionId !== undefined) { + await assertActiveDeviceSession(verified.claims.deviceSessionId, user.id); + } + return true; + } catch { + return false; + } } function tokenFromHeaders(requestHeaders: Headers): string | null { @@ -419,7 +446,25 @@ export async function createDelegatedResourceToken( resource: DelegableResource, options?: CreateDelegatedResourceTokenOptions ): Promise<{ token: string; expiresAt: string; user: User; tokenSource?: string }> { - const authority = await getResourceDelegationAuthority(user, options); + const authority = await getResourceDelegationAuthority(user, { headers: options?.headers }); + let organizationRole = options?.organizationRole; + if (options?.organizationId) { + // Explicit delegation follows REST organization access, including inherited and global-admin access. + // Control-token callers retain the direct-membership check in getResourceDelegationAuthority. + const context = await getAuthorizedOrgContext(options.organizationId); + if (!context.success || context.data.user.id !== authority.user.id) { + forbidden('Unauthorized organization resource delegation request'); + } + const role = context.data.user.role; + if ( + role === 'billing_manager' || + (resource === 'attribution' && role === 'admin') || + (organizationRole !== undefined && organizationRole !== role) + ) { + forbidden('Organization role cannot issue this resource token'); + } + organizationRole = role; + } if (authority.organizationId && authority.organizationId !== options?.organizationId) { forbidden('Scoped credentials cannot mint tokens for another organization'); } @@ -452,7 +497,7 @@ export async function createDelegatedResourceToken( credentialExchange: false, extra: { organizationId: options?.organizationId ?? authority.organizationId, - organizationRole: options?.organizationRole, + organizationRole, tokenSource: options?.tokenSource ?? authority.tokenSource, }, });