From 5aaa700c060c55f985966752efe0edd073d58f82 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 12 May 2026 16:29:23 -0700 Subject: [PATCH] Drop invalid Freebuff login auth codes --- .../api/auth/[...nextauth]/auth-options.ts | 9 ++- freebuff/web/src/app/login/page.tsx | 10 ++- freebuff/web/src/app/onboard/_helpers.ts | 63 ++++----------- .../web/src/components/login/login-card.tsx | 7 +- .../src/components/sign-in/sign-in-button.tsx | 9 ++- freebuff/web/src/lib/cli-auth-code-shape.ts | 81 +++++++++++++++++++ 6 files changed, 118 insertions(+), 61 deletions(-) create mode 100644 freebuff/web/src/lib/cli-auth-code-shape.ts 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 b2b4467578..53a0d05aea 100644 --- a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -17,6 +17,7 @@ import type { Adapter } from 'next-auth/adapters' import { getCliAuthCodeHashPrefix, + getCliAuthOnboardSearchParams, isCliAuthCodeCandidate, } from '@/app/onboard/_helpers' import { logger } from '@/util/logger' @@ -131,12 +132,14 @@ export const authOptions: NextAuthOptions = { }, 'Freebuff auth redirect received non-CLI-shaped auth_code', ) + return baseUrl } const onboardUrl = new URL(`${baseUrl}/onboard`) - potentialRedirectUrl.searchParams.forEach((value, key) => { - onboardUrl.searchParams.set(key, value) - }) + onboardUrl.search = getCliAuthOnboardSearchParams( + potentialRedirectUrl.searchParams, + authCode, + ).toString() return onboardUrl.toString() } diff --git a/freebuff/web/src/app/login/page.tsx b/freebuff/web/src/app/login/page.tsx index 311cc2931d..6dd45aca90 100644 --- a/freebuff/web/src/app/login/page.tsx +++ b/freebuff/web/src/app/login/page.tsx @@ -29,10 +29,12 @@ export default async function LoginPage({ const resolvedSearchParams = searchParams ? await searchParams : {} const rawAuthCode = resolvedSearchParams?.auth_code const authCode = Array.isArray(rawAuthCode) ? rawAuthCode[0] : rawAuthCode + const validAuthCode = + authCode && isCliAuthCodeCandidate(authCode) ? authCode : undefined const searchParamKeys = Object.keys(resolvedSearchParams).sort() if (authCode) { - if (!isCliAuthCodeCandidate(authCode)) { + if (!validAuthCode) { const headerStore = await headers() logger.warn( { @@ -80,7 +82,9 @@ export default async function LoginPage({ ) } - const { expiresAt } = parseAuthCode(authCode) + const { expiresAt } = validAuthCode + ? parseAuthCode(validAuthCode) + : { expiresAt: '' } if (expiresAt && isAuthCodeExpired(expiresAt)) { return ( @@ -122,7 +126,7 @@ export default async function LoginPage({
- +
) diff --git a/freebuff/web/src/app/onboard/_helpers.ts b/freebuff/web/src/app/onboard/_helpers.ts index 35901fb112..53823389be 100644 --- a/freebuff/web/src/app/onboard/_helpers.ts +++ b/freebuff/web/src/app/onboard/_helpers.ts @@ -2,8 +2,19 @@ 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 +import { + getCliAuthOnboardSearchParams, + isCliAuthCodeCandidate, + isOpaqueCliAuthCodeToken, + parseCliAuthCodeShape, +} from '@/lib/cli-auth-code-shape' + +export { + getCliAuthOnboardSearchParams, + isCliAuthCodeCandidate, + isOpaqueCliAuthCodeToken, +} + 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' @@ -20,23 +31,6 @@ export function buildCliAuthCode( return `${fingerprintId}.${expiresAt}.${fingerprintHash}` } -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) } @@ -123,36 +117,7 @@ export function parseAuthCode(authCode: string): { expiresAt: string receivedHash: string } { - const normalizedAuthCode = authCode.trim() - const hashSeparatorIndex = normalizedAuthCode.lastIndexOf('.') - const expiresSeparatorIndex = normalizedAuthCode.lastIndexOf( - '.', - hashSeparatorIndex - 1, - ) - - 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: '' } - } - - const fingerprintId = normalizedAuthCode.slice(0, expiresSeparatorIndex) - const expiresAt = normalizedAuthCode.slice( - expiresSeparatorIndex + 1, - hashSeparatorIndex, - ) - const receivedHash = normalizedAuthCode.slice(hashSeparatorIndex + 1) - - return { fingerprintId, expiresAt, receivedHash } + return parseCliAuthCodeShape(authCode) } export function validateAuthCode( diff --git a/freebuff/web/src/components/login/login-card.tsx b/freebuff/web/src/components/login/login-card.tsx index c1338f4325..104045932e 100644 --- a/freebuff/web/src/components/login/login-card.tsx +++ b/freebuff/web/src/components/login/login-card.tsx @@ -14,6 +14,7 @@ import { CardContent, CardFooter, } from '@/components/ui/card' +import { getCliAuthOnboardPath } from '@/lib/cli-auth-code-shape' export function LoginCard({ authCode }: { authCode?: string | null }) { const { data: session } = useSession() @@ -32,7 +33,7 @@ export function LoginCard({ authCode }: { authCode?: string | null }) { let callbackUrl = '/' if (authCode) { - callbackUrl = `/onboard?${searchParams.toString()}` + callbackUrl = getCliAuthOnboardPath(searchParams, authCode) } window.location.href = callbackUrl @@ -41,11 +42,9 @@ export function LoginCard({ authCode }: { authCode?: string | null }) { const handleUseAnotherAccount = () => { persistReferrer() - const searchParamsString = searchParams.toString() - let callbackUrl = '/login' if (authCode) { - callbackUrl = `/onboard?${searchParamsString}` + callbackUrl = getCliAuthOnboardPath(searchParams, authCode) } signIn('github', { callbackUrl, prompt: 'login' }) diff --git a/freebuff/web/src/components/sign-in/sign-in-button.tsx b/freebuff/web/src/components/sign-in/sign-in-button.tsx index 66fb41fb82..7d7725f499 100644 --- a/freebuff/web/src/components/sign-in/sign-in-button.tsx +++ b/freebuff/web/src/components/sign-in/sign-in-button.tsx @@ -7,6 +7,11 @@ import { useTransition } from 'react' import { Icons } from '../icons' import { Button } from '../ui/button' +import { + getCliAuthOnboardPath, + isCliAuthCodeCandidate, +} from '@/lib/cli-auth-code-shape' + import type { OAuthProviderType } from 'next-auth/providers/oauth-types' export function SignInButton({ @@ -34,8 +39,8 @@ export function SignInButton({ if (pathname === '/login') { const authCode = searchParams.get('auth_code') - if (authCode) { - callbackUrl = `/onboard?${searchParams.toString()}` + if (authCode && isCliAuthCodeCandidate(authCode)) { + callbackUrl = getCliAuthOnboardPath(searchParams, authCode) } else { callbackUrl = '/' } diff --git a/freebuff/web/src/lib/cli-auth-code-shape.ts b/freebuff/web/src/lib/cli-auth-code-shape.ts new file mode 100644 index 0000000000..00436dee09 --- /dev/null +++ b/freebuff/web/src/lib/cli-auth-code-shape.ts @@ -0,0 +1,81 @@ +const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ +const CLI_AUTH_CODE_HASH_RE = /^[a-f0-9]{64}$/i + +export function isOpaqueCliAuthCodeToken(authCode: string): boolean { + return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim()) +} + +export function parseCliAuthCodeShape(authCode: string): { + fingerprintId: string + expiresAt: string + receivedHash: string +} { + const normalizedAuthCode = authCode.trim() + const hashSeparatorIndex = normalizedAuthCode.lastIndexOf('.') + const expiresSeparatorIndex = normalizedAuthCode.lastIndexOf( + '.', + hashSeparatorIndex - 1, + ) + + 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: '' } + } + + const fingerprintId = normalizedAuthCode.slice(0, expiresSeparatorIndex) + const expiresAt = normalizedAuthCode.slice( + expiresSeparatorIndex + 1, + hashSeparatorIndex, + ) + const receivedHash = normalizedAuthCode.slice(hashSeparatorIndex + 1) + + return { fingerprintId, expiresAt, receivedHash } +} + +export function isCliAuthCodeCandidate(authCode: string): boolean { + if (isOpaqueCliAuthCodeToken(authCode)) { + return true + } + + const { fingerprintId, expiresAt, receivedHash } = + parseCliAuthCodeShape(authCode) + return ( + fingerprintId.length > 0 && + /^\d+$/.test(expiresAt) && + CLI_AUTH_CODE_HASH_RE.test(receivedHash) + ) +} + +export function getCliAuthOnboardSearchParams( + searchParams: URLSearchParams, + authCode: string, +): URLSearchParams { + const onboardParams = new URLSearchParams() + searchParams.forEach((value, key) => { + if (key !== 'auth_code') { + onboardParams.append(key, value) + } + }) + onboardParams.set('auth_code', authCode) + return onboardParams +} + +export function getCliAuthOnboardPath( + searchParams: URLSearchParams, + authCode: string, +): string { + return `/onboard?${getCliAuthOnboardSearchParams( + searchParams, + authCode, + ).toString()}` +}