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..315284d95 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/__tests__/helpers.test.ts b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts index 4b4596a8b..4d9d0eab9 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/_db.ts b/freebuff/web/src/app/onboard/_db.ts index 078d757d5..0e3858798 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/_helpers.ts b/freebuff/web/src/app/onboard/_helpers.ts index d502d0d20..850a3eaec 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 287b761f4..21f6e6135 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,10 @@ 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), 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..455375d60 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/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 12604ea60..84f189390 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( @@ -1144,47 +1160,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 +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', diff --git a/web/src/app/onboard/__tests__/helpers.test.ts b/web/src/app/onboard/__tests__/helpers.test.ts index 6c5c433e5..2d10f2447 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/_db.ts b/web/src/app/onboard/_db.ts index 078d757d5..0e3858798 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/_helpers.ts b/web/src/app/onboard/_helpers.ts index d502d0d20..850a3eaec 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: '' } } diff --git a/web/src/app/onboard/page.tsx b/web/src/app/onboard/page.tsx index 6e5ea8f88..aba3ded26 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,