diff --git a/docs/authentication.md b/docs/authentication.md index c8fad1c88..d4054b87f 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -13,10 +13,13 @@ sequenceDiagram participant DB as Database CLI->>Web: POST /api/auth/cli/code {fingerprintId} - Web->>Web: Generate auth code (1h expiry) - Web->>CLI: Return login URL + Web->>Web: Generate signed auth payload (1h expiry) + Web->>DB: Store payload behind opaque browser token + Web->>CLI: Return login URL with opaque token CLI->>CLI: Open browser Note over Web: User completes OAuth + Web->>DB: Resolve opaque token to signed payload + Web->>DB: Delete opaque token Web->>DB: Check fingerprint ownership Web->>DB: Create/update session loop Every 5s @@ -64,11 +67,14 @@ sequenceDiagram ### 4. Failure: Invalid/Expired Code - Auth code validation fails or expired (1h limit) +- Opaque browser tokens resolve expired signed payloads before returning the expired-code error - Returns authentication error ## Security Features -- Auth codes expire after 1 hour +- Signed auth payloads expire after 1 hour +- Browser login URLs use opaque 43-character tokens instead of exposing the signed auth payload +- Opaque browser tokens are stored in `verificationToken` under `cli-login:` and consumed with `DELETE ... RETURNING` when onboarding resolves them - Fingerprint uniqueness: hardware info + 8 random bytes - Ownership conflicts blocked and logged - Sessions linked to fingerprint_id in database 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 dfd77dca2..6622af094 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' @@ -6,6 +8,7 @@ import { and, eq, gt } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod/v4' +import { buildCliAuthCode } from '@/app/onboard/_helpers' import { logger } from '@/util/logger' import { getLoginUrlOrigin } from './_origin' @@ -55,6 +58,19 @@ export async function POST(req: Request) { ) } + const authCode = buildCliAuthCode( + fingerprintId, + expiresAt.toString(), + 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 +80,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/__tests__/helpers.test.ts b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts index 4b4596a8b..0a19061b8 100644 --- a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts +++ b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts @@ -1,7 +1,14 @@ import { genAuthCode } from '@codebuff/common/util/credentials' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { parseAuthCode, validateAuthCode, isAuthCodeExpired } from '../_helpers' +import { + buildCliAuthCode, + isAuthCodeExpired, + isOpaqueCliAuthCodeToken, + parseAuthCode, + resolveCliAuthCode, + validateAuthCode, +} from '../_helpers' describe('freebuff onboard/_helpers', () => { describe('parseAuthCode', () => { @@ -23,6 +30,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' @@ -68,6 +85,117 @@ describe('freebuff onboard/_helpers', () => { }) }) + describe('opaque CLI auth code tokens', () => { + const testSecret = 'test-secret-key' + const testFingerprintId = 'fp-abc123' + + test('builds the signed auth code payload', () => { + expect(buildCliAuthCode('fingerprint-id', '1704067200000', 'hash')).toBe( + 'fingerprint-id.1704067200000.hash', + ) + }) + + test('identifies 43 character base64url browser tokens only', () => { + const opaqueToken = 'A'.repeat(41) + '-_' + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + '1704067200000', + 'a'.repeat(64), + ) + + expect(isOpaqueCliAuthCodeToken(opaqueToken)).toBe(true) + expect(isOpaqueCliAuthCodeToken(` ${opaqueToken}\n`)).toBe(true) + expect(isOpaqueCliAuthCodeToken(signedAuthCode)).toBe(false) + expect(isOpaqueCliAuthCodeToken('A'.repeat(42))).toBe(false) + expect(isOpaqueCliAuthCodeToken(`${'A'.repeat(42)}.`)).toBe(false) + }) + + test('resolves an opaque browser token before validation', async () => { + const expiresAt = '4102444800000' + const fingerprintHash = genAuthCode( + testFingerprintId, + expiresAt, + testSecret, + ) + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + expiresAt, + fingerprintHash, + ) + const opaqueToken = 'a'.repeat(43) + + const result = await resolveCliAuthCode(opaqueToken, async (token) => { + expect(token).toBe(opaqueToken) + return signedAuthCode + }) + + expect(result).toEqual({ + authCode: signedAuthCode, + resolvedOpaqueToken: true, + }) + + const parsed = parseAuthCode(result.authCode) + expect( + validateAuthCode( + parsed.receivedHash, + parsed.fingerprintId, + parsed.expiresAt, + testSecret, + ).valid, + ).toBe(true) + }) + + test('does not look up already signed auth codes', async () => { + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + '4102444800000', + 'a'.repeat(64), + ) + let lookedUp = false + + const result = await resolveCliAuthCode(signedAuthCode, async () => { + lookedUp = true + return null + }) + + expect(lookedUp).toBe(false) + expect(result).toEqual({ + authCode: signedAuthCode, + resolvedOpaqueToken: false, + }) + }) + + test('resolves expired stored payloads so callers can show expired', async () => { + const expiresAt = '0' + const fingerprintHash = genAuthCode( + testFingerprintId, + expiresAt, + testSecret, + ) + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + expiresAt, + fingerprintHash, + ) + + const result = await resolveCliAuthCode( + 'b'.repeat(43), + async () => signedAuthCode, + ) + const parsed = parseAuthCode(result.authCode) + + expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true) + expect( + validateAuthCode( + parsed.receivedHash, + parsed.fingerprintId, + parsed.expiresAt, + testSecret, + ).valid, + ).toBe(true) + }) + }) + describe('isAuthCodeExpired', () => { let originalDateNow: typeof Date.now diff --git a/freebuff/web/src/app/onboard/_db.ts b/freebuff/web/src/app/onboard/_db.ts index 078d757d5..cf9724b16 100644 --- a/freebuff/web/src/app/onboard/_db.ts +++ b/freebuff/web/src/app/onboard/_db.ts @@ -32,6 +32,19 @@ export async function hasCliSessionForAuthHash( return existing.length > 0 } +export async function consumeCliAuthCodeToken( + authCodeToken: string, +): Promise { + const deleted = await db + .delete(schema.verificationToken) + .where( + eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`), + ) + .returning({ authCode: schema.verificationToken.token }) + + return deleted[0]?.authCode ?? null +} + export async function checkFingerprintConflict( fingerprintId: string, userId: string, diff --git a/freebuff/web/src/app/onboard/_helpers.ts b/freebuff/web/src/app/onboard/_helpers.ts index d502d0d20..a3daf585a 100644 --- a/freebuff/web/src/app/onboard/_helpers.ts +++ b/freebuff/web/src/app/onboard/_helpers.ts @@ -1,5 +1,39 @@ import { genAuthCode } from '@codebuff/common/util/credentials' +const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ + +export function buildCliAuthCode( + fingerprintId: string, + expiresAt: string, + fingerprintHash: string, +): string { + return `${fingerprintId}.${expiresAt}.${fingerprintHash}` +} + +export function isOpaqueCliAuthCodeToken(authCode: string): boolean { + return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim()) +} + +export async function resolveCliAuthCode( + authCode: string, + consumeCliAuthCodeToken: (authCodeToken: string) => Promise, +): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> { + const normalizedAuthCode = authCode.trim() + if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) { + return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + } + + const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode) + if (!signedAuthCode) { + return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + } + + return { + authCode: signedAuthCode, + resolvedOpaqueToken: true, + } +} + export function parseAuthCode(authCode: string): { fingerprintId: string expiresAt: string @@ -13,6 +47,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 287b761f4..e39a4a0b3 100644 --- a/freebuff/web/src/app/onboard/page.tsx +++ b/freebuff/web/src/app/onboard/page.tsx @@ -6,11 +6,17 @@ import { getServerSession } from 'next-auth' import { checkFingerprintConflict, + consumeCliAuthCodeToken, createCliSession, getSessionTokenFromCookies, hasCliSessionForAuthHash, } from './_db' -import { isAuthCodeExpired, parseAuthCode, validateAuthCode } from './_helpers' +import { + isAuthCodeExpired, + parseAuthCode, + resolveCliAuthCode, + validateAuthCode, +} from './_helpers' import { authOptions } from '../api/auth/[...nextauth]/auth-options' import { @@ -91,7 +97,10 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } - const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode) + const { authCode: resolvedAuthCode, resolvedOpaqueToken } = + await resolveCliAuthCode(authCode, consumeCliAuthCodeToken) + const { fingerprintId, expiresAt, receivedHash } = + parseAuthCode(resolvedAuthCode) const { valid, expectedHash: fingerprintHash } = validateAuthCode( receivedHash, fingerprintId, @@ -103,6 +112,10 @@ const Onboard = async ({ searchParams }: PageProps) => { logger.warn( { authCodeLength: authCode.length, + resolvedAuthCode: resolvedOpaqueToken, + resolvedAuthCodeLength: resolvedAuthCode.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/api/auth/cli/code/route.ts b/web/src/app/api/auth/cli/code/route.ts index 993a82154..1149a46de 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' @@ -6,6 +8,7 @@ import { and, eq, gt } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod/v4' +import { buildCliAuthCode } from '@/app/onboard/_helpers' import { logger } from '@/util/logger' import { getLoginUrlOrigin } from './_origin' @@ -57,6 +60,19 @@ export async function POST(req: Request) { ) } + const authCode = buildCliAuthCode( + fingerprintId, + expiresAt.toString(), + 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 +82,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/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 12604ea60..b72023e14 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,115 +520,133 @@ 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 () => { + // Use a TEST-NET-1 IP (RFC 5737) that geoip-lite cannot resolve, with + // no cf-ipcountry header. This avoids the dev-only localhost bypass + // (which kicks in when there is no cf-ipcountry AND no/loopback IP). const req = new NextRequest( 'http://localhost:3000/api/v1/chat/completions', { method: 'POST', - headers: { Authorization: 'Bearer test-api-key-new-free' }, + headers: { + Authorization: 'Bearer test-api-key-new-free', + 'cf-connecting-ip': '192.0.2.1', + }, body: JSON.stringify({ model: 'minimax/minimax-m2.7', stream: false, @@ -658,7 +676,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { const body = await response.json() expect(body.error).toBe('free_mode_unavailable') expect(body.countryCode).toBe('UNKNOWN') - expect(body.countryBlockReason).toBe('missing_client_ip') + expect(body.countryBlockReason).toBe('unresolved_client_ip') }) it('rejects free-mode requests from anonymized Cloudflare country codes', async () => { @@ -1033,39 +1051,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( @@ -1144,47 +1166,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', @@ -1395,45 +1424,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', diff --git a/web/src/app/onboard/__tests__/helpers.test.ts b/web/src/app/onboard/__tests__/helpers.test.ts index 6c5c433e5..c47c2f642 100644 --- a/web/src/app/onboard/__tests__/helpers.test.ts +++ b/web/src/app/onboard/__tests__/helpers.test.ts @@ -1,7 +1,14 @@ import { genAuthCode } from '@codebuff/common/util/credentials' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { parseAuthCode, validateAuthCode, isAuthCodeExpired } from '../_helpers' +import { + buildCliAuthCode, + isAuthCodeExpired, + isOpaqueCliAuthCodeToken, + parseAuthCode, + resolveCliAuthCode, + validateAuthCode, +} from '../_helpers' describe('onboard/_helpers', () => { describe('parseAuthCode', () => { @@ -32,6 +39,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' @@ -196,6 +213,117 @@ describe('onboard/_helpers', () => { }) }) + describe('opaque CLI auth code tokens', () => { + const testSecret = 'test-secret-key' + const testFingerprintId = 'fp-abc123' + + test('builds the signed auth code payload', () => { + expect(buildCliAuthCode('fingerprint-id', '1704067200000', 'hash')).toBe( + 'fingerprint-id.1704067200000.hash', + ) + }) + + test('identifies 43 character base64url browser tokens only', () => { + const opaqueToken = 'A'.repeat(41) + '-_' + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + '1704067200000', + 'a'.repeat(64), + ) + + expect(isOpaqueCliAuthCodeToken(opaqueToken)).toBe(true) + expect(isOpaqueCliAuthCodeToken(` ${opaqueToken}\n`)).toBe(true) + expect(isOpaqueCliAuthCodeToken(signedAuthCode)).toBe(false) + expect(isOpaqueCliAuthCodeToken('A'.repeat(42))).toBe(false) + expect(isOpaqueCliAuthCodeToken(`${'A'.repeat(42)}.`)).toBe(false) + }) + + test('resolves an opaque browser token before validation', async () => { + const expiresAt = '4102444800000' + const fingerprintHash = genAuthCode( + testFingerprintId, + expiresAt, + testSecret, + ) + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + expiresAt, + fingerprintHash, + ) + const opaqueToken = 'a'.repeat(43) + + const result = await resolveCliAuthCode(opaqueToken, async (token) => { + expect(token).toBe(opaqueToken) + return signedAuthCode + }) + + expect(result).toEqual({ + authCode: signedAuthCode, + resolvedOpaqueToken: true, + }) + + const parsed = parseAuthCode(result.authCode) + expect( + validateAuthCode( + parsed.receivedHash, + parsed.fingerprintId, + parsed.expiresAt, + testSecret, + ).valid, + ).toBe(true) + }) + + test('does not look up already signed auth codes', async () => { + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + '4102444800000', + 'a'.repeat(64), + ) + let lookedUp = false + + const result = await resolveCliAuthCode(signedAuthCode, async () => { + lookedUp = true + return null + }) + + expect(lookedUp).toBe(false) + expect(result).toEqual({ + authCode: signedAuthCode, + resolvedOpaqueToken: false, + }) + }) + + test('resolves expired stored payloads so callers can show expired', async () => { + const expiresAt = '0' + const fingerprintHash = genAuthCode( + testFingerprintId, + expiresAt, + testSecret, + ) + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + expiresAt, + fingerprintHash, + ) + + const result = await resolveCliAuthCode( + 'b'.repeat(43), + async () => signedAuthCode, + ) + const parsed = parseAuthCode(result.authCode) + + expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true) + expect( + validateAuthCode( + parsed.receivedHash, + parsed.fingerprintId, + parsed.expiresAt, + testSecret, + ).valid, + ).toBe(true) + }) + }) + describe('isAuthCodeExpired', () => { let originalDateNow: typeof Date.now diff --git a/web/src/app/onboard/_db.ts b/web/src/app/onboard/_db.ts index 078d757d5..cf9724b16 100644 --- a/web/src/app/onboard/_db.ts +++ b/web/src/app/onboard/_db.ts @@ -32,6 +32,19 @@ export async function hasCliSessionForAuthHash( return existing.length > 0 } +export async function consumeCliAuthCodeToken( + authCodeToken: string, +): Promise { + const deleted = await db + .delete(schema.verificationToken) + .where( + eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`), + ) + .returning({ authCode: schema.verificationToken.token }) + + return deleted[0]?.authCode ?? null +} + export async function checkFingerprintConflict( fingerprintId: string, userId: string, diff --git a/web/src/app/onboard/_helpers.ts b/web/src/app/onboard/_helpers.ts index d502d0d20..a3daf585a 100644 --- a/web/src/app/onboard/_helpers.ts +++ b/web/src/app/onboard/_helpers.ts @@ -1,5 +1,39 @@ import { genAuthCode } from '@codebuff/common/util/credentials' +const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ + +export function buildCliAuthCode( + fingerprintId: string, + expiresAt: string, + fingerprintHash: string, +): string { + return `${fingerprintId}.${expiresAt}.${fingerprintHash}` +} + +export function isOpaqueCliAuthCodeToken(authCode: string): boolean { + return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim()) +} + +export async function resolveCliAuthCode( + authCode: string, + consumeCliAuthCodeToken: (authCodeToken: string) => Promise, +): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> { + const normalizedAuthCode = authCode.trim() + if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) { + return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + } + + const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode) + if (!signedAuthCode) { + return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + } + + return { + authCode: signedAuthCode, + resolvedOpaqueToken: true, + } +} + export function parseAuthCode(authCode: string): { fingerprintId: string expiresAt: string @@ -13,6 +47,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/web/src/app/onboard/page.tsx b/web/src/app/onboard/page.tsx index 6e5ea8f88..d751222e0 100644 --- a/web/src/app/onboard/page.tsx +++ b/web/src/app/onboard/page.tsx @@ -6,11 +6,17 @@ import { getServerSession } from 'next-auth' import { checkFingerprintConflict, + consumeCliAuthCodeToken, createCliSession, getSessionTokenFromCookies, hasCliSessionForAuthHash, } from './_db' -import { isAuthCodeExpired, parseAuthCode, validateAuthCode } from './_helpers' +import { + isAuthCodeExpired, + parseAuthCode, + resolveCliAuthCode, + validateAuthCode, +} from './_helpers' import { authOptions } from '../api/auth/[...nextauth]/auth-options' import CardWithBeams from '@/components/card-with-beams' @@ -48,7 +54,12 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } - const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode) + const { authCode: resolvedAuthCode } = await resolveCliAuthCode( + authCode, + consumeCliAuthCodeToken, + ) + const { fingerprintId, expiresAt, receivedHash } = + parseAuthCode(resolvedAuthCode) const { valid, expectedHash: fingerprintHash } = validateAuthCode( receivedHash, fingerprintId,