Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ type RouteContext = {

export async function GET(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse || !user) {
const signInUrl = new URL('/users/sign_in', request.url);
signInUrl.searchParams.set('callbackPath', request.nextUrl.pathname);
return NextResponse.redirect(signInUrl);
}

try {
await ensureOrganizationAccess({ user }, id, ORGANIZATION_BILLING_ROLES);
await ensureOrganizationAccess({ user, tokenSource }, id, ORGANIZATION_BILLING_ROLES);
await requireActiveSubscriptionOrTrial(id);
} catch {
return NextResponse.redirect(new URL('/integrations?error=unauthorized', request.url));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ type RouteContext = {

export async function GET(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse || !user) {
const signInUrl = new URL('/users/sign_in', request.url);
signInUrl.searchParams.set('callbackPath', request.nextUrl.pathname);
return NextResponse.redirect(signInUrl);
}

try {
await ensureOrganizationAccess({ user }, id, ORGANIZATION_MANAGE_ROLES);
await ensureOrganizationAccess({ user, tokenSource }, id, ORGANIZATION_MANAGE_ROLES);
} catch {
return NextResponse.redirect(
new URL(`/organizations/${id}/integrations/slack?error=permission_required`, request.url)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/auto-routing/mode/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async function resolveOwner(
request: NextRequest,
roles?: Parameters<typeof ensureOrganizationAccess>[2]
): Promise<AutoRoutingModeOwnerQuery | { response: NextResponse<{ error: string }> }> {
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (!user || authFailedResponse) {
return { response: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
}
Expand All @@ -55,7 +55,7 @@ async function resolveOwner(
}

try {
await ensureOrganizationAccess({ user }, organizationId, roles);
await ensureOrganizationAccess({ user, tokenSource }, organizationId, roles);
} catch (error) {
const response = trpcErrorResponse(error);
if (response) return { response };
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/claw/openclaw-export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function jsonError(message: string, status: number, code?: string): Response {
export async function POST(request: NextRequest): Promise<Response> {
await connection();

const { user, authFailedResponse } = await getUserFromAuth({
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({
adminOnly: false,
});
if (authFailedResponse) {
Expand Down Expand Up @@ -75,7 +75,7 @@ export async function POST(request: NextRequest): Promise<Response> {
let instance: Awaited<ReturnType<typeof getActiveInstance>>;
try {
if (organizationId) {
await ensureOrganizationAccess({ user } as TRPCContext, organizationId);
await ensureOrganizationAccess({ user, tokenSource } as TRPCContext, organizationId);
await requireActiveSubscriptionOrTrial(organizationId);
instance = await getActiveOrgInstance(user.id, organizationId);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function handleTRPCError(error: unknown): NextResponse {
*/
export async function POST(request: Request) {
try {
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false });

if (authFailedResponse) {
return authFailedResponse;
Expand Down Expand Up @@ -82,7 +82,7 @@ export async function POST(request: Request) {

if (organizationId) {
// Organization context
await ensureOrganizationAccess({ user }, organizationId);
await ensureOrganizationAccess({ user, tokenSource }, organizationId);

const sessionOwnership = await verifyOrgOwnsSessionByCloudAgentId(
db,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/integrations/google/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function oauthSentryContext(searchParams: URLSearchParams): {
*/
export async function GET(request: NextRequest) {
try {
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) {
return NextResponse.redirect(new URL('/users/sign_in', APP_URL));
}
Expand Down Expand Up @@ -133,7 +133,7 @@ export async function GET(request: NextRequest) {
}

if (verifiedState.owner.type === 'org') {
await ensureOrganizationAccess({ user }, verifiedState.owner.id);
await ensureOrganizationAccess({ user, tokenSource }, verifiedState.owner.id);
} else if (verifiedState.owner.id !== user.id) {
return NextResponse.redirect(new URL('/claw/settings?error=unauthorized', APP_URL));
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/integrations/google/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
let organizationId: string | undefined;

try {
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) {
return NextResponse.redirect(new URL('/users/sign_in', APP_URL));
}
Expand All @@ -53,7 +53,7 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(new URL('/claw/settings?error=invalid_organization', APP_URL));
}
organizationId = parsedOrgId.data;
await ensureOrganizationAccess({ user }, organizationId);
await ensureOrganizationAccess({ user, tokenSource }, organizationId);
await requireOrganizationKiloClawComputeEntitlement(organizationId);
} else {
await requireKiloClawAccess(user.id);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/integrations/google/disconnect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function POST(request: NextRequest) {
}

// Disconnect revokes stored OAuth credentials, so authenticated owners can perform it after billing access expires.
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) {
return NextResponse.redirect(new URL('/users/sign_in', APP_URL), 303);
}
Expand All @@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
);
}
organizationId = parsedOrgId.data;
await ensureOrganizationAccess({ user }, organizationId);
await ensureOrganizationAccess({ user, tokenSource }, organizationId);
}

const instance = organizationId
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/api/organizations/[id]/defaults/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ type DefaultsResponse = {
};

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<DefaultsResponse | { error: string }>> {
const organizationId = (await params).id;
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId);
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, {
requestMethod: request.method,
});
if (!success) {
return nextResponse;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<ResponseBody | { error: string }>> {
const organizationId = (await params).id;
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId);
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, {
requestMethod: request.method,
});
if (!success) {
return nextResponse;
}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/api/organizations/[id]/guardrails/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
* everything off.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<OrganizationGuardrailsPolicy | { error: string }>> {
const organizationId = (await params).id;
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId);
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, {
requestMethod: request.method,
});
if (!success) {
return nextResponse;
}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/api/organizations/[id]/modes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type { OrganizationMode } from '@/lib/organizations/organization-modes';
import { getAllOrganizationModes } from '@/lib/organizations/organization-modes';

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<{ modes: OrganizationMode[] } | { error: string }>> {
const organizationId = (await params).id;
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId);
const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, {
requestMethod: request.method,
});
if (!success) {
return nextResponse;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ 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)(
test.each(['cookie', 'legacy-1h', 'legacy-5y'] as const)(
'preserves %s for absent, empty, malformed and empty-object bodies',
async auth => {
const headers = auth.startsWith('legacy')
Expand All @@ -151,7 +151,6 @@ for (const enabled of [false, true]) {
)
)
: 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);
Expand Down Expand Up @@ -181,6 +180,18 @@ for (const enabled of [false, true]) {
}
);

test('rejects an empty Authorization header even with a valid session cookie', async () => {
const headers = new Headers({ cookie: 'next-auth.session-token=test-session' });
headers.set('authorization', '');

for (const body of [undefined, '', '{', '{}']) {
const response = await request(headers, body);
expect(response.status).toBe(401);
}

expect(await auditLogs()).toHaveLength(0);
});

test.each([900, 3600, 7200])(
'preserves legacy human tokens with deviceAuthRequestCode and %s-second lifetime',
async expiresIn => {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/api/organizations/[id]/user-tokens/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const organizationId = (await params).id;

// Verify user has access to the organization (any member role is sufficient)
const result = await getAuthorizedOrgContext(organizationId);
const result = await getAuthorizedOrgContext(organizationId, undefined, {
requestMethod: request.method,
});

if (!result.success) {
return result.nextResponse;
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/payments/topup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<unknown>>
let accountCreatedAt: Date | string = currentUser.created_at;
let accountUsedMicrodollars = currentUser.microdollars_used;
if (organizationId) {
const orgContext = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES);
const orgContext = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES, {
requestMethod: request.method,
});
if (!orgContext.success) {
return orgContext.nextResponse;
}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lib/auth/cloud-agent-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,15 @@ jest.mock('@kilocode/web-shared/lib/admin/admin-access-log', () => ({
authViaTokenFromHeaders: (headers: Headers) => headers.has('authorization'),
clientIpFromHeaders: () => null,
routeFromHeaders: () => null,
methodFromHeaders: (_headers: Headers, requestMethod?: string | null) => requestMethod ?? null,
userAgentProductFromHeaders: () => null,
emitAdminAccessEvent: jest.fn(),
emitPrivilegedAccessEvent: jest.fn(),
recordKiloAdminElevation: jest.fn(),
recordKiloAdminElevationForRequest: jest.fn(),
elevateViaKiloAdmin: async (_ctx: unknown, params: { grant: unknown }) => params.grant,
serviceTarget: (service: string) => `service:${service}`,
organizationTarget: (id: string) => `organization:${id}`,
}));
jest.mock('@kilocode/web-shared/lib/web-session-revocation', () => ({
isWebSessionCurrent: () => true,
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/lib/auth/resource-delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ import {
import { db } from '@kilocode/web-shared/lib/drizzle';
import { generateApiToken, TOKEN_EXPIRY } from '@kilocode/web-shared/lib/tokens';
import { getUserFromSessionForCredentialIssuance } from '@kilocode/web-shared/lib/user/server';
import {
recordKiloAdminElevationForRequest,
serviceTarget,
} from '@kilocode/web-shared/lib/admin/admin-access-log';
import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth';

const ONE_HOUR_SECONDS = 60 * 60;
Expand Down Expand Up @@ -358,6 +362,16 @@ export async function isModernResourceDelegationRequest(requestHeaders: Headers)
return verified.claims.tokenPurpose !== undefined;
}

function mintsWorkerAdminToken(
resource: ControlResource,
authority: ResourceDelegationAuthority,
options?: CreateControlTokenOptions
): boolean {
return (
resource !== 'cloud-agent-next' && options?.extra?.isAdmin === true && authority.user.is_admin
);
}

async function createModernControlToken(
authority: ResourceDelegationAuthority,
resource: ControlResource,
Expand All @@ -373,6 +387,15 @@ async function createModernControlToken(
if (expiresIn <= 0) unauthorized('Resource delegation authority has expired');
const memberships =
resource === 'cloud-agent-next' ? undefined : await membershipsFor(authority.user.id);
if (mintsWorkerAdminToken(resource, authority, options)) {
await recordKiloAdminElevationForRequest({
user: authority.user,
tokenSource: options?.tokenSource ?? authority.tokenSource,
reason: 'delegated_resource_token_mint',
target: serviceTarget(resource),
headersList: options?.headers,
});
}
const payload = buildModernKiloTokenPayload({
userId: authority.user.id,
pepper: authority.user.api_token_pepper,
Expand Down Expand Up @@ -480,6 +503,15 @@ export async function createControlTokenForRequest(
const expiresIn =
options?.legacyExpiresIn ??
(resource === 'cloud-agent-next' ? TOKEN_EXPIRY.default : ONE_HOUR_SECONDS);
if (mintsWorkerAdminToken(resource, authority, options)) {
await recordKiloAdminElevationForRequest({
user: authority.user,
tokenSource: options?.tokenSource,
reason: 'delegated_resource_token_mint',
target: serviceTarget(resource),
headersList: options?.headers,
});
}
const token = generateApiToken(
authority.user,
{
Expand Down
44 changes: 43 additions & 1 deletion apps/web/src/lib/organizations/organization-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { getAuthorizedOrgContext } from './organization-auth';
import { db } from '@kilocode/web-shared/lib/drizzle';
import { organization_memberships, organizations } from '@kilocode/db/schema';
Expand Down Expand Up @@ -82,6 +82,48 @@ describe('getAuthorizedOrgContext', () => {
});
});

test('records the elevation and still succeeds when the membership query throws', async () => {
const adminUser = await insertTestUser({ is_admin: true });
const events: AdminAccessEvent[] = [];
setAdminAccessSinkForTest(event => events.push(event));

const originalSelect = db.select.bind(db);
const selectSpy = jest.spyOn(db, 'select').mockImplementation((...args) => {
const builder = originalSelect(...args);
return new Proxy(builder, {
get(target, property, receiver) {
if (property === 'from') {
return (table: Parameters<typeof target.from>[0]) => {
if (table === organization_memberships) {
throw new Error('membership query failed');
}
return target.from(table);
};
}
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
});

let result: Awaited<ReturnType<typeof getAuthorizedOrgContext>>;
try {
result = await getAuthorizedOrgContext(testOrganizationId, undefined, {
getUserFromAuthOverride: async () => ({ user: adminUser, authFailedResponse: null }),
});
} finally {
selectSpy.mockRestore();
setAdminAccessSinkForTest(null);
}

expect(result.success).toBe(true);
expect(events.find(event => event.kind === 'kilo_admin_elevation')).toMatchObject({
kind: 'kilo_admin_elevation',
membershipRole: null,
wouldPassWithoutAdmin: null,
});
});

test('should allow admin users even if they are not in organization_memberships', async () => {
const adminUser = await insertTestUser({ is_admin: true });

Expand Down
Loading
Loading