Skip to content

Commit a39327d

Browse files
committed
Tighten opaque CLI auth tokens
1 parent 2e7ad9e commit a39327d

11 files changed

Lines changed: 360 additions & 34 deletions

File tree

docs/authentication.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ sequenceDiagram
1313
participant DB as Database
1414
1515
CLI->>Web: POST /api/auth/cli/code {fingerprintId}
16-
Web->>Web: Generate auth code (1h expiry)
17-
Web->>CLI: Return login URL
16+
Web->>Web: Generate signed auth payload (1h expiry)
17+
Web->>DB: Store payload behind opaque browser token
18+
Web->>CLI: Return login URL with opaque token
1819
CLI->>CLI: Open browser
1920
Note over Web: User completes OAuth
21+
Web->>DB: Resolve opaque token to signed payload
22+
Web->>DB: Delete opaque token
2023
Web->>DB: Check fingerprint ownership
2124
Web->>DB: Create/update session
2225
loop Every 5s
@@ -64,11 +67,14 @@ sequenceDiagram
6467
### 4. Failure: Invalid/Expired Code
6568

6669
- Auth code validation fails or expired (1h limit)
70+
- Opaque browser tokens resolve expired signed payloads before returning the expired-code error
6771
- Returns authentication error
6872

6973
## Security Features
7074

71-
- Auth codes expire after 1 hour
75+
- Signed auth payloads expire after 1 hour
76+
- 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
7278
- Fingerprint uniqueness: hardware info + 8 random bytes
7379
- Ownership conflicts blocked and logged
7480
- Sessions linked to fingerprint_id in database

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { and, eq, gt } from 'drizzle-orm'
88
import { NextResponse } from 'next/server'
99
import { z } from 'zod/v4'
1010

11+
import { buildCliAuthCode } from '@/app/onboard/_helpers'
1112
import { logger } from '@/util/logger'
1213

1314
import { getLoginUrlOrigin } from './_origin'
@@ -57,7 +58,11 @@ export async function POST(req: Request) {
5758
)
5859
}
5960

60-
const authCode = `${fingerprintId}.${expiresAt}.${fingerprintHash}`
61+
const authCode = buildCliAuthCode(
62+
fingerprintId,
63+
expiresAt.toString(),
64+
fingerprintHash,
65+
)
6166
const loginToken = randomBytes(32).toString('base64url')
6267

6368
await db.insert(schema.verificationToken).values({

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

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { genAuthCode } from '@codebuff/common/util/credentials'
22
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
33

4-
import { parseAuthCode, validateAuthCode, isAuthCodeExpired } from '../_helpers'
4+
import {
5+
buildCliAuthCode,
6+
isAuthCodeExpired,
7+
isOpaqueCliAuthCodeToken,
8+
parseAuthCode,
9+
resolveCliAuthCode,
10+
validateAuthCode,
11+
} from '../_helpers'
512

613
describe('freebuff onboard/_helpers', () => {
714
describe('parseAuthCode', () => {
@@ -78,6 +85,117 @@ describe('freebuff onboard/_helpers', () => {
7885
})
7986
})
8087

88+
describe('opaque CLI auth code tokens', () => {
89+
const testSecret = 'test-secret-key'
90+
const testFingerprintId = 'fp-abc123'
91+
92+
test('builds the signed auth code payload', () => {
93+
expect(buildCliAuthCode('fingerprint-id', '1704067200000', 'hash')).toBe(
94+
'fingerprint-id.1704067200000.hash',
95+
)
96+
})
97+
98+
test('identifies 43 character base64url browser tokens only', () => {
99+
const opaqueToken = 'A'.repeat(41) + '-_'
100+
const signedAuthCode = buildCliAuthCode(
101+
testFingerprintId,
102+
'1704067200000',
103+
'a'.repeat(64),
104+
)
105+
106+
expect(isOpaqueCliAuthCodeToken(opaqueToken)).toBe(true)
107+
expect(isOpaqueCliAuthCodeToken(` ${opaqueToken}\n`)).toBe(true)
108+
expect(isOpaqueCliAuthCodeToken(signedAuthCode)).toBe(false)
109+
expect(isOpaqueCliAuthCodeToken('A'.repeat(42))).toBe(false)
110+
expect(isOpaqueCliAuthCodeToken(`${'A'.repeat(42)}.`)).toBe(false)
111+
})
112+
113+
test('resolves an opaque browser token before validation', async () => {
114+
const expiresAt = '4102444800000'
115+
const fingerprintHash = genAuthCode(
116+
testFingerprintId,
117+
expiresAt,
118+
testSecret,
119+
)
120+
const signedAuthCode = buildCliAuthCode(
121+
testFingerprintId,
122+
expiresAt,
123+
fingerprintHash,
124+
)
125+
const opaqueToken = 'a'.repeat(43)
126+
127+
const result = await resolveCliAuthCode(opaqueToken, async (token) => {
128+
expect(token).toBe(opaqueToken)
129+
return signedAuthCode
130+
})
131+
132+
expect(result).toEqual({
133+
authCode: signedAuthCode,
134+
resolvedOpaqueToken: true,
135+
})
136+
137+
const parsed = parseAuthCode(result.authCode)
138+
expect(
139+
validateAuthCode(
140+
parsed.receivedHash,
141+
parsed.fingerprintId,
142+
parsed.expiresAt,
143+
testSecret,
144+
).valid,
145+
).toBe(true)
146+
})
147+
148+
test('does not look up already signed auth codes', async () => {
149+
const signedAuthCode = buildCliAuthCode(
150+
testFingerprintId,
151+
'4102444800000',
152+
'a'.repeat(64),
153+
)
154+
let lookedUp = false
155+
156+
const result = await resolveCliAuthCode(signedAuthCode, async () => {
157+
lookedUp = true
158+
return null
159+
})
160+
161+
expect(lookedUp).toBe(false)
162+
expect(result).toEqual({
163+
authCode: signedAuthCode,
164+
resolvedOpaqueToken: false,
165+
})
166+
})
167+
168+
test('resolves expired stored payloads so callers can show expired', async () => {
169+
const expiresAt = '0'
170+
const fingerprintHash = genAuthCode(
171+
testFingerprintId,
172+
expiresAt,
173+
testSecret,
174+
)
175+
const signedAuthCode = buildCliAuthCode(
176+
testFingerprintId,
177+
expiresAt,
178+
fingerprintHash,
179+
)
180+
181+
const result = await resolveCliAuthCode(
182+
'b'.repeat(43),
183+
async () => signedAuthCode,
184+
)
185+
const parsed = parseAuthCode(result.authCode)
186+
187+
expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true)
188+
expect(
189+
validateAuthCode(
190+
parsed.receivedHash,
191+
parsed.fingerprintId,
192+
parsed.expiresAt,
193+
testSecret,
194+
).valid,
195+
).toBe(true)
196+
})
197+
})
198+
81199
describe('isAuthCodeExpired', () => {
82200
let originalDateNow: typeof Date.now
83201

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

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,17 @@ export async function hasCliSessionForAuthHash(
3232
return existing.length > 0
3333
}
3434

35-
export async function getCliAuthCodeForToken(
35+
export async function consumeCliAuthCodeToken(
3636
authCodeToken: string,
3737
): Promise<string | null> {
38-
const existing = await db
39-
.select({ authCode: schema.verificationToken.token })
40-
.from(schema.verificationToken)
38+
const deleted = await db
39+
.delete(schema.verificationToken)
4140
.where(
42-
and(
43-
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
44-
gt(schema.verificationToken.expires, new Date()),
45-
),
41+
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
4642
)
47-
.limit(1)
43+
.returning({ authCode: schema.verificationToken.token })
4844

49-
return existing[0]?.authCode ?? null
45+
return deleted[0]?.authCode ?? null
5046
}
5147

5248
export async function checkFingerprintConflict(

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
import { genAuthCode } from '@codebuff/common/util/credentials'
22

3+
const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
4+
5+
export function buildCliAuthCode(
6+
fingerprintId: string,
7+
expiresAt: string,
8+
fingerprintHash: string,
9+
): string {
10+
return `${fingerprintId}.${expiresAt}.${fingerprintHash}`
11+
}
12+
13+
export function isOpaqueCliAuthCodeToken(authCode: string): boolean {
14+
return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim())
15+
}
16+
17+
export async function resolveCliAuthCode(
18+
authCode: string,
19+
consumeCliAuthCodeToken: (authCodeToken: string) => Promise<string | null>,
20+
): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> {
21+
const normalizedAuthCode = authCode.trim()
22+
if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) {
23+
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
24+
}
25+
26+
const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode)
27+
if (!signedAuthCode) {
28+
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
29+
}
30+
31+
return {
32+
authCode: signedAuthCode,
33+
resolvedOpaqueToken: true,
34+
}
35+
}
36+
337
export function parseAuthCode(authCode: string): {
438
fingerprintId: string
539
expiresAt: string

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@ import { getServerSession } from 'next-auth'
66

77
import {
88
checkFingerprintConflict,
9+
consumeCliAuthCodeToken,
910
createCliSession,
10-
getCliAuthCodeForToken,
1111
getSessionTokenFromCookies,
1212
hasCliSessionForAuthHash,
1313
} from './_db'
14-
import { isAuthCodeExpired, parseAuthCode, validateAuthCode } from './_helpers'
14+
import {
15+
isAuthCodeExpired,
16+
parseAuthCode,
17+
resolveCliAuthCode,
18+
validateAuthCode,
19+
} from './_helpers'
1520
import { authOptions } from '../api/auth/[...nextauth]/auth-options'
1621

1722
import {
@@ -92,7 +97,8 @@ const Onboard = async ({ searchParams }: PageProps) => {
9297
)
9398
}
9499

95-
const resolvedAuthCode = (await getCliAuthCodeForToken(authCode)) ?? authCode
100+
const { authCode: resolvedAuthCode, resolvedOpaqueToken } =
101+
await resolveCliAuthCode(authCode, consumeCliAuthCodeToken)
96102
const { fingerprintId, expiresAt, receivedHash } =
97103
parseAuthCode(resolvedAuthCode)
98104
const { valid, expectedHash: fingerprintHash } = validateAuthCode(
@@ -106,7 +112,7 @@ const Onboard = async ({ searchParams }: PageProps) => {
106112
logger.warn(
107113
{
108114
authCodeLength: authCode.length,
109-
resolvedAuthCode: resolvedAuthCode !== authCode,
115+
resolvedAuthCode: resolvedOpaqueToken,
110116
resolvedAuthCodeLength: resolvedAuthCode.length,
111117
dotCount: authCode.match(/\./g)?.length ?? 0,
112118
hyphenCount: authCode.match(/-/g)?.length ?? 0,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { and, eq, gt } from 'drizzle-orm'
88
import { NextResponse } from 'next/server'
99
import { z } from 'zod/v4'
1010

11+
import { buildCliAuthCode } from '@/app/onboard/_helpers'
1112
import { logger } from '@/util/logger'
1213

1314
import { getLoginUrlOrigin } from './_origin'
@@ -59,7 +60,11 @@ export async function POST(req: Request) {
5960
)
6061
}
6162

62-
const authCode = `${fingerprintId}.${expiresAt}.${fingerprintHash}`
63+
const authCode = buildCliAuthCode(
64+
fingerprintId,
65+
expiresAt.toString(),
66+
fingerprintHash,
67+
)
6368
const loginToken = randomBytes(32).toString('base64url')
6469

6570
await db.insert(schema.verificationToken).values({

0 commit comments

Comments
 (0)