Skip to content

Commit 1435554

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

11 files changed

Lines changed: 331 additions & 45 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/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: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
44
import {
55
buildCliAuthCode,
66
getCliAuthCodeHashPrefix,
7+
getCliAuthCodeTokenIdentifier,
8+
getConsumedCliAuthCodeTokenIdentifier,
79
isAuthCodeExpired,
810
isOpaqueCliAuthCodeToken,
911
parseAuthCode,
@@ -118,6 +120,15 @@ describe('freebuff onboard/_helpers', () => {
118120
)
119121
})
120122

123+
test('builds active and consumed token identifiers', () => {
124+
expect(getCliAuthCodeTokenIdentifier('token-123')).toBe(
125+
'cli-login:token-123',
126+
)
127+
expect(getConsumedCliAuthCodeTokenIdentifier('token-123')).toBe(
128+
'cli-login-consumed:token-123',
129+
)
130+
})
131+
121132
test('resolves an opaque browser token before validation', async () => {
122133
const expiresAt = '4102444800000'
123134
const fingerprintHash = genAuthCode(
@@ -134,10 +145,11 @@ describe('freebuff onboard/_helpers', () => {
134145

135146
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
136147
expect(token).toBe(opaqueToken)
137-
return signedAuthCode
148+
return { status: 'resolved', authCode: signedAuthCode }
138149
})
139150

140151
expect(result).toEqual({
152+
status: 'ready',
141153
authCode: signedAuthCode,
142154
resolvedOpaqueToken: true,
143155
})
@@ -163,16 +175,47 @@ describe('freebuff onboard/_helpers', () => {
163175

164176
const result = await resolveCliAuthCode(signedAuthCode, async () => {
165177
lookedUp = true
166-
return null
178+
return { status: 'missing' }
167179
})
168180

169181
expect(lookedUp).toBe(false)
170182
expect(result).toEqual({
183+
status: 'ready',
171184
authCode: signedAuthCode,
172185
resolvedOpaqueToken: false,
173186
})
174187
})
175188

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

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

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

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

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

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

9+
import {
10+
getCliAuthCodeTokenIdentifier,
11+
getConsumedCliAuthCodeTokenIdentifier,
12+
type CliAuthCodeTokenConsumeResult,
13+
} from './_helpers'
14+
915
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
1016
tx: infer T,
1117
) => any
@@ -34,15 +40,34 @@ export async function hasCliSessionForAuthHash(
3440

3541
export async function consumeCliAuthCodeToken(
3642
authCodeToken: string,
37-
): Promise<string | null> {
38-
const deleted = await db
39-
.delete(schema.verificationToken)
43+
): Promise<CliAuthCodeTokenConsumeResult> {
44+
const consumedIdentifier =
45+
getConsumedCliAuthCodeTokenIdentifier(authCodeToken)
46+
47+
const consumed = await db
48+
.update(schema.verificationToken)
49+
.set({ identifier: consumedIdentifier })
4050
.where(
41-
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
51+
eq(
52+
schema.verificationToken.identifier,
53+
getCliAuthCodeTokenIdentifier(authCodeToken),
54+
),
4255
)
4356
.returning({ authCode: schema.verificationToken.token })
4457

45-
return deleted[0]?.authCode ?? null
58+
if (consumed[0]) {
59+
return { status: 'resolved', authCode: consumed[0].authCode }
60+
}
61+
62+
const existingConsumed = await db
63+
.select({ id: schema.verificationToken.identifier })
64+
.from(schema.verificationToken)
65+
.where(eq(schema.verificationToken.identifier, consumedIdentifier))
66+
.limit(1)
67+
68+
return existingConsumed[0]
69+
? { status: 'already_consumed' }
70+
: { status: 'missing' }
4671
}
4772

4873
export async function checkFingerprintConflict(

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

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ 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:'
68

79
export function buildCliAuthCode(
810
fingerprintId: string,
@@ -20,23 +22,74 @@ export function getCliAuthCodeHashPrefix(authCode: string): string {
2022
return createHash('sha256').update(authCode.trim()).digest('hex').slice(0, 12)
2123
}
2224

25+
export function getCliAuthCodeTokenIdentifier(authCodeToken: string): string {
26+
return `${CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}`
27+
}
28+
29+
export function getConsumedCliAuthCodeTokenIdentifier(
30+
authCodeToken: string,
31+
): string {
32+
return `${CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}`
33+
}
34+
35+
export type CliAuthCodeTokenConsumeResult =
36+
| { status: 'resolved'; authCode: string }
37+
| { status: 'already_consumed' }
38+
| { status: 'missing' }
39+
40+
export type CliAuthCodeResolution =
41+
| {
42+
status: 'ready'
43+
authCode: string
44+
resolvedOpaqueToken: boolean
45+
}
46+
| {
47+
status: 'already_consumed'
48+
authCode: string
49+
resolvedOpaqueToken: false
50+
}
51+
| {
52+
status: 'missing'
53+
authCode: string
54+
resolvedOpaqueToken: false
55+
}
56+
2357
export async function resolveCliAuthCode(
2458
authCode: string,
25-
consumeCliAuthCodeToken: (authCodeToken: string) => Promise<string | null>,
26-
): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> {
59+
consumeCliAuthCodeToken: (
60+
authCodeToken: string,
61+
) => Promise<CliAuthCodeTokenConsumeResult>,
62+
): Promise<CliAuthCodeResolution> {
2763
const normalizedAuthCode = authCode.trim()
2864
if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) {
29-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
65+
return {
66+
status: 'ready',
67+
authCode: normalizedAuthCode,
68+
resolvedOpaqueToken: false,
69+
}
3070
}
3171

32-
const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode)
33-
if (!signedAuthCode) {
34-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
72+
const tokenResult = await consumeCliAuthCodeToken(normalizedAuthCode)
73+
if (tokenResult.status === 'resolved') {
74+
return {
75+
status: 'ready',
76+
authCode: tokenResult.authCode,
77+
resolvedOpaqueToken: true,
78+
}
79+
}
80+
81+
if (tokenResult.status === 'already_consumed') {
82+
return {
83+
status: 'already_consumed',
84+
authCode: normalizedAuthCode,
85+
resolvedOpaqueToken: false,
86+
}
3587
}
3688

3789
return {
38-
authCode: signedAuthCode,
39-
resolvedOpaqueToken: true,
90+
status: 'missing',
91+
authCode: normalizedAuthCode,
92+
resolvedOpaqueToken: false,
4093
}
4194
}
4295

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)