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
9 changes: 6 additions & 3 deletions freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Adapter } from 'next-auth/adapters'

import {
getCliAuthCodeHashPrefix,
getCliAuthOnboardSearchParams,
isCliAuthCodeCandidate,
} from '@/app/onboard/_helpers'
import { logger } from '@/util/logger'
Expand Down Expand Up @@ -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()
}

Expand Down
10 changes: 7 additions & 3 deletions freebuff/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -80,7 +82,9 @@ export default async function LoginPage({
)
}

const { expiresAt } = parseAuthCode(authCode)
const { expiresAt } = validAuthCode
? parseAuthCode(validAuthCode)
: { expiresAt: '' }

if (expiresAt && isAuthCodeExpired(expiresAt)) {
return (
Expand Down Expand Up @@ -122,7 +126,7 @@ export default async function LoginPage({
<HeroGrid />
<BackgroundBeams />
<main className="relative z-10 flex flex-col items-center justify-center min-h-screen py-20">
<LoginCard authCode={authCode} />
<LoginCard authCode={validAuthCode} />
</main>
</div>
)
Expand Down
63 changes: 14 additions & 49 deletions freebuff/web/src/app/onboard/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
}
Expand Down Expand Up @@ -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(
/^(?<fingerprintId>.+)-(?<expiresAt>\d+)-(?<receivedHash>[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(
Expand Down
7 changes: 3 additions & 4 deletions freebuff/web/src/components/login/login-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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' })
Expand Down
9 changes: 7 additions & 2 deletions freebuff/web/src/components/sign-in/sign-in-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 = '/'
}
Expand Down
81 changes: 81 additions & 0 deletions freebuff/web/src/lib/cli-auth-code-shape.ts
Original file line number Diff line number Diff line change
@@ -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(
/^(?<fingerprintId>.+)-(?<expiresAt>\d+)-(?<receivedHash>[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()}`
}
Loading