Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 59 additions & 2 deletions freebuff/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,17 +19,67 @@ 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,
}: {
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)) {
Expand Down
29 changes: 29 additions & 0 deletions freebuff/web/src/app/onboard/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getConsumedCliAuthCodeTokenIdentifier,
getConsumedCliAuthCodeTokenValue,
isAuthCodeExpired,
isCliAuthCodeCandidate,
isOpaqueCliAuthCodeToken,
parseAuthCode,
resolveCliAuthCode,
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions freebuff/web/src/app/onboard/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
}
Expand Down
Loading