From 4a83c5a5a5fd180ded2e99d0a656f3fb9b8ea06a Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 30 Jun 2026 15:36:03 +0200 Subject: [PATCH 1/2] fix(auth): atomic single-use consume in device-auth poll (Finding 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pollDeviceAuthRequest read the approved request, minted a token, then issued an unconditional 'expired' update — so two concurrent polls could each mint a long-lived API token from one approval. Replace it with a guarded compare-and-swap that flips 'approved' -> 'expired' and returns the row only to the winning caller; losers normalize to 'expired'. The token is minted only after the row is consumed, so at most one token is issued per approval. --- .../src/lib/device-auth/device-auth.test.ts | 15 ++++++++++ apps/web/src/lib/device-auth/device-auth.ts | 28 ++++++++++++------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/device-auth/device-auth.test.ts b/apps/web/src/lib/device-auth/device-auth.test.ts index 1b5ca6905d..2889b8a42d 100644 --- a/apps/web/src/lib/device-auth/device-auth.test.ts +++ b/apps/web/src/lib/device-auth/device-auth.test.ts @@ -253,6 +253,21 @@ describe('Device Auth', () => { expect(secondResult.token).toBeUndefined(); }); + test('concurrent polls mint at most one token from a single approval', async () => { + const { code } = await createDeviceAuthRequest({}); + await approveDeviceAuthRequest(code, testUserId); + + const results = await Promise.all([ + pollDeviceAuthRequest(code), + pollDeviceAuthRequest(code), + ]); + + const approved = results.filter(r => r.status === 'approved' && r.token); + const expired = results.filter(r => r.status === 'expired'); + expect(approved).toHaveLength(1); + expect(expired).toHaveLength(1); + }); + test('normalizes responses - non-existent code returns expired', async () => { const result = await pollDeviceAuthRequest('FAKE-CODE'); expect(result.status).toBe('expired'); diff --git a/apps/web/src/lib/device-auth/device-auth.ts b/apps/web/src/lib/device-auth/device-auth.ts index 88578bad0f..4e32f64abe 100644 --- a/apps/web/src/lib/device-auth/device-auth.ts +++ b/apps/web/src/lib/device-auth/device-auth.ts @@ -176,11 +176,27 @@ export async function pollDeviceAuthRequest(code: string): Promise<{ return { status: request.status as 'pending' | 'denied' }; } - // For approved requests, fetch user and generate token + // Atomically consume the approval before minting: a single guarded + // compare-and-swap flips 'approved' -> 'expired' and returns the row only to + // the caller that won. This prevents concurrent polls from each minting a + // long-lived token from one approval. + const consumed = await db + .update(device_auth_requests) + .set({ status: 'expired' }) + .where(and(eq(device_auth_requests.code, code), eq(device_auth_requests.status, 'approved'))) + .returning({ kiloUserId: device_auth_requests.kilo_user_id }); + + if (consumed.length === 0 || !consumed[0].kiloUserId) { + // Lost the race to another poller (or the status changed) — normalize to + // expired so the code cannot be polled into a second token. + return { status: 'expired' }; + } + const kiloUserId = consumed[0].kiloUserId; + const [user] = await db .select() .from(kilocode_users) - .where(eq(kilocode_users.id, request.kilo_user_id)) + .where(eq(kilocode_users.id, kiloUserId)) .limit(1); if (!user) { @@ -189,14 +205,6 @@ export async function pollDeviceAuthRequest(code: string): Promise<{ const token = generateApiToken(user, { deviceAuthRequestCode: code }); - // Mark as consumed to enforce single-use - await db - .update(device_auth_requests) - .set({ - status: 'expired', - }) - .where(eq(device_auth_requests.code, code)); - return { status: 'approved', token, From 45d97c07ac08875d66eca98a9418f8f5b31f6849 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 30 Jun 2026 15:37:26 +0200 Subject: [PATCH 2/2] fix(auth): re-check blocked/blacklist when minting device-auth token (Finding 2) Authorization was only checked when the user approved the device request; the later poll minted a ~5-year API token without re-checking. A user could approve while in good standing, get blocked, then poll and receive a fresh token still accepted by pepper-only downstream services. Re-validate the user at mint time (blocked_reason or isUserBlacklistedByDomain) and return 'denied' instead of minting. The approval is already consumed, so a blocked user cannot retry the code into a token. --- .../src/lib/device-auth/device-auth.test.ts | 21 +++++++++++++++++++ apps/web/src/lib/device-auth/device-auth.ts | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/web/src/lib/device-auth/device-auth.test.ts b/apps/web/src/lib/device-auth/device-auth.test.ts index 2889b8a42d..983d28613b 100644 --- a/apps/web/src/lib/device-auth/device-auth.test.ts +++ b/apps/web/src/lib/device-auth/device-auth.test.ts @@ -268,6 +268,27 @@ describe('Device Auth', () => { expect(expired).toHaveLength(1); }); + test('does not mint a token when the user is blocked after approval', async () => { + const { code } = await createDeviceAuthRequest({}); + await approveDeviceAuthRequest(code, testUserId); + + // User becomes blocked between approval and the poll. + await db + .update(kilocode_users) + .set({ blocked_reason: 'blocked after approval' }) + .where(eq(kilocode_users.id, testUserId)); + + const result = await pollDeviceAuthRequest(code); + + expect(result.status).toBe('denied'); + expect(result.token).toBeUndefined(); + + // The approval is consumed, so retrying cannot mint a token either. + const retry = await pollDeviceAuthRequest(code); + expect(retry.status).toBe('expired'); + expect(retry.token).toBeUndefined(); + }); + test('normalizes responses - non-existent code returns expired', async () => { const result = await pollDeviceAuthRequest('FAKE-CODE'); expect(result.status).toBe('expired'); diff --git a/apps/web/src/lib/device-auth/device-auth.ts b/apps/web/src/lib/device-auth/device-auth.ts index 4e32f64abe..b4471e72d0 100644 --- a/apps/web/src/lib/device-auth/device-auth.ts +++ b/apps/web/src/lib/device-auth/device-auth.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/drizzle'; import { device_auth_requests, kilocode_users } from '@kilocode/db/schema'; import { eq, and, lt, sql } from 'drizzle-orm'; import { generateApiToken } from '@/lib/tokens'; +import { isUserBlacklistedByDomain } from '@/lib/user/server'; import { randomInt } from 'node:crypto'; const CODE_LENGTH = 8; @@ -203,6 +204,14 @@ export async function pollDeviceAuthRequest(code: string): Promise<{ throw new Error('User not found'); } + // Re-check authorization at mint time. Authorization is verified when the + // user approves the request, but they may have been blocked or blacklisted + // between approval and this poll. The approval is already consumed above, so + // a now-blocked user cannot retry this code into a fresh long-lived token. + if (user.blocked_reason || (await isUserBlacklistedByDomain(user))) { + return { status: 'denied' }; + } + const token = generateApiToken(user, { deviceAuthRequestCode: code }); return {