Skip to content

Commit b5e8086

Browse files
authored
Drop invalid Freebuff login auth codes (#664)
1 parent a5862a5 commit b5e8086

6 files changed

Lines changed: 118 additions & 61 deletions

File tree

freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { Adapter } from 'next-auth/adapters'
1717

1818
import {
1919
getCliAuthCodeHashPrefix,
20+
getCliAuthOnboardSearchParams,
2021
isCliAuthCodeCandidate,
2122
} from '@/app/onboard/_helpers'
2223
import { logger } from '@/util/logger'
@@ -131,12 +132,14 @@ export const authOptions: NextAuthOptions = {
131132
},
132133
'Freebuff auth redirect received non-CLI-shaped auth_code',
133134
)
135+
return baseUrl
134136
}
135137

136138
const onboardUrl = new URL(`${baseUrl}/onboard`)
137-
potentialRedirectUrl.searchParams.forEach((value, key) => {
138-
onboardUrl.searchParams.set(key, value)
139-
})
139+
onboardUrl.search = getCliAuthOnboardSearchParams(
140+
potentialRedirectUrl.searchParams,
141+
authCode,
142+
).toString()
140143
return onboardUrl.toString()
141144
}
142145

freebuff/web/src/app/login/page.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ export default async function LoginPage({
2929
const resolvedSearchParams = searchParams ? await searchParams : {}
3030
const rawAuthCode = resolvedSearchParams?.auth_code
3131
const authCode = Array.isArray(rawAuthCode) ? rawAuthCode[0] : rawAuthCode
32+
const validAuthCode =
33+
authCode && isCliAuthCodeCandidate(authCode) ? authCode : undefined
3234
const searchParamKeys = Object.keys(resolvedSearchParams).sort()
3335

3436
if (authCode) {
35-
if (!isCliAuthCodeCandidate(authCode)) {
37+
if (!validAuthCode) {
3638
const headerStore = await headers()
3739
logger.warn(
3840
{
@@ -80,7 +82,9 @@ export default async function LoginPage({
8082
)
8183
}
8284

83-
const { expiresAt } = parseAuthCode(authCode)
85+
const { expiresAt } = validAuthCode
86+
? parseAuthCode(validAuthCode)
87+
: { expiresAt: '' }
8488

8589
if (expiresAt && isAuthCodeExpired(expiresAt)) {
8690
return (
@@ -122,7 +126,7 @@ export default async function LoginPage({
122126
<HeroGrid />
123127
<BackgroundBeams />
124128
<main className="relative z-10 flex flex-col items-center justify-center min-h-screen py-20">
125-
<LoginCard authCode={authCode} />
129+
<LoginCard authCode={validAuthCode} />
126130
</main>
127131
</div>
128132
)

freebuff/web/src/app/onboard/_helpers.ts

Lines changed: 14 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,19 @@ import { createHash } from 'node:crypto'
22

33
import { genAuthCode } from '@codebuff/common/util/credentials'
44

5-
const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
6-
const CLI_AUTH_CODE_HASH_RE = /^[a-f0-9]{64}$/i
5+
import {
6+
getCliAuthOnboardSearchParams,
7+
isCliAuthCodeCandidate,
8+
isOpaqueCliAuthCodeToken,
9+
parseCliAuthCodeShape,
10+
} from '@/lib/cli-auth-code-shape'
11+
12+
export {
13+
getCliAuthOnboardSearchParams,
14+
isCliAuthCodeCandidate,
15+
isOpaqueCliAuthCodeToken,
16+
}
17+
718
const CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login:'
819
const CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login-consumed:'
920
const CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE = 'consumed'
@@ -20,23 +31,6 @@ export function buildCliAuthCode(
2031
return `${fingerprintId}.${expiresAt}.${fingerprintHash}`
2132
}
2233

23-
export function isOpaqueCliAuthCodeToken(authCode: string): boolean {
24-
return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim())
25-
}
26-
27-
export function isCliAuthCodeCandidate(authCode: string): boolean {
28-
if (isOpaqueCliAuthCodeToken(authCode)) {
29-
return true
30-
}
31-
32-
const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode)
33-
return (
34-
fingerprintId.length > 0 &&
35-
/^\d+$/.test(expiresAt) &&
36-
CLI_AUTH_CODE_HASH_RE.test(receivedHash)
37-
)
38-
}
39-
4034
export function getCliAuthCodeHashPrefix(authCode: string): string {
4135
return getCliAuthCodeHash(authCode).slice(0, 12)
4236
}
@@ -123,36 +117,7 @@ export function parseAuthCode(authCode: string): {
123117
expiresAt: string
124118
receivedHash: string
125119
} {
126-
const normalizedAuthCode = authCode.trim()
127-
const hashSeparatorIndex = normalizedAuthCode.lastIndexOf('.')
128-
const expiresSeparatorIndex = normalizedAuthCode.lastIndexOf(
129-
'.',
130-
hashSeparatorIndex - 1,
131-
)
132-
133-
if (hashSeparatorIndex === -1 || expiresSeparatorIndex === -1) {
134-
const legacyMatch = normalizedAuthCode.match(
135-
/^(?<fingerprintId>.+)-(?<expiresAt>\d+)-(?<receivedHash>[a-f0-9]{64})$/i,
136-
)
137-
if (legacyMatch?.groups) {
138-
return {
139-
fingerprintId: legacyMatch.groups.fingerprintId,
140-
expiresAt: legacyMatch.groups.expiresAt,
141-
receivedHash: legacyMatch.groups.receivedHash,
142-
}
143-
}
144-
145-
return { fingerprintId: '', expiresAt: '', receivedHash: '' }
146-
}
147-
148-
const fingerprintId = normalizedAuthCode.slice(0, expiresSeparatorIndex)
149-
const expiresAt = normalizedAuthCode.slice(
150-
expiresSeparatorIndex + 1,
151-
hashSeparatorIndex,
152-
)
153-
const receivedHash = normalizedAuthCode.slice(hashSeparatorIndex + 1)
154-
155-
return { fingerprintId, expiresAt, receivedHash }
120+
return parseCliAuthCodeShape(authCode)
156121
}
157122

158123
export function validateAuthCode(

freebuff/web/src/components/login/login-card.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
CardContent,
1515
CardFooter,
1616
} from '@/components/ui/card'
17+
import { getCliAuthOnboardPath } from '@/lib/cli-auth-code-shape'
1718

1819
export function LoginCard({ authCode }: { authCode?: string | null }) {
1920
const { data: session } = useSession()
@@ -32,7 +33,7 @@ export function LoginCard({ authCode }: { authCode?: string | null }) {
3233
let callbackUrl = '/'
3334

3435
if (authCode) {
35-
callbackUrl = `/onboard?${searchParams.toString()}`
36+
callbackUrl = getCliAuthOnboardPath(searchParams, authCode)
3637
}
3738

3839
window.location.href = callbackUrl
@@ -41,11 +42,9 @@ export function LoginCard({ authCode }: { authCode?: string | null }) {
4142
const handleUseAnotherAccount = () => {
4243
persistReferrer()
4344

44-
const searchParamsString = searchParams.toString()
45-
4645
let callbackUrl = '/login'
4746
if (authCode) {
48-
callbackUrl = `/onboard?${searchParamsString}`
47+
callbackUrl = getCliAuthOnboardPath(searchParams, authCode)
4948
}
5049

5150
signIn('github', { callbackUrl, prompt: 'login' })

freebuff/web/src/components/sign-in/sign-in-button.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { useTransition } from 'react'
77
import { Icons } from '../icons'
88
import { Button } from '../ui/button'
99

10+
import {
11+
getCliAuthOnboardPath,
12+
isCliAuthCodeCandidate,
13+
} from '@/lib/cli-auth-code-shape'
14+
1015
import type { OAuthProviderType } from 'next-auth/providers/oauth-types'
1116

1217
export function SignInButton({
@@ -34,8 +39,8 @@ export function SignInButton({
3439
if (pathname === '/login') {
3540
const authCode = searchParams.get('auth_code')
3641

37-
if (authCode) {
38-
callbackUrl = `/onboard?${searchParams.toString()}`
42+
if (authCode && isCliAuthCodeCandidate(authCode)) {
43+
callbackUrl = getCliAuthOnboardPath(searchParams, authCode)
3944
} else {
4045
callbackUrl = '/'
4146
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
2+
const CLI_AUTH_CODE_HASH_RE = /^[a-f0-9]{64}$/i
3+
4+
export function isOpaqueCliAuthCodeToken(authCode: string): boolean {
5+
return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim())
6+
}
7+
8+
export function parseCliAuthCodeShape(authCode: string): {
9+
fingerprintId: string
10+
expiresAt: string
11+
receivedHash: string
12+
} {
13+
const normalizedAuthCode = authCode.trim()
14+
const hashSeparatorIndex = normalizedAuthCode.lastIndexOf('.')
15+
const expiresSeparatorIndex = normalizedAuthCode.lastIndexOf(
16+
'.',
17+
hashSeparatorIndex - 1,
18+
)
19+
20+
if (hashSeparatorIndex === -1 || expiresSeparatorIndex === -1) {
21+
const legacyMatch = normalizedAuthCode.match(
22+
/^(?<fingerprintId>.+)-(?<expiresAt>\d+)-(?<receivedHash>[a-f0-9]{64})$/i,
23+
)
24+
if (legacyMatch?.groups) {
25+
return {
26+
fingerprintId: legacyMatch.groups.fingerprintId,
27+
expiresAt: legacyMatch.groups.expiresAt,
28+
receivedHash: legacyMatch.groups.receivedHash,
29+
}
30+
}
31+
32+
return { fingerprintId: '', expiresAt: '', receivedHash: '' }
33+
}
34+
35+
const fingerprintId = normalizedAuthCode.slice(0, expiresSeparatorIndex)
36+
const expiresAt = normalizedAuthCode.slice(
37+
expiresSeparatorIndex + 1,
38+
hashSeparatorIndex,
39+
)
40+
const receivedHash = normalizedAuthCode.slice(hashSeparatorIndex + 1)
41+
42+
return { fingerprintId, expiresAt, receivedHash }
43+
}
44+
45+
export function isCliAuthCodeCandidate(authCode: string): boolean {
46+
if (isOpaqueCliAuthCodeToken(authCode)) {
47+
return true
48+
}
49+
50+
const { fingerprintId, expiresAt, receivedHash } =
51+
parseCliAuthCodeShape(authCode)
52+
return (
53+
fingerprintId.length > 0 &&
54+
/^\d+$/.test(expiresAt) &&
55+
CLI_AUTH_CODE_HASH_RE.test(receivedHash)
56+
)
57+
}
58+
59+
export function getCliAuthOnboardSearchParams(
60+
searchParams: URLSearchParams,
61+
authCode: string,
62+
): URLSearchParams {
63+
const onboardParams = new URLSearchParams()
64+
searchParams.forEach((value, key) => {
65+
if (key !== 'auth_code') {
66+
onboardParams.append(key, value)
67+
}
68+
})
69+
onboardParams.set('auth_code', authCode)
70+
return onboardParams
71+
}
72+
73+
export function getCliAuthOnboardPath(
74+
searchParams: URLSearchParams,
75+
authCode: string,
76+
): string {
77+
return `/onboard?${getCliAuthOnboardSearchParams(
78+
searchParams,
79+
authCode,
80+
).toString()}`
81+
}

0 commit comments

Comments
 (0)