Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ sequenceDiagram
participant DB as Database

CLI->>Web: POST /api/auth/cli/code {fingerprintId}
Web->>Web: Generate auth code (1h expiry)
Web->>CLI: Return login URL
Web->>Web: Generate signed auth payload (1h expiry)
Web->>DB: Store payload behind opaque browser token
Web->>CLI: Return login URL with opaque token
CLI->>CLI: Open browser
Note over Web: User completes OAuth
Web->>DB: Resolve opaque token to signed payload
Web->>DB: Delete opaque token
Web->>DB: Check fingerprint ownership
Web->>DB: Create/update session
loop Every 5s
Expand Down Expand Up @@ -64,11 +67,14 @@ sequenceDiagram
### 4. Failure: Invalid/Expired Code

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

## Security Features

- Auth codes expire after 1 hour
- Signed auth payloads expire after 1 hour
- Browser login URLs use opaque 43-character tokens instead of exposing the signed auth payload
- Opaque browser tokens are stored in `verificationToken` under `cli-login:<token>` and consumed with `DELETE ... RETURNING` when onboarding resolves them
- Fingerprint uniqueness: hardware info + 8 random bytes
- Ownership conflicts blocked and logged
- Sessions linked to fingerprint_id in database
Expand Down
21 changes: 17 additions & 4 deletions freebuff/web/src/app/api/auth/cli/code/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { randomBytes } from 'node:crypto'

import { genAuthCode } from '@codebuff/common/util/credentials'
import db from '@codebuff/internal/db'
import * as schema from '@codebuff/internal/db/schema'
Expand All @@ -6,6 +8,7 @@ import { and, eq, gt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod/v4'

import { buildCliAuthCode } from '@/app/onboard/_helpers'
import { logger } from '@/util/logger'

import { getLoginUrlOrigin } from './_origin'
Expand Down Expand Up @@ -55,6 +58,19 @@ export async function POST(req: Request) {
)
}

const authCode = buildCliAuthCode(
fingerprintId,
expiresAt.toString(),
fingerprintHash,
)
const loginToken = randomBytes(32).toString('base64url')

await db.insert(schema.verificationToken).values({
identifier: `cli-login:${loginToken}`,
token: authCode,
expires: new Date(expiresAt),
})

const loginUrl = new URL(
'/login',
getLoginUrlOrigin(
Expand All @@ -64,10 +80,7 @@ export async function POST(req: Request) {
env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
),
)
loginUrl.searchParams.set(
'auth_code',
`${fingerprintId}.${expiresAt}.${fingerprintHash}`,
)
loginUrl.searchParams.set('auth_code', loginToken)

return NextResponse.json({
fingerprintId,
Expand Down
130 changes: 129 additions & 1 deletion freebuff/web/src/app/onboard/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { genAuthCode } from '@codebuff/common/util/credentials'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'

import { parseAuthCode, validateAuthCode, isAuthCodeExpired } from '../_helpers'
import {
buildCliAuthCode,
isAuthCodeExpired,
isOpaqueCliAuthCodeToken,
parseAuthCode,
resolveCliAuthCode,
validateAuthCode,
} from '../_helpers'

describe('freebuff onboard/_helpers', () => {
describe('parseAuthCode', () => {
Expand All @@ -23,6 +30,16 @@ describe('freebuff onboard/_helpers', () => {
expect(result.receivedHash).toBe('hashvalue')
})

test('parses legacy hyphen-delimited auth code', () => {
const receivedHash = 'a'.repeat(64)
const authCode = `1234567890abcdef1234567890abcdef-1704067200000-${receivedHash}`
const result = parseAuthCode(authCode)

expect(result.fingerprintId).toBe('1234567890abcdef1234567890abcdef')
expect(result.expiresAt).toBe('1704067200000')
expect(result.receivedHash).toBe(receivedHash)
})

test('handles auth code missing separator before expiresAt', () => {
const authCode =
'fingerprint-1231704067200000.abc123hashabc123hashabc123hash'
Expand Down Expand Up @@ -68,6 +85,117 @@ describe('freebuff onboard/_helpers', () => {
})
})

describe('opaque CLI auth code tokens', () => {
const testSecret = 'test-secret-key'
const testFingerprintId = 'fp-abc123'

test('builds the signed auth code payload', () => {
expect(buildCliAuthCode('fingerprint-id', '1704067200000', 'hash')).toBe(
'fingerprint-id.1704067200000.hash',
)
})

test('identifies 43 character base64url browser tokens only', () => {
const opaqueToken = 'A'.repeat(41) + '-_'
const signedAuthCode = buildCliAuthCode(
testFingerprintId,
'1704067200000',
'a'.repeat(64),
)

expect(isOpaqueCliAuthCodeToken(opaqueToken)).toBe(true)
expect(isOpaqueCliAuthCodeToken(` ${opaqueToken}\n`)).toBe(true)
expect(isOpaqueCliAuthCodeToken(signedAuthCode)).toBe(false)
expect(isOpaqueCliAuthCodeToken('A'.repeat(42))).toBe(false)
expect(isOpaqueCliAuthCodeToken(`${'A'.repeat(42)}.`)).toBe(false)
})

test('resolves an opaque browser token before validation', async () => {
const expiresAt = '4102444800000'
const fingerprintHash = genAuthCode(
testFingerprintId,
expiresAt,
testSecret,
)
const signedAuthCode = buildCliAuthCode(
testFingerprintId,
expiresAt,
fingerprintHash,
)
const opaqueToken = 'a'.repeat(43)

const result = await resolveCliAuthCode(opaqueToken, async (token) => {
expect(token).toBe(opaqueToken)
return signedAuthCode
})

expect(result).toEqual({
authCode: signedAuthCode,
resolvedOpaqueToken: true,
})

const parsed = parseAuthCode(result.authCode)
expect(
validateAuthCode(
parsed.receivedHash,
parsed.fingerprintId,
parsed.expiresAt,
testSecret,
).valid,
).toBe(true)
})

test('does not look up already signed auth codes', async () => {
const signedAuthCode = buildCliAuthCode(
testFingerprintId,
'4102444800000',
'a'.repeat(64),
)
let lookedUp = false

const result = await resolveCliAuthCode(signedAuthCode, async () => {
lookedUp = true
return null
})

expect(lookedUp).toBe(false)
expect(result).toEqual({
authCode: signedAuthCode,
resolvedOpaqueToken: false,
})
})

test('resolves expired stored payloads so callers can show expired', async () => {
const expiresAt = '0'
const fingerprintHash = genAuthCode(
testFingerprintId,
expiresAt,
testSecret,
)
const signedAuthCode = buildCliAuthCode(
testFingerprintId,
expiresAt,
fingerprintHash,
)

const result = await resolveCliAuthCode(
'b'.repeat(43),
async () => signedAuthCode,
)
const parsed = parseAuthCode(result.authCode)

expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true)
expect(
validateAuthCode(
parsed.receivedHash,
parsed.fingerprintId,
parsed.expiresAt,
testSecret,
).valid,
).toBe(true)
})
})

describe('isAuthCodeExpired', () => {
let originalDateNow: typeof Date.now

Expand Down
13 changes: 13 additions & 0 deletions freebuff/web/src/app/onboard/_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ export async function hasCliSessionForAuthHash(
return existing.length > 0
}

export async function consumeCliAuthCodeToken(
authCodeToken: string,
): Promise<string | null> {
const deleted = await db
.delete(schema.verificationToken)
.where(
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
)
.returning({ authCode: schema.verificationToken.token })

return deleted[0]?.authCode ?? null
}

export async function checkFingerprintConflict(
fingerprintId: string,
userId: string,
Expand Down
45 changes: 45 additions & 0 deletions freebuff/web/src/app/onboard/_helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
import { genAuthCode } from '@codebuff/common/util/credentials'

const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/

export function buildCliAuthCode(
fingerprintId: string,
expiresAt: string,
fingerprintHash: string,
): string {
return `${fingerprintId}.${expiresAt}.${fingerprintHash}`
}

export function isOpaqueCliAuthCodeToken(authCode: string): boolean {
return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim())
}

export async function resolveCliAuthCode(
authCode: string,
consumeCliAuthCodeToken: (authCodeToken: string) => Promise<string | null>,
): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> {
const normalizedAuthCode = authCode.trim()
if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) {
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
}

const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode)
if (!signedAuthCode) {
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
}

return {
authCode: signedAuthCode,
resolvedOpaqueToken: true,
}
}

export function parseAuthCode(authCode: string): {
fingerprintId: string
expiresAt: string
Expand All @@ -13,6 +47,17 @@ export function parseAuthCode(authCode: string): {
)

if (hashSeparatorIndex === -1 || expiresSeparatorIndex === -1) {
const legacyMatch = normalizedAuthCode.match(
/^(?<fingerprintId>.+)-(?<expiresAt>\d+)-(?<receivedHash>[a-f0-9]{64})$/i,
)
if (legacyMatch?.groups) {
return {
fingerprintId: legacyMatch.groups.fingerprintId,
expiresAt: legacyMatch.groups.expiresAt,
receivedHash: legacyMatch.groups.receivedHash,
}
}

return { fingerprintId: '', expiresAt: '', receivedHash: '' }
}

Expand Down
17 changes: 15 additions & 2 deletions freebuff/web/src/app/onboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import { getServerSession } from 'next-auth'

import {
checkFingerprintConflict,
consumeCliAuthCodeToken,
createCliSession,
getSessionTokenFromCookies,
hasCliSessionForAuthHash,
} from './_db'
import { isAuthCodeExpired, parseAuthCode, validateAuthCode } from './_helpers'
import {
isAuthCodeExpired,
parseAuthCode,
resolveCliAuthCode,
validateAuthCode,
} from './_helpers'
import { authOptions } from '../api/auth/[...nextauth]/auth-options'

import {
Expand Down Expand Up @@ -91,7 +97,10 @@ const Onboard = async ({ searchParams }: PageProps) => {
)
}

const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode)
const { authCode: resolvedAuthCode, resolvedOpaqueToken } =
await resolveCliAuthCode(authCode, consumeCliAuthCodeToken)
const { fingerprintId, expiresAt, receivedHash } =
parseAuthCode(resolvedAuthCode)
const { valid, expectedHash: fingerprintHash } = validateAuthCode(
receivedHash,
fingerprintId,
Expand All @@ -103,6 +112,10 @@ const Onboard = async ({ searchParams }: PageProps) => {
logger.warn(
{
authCodeLength: authCode.length,
resolvedAuthCode: resolvedOpaqueToken,
resolvedAuthCodeLength: resolvedAuthCode.length,
dotCount: authCode.match(/\./g)?.length ?? 0,
hyphenCount: authCode.match(/-/g)?.length ?? 0,
fingerprintIdPrefix: fingerprintId.slice(0, 24),
fingerprintIdLength: fingerprintId.length,
expiresAt,
Expand Down
21 changes: 17 additions & 4 deletions web/src/app/api/auth/cli/code/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { randomBytes } from 'node:crypto'

import { genAuthCode } from '@codebuff/common/util/credentials'
import db from '@codebuff/internal/db'
import * as schema from '@codebuff/internal/db/schema'
Expand All @@ -6,6 +8,7 @@ import { and, eq, gt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod/v4'

import { buildCliAuthCode } from '@/app/onboard/_helpers'
import { logger } from '@/util/logger'

import { getLoginUrlOrigin } from './_origin'
Expand Down Expand Up @@ -57,6 +60,19 @@ export async function POST(req: Request) {
)
}

const authCode = buildCliAuthCode(
fingerprintId,
expiresAt.toString(),
fingerprintHash,
)
const loginToken = randomBytes(32).toString('base64url')

await db.insert(schema.verificationToken).values({
identifier: `cli-login:${loginToken}`,
token: authCode,
expires: new Date(expiresAt),
})

const loginUrl = new URL(
'/login',
getLoginUrlOrigin(
Expand All @@ -66,10 +82,7 @@ export async function POST(req: Request) {
env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
),
)
loginUrl.searchParams.set(
'auth_code',
`${fingerprintId}.${expiresAt}.${fingerprintHash}`,
)
loginUrl.searchParams.set('auth_code', loginToken)

return NextResponse.json({
fingerprintId,
Expand Down
Loading
Loading