Skip to content

Commit 5016388

Browse files
committed
Classify reused CLI auth tokens
1 parent 54df847 commit 5016388

9 files changed

Lines changed: 263 additions & 41 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>` when onboarding resolves them
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/onboard/__tests__/helpers.test.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,11 @@ describe('freebuff onboard/_helpers', () => {
134134

135135
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
136136
expect(token).toBe(opaqueToken)
137-
return signedAuthCode
137+
return { status: 'resolved', authCode: signedAuthCode }
138138
})
139139

140140
expect(result).toEqual({
141+
status: 'ready',
141142
authCode: signedAuthCode,
142143
resolvedOpaqueToken: true,
143144
})
@@ -163,16 +164,47 @@ describe('freebuff onboard/_helpers', () => {
163164

164165
const result = await resolveCliAuthCode(signedAuthCode, async () => {
165166
lookedUp = true
166-
return null
167+
return { status: 'missing' }
167168
})
168169

169170
expect(lookedUp).toBe(false)
170171
expect(result).toEqual({
172+
status: 'ready',
171173
authCode: signedAuthCode,
172174
resolvedOpaqueToken: false,
173175
})
174176
})
175177

178+
test('classifies reused opaque browser tokens as already consumed', async () => {
179+
const opaqueToken = 'c'.repeat(43)
180+
181+
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
182+
expect(token).toBe(opaqueToken)
183+
return { status: 'already_consumed' }
184+
})
185+
186+
expect(result).toEqual({
187+
status: 'already_consumed',
188+
authCode: opaqueToken,
189+
resolvedOpaqueToken: false,
190+
})
191+
})
192+
193+
test('keeps never-issued opaque browser tokens invalid', async () => {
194+
const opaqueToken = 'd'.repeat(43)
195+
196+
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
197+
expect(token).toBe(opaqueToken)
198+
return { status: 'missing' }
199+
})
200+
201+
expect(result).toEqual({
202+
status: 'missing',
203+
authCode: opaqueToken,
204+
resolvedOpaqueToken: false,
205+
})
206+
})
207+
176208
test('resolves expired stored payloads so callers can show expired', async () => {
177209
const expiresAt = '0'
178210
const fingerprintHash = genAuthCode(
@@ -186,10 +218,10 @@ describe('freebuff onboard/_helpers', () => {
186218
fingerprintHash,
187219
)
188220

189-
const result = await resolveCliAuthCode(
190-
'b'.repeat(43),
191-
async () => signedAuthCode,
192-
)
221+
const result = await resolveCliAuthCode('b'.repeat(43), async () => ({
222+
status: 'resolved',
223+
authCode: signedAuthCode,
224+
}))
193225
const parsed = parseAuthCode(result.authCode)
194226

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

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

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

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

9+
import type { CliAuthCodeTokenConsumeResult } from './_helpers'
10+
911
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
1012
tx: infer T,
1113
) => any
@@ -34,15 +36,30 @@ export async function hasCliSessionForAuthHash(
3436

3537
export async function consumeCliAuthCodeToken(
3638
authCodeToken: string,
37-
): Promise<string | null> {
38-
const deleted = await db
39-
.delete(schema.verificationToken)
39+
): Promise<CliAuthCodeTokenConsumeResult> {
40+
const consumedIdentifier = `cli-login-consumed:${authCodeToken}`
41+
42+
const consumed = await db
43+
.update(schema.verificationToken)
44+
.set({ identifier: consumedIdentifier })
4045
.where(
4146
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
4247
)
4348
.returning({ authCode: schema.verificationToken.token })
4449

45-
return deleted[0]?.authCode ?? null
50+
if (consumed[0]) {
51+
return { status: 'resolved', authCode: consumed[0].authCode }
52+
}
53+
54+
const existingConsumed = await db
55+
.select({ id: schema.verificationToken.identifier })
56+
.from(schema.verificationToken)
57+
.where(eq(schema.verificationToken.identifier, consumedIdentifier))
58+
.limit(1)
59+
60+
return existingConsumed[0]
61+
? { status: 'already_consumed' }
62+
: { status: 'missing' }
4663
}
4764

4865
export async function checkFingerprintConflict(

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

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,64 @@ export function getCliAuthCodeHashPrefix(authCode: string): string {
2020
return createHash('sha256').update(authCode.trim()).digest('hex').slice(0, 12)
2121
}
2222

23+
export type CliAuthCodeTokenConsumeResult =
24+
| { status: 'resolved'; authCode: string }
25+
| { status: 'already_consumed' }
26+
| { status: 'missing' }
27+
28+
export type CliAuthCodeResolution =
29+
| {
30+
status: 'ready'
31+
authCode: string
32+
resolvedOpaqueToken: boolean
33+
}
34+
| {
35+
status: 'already_consumed'
36+
authCode: string
37+
resolvedOpaqueToken: false
38+
}
39+
| {
40+
status: 'missing'
41+
authCode: string
42+
resolvedOpaqueToken: false
43+
}
44+
2345
export async function resolveCliAuthCode(
2446
authCode: string,
25-
consumeCliAuthCodeToken: (authCodeToken: string) => Promise<string | null>,
26-
): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> {
47+
consumeCliAuthCodeToken: (
48+
authCodeToken: string,
49+
) => Promise<CliAuthCodeTokenConsumeResult>,
50+
): Promise<CliAuthCodeResolution> {
2751
const normalizedAuthCode = authCode.trim()
2852
if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) {
29-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
53+
return {
54+
status: 'ready',
55+
authCode: normalizedAuthCode,
56+
resolvedOpaqueToken: false,
57+
}
3058
}
3159

32-
const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode)
33-
if (!signedAuthCode) {
34-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
60+
const tokenResult = await consumeCliAuthCodeToken(normalizedAuthCode)
61+
if (tokenResult.status === 'resolved') {
62+
return {
63+
status: 'ready',
64+
authCode: tokenResult.authCode,
65+
resolvedOpaqueToken: true,
66+
}
67+
}
68+
69+
if (tokenResult.status === 'already_consumed') {
70+
return {
71+
status: 'already_consumed',
72+
authCode: normalizedAuthCode,
73+
resolvedOpaqueToken: false,
74+
}
3575
}
3676

3777
return {
38-
authCode: signedAuthCode,
39-
resolvedOpaqueToken: true,
78+
status: 'missing',
79+
authCode: normalizedAuthCode,
80+
resolvedOpaqueToken: false,
4081
}
4182
}
4283

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/onboard/__tests__/helpers.test.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,11 @@ describe('onboard/_helpers', () => {
262262

263263
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
264264
expect(token).toBe(opaqueToken)
265-
return signedAuthCode
265+
return { status: 'resolved', authCode: signedAuthCode }
266266
})
267267

268268
expect(result).toEqual({
269+
status: 'ready',
269270
authCode: signedAuthCode,
270271
resolvedOpaqueToken: true,
271272
})
@@ -291,16 +292,47 @@ describe('onboard/_helpers', () => {
291292

292293
const result = await resolveCliAuthCode(signedAuthCode, async () => {
293294
lookedUp = true
294-
return null
295+
return { status: 'missing' }
295296
})
296297

297298
expect(lookedUp).toBe(false)
298299
expect(result).toEqual({
300+
status: 'ready',
299301
authCode: signedAuthCode,
300302
resolvedOpaqueToken: false,
301303
})
302304
})
303305

306+
test('classifies reused opaque browser tokens as already consumed', async () => {
307+
const opaqueToken = 'c'.repeat(43)
308+
309+
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
310+
expect(token).toBe(opaqueToken)
311+
return { status: 'already_consumed' }
312+
})
313+
314+
expect(result).toEqual({
315+
status: 'already_consumed',
316+
authCode: opaqueToken,
317+
resolvedOpaqueToken: false,
318+
})
319+
})
320+
321+
test('keeps never-issued opaque browser tokens invalid', async () => {
322+
const opaqueToken = 'd'.repeat(43)
323+
324+
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
325+
expect(token).toBe(opaqueToken)
326+
return { status: 'missing' }
327+
})
328+
329+
expect(result).toEqual({
330+
status: 'missing',
331+
authCode: opaqueToken,
332+
resolvedOpaqueToken: false,
333+
})
334+
})
335+
304336
test('resolves expired stored payloads so callers can show expired', async () => {
305337
const expiresAt = '0'
306338
const fingerprintHash = genAuthCode(
@@ -314,10 +346,10 @@ describe('onboard/_helpers', () => {
314346
fingerprintHash,
315347
)
316348

317-
const result = await resolveCliAuthCode(
318-
'b'.repeat(43),
319-
async () => signedAuthCode,
320-
)
349+
const result = await resolveCliAuthCode('b'.repeat(43), async () => ({
350+
status: 'resolved',
351+
authCode: signedAuthCode,
352+
}))
321353
const parsed = parseAuthCode(result.authCode)
322354

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

web/src/app/onboard/_db.ts

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

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

9+
import type { CliAuthCodeTokenConsumeResult } from './_helpers'
10+
911
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
1012
tx: infer T,
1113
) => any
@@ -34,15 +36,30 @@ export async function hasCliSessionForAuthHash(
3436

3537
export async function consumeCliAuthCodeToken(
3638
authCodeToken: string,
37-
): Promise<string | null> {
38-
const deleted = await db
39-
.delete(schema.verificationToken)
39+
): Promise<CliAuthCodeTokenConsumeResult> {
40+
const consumedIdentifier = `cli-login-consumed:${authCodeToken}`
41+
42+
const consumed = await db
43+
.update(schema.verificationToken)
44+
.set({ identifier: consumedIdentifier })
4045
.where(
4146
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
4247
)
4348
.returning({ authCode: schema.verificationToken.token })
4449

45-
return deleted[0]?.authCode ?? null
50+
if (consumed[0]) {
51+
return { status: 'resolved', authCode: consumed[0].authCode }
52+
}
53+
54+
const existingConsumed = await db
55+
.select({ id: schema.verificationToken.identifier })
56+
.from(schema.verificationToken)
57+
.where(eq(schema.verificationToken.identifier, consumedIdentifier))
58+
.limit(1)
59+
60+
return existingConsumed[0]
61+
? { status: 'already_consumed' }
62+
: { status: 'missing' }
4663
}
4764

4865
export async function checkFingerprintConflict(

0 commit comments

Comments
 (0)