From 049e836e7d451801fcf91f6fd354e24540524c2f Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 8 May 2026 15:11:41 -0700 Subject: [PATCH 1/4] Fix legacy CLI auth code parsing --- .../web/src/app/onboard/__tests__/helpers.test.ts | 10 ++++++++++ freebuff/web/src/app/onboard/_helpers.ts | 11 +++++++++++ freebuff/web/src/app/onboard/page.tsx | 2 ++ web/src/app/onboard/__tests__/helpers.test.ts | 10 ++++++++++ web/src/app/onboard/_helpers.ts | 11 +++++++++++ 5 files changed, 44 insertions(+) diff --git a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts index 4b4596a8ba..4d9d0eab90 100644 --- a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts +++ b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts @@ -23,6 +23,16 @@ describe('freebuff onboard/_helpers', () => { expect(result.receivedHash).toBe('hashvalue') }) + test('parses legacy hyphen-delimited auth code', () => { + const receivedHash = 'a'.repeat(64) + const authCode = `1234567890abcdef1234567890abcdef-1704067200000-${receivedHash}` + const result = parseAuthCode(authCode) + + expect(result.fingerprintId).toBe('1234567890abcdef1234567890abcdef') + expect(result.expiresAt).toBe('1704067200000') + expect(result.receivedHash).toBe(receivedHash) + }) + test('handles auth code missing separator before expiresAt', () => { const authCode = 'fingerprint-1231704067200000.abc123hashabc123hashabc123hash' diff --git a/freebuff/web/src/app/onboard/_helpers.ts b/freebuff/web/src/app/onboard/_helpers.ts index d502d0d200..850a3eaece 100644 --- a/freebuff/web/src/app/onboard/_helpers.ts +++ b/freebuff/web/src/app/onboard/_helpers.ts @@ -13,6 +13,17 @@ export function parseAuthCode(authCode: string): { ) if (hashSeparatorIndex === -1 || expiresSeparatorIndex === -1) { + const legacyMatch = normalizedAuthCode.match( + /^(?.+)-(?\d+)-(?[a-f0-9]{64})$/i, + ) + if (legacyMatch?.groups) { + return { + fingerprintId: legacyMatch.groups.fingerprintId, + expiresAt: legacyMatch.groups.expiresAt, + receivedHash: legacyMatch.groups.receivedHash, + } + } + return { fingerprintId: '', expiresAt: '', receivedHash: '' } } diff --git a/freebuff/web/src/app/onboard/page.tsx b/freebuff/web/src/app/onboard/page.tsx index 287b761f47..180758a231 100644 --- a/freebuff/web/src/app/onboard/page.tsx +++ b/freebuff/web/src/app/onboard/page.tsx @@ -103,6 +103,8 @@ const Onboard = async ({ searchParams }: PageProps) => { logger.warn( { authCodeLength: authCode.length, + dotCount: authCode.match(/\./g)?.length ?? 0, + hyphenCount: authCode.match(/-/g)?.length ?? 0, fingerprintIdPrefix: fingerprintId.slice(0, 24), fingerprintIdLength: fingerprintId.length, expiresAt, diff --git a/web/src/app/onboard/__tests__/helpers.test.ts b/web/src/app/onboard/__tests__/helpers.test.ts index 6c5c433e5c..2d10f24472 100644 --- a/web/src/app/onboard/__tests__/helpers.test.ts +++ b/web/src/app/onboard/__tests__/helpers.test.ts @@ -32,6 +32,16 @@ describe('onboard/_helpers', () => { expect(result.receivedHash).toBe('abc123hash') }) + test('parses legacy hyphen-delimited auth code', () => { + const receivedHash = 'a'.repeat(64) + const authCode = `1234567890abcdef1234567890abcdef-1704067200000-${receivedHash}` + const result = parseAuthCode(authCode) + + expect(result.fingerprintId).toBe('1234567890abcdef1234567890abcdef') + expect(result.expiresAt).toBe('1704067200000') + expect(result.receivedHash).toBe(receivedHash) + }) + test('handles auth code missing separator before expiresAt', () => { const authCode = 'fingerprint-1231704067200000.abc123hashabc123hashabc123hash' diff --git a/web/src/app/onboard/_helpers.ts b/web/src/app/onboard/_helpers.ts index d502d0d200..850a3eaece 100644 --- a/web/src/app/onboard/_helpers.ts +++ b/web/src/app/onboard/_helpers.ts @@ -13,6 +13,17 @@ export function parseAuthCode(authCode: string): { ) if (hashSeparatorIndex === -1 || expiresSeparatorIndex === -1) { + const legacyMatch = normalizedAuthCode.match( + /^(?.+)-(?\d+)-(?[a-f0-9]{64})$/i, + ) + if (legacyMatch?.groups) { + return { + fingerprintId: legacyMatch.groups.fingerprintId, + expiresAt: legacyMatch.groups.expiresAt, + receivedHash: legacyMatch.groups.receivedHash, + } + } + return { fingerprintId: '', expiresAt: '', receivedHash: '' } } From baf5f0b77d68030383300a4d5786afa2d838ce67 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 8 May 2026 15:20:46 -0700 Subject: [PATCH 2/4] Stabilize Gemini thinker session test --- .../completions/__tests__/completions.test.ts | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 12604ea60a..59317e23c7 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -1144,47 +1144,54 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(body.error).toBe('session_model_mismatch') }) - it('requires an active session check for the Gemini thinker subagent', async () => { - const checkFreeModeRateLimitForTest = mock((userId: string) => { - expect(userId).toBe('user-new-free-gemini') - return { limited: false as const } - }) + it( + 'requires an active session check for the Gemini thinker subagent', + async () => { + const checkFreeModeRateLimitForTest = mock((userId: string) => { + expect(userId).toBe('user-new-free-gemini') + return { limited: false as const } + }) - const response = await postChatCompletions({ - req: new NextRequest('http://localhost:3000/api/v1/chat/completions', { - method: 'POST', - headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), - body: JSON.stringify({ - model: FREEBUFF_GEMINI_PRO_MODEL_ID, - stream: false, - codebuff_metadata: { - run_id: 'run-gemini-thinker-child', - client_id: 'test-client-id-123', - cost_mode: 'free', - freebuff_instance_id: 'inst-123', + const response = await postChatCompletions({ + req: new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), + body: JSON.stringify({ + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-gemini-thinker-child', + client_id: 'test-client-id-123', + cost_mode: 'free', + freebuff_instance_id: 'inst-123', + }, + }), }, - }), - }), - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - trackEvent: mockTrackEvent, - getUserUsageData: mockGetUserUsageData, - getAgentRunFromId: mockGetAgentRunFromId, - fetch: mockFetch, - insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: async (params) => { - expect(params.requireActiveSession).toBe(true) - expect(params.requestedModel).toBe(FREEBUFF_GEMINI_PRO_MODEL_ID) - expect(params.claimedInstanceId).toBe('inst-123') - return { ok: true, reason: 'active', remainingMs: 60_000 } - }, - checkFreeModeRateLimit: checkFreeModeRateLimitForTest, - }) + ), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: async (params) => { + expect(params.requireActiveSession).toBe(true) + expect(params.requestedModel).toBe(FREEBUFF_GEMINI_PRO_MODEL_ID) + expect(params.claimedInstanceId).toBe('inst-123') + return { ok: true, reason: 'active', remainingMs: 60_000 } + }, + checkFreeModeRateLimit: checkFreeModeRateLimitForTest, + }) - expect(response.status).toBe(200) - expect(checkFreeModeRateLimitForTest).toHaveBeenCalledTimes(1) - }) + expect(response.status).toBe(200) + expect(checkFreeModeRateLimitForTest).toHaveBeenCalledTimes(1) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) it( 'counts child Gemini thinker requests toward the free-mode request limit', From ca95525689392847ca5a621ae934adb90306a892 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 8 May 2026 15:29:15 -0700 Subject: [PATCH 3/4] Stabilize chat completions fetch-path tests --- .../completions/__tests__/completions.test.ts | 342 +++++++++--------- 1 file changed, 181 insertions(+), 161 deletions(-) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 59317e23c7..84f189390c 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -520,108 +520,120 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(body.message).not.toContain(nextQuotaReset) }) - it('lets a new account with no paid relationship through for non-free mode', async () => { - const req = new NextRequest( - 'http://localhost:3000/api/v1/chat/completions', - { - method: 'POST', - headers: { Authorization: 'Bearer test-api-key-new-free' }, - body: JSON.stringify({ - model: 'test/test-model', - stream: false, - codebuff_metadata: { - run_id: 'run-123', - client_id: 'test-client-id-123', - }, - }), - }, - ) + it( + 'lets a new account with no paid relationship through for non-free mode', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-new-free' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) - const response = await postChatCompletions({ - req, - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - trackEvent: mockTrackEvent, - getUserUsageData: mockGetUserUsageData, - getAgentRunFromId: mockGetAgentRunFromId, - fetch: mockFetch, - insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: mockCheckSessionAdmissibleAllow, - }) + const response = await postChatCompletions({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) - expect(response.status).toBe(200) - }) + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) - it('lets a BYOK free-tier new account through the paid-plan gate', async () => { - const req = new NextRequest( - 'http://localhost:3000/api/v1/chat/completions', - { - method: 'POST', - headers: { - Authorization: 'Bearer test-api-key-new-free', - 'x-openrouter-api-key': 'sk-or-byok-test', - }, - body: JSON.stringify({ - model: 'test/test-model', - stream: false, - codebuff_metadata: { - run_id: 'run-123', - client_id: 'test-client-id-123', + it( + 'lets a BYOK free-tier new account through the paid-plan gate', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { + Authorization: 'Bearer test-api-key-new-free', + 'x-openrouter-api-key': 'sk-or-byok-test', }, - }), - }, - ) + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) - const response = await postChatCompletions({ - req, - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - trackEvent: mockTrackEvent, - getUserUsageData: mockGetUserUsageData, - getAgentRunFromId: mockGetAgentRunFromId, - fetch: mockFetch, - insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: mockCheckSessionAdmissibleAllow, - }) + const response = await postChatCompletions({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) - expect(response.status).toBe(200) - }) + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) - it('lets a freebuff/free-mode request through even for a brand-new unpaid account', async () => { - const req = new NextRequest( - 'http://localhost:3000/api/v1/chat/completions', - { - method: 'POST', - headers: allowedFreeModeHeaders('test-api-key-new-free'), - body: JSON.stringify({ - model: 'minimax/minimax-m2.7', - stream: false, - codebuff_metadata: { - run_id: 'run-free', - client_id: 'test-client-id-123', - cost_mode: 'free', - }, - }), - }, - ) + it( + 'lets a freebuff/free-mode request through even for a brand-new unpaid account', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: 'minimax/minimax-m2.7', + stream: false, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) - const response = await postChatCompletions({ - req, - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - trackEvent: mockTrackEvent, - getUserUsageData: mockGetUserUsageData, - getAgentRunFromId: mockGetAgentRunFromId, - fetch: mockFetch, - insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: mockCheckSessionAdmissibleAllow, - }) + const response = await postChatCompletions({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) - expect(response.status).toBe(200) - }) + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) it('rejects free-mode requests when location is unknown', async () => { const req = new NextRequest( @@ -1033,39 +1045,43 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(body.error).toBe('free_mode_invalid_agent_model') }) - it('allows browser-use as a free-mode subagent under a freebuff root', async () => { - const req = new NextRequest( - 'http://localhost:3000/api/v1/chat/completions', - { - method: 'POST', - headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), - body: JSON.stringify({ - model: 'google/gemini-3.1-flash-lite-preview', - stream: false, - codebuff_metadata: { - run_id: 'run-browser-use-child', - client_id: 'test-client-id-123', - cost_mode: 'free', - }, - }), - }, - ) + it( + 'allows browser-use as a free-mode subagent under a freebuff root', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), + body: JSON.stringify({ + model: 'google/gemini-3.1-flash-lite-preview', + stream: false, + codebuff_metadata: { + run_id: 'run-browser-use-child', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) - const response = await postChatCompletions({ - req, - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - trackEvent: mockTrackEvent, - getUserUsageData: mockGetUserUsageData, - getAgentRunFromId: mockGetAgentRunFromId, - fetch: mockFetch, - insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: mockCheckSessionAdmissibleAllow, - }) + const response = await postChatCompletions({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) - expect(response.status).toBe(200) - }) + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) it('rejects standalone free-mode reviewer runs even when the model is allowlisted', async () => { const req = new NextRequest( @@ -1402,45 +1418,49 @@ describe('/api/v1/chat/completions POST endpoint', () => { }) describe('Successful responses', () => { - it('returns stream with correct headers', async () => { - const req = new NextRequest( - 'http://localhost:3000/api/v1/chat/completions', - { - method: 'POST', - headers: { Authorization: 'Bearer test-api-key-123' }, - body: JSON.stringify({ - stream: true, - codebuff_metadata: { - run_id: 'run-123', - client_id: 'test-client-id-123', - client_request_id: 'test-client-session-id-123', - }, - }), - }, - ) + it( + 'returns stream with correct headers', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + stream: true, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + client_request_id: 'test-client-session-id-123', + }, + }), + }, + ) - const response = await postChatCompletions({ - req, - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - trackEvent: mockTrackEvent, - getUserUsageData: mockGetUserUsageData, - getAgentRunFromId: mockGetAgentRunFromId, - fetch: mockFetch, - insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: mockCheckSessionAdmissibleAllow, - }) + const response = await postChatCompletions({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) - if (response.status !== 200) { - const errorBody = await response.json() - console.log('Error response:', errorBody) - } - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('text/event-stream') - expect(response.headers.get('Cache-Control')).toBe('no-cache') - expect(response.headers.get('Connection')).toBe('keep-alive') - }) + if (response.status !== 200) { + const errorBody = await response.json() + console.log('Error response:', errorBody) + } + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/event-stream') + expect(response.headers.get('Cache-Control')).toBe('no-cache') + expect(response.headers.get('Connection')).toBe('keep-alive') + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) it( 'returns JSON response for non-streaming requests', From 4a632e1e9baaf17c39155d429c5d00be526f65ad Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 8 May 2026 16:11:30 -0700 Subject: [PATCH 4/4] Use opaque CLI auth tokens --- freebuff/web/src/app/api/auth/cli/code/route.ts | 16 ++++++++++++---- freebuff/web/src/app/onboard/_db.ts | 17 +++++++++++++++++ freebuff/web/src/app/onboard/page.tsx | 7 ++++++- web/src/app/api/auth/cli/code/route.ts | 16 ++++++++++++---- web/src/app/onboard/_db.ts | 17 +++++++++++++++++ web/src/app/onboard/page.tsx | 5 ++++- 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/freebuff/web/src/app/api/auth/cli/code/route.ts b/freebuff/web/src/app/api/auth/cli/code/route.ts index dfd77dca23..315284d95d 100644 --- a/freebuff/web/src/app/api/auth/cli/code/route.ts +++ b/freebuff/web/src/app/api/auth/cli/code/route.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto' + import { genAuthCode } from '@codebuff/common/util/credentials' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -55,6 +57,15 @@ export async function POST(req: Request) { ) } + const authCode = `${fingerprintId}.${expiresAt}.${fingerprintHash}` + const loginToken = randomBytes(32).toString('base64url') + + await db.insert(schema.verificationToken).values({ + identifier: `cli-login:${loginToken}`, + token: authCode, + expires: new Date(expiresAt), + }) + const loginUrl = new URL( '/login', getLoginUrlOrigin( @@ -64,10 +75,7 @@ export async function POST(req: Request) { env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', ), ) - loginUrl.searchParams.set( - 'auth_code', - `${fingerprintId}.${expiresAt}.${fingerprintHash}`, - ) + loginUrl.searchParams.set('auth_code', loginToken) return NextResponse.json({ fingerprintId, diff --git a/freebuff/web/src/app/onboard/_db.ts b/freebuff/web/src/app/onboard/_db.ts index 078d757d59..0e38587988 100644 --- a/freebuff/web/src/app/onboard/_db.ts +++ b/freebuff/web/src/app/onboard/_db.ts @@ -32,6 +32,23 @@ export async function hasCliSessionForAuthHash( return existing.length > 0 } +export async function getCliAuthCodeForToken( + authCodeToken: string, +): Promise { + const existing = await db + .select({ authCode: schema.verificationToken.token }) + .from(schema.verificationToken) + .where( + and( + eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`), + gt(schema.verificationToken.expires, new Date()), + ), + ) + .limit(1) + + return existing[0]?.authCode ?? null +} + export async function checkFingerprintConflict( fingerprintId: string, userId: string, diff --git a/freebuff/web/src/app/onboard/page.tsx b/freebuff/web/src/app/onboard/page.tsx index 180758a231..21f6e6135c 100644 --- a/freebuff/web/src/app/onboard/page.tsx +++ b/freebuff/web/src/app/onboard/page.tsx @@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth' import { checkFingerprintConflict, createCliSession, + getCliAuthCodeForToken, getSessionTokenFromCookies, hasCliSessionForAuthHash, } from './_db' @@ -91,7 +92,9 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } - const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode) + const resolvedAuthCode = (await getCliAuthCodeForToken(authCode)) ?? authCode + const { fingerprintId, expiresAt, receivedHash } = + parseAuthCode(resolvedAuthCode) const { valid, expectedHash: fingerprintHash } = validateAuthCode( receivedHash, fingerprintId, @@ -103,6 +106,8 @@ const Onboard = async ({ searchParams }: PageProps) => { logger.warn( { authCodeLength: authCode.length, + resolvedAuthCode: resolvedAuthCode !== authCode, + resolvedAuthCodeLength: resolvedAuthCode.length, dotCount: authCode.match(/\./g)?.length ?? 0, hyphenCount: authCode.match(/-/g)?.length ?? 0, fingerprintIdPrefix: fingerprintId.slice(0, 24), diff --git a/web/src/app/api/auth/cli/code/route.ts b/web/src/app/api/auth/cli/code/route.ts index 993a821547..455375d60a 100644 --- a/web/src/app/api/auth/cli/code/route.ts +++ b/web/src/app/api/auth/cli/code/route.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto' + import { genAuthCode } from '@codebuff/common/util/credentials' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -57,6 +59,15 @@ export async function POST(req: Request) { ) } + const authCode = `${fingerprintId}.${expiresAt}.${fingerprintHash}` + const loginToken = randomBytes(32).toString('base64url') + + await db.insert(schema.verificationToken).values({ + identifier: `cli-login:${loginToken}`, + token: authCode, + expires: new Date(expiresAt), + }) + const loginUrl = new URL( '/login', getLoginUrlOrigin( @@ -66,10 +77,7 @@ export async function POST(req: Request) { env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', ), ) - loginUrl.searchParams.set( - 'auth_code', - `${fingerprintId}.${expiresAt}.${fingerprintHash}`, - ) + loginUrl.searchParams.set('auth_code', loginToken) return NextResponse.json({ fingerprintId, diff --git a/web/src/app/onboard/_db.ts b/web/src/app/onboard/_db.ts index 078d757d59..0e38587988 100644 --- a/web/src/app/onboard/_db.ts +++ b/web/src/app/onboard/_db.ts @@ -32,6 +32,23 @@ export async function hasCliSessionForAuthHash( return existing.length > 0 } +export async function getCliAuthCodeForToken( + authCodeToken: string, +): Promise { + const existing = await db + .select({ authCode: schema.verificationToken.token }) + .from(schema.verificationToken) + .where( + and( + eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`), + gt(schema.verificationToken.expires, new Date()), + ), + ) + .limit(1) + + return existing[0]?.authCode ?? null +} + export async function checkFingerprintConflict( fingerprintId: string, userId: string, diff --git a/web/src/app/onboard/page.tsx b/web/src/app/onboard/page.tsx index 6e5ea8f883..aba3ded266 100644 --- a/web/src/app/onboard/page.tsx +++ b/web/src/app/onboard/page.tsx @@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth' import { checkFingerprintConflict, createCliSession, + getCliAuthCodeForToken, getSessionTokenFromCookies, hasCliSessionForAuthHash, } from './_db' @@ -48,7 +49,9 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } - const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode) + const resolvedAuthCode = (await getCliAuthCodeForToken(authCode)) ?? authCode + const { fingerprintId, expiresAt, receivedHash } = + parseAuthCode(resolvedAuthCode) const { valid, expectedHash: fingerprintHash } = validateAuthCode( receivedHash, fingerprintId,