diff --git a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts index ae0c4f04d4..b2b4467578 100644 --- a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -15,6 +15,10 @@ import GitHubProvider from 'next-auth/providers/github' import type { NextAuthOptions } from 'next-auth' import type { Adapter } from 'next-auth/adapters' +import { + getCliAuthCodeHashPrefix, + isCliAuthCodeCandidate, +} from '@/app/onboard/_helpers' import { logger } from '@/util/logger' async function createAndLinkStripeCustomer(params: { @@ -104,6 +108,31 @@ export const authOptions: NextAuthOptions = { const authCode = potentialRedirectUrl.searchParams.get('auth_code') if (authCode) { + if (!isCliAuthCodeCandidate(authCode)) { + const searchParamKeys = Array.from( + potentialRedirectUrl.searchParams.keys(), + ).sort() + logger.warn( + { + authCodeLength: authCode.length, + authCodeTrimmedLength: authCode.trim().length, + authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), + authCodeParamCount: + potentialRedirectUrl.searchParams.getAll('auth_code').length, + searchParamKeys, + searchParamCount: searchParamKeys.length, + hasCallbackUrlParam: searchParamKeys.includes('callbackUrl'), + hasCodeParam: searchParamKeys.includes('code'), + hasRedirectParam: searchParamKeys.includes('redirect'), + dotCount: authCode.match(/\./g)?.length ?? 0, + hyphenCount: authCode.match(/-/g)?.length ?? 0, + redirectUrlOrigin: potentialRedirectUrl.origin, + baseUrl, + }, + 'Freebuff auth redirect received non-CLI-shaped auth_code', + ) + } + const onboardUrl = new URL(`${baseUrl}/onboard`) potentialRedirectUrl.searchParams.forEach((value, key) => { onboardUrl.searchParams.set(key, value) diff --git a/freebuff/web/src/app/login/page.tsx b/freebuff/web/src/app/login/page.tsx index 9a37fac3ec..311cc2931d 100644 --- a/freebuff/web/src/app/login/page.tsx +++ b/freebuff/web/src/app/login/page.tsx @@ -1,7 +1,14 @@ 'use server' import { env } from '@codebuff/common/env' +import { headers } from 'next/headers' +import { + getCliAuthCodeHashPrefix, + isAuthCodeExpired, + isCliAuthCodeCandidate, + parseAuthCode, +} from '@/app/onboard/_helpers' import { BackgroundBeams } from '@/components/background-beams' import { HeroGrid } from '@/components/hero-grid' import { LoginCard } from '@/components/login/login-card' @@ -12,7 +19,7 @@ import { CardDescription, CardContent, } from '@/components/ui/card' -import { isAuthCodeExpired, parseAuthCode } from '@/app/onboard/_helpers' +import { logger } from '@/util/logger' export default async function LoginPage({ searchParams, @@ -20,9 +27,59 @@ export default async function LoginPage({ searchParams?: Promise<{ [key: string]: string | string[] | undefined }> }) { const resolvedSearchParams = searchParams ? await searchParams : {} - const authCode = resolvedSearchParams?.auth_code as string | undefined + const rawAuthCode = resolvedSearchParams?.auth_code + const authCode = Array.isArray(rawAuthCode) ? rawAuthCode[0] : rawAuthCode + const searchParamKeys = Object.keys(resolvedSearchParams).sort() if (authCode) { + if (!isCliAuthCodeCandidate(authCode)) { + const headerStore = await headers() + logger.warn( + { + authCodeLength: authCode.length, + authCodeTrimmedLength: authCode.trim().length, + authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), + authCodeParamCount: Array.isArray(rawAuthCode) + ? rawAuthCode.length + : 1, + searchParamKeys, + searchParamCount: searchParamKeys.length, + hasCallbackUrlParam: searchParamKeys.includes('callbackUrl'), + hasCodeParam: searchParamKeys.includes('code'), + hasRedirectParam: searchParamKeys.includes('redirect'), + dotCount: authCode.match(/\./g)?.length ?? 0, + hyphenCount: authCode.match(/-/g)?.length ?? 0, + requestHost: headerStore.get('host') ?? '', + forwardedHost: headerStore.get('x-forwarded-host') ?? '', + forwardedProto: headerStore.get('x-forwarded-proto') ?? '', + originHeader: headerStore.get('origin') ?? '', + referer: headerStore.get('referer') ?? '', + userAgent: headerStore.get('user-agent') ?? '', + referrerParam: + typeof resolvedSearchParams.referrer === 'string' + ? resolvedSearchParams.referrer + : '', + utmSource: + typeof resolvedSearchParams.utm_source === 'string' + ? resolvedSearchParams.utm_source + : '', + utmMedium: + typeof resolvedSearchParams.utm_medium === 'string' + ? resolvedSearchParams.utm_medium + : '', + utmCampaign: + typeof resolvedSearchParams.utm_campaign === 'string' + ? resolvedSearchParams.utm_campaign + : '', + utmContent: + typeof resolvedSearchParams.utm_content === 'string' + ? resolvedSearchParams.utm_content + : '', + }, + 'Freebuff login received non-CLI-shaped auth_code', + ) + } + const { expiresAt } = parseAuthCode(authCode) if (expiresAt && isAuthCodeExpired(expiresAt)) { diff --git a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts index 8123604430..04890eeb34 100644 --- a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts +++ b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts @@ -8,6 +8,7 @@ import { getConsumedCliAuthCodeTokenIdentifier, getConsumedCliAuthCodeTokenValue, isAuthCodeExpired, + isCliAuthCodeCandidate, isOpaqueCliAuthCodeToken, parseAuthCode, resolveCliAuthCode, @@ -114,6 +115,34 @@ describe('freebuff onboard/_helpers', () => { expect(isOpaqueCliAuthCodeToken(`${'A'.repeat(42)}.`)).toBe(false) }) + test('identifies auth code candidates by supported shapes', () => { + const opaqueToken = 'A'.repeat(41) + '-_' + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + '1704067200000', + 'a'.repeat(64), + ) + const legacyAuthCode = `1234567890abcdef-1704067200000-${'b'.repeat( + 64, + )}` + + expect(isCliAuthCodeCandidate(opaqueToken)).toBe(true) + expect(isCliAuthCodeCandidate(signedAuthCode)).toBe(true) + expect(isCliAuthCodeCandidate(legacyAuthCode)).toBe(true) + expect(isCliAuthCodeCandidate(crypto.randomUUID())).toBe(false) + expect(isCliAuthCodeCandidate('F0xe_Mt2yA2az_LUXGxlBsGDIgJ')).toBe(false) + expect( + isCliAuthCodeCandidate( + buildCliAuthCode(testFingerprintId, 'not-a-number', 'a'.repeat(64)), + ), + ).toBe(false) + expect( + isCliAuthCodeCandidate( + buildCliAuthCode(testFingerprintId, '1704067200000', 'short-hash'), + ), + ).toBe(false) + }) + test('hashes auth codes for log correlation without logging the token', () => { expect(getCliAuthCodeHashPrefix('a'.repeat(43))).toBe('66d34fba71f8') expect(getCliAuthCodeHashPrefix(` ${'a'.repeat(43)}\n`)).toBe( diff --git a/freebuff/web/src/app/onboard/_helpers.ts b/freebuff/web/src/app/onboard/_helpers.ts index 58d5204a5f..35901fb112 100644 --- a/freebuff/web/src/app/onboard/_helpers.ts +++ b/freebuff/web/src/app/onboard/_helpers.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { genAuthCode } from '@codebuff/common/util/credentials' const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ +const CLI_AUTH_CODE_HASH_RE = /^[a-f0-9]{64}$/i const CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login:' const CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login-consumed:' const CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE = 'consumed' @@ -23,6 +24,19 @@ export function isOpaqueCliAuthCodeToken(authCode: string): boolean { return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim()) } +export function isCliAuthCodeCandidate(authCode: string): boolean { + if (isOpaqueCliAuthCodeToken(authCode)) { + return true + } + + const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode) + return ( + fingerprintId.length > 0 && + /^\d+$/.test(expiresAt) && + CLI_AUTH_CODE_HASH_RE.test(receivedHash) + ) +} + export function getCliAuthCodeHashPrefix(authCode: string): string { return getCliAuthCodeHash(authCode).slice(0, 12) }