Skip to content

Commit a9802be

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

11 files changed

Lines changed: 376 additions & 36 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: 118 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,116 @@ 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 { authCode: signedAuthCode }
130+
})
131+
132+
expect(result).toEqual({
133+
authCode: signedAuthCode,
134+
authCodeToken: opaqueToken,
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+
authCodeToken: null,
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('b'.repeat(43), async () => ({
182+
authCode: signedAuthCode,
183+
}))
184+
const parsed = parseAuthCode(result.authCode)
185+
186+
expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true)
187+
expect(
188+
validateAuthCode(
189+
parsed.receivedHash,
190+
parsed.fingerprintId,
191+
parsed.expiresAt,
192+
testSecret,
193+
).valid,
194+
).toBe(true)
195+
})
196+
})
197+
81198
describe('isAuthCodeExpired', () => {
82199
let originalDateNow: typeof Date.now
83200

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

Lines changed: 9 additions & 11 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 { CliAuthCodeTokenRecord } from './_helpers'
10+
911
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
1012
tx: infer T,
1113
) => any
@@ -32,21 +34,17 @@ export async function hasCliSessionForAuthHash(
3234
return existing.length > 0
3335
}
3436

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

49-
return existing[0]?.authCode ?? null
47+
return deleted[0] ?? null
5048
}
5149

5250
export async function checkFingerprintConflict(

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,45 @@
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 type CliAuthCodeTokenRecord = {
6+
authCode: string
7+
}
8+
9+
export function buildCliAuthCode(
10+
fingerprintId: string,
11+
expiresAt: string,
12+
fingerprintHash: string,
13+
): string {
14+
return `${fingerprintId}.${expiresAt}.${fingerprintHash}`
15+
}
16+
17+
export function isOpaqueCliAuthCodeToken(authCode: string): boolean {
18+
return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim())
19+
}
20+
21+
export async function resolveCliAuthCode(
22+
authCode: string,
23+
consumeCliAuthCodeToken: (
24+
authCodeToken: string,
25+
) => Promise<CliAuthCodeTokenRecord | null>,
26+
): Promise<{ authCode: string; authCodeToken: string | null }> {
27+
const normalizedAuthCode = authCode.trim()
28+
if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) {
29+
return { authCode: normalizedAuthCode, authCodeToken: null }
30+
}
31+
32+
const tokenRecord = await consumeCliAuthCodeToken(normalizedAuthCode)
33+
if (!tokenRecord) {
34+
return { authCode: normalizedAuthCode, authCodeToken: null }
35+
}
36+
37+
return {
38+
authCode: tokenRecord.authCode,
39+
authCodeToken: normalizedAuthCode,
40+
}
41+
}
42+
343
export function parseAuthCode(authCode: string): {
444
fingerprintId: string
545
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, authCodeToken } =
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: Boolean(authCodeToken),
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)