Skip to content

Commit 873c191

Browse files
authored
Classify reused CLI auth tokens (#634)
1 parent 54df847 commit 873c191

11 files changed

Lines changed: 401 additions & 49 deletions

File tree

docs/authentication.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ sequenceDiagram
1919
CLI->>CLI: Open browser
2020
Note over Web: User completes OAuth
2121
Web->>DB: Resolve opaque token to signed payload
22-
Web->>DB: Delete opaque token
22+
Web->>DB: Mark opaque token consumed
2323
Web->>DB: Check fingerprint ownership
2424
Web->>DB: Create/update session
2525
loop Every 5s
@@ -74,7 +74,7 @@ sequenceDiagram
7474

7575
- Signed auth payloads expire after 1 hour
7676
- Browser login URLs use opaque 43-character tokens instead of exposing the signed auth payload
77-
- Opaque browser tokens are stored in `verificationToken` under `cli-login:<token>` and consumed with `DELETE ... RETURNING` when onboarding resolves them
77+
- Opaque browser tokens are stored in `verificationToken` under `cli-login:<token>` and atomically moved to `cli-login-consumed:<token-hash>` when onboarding resolves them; consumed markers scrub the signed auth payload from the `token` column
7878
- Fingerprint uniqueness: hardware info + 8 random bytes
7979
- Ownership conflicts blocked and logged
8080
- Sessions linked to fingerprint_id in database

freebuff/web/src/app/api/auth/cli/code/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { z } from 'zod/v4'
1111
import {
1212
buildCliAuthCode,
1313
getCliAuthCodeHashPrefix,
14+
getCliAuthCodeTokenIdentifier,
1415
} from '@/app/onboard/_helpers'
1516
import { logger } from '@/util/logger'
1617

@@ -69,7 +70,7 @@ export async function POST(req: Request) {
6970
const loginToken = randomBytes(32).toString('base64url')
7071

7172
await db.insert(schema.verificationToken).values({
72-
identifier: `cli-login:${loginToken}`,
73+
identifier: getCliAuthCodeTokenIdentifier(loginToken),
7374
token: authCode,
7475
expires: new Date(expiresAt),
7576
})

freebuff/web/src/app/onboard/__tests__/helpers.test.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
44
import {
55
buildCliAuthCode,
66
getCliAuthCodeHashPrefix,
7+
getCliAuthCodeTokenIdentifier,
8+
getConsumedCliAuthCodeTokenIdentifier,
9+
getConsumedCliAuthCodeTokenValue,
710
isAuthCodeExpired,
811
isOpaqueCliAuthCodeToken,
912
parseAuthCode,
@@ -118,6 +121,16 @@ describe('freebuff onboard/_helpers', () => {
118121
)
119122
})
120123

124+
test('builds active and consumed token identifiers', () => {
125+
expect(getCliAuthCodeTokenIdentifier('token-123')).toBe(
126+
'cli-login:token-123',
127+
)
128+
expect(getConsumedCliAuthCodeTokenIdentifier('token-123')).toBe(
129+
'cli-login-consumed:034192845dc489deca291f9f5ae0bb8e5472c991020bf64b3ebc6dec5a1d7e47',
130+
)
131+
expect(getConsumedCliAuthCodeTokenValue()).toBe('consumed')
132+
})
133+
121134
test('resolves an opaque browser token before validation', async () => {
122135
const expiresAt = '4102444800000'
123136
const fingerprintHash = genAuthCode(
@@ -134,10 +147,11 @@ describe('freebuff onboard/_helpers', () => {
134147

135148
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
136149
expect(token).toBe(opaqueToken)
137-
return signedAuthCode
150+
return { status: 'resolved', authCode: signedAuthCode }
138151
})
139152

140153
expect(result).toEqual({
154+
status: 'ready',
141155
authCode: signedAuthCode,
142156
resolvedOpaqueToken: true,
143157
})
@@ -163,16 +177,47 @@ describe('freebuff onboard/_helpers', () => {
163177

164178
const result = await resolveCliAuthCode(signedAuthCode, async () => {
165179
lookedUp = true
166-
return null
180+
return { status: 'missing' }
167181
})
168182

169183
expect(lookedUp).toBe(false)
170184
expect(result).toEqual({
185+
status: 'ready',
171186
authCode: signedAuthCode,
172187
resolvedOpaqueToken: false,
173188
})
174189
})
175190

191+
test('classifies reused opaque browser tokens as already consumed', async () => {
192+
const opaqueToken = 'c'.repeat(43)
193+
194+
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
195+
expect(token).toBe(opaqueToken)
196+
return { status: 'already_consumed' }
197+
})
198+
199+
expect(result).toEqual({
200+
status: 'already_consumed',
201+
authCode: opaqueToken,
202+
resolvedOpaqueToken: false,
203+
})
204+
})
205+
206+
test('keeps never-issued opaque browser tokens invalid', async () => {
207+
const opaqueToken = 'd'.repeat(43)
208+
209+
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
210+
expect(token).toBe(opaqueToken)
211+
return { status: 'missing' }
212+
})
213+
214+
expect(result).toEqual({
215+
status: 'missing',
216+
authCode: opaqueToken,
217+
resolvedOpaqueToken: false,
218+
})
219+
})
220+
176221
test('resolves expired stored payloads so callers can show expired', async () => {
177222
const expiresAt = '0'
178223
const fingerprintHash = genAuthCode(
@@ -186,10 +231,10 @@ describe('freebuff onboard/_helpers', () => {
186231
fingerprintHash,
187232
)
188233

189-
const result = await resolveCliAuthCode(
190-
'b'.repeat(43),
191-
async () => signedAuthCode,
192-
)
234+
const result = await resolveCliAuthCode('b'.repeat(43), async () => ({
235+
status: 'resolved',
236+
authCode: signedAuthCode,
237+
}))
193238
const parsed = parseAuthCode(result.authCode)
194239

195240
expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true)

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

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import { cookies } from 'next/headers'
66

77
import { logger } from '@/util/logger'
88

9+
import {
10+
getCliAuthCodeTokenIdentifier,
11+
getConsumedCliAuthCodeTokenIdentifier,
12+
getConsumedCliAuthCodeTokenValue,
13+
type CliAuthCodeTokenConsumeResult,
14+
} from './_helpers'
15+
916
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
1017
tx: infer T,
1118
) => any
@@ -34,15 +41,53 @@ export async function hasCliSessionForAuthHash(
3441

3542
export async function consumeCliAuthCodeToken(
3643
authCodeToken: string,
37-
): Promise<string | null> {
38-
const deleted = await db
39-
.delete(schema.verificationToken)
44+
): Promise<CliAuthCodeTokenConsumeResult> {
45+
const activeIdentifier = getCliAuthCodeTokenIdentifier(authCodeToken)
46+
const consumedIdentifier =
47+
getConsumedCliAuthCodeTokenIdentifier(authCodeToken)
48+
const getConsumedTokenStatus =
49+
async (): Promise<CliAuthCodeTokenConsumeResult> => {
50+
const existingConsumed = await db
51+
.select({ id: schema.verificationToken.identifier })
52+
.from(schema.verificationToken)
53+
.where(eq(schema.verificationToken.identifier, consumedIdentifier))
54+
.limit(1)
55+
56+
return existingConsumed[0]
57+
? { status: 'already_consumed' }
58+
: { status: 'missing' }
59+
}
60+
61+
const active = await db
62+
.select({ authCode: schema.verificationToken.token })
63+
.from(schema.verificationToken)
64+
.where(eq(schema.verificationToken.identifier, activeIdentifier))
65+
.limit(1)
66+
const authCode = active[0]?.authCode
67+
68+
if (!authCode) {
69+
return getConsumedTokenStatus()
70+
}
71+
72+
const consumed = await db
73+
.update(schema.verificationToken)
74+
.set({
75+
identifier: consumedIdentifier,
76+
token: getConsumedCliAuthCodeTokenValue(),
77+
})
4078
.where(
41-
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
79+
and(
80+
eq(schema.verificationToken.identifier, activeIdentifier),
81+
eq(schema.verificationToken.token, authCode),
82+
),
4283
)
43-
.returning({ authCode: schema.verificationToken.token })
84+
.returning({ id: schema.verificationToken.identifier })
85+
86+
if (consumed[0]) {
87+
return { status: 'resolved', authCode }
88+
}
4489

45-
return deleted[0]?.authCode ?? null
90+
return getConsumedTokenStatus()
4691
}
4792

4893
export async function checkFingerprintConflict(

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

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { createHash } from 'node:crypto'
33
import { genAuthCode } from '@codebuff/common/util/credentials'
44

55
const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
6+
const CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login:'
7+
const CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login-consumed:'
8+
const CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE = 'consumed'
9+
10+
function getCliAuthCodeHash(authCode: string): string {
11+
return createHash('sha256').update(authCode.trim()).digest('hex')
12+
}
613

714
export function buildCliAuthCode(
815
fingerprintId: string,
@@ -17,26 +24,83 @@ export function isOpaqueCliAuthCodeToken(authCode: string): boolean {
1724
}
1825

1926
export function getCliAuthCodeHashPrefix(authCode: string): string {
20-
return createHash('sha256').update(authCode.trim()).digest('hex').slice(0, 12)
27+
return getCliAuthCodeHash(authCode).slice(0, 12)
28+
}
29+
30+
export function getCliAuthCodeTokenIdentifier(authCodeToken: string): string {
31+
return `${CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}`
32+
}
33+
34+
export function getConsumedCliAuthCodeTokenIdentifier(
35+
authCodeToken: string,
36+
): string {
37+
return `${CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${getCliAuthCodeHash(
38+
authCodeToken,
39+
)}`
2140
}
2241

42+
export function getConsumedCliAuthCodeTokenValue(): string {
43+
return CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE
44+
}
45+
46+
export type CliAuthCodeTokenConsumeResult =
47+
| { status: 'resolved'; authCode: string }
48+
| { status: 'already_consumed' }
49+
| { status: 'missing' }
50+
51+
export type CliAuthCodeResolution =
52+
| {
53+
status: 'ready'
54+
authCode: string
55+
resolvedOpaqueToken: boolean
56+
}
57+
| {
58+
status: 'already_consumed'
59+
authCode: string
60+
resolvedOpaqueToken: false
61+
}
62+
| {
63+
status: 'missing'
64+
authCode: string
65+
resolvedOpaqueToken: false
66+
}
67+
2368
export async function resolveCliAuthCode(
2469
authCode: string,
25-
consumeCliAuthCodeToken: (authCodeToken: string) => Promise<string | null>,
26-
): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> {
70+
consumeCliAuthCodeToken: (
71+
authCodeToken: string,
72+
) => Promise<CliAuthCodeTokenConsumeResult>,
73+
): Promise<CliAuthCodeResolution> {
2774
const normalizedAuthCode = authCode.trim()
2875
if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) {
29-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
76+
return {
77+
status: 'ready',
78+
authCode: normalizedAuthCode,
79+
resolvedOpaqueToken: false,
80+
}
3081
}
3182

32-
const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode)
33-
if (!signedAuthCode) {
34-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
83+
const tokenResult = await consumeCliAuthCodeToken(normalizedAuthCode)
84+
if (tokenResult.status === 'resolved') {
85+
return {
86+
status: 'ready',
87+
authCode: tokenResult.authCode,
88+
resolvedOpaqueToken: true,
89+
}
90+
}
91+
92+
if (tokenResult.status === 'already_consumed') {
93+
return {
94+
status: 'already_consumed',
95+
authCode: normalizedAuthCode,
96+
resolvedOpaqueToken: false,
97+
}
3598
}
3699

37100
return {
38-
authCode: signedAuthCode,
39-
resolvedOpaqueToken: true,
101+
status: 'missing',
102+
authCode: normalizedAuthCode,
103+
resolvedOpaqueToken: false,
40104
}
41105
}
42106

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,37 @@ const Onboard = async ({ searchParams }: PageProps) => {
9999
)
100100
}
101101

102-
const { authCode: resolvedAuthCode, resolvedOpaqueToken } =
103-
await resolveCliAuthCode(authCode, consumeCliAuthCodeToken)
102+
const authCodeResolution = await resolveCliAuthCode(
103+
authCode,
104+
consumeCliAuthCodeToken,
105+
)
106+
107+
if (authCodeResolution.status === 'already_consumed') {
108+
logger.info(
109+
{
110+
authCodeLength: authCode.length,
111+
authCodeTrimmedLength: authCode.trim().length,
112+
authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode),
113+
isOpaqueAuthCodeToken: isOpaqueCliAuthCodeToken(authCode),
114+
userId: user.id,
115+
},
116+
'Reused Freebuff CLI auth code token',
117+
)
118+
119+
return (
120+
<StatusCard
121+
title="Login link already used"
122+
description="This browser login link has already been used."
123+
message="Return to your terminal to continue, or restart Freebuff if it is still waiting for login."
124+
/>
125+
)
126+
}
127+
128+
const {
129+
authCode: resolvedAuthCode,
130+
resolvedOpaqueToken,
131+
status: authCodeResolutionStatus,
132+
} = authCodeResolution
104133
const { fingerprintId, expiresAt, receivedHash } =
105134
parseAuthCode(resolvedAuthCode)
106135
const { valid, expectedHash: fingerprintHash } = validateAuthCode(
@@ -117,6 +146,7 @@ const Onboard = async ({ searchParams }: PageProps) => {
117146
authCodeTrimmedLength: authCode.trim().length,
118147
authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode),
119148
isOpaqueAuthCodeToken: isOpaqueCliAuthCodeToken(authCode),
149+
authCodeResolutionStatus,
120150
resolvedAuthCode: resolvedOpaqueToken,
121151
resolvedAuthCodeLength: resolvedAuthCode.length,
122152
userId: user.id,

web/src/app/api/auth/cli/code/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { z } from 'zod/v4'
1111
import {
1212
buildCliAuthCode,
1313
getCliAuthCodeHashPrefix,
14+
getCliAuthCodeTokenIdentifier,
1415
} from '@/app/onboard/_helpers'
1516
import { logger } from '@/util/logger'
1617

@@ -71,7 +72,7 @@ export async function POST(req: Request) {
7172
const loginToken = randomBytes(32).toString('base64url')
7273

7374
await db.insert(schema.verificationToken).values({
74-
identifier: `cli-login:${loginToken}`,
75+
identifier: getCliAuthCodeTokenIdentifier(loginToken),
7576
token: authCode,
7677
expires: new Date(expiresAt),
7778
})

0 commit comments

Comments
 (0)