Skip to content

Commit e1d50b7

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

11 files changed

Lines changed: 387 additions & 47 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; 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:token-123',
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: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ 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'
69

710
export function buildCliAuthCode(
811
fingerprintId: string,
@@ -20,23 +23,78 @@ export function getCliAuthCodeHashPrefix(authCode: string): string {
2023
return createHash('sha256').update(authCode.trim()).digest('hex').slice(0, 12)
2124
}
2225

26+
export function getCliAuthCodeTokenIdentifier(authCodeToken: string): string {
27+
return `${CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}`
28+
}
29+
30+
export function getConsumedCliAuthCodeTokenIdentifier(
31+
authCodeToken: string,
32+
): string {
33+
return `${CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}`
34+
}
35+
36+
export function getConsumedCliAuthCodeTokenValue(): string {
37+
return CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE
38+
}
39+
40+
export type CliAuthCodeTokenConsumeResult =
41+
| { status: 'resolved'; authCode: string }
42+
| { status: 'already_consumed' }
43+
| { status: 'missing' }
44+
45+
export type CliAuthCodeResolution =
46+
| {
47+
status: 'ready'
48+
authCode: string
49+
resolvedOpaqueToken: boolean
50+
}
51+
| {
52+
status: 'already_consumed'
53+
authCode: string
54+
resolvedOpaqueToken: false
55+
}
56+
| {
57+
status: 'missing'
58+
authCode: string
59+
resolvedOpaqueToken: false
60+
}
61+
2362
export async function resolveCliAuthCode(
2463
authCode: string,
25-
consumeCliAuthCodeToken: (authCodeToken: string) => Promise<string | null>,
26-
): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> {
64+
consumeCliAuthCodeToken: (
65+
authCodeToken: string,
66+
) => Promise<CliAuthCodeTokenConsumeResult>,
67+
): Promise<CliAuthCodeResolution> {
2768
const normalizedAuthCode = authCode.trim()
2869
if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) {
29-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
70+
return {
71+
status: 'ready',
72+
authCode: normalizedAuthCode,
73+
resolvedOpaqueToken: false,
74+
}
3075
}
3176

32-
const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode)
33-
if (!signedAuthCode) {
34-
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
77+
const tokenResult = await consumeCliAuthCodeToken(normalizedAuthCode)
78+
if (tokenResult.status === 'resolved') {
79+
return {
80+
status: 'ready',
81+
authCode: tokenResult.authCode,
82+
resolvedOpaqueToken: true,
83+
}
84+
}
85+
86+
if (tokenResult.status === 'already_consumed') {
87+
return {
88+
status: 'already_consumed',
89+
authCode: normalizedAuthCode,
90+
resolvedOpaqueToken: false,
91+
}
3592
}
3693

3794
return {
38-
authCode: signedAuthCode,
39-
resolvedOpaqueToken: true,
95+
status: 'missing',
96+
authCode: normalizedAuthCode,
97+
resolvedOpaqueToken: false,
4098
}
4199
}
42100

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)