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
4 changes: 2 additions & 2 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ sequenceDiagram
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: Mark opaque token consumed
Web->>DB: Check fingerprint ownership
Web->>DB: Create/update session
loop Every 5s
Expand Down Expand Up @@ -74,7 +74,7 @@ sequenceDiagram

- 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
- Opaque browser tokens are stored in `verificationToken` under `cli-login:<token>` and atomically moved to `cli-login-consumed:<token-hash>` when onboarding resolves them; consumed markers scrub the signed auth payload from the `token` column
- Fingerprint uniqueness: hardware info + 8 random bytes
- Ownership conflicts blocked and logged
- Sessions linked to fingerprint_id in database
Expand Down
3 changes: 2 additions & 1 deletion freebuff/web/src/app/api/auth/cli/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { z } from 'zod/v4'
import {
buildCliAuthCode,
getCliAuthCodeHashPrefix,
getCliAuthCodeTokenIdentifier,
} from '@/app/onboard/_helpers'
import { logger } from '@/util/logger'

Expand Down Expand Up @@ -69,7 +70,7 @@ export async function POST(req: Request) {
const loginToken = randomBytes(32).toString('base64url')

await db.insert(schema.verificationToken).values({
identifier: `cli-login:${loginToken}`,
identifier: getCliAuthCodeTokenIdentifier(loginToken),
token: authCode,
expires: new Date(expiresAt),
})
Expand Down
57 changes: 51 additions & 6 deletions freebuff/web/src/app/onboard/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
buildCliAuthCode,
getCliAuthCodeHashPrefix,
getCliAuthCodeTokenIdentifier,
getConsumedCliAuthCodeTokenIdentifier,
getConsumedCliAuthCodeTokenValue,
isAuthCodeExpired,
isOpaqueCliAuthCodeToken,
parseAuthCode,
Expand Down Expand Up @@ -118,6 +121,16 @@ describe('freebuff onboard/_helpers', () => {
)
})

test('builds active and consumed token identifiers', () => {
expect(getCliAuthCodeTokenIdentifier('token-123')).toBe(
'cli-login:token-123',
)
expect(getConsumedCliAuthCodeTokenIdentifier('token-123')).toBe(
'cli-login-consumed:034192845dc489deca291f9f5ae0bb8e5472c991020bf64b3ebc6dec5a1d7e47',
)
expect(getConsumedCliAuthCodeTokenValue()).toBe('consumed')
})

test('resolves an opaque browser token before validation', async () => {
const expiresAt = '4102444800000'
const fingerprintHash = genAuthCode(
Expand All @@ -134,10 +147,11 @@ describe('freebuff onboard/_helpers', () => {

const result = await resolveCliAuthCode(opaqueToken, async (token) => {
expect(token).toBe(opaqueToken)
return signedAuthCode
return { status: 'resolved', authCode: signedAuthCode }
})

expect(result).toEqual({
status: 'ready',
authCode: signedAuthCode,
resolvedOpaqueToken: true,
})
Expand All @@ -163,16 +177,47 @@ describe('freebuff onboard/_helpers', () => {

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

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

test('classifies reused opaque browser tokens as already consumed', async () => {
const opaqueToken = 'c'.repeat(43)

const result = await resolveCliAuthCode(opaqueToken, async (token) => {
expect(token).toBe(opaqueToken)
return { status: 'already_consumed' }
})

expect(result).toEqual({
status: 'already_consumed',
authCode: opaqueToken,
resolvedOpaqueToken: false,
})
})

test('keeps never-issued opaque browser tokens invalid', async () => {
const opaqueToken = 'd'.repeat(43)

const result = await resolveCliAuthCode(opaqueToken, async (token) => {
expect(token).toBe(opaqueToken)
return { status: 'missing' }
})

expect(result).toEqual({
status: 'missing',
authCode: opaqueToken,
resolvedOpaqueToken: false,
})
})

test('resolves expired stored payloads so callers can show expired', async () => {
const expiresAt = '0'
const fingerprintHash = genAuthCode(
Expand All @@ -186,10 +231,10 @@ describe('freebuff onboard/_helpers', () => {
fingerprintHash,
)

const result = await resolveCliAuthCode(
'b'.repeat(43),
async () => signedAuthCode,
)
const result = await resolveCliAuthCode('b'.repeat(43), async () => ({
status: 'resolved',
authCode: signedAuthCode,
}))
const parsed = parseAuthCode(result.authCode)

expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true)
Expand Down
57 changes: 51 additions & 6 deletions freebuff/web/src/app/onboard/_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { cookies } from 'next/headers'

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

import {
getCliAuthCodeTokenIdentifier,
getConsumedCliAuthCodeTokenIdentifier,
getConsumedCliAuthCodeTokenValue,
type CliAuthCodeTokenConsumeResult,
} from './_helpers'

type DbTransaction = Parameters<typeof db.transaction>[0] extends (
tx: infer T,
) => any
Expand Down Expand Up @@ -34,15 +41,53 @@ export async function hasCliSessionForAuthHash(

export async function consumeCliAuthCodeToken(
authCodeToken: string,
): Promise<string | null> {
const deleted = await db
.delete(schema.verificationToken)
): Promise<CliAuthCodeTokenConsumeResult> {
const activeIdentifier = getCliAuthCodeTokenIdentifier(authCodeToken)
const consumedIdentifier =
getConsumedCliAuthCodeTokenIdentifier(authCodeToken)
const getConsumedTokenStatus =
async (): Promise<CliAuthCodeTokenConsumeResult> => {
const existingConsumed = await db
.select({ id: schema.verificationToken.identifier })
.from(schema.verificationToken)
.where(eq(schema.verificationToken.identifier, consumedIdentifier))
.limit(1)

return existingConsumed[0]
? { status: 'already_consumed' }
: { status: 'missing' }
}

const active = await db
.select({ authCode: schema.verificationToken.token })
.from(schema.verificationToken)
.where(eq(schema.verificationToken.identifier, activeIdentifier))
.limit(1)
const authCode = active[0]?.authCode

if (!authCode) {
return getConsumedTokenStatus()
}

const consumed = await db
.update(schema.verificationToken)
.set({
identifier: consumedIdentifier,
token: getConsumedCliAuthCodeTokenValue(),
})
.where(
eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`),
and(
eq(schema.verificationToken.identifier, activeIdentifier),
eq(schema.verificationToken.token, authCode),
),
)
.returning({ authCode: schema.verificationToken.token })
.returning({ id: schema.verificationToken.identifier })

if (consumed[0]) {
return { status: 'resolved', authCode }
}

return deleted[0]?.authCode ?? null
return getConsumedTokenStatus()
}

export async function checkFingerprintConflict(
Expand Down
82 changes: 73 additions & 9 deletions freebuff/web/src/app/onboard/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { createHash } from 'node:crypto'
import { genAuthCode } from '@codebuff/common/util/credentials'

const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
const CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login:'
const CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login-consumed:'
const CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE = 'consumed'

function getCliAuthCodeHash(authCode: string): string {
return createHash('sha256').update(authCode.trim()).digest('hex')
}

export function buildCliAuthCode(
fingerprintId: string,
Expand All @@ -17,26 +24,83 @@ export function isOpaqueCliAuthCodeToken(authCode: string): boolean {
}

export function getCliAuthCodeHashPrefix(authCode: string): string {
return createHash('sha256').update(authCode.trim()).digest('hex').slice(0, 12)
return getCliAuthCodeHash(authCode).slice(0, 12)
}

export function getCliAuthCodeTokenIdentifier(authCodeToken: string): string {
return `${CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}`
}

export function getConsumedCliAuthCodeTokenIdentifier(
authCodeToken: string,
): string {
return `${CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${getCliAuthCodeHash(
authCodeToken,
)}`
}

export function getConsumedCliAuthCodeTokenValue(): string {
return CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE
}

export type CliAuthCodeTokenConsumeResult =
| { status: 'resolved'; authCode: string }
| { status: 'already_consumed' }
| { status: 'missing' }

export type CliAuthCodeResolution =
| {
status: 'ready'
authCode: string
resolvedOpaqueToken: boolean
}
| {
status: 'already_consumed'
authCode: string
resolvedOpaqueToken: false
}
| {
status: 'missing'
authCode: string
resolvedOpaqueToken: false
}

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

const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode)
if (!signedAuthCode) {
return { authCode: normalizedAuthCode, resolvedOpaqueToken: false }
const tokenResult = await consumeCliAuthCodeToken(normalizedAuthCode)
if (tokenResult.status === 'resolved') {
return {
status: 'ready',
authCode: tokenResult.authCode,
resolvedOpaqueToken: true,
}
}

if (tokenResult.status === 'already_consumed') {
return {
status: 'already_consumed',
authCode: normalizedAuthCode,
resolvedOpaqueToken: false,
}
}

return {
authCode: signedAuthCode,
resolvedOpaqueToken: true,
status: 'missing',
authCode: normalizedAuthCode,
resolvedOpaqueToken: false,
}
}

Expand Down
34 changes: 32 additions & 2 deletions freebuff/web/src/app/onboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,37 @@ const Onboard = async ({ searchParams }: PageProps) => {
)
}

const { authCode: resolvedAuthCode, resolvedOpaqueToken } =
await resolveCliAuthCode(authCode, consumeCliAuthCodeToken)
const authCodeResolution = await resolveCliAuthCode(
authCode,
consumeCliAuthCodeToken,
)

if (authCodeResolution.status === 'already_consumed') {
logger.info(
{
authCodeLength: authCode.length,
authCodeTrimmedLength: authCode.trim().length,
authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode),
isOpaqueAuthCodeToken: isOpaqueCliAuthCodeToken(authCode),
userId: user.id,
},
'Reused Freebuff CLI auth code token',
)

return (
<StatusCard
title="Login link already used"
description="This browser login link has already been used."
message="Return to your terminal to continue, or restart Freebuff if it is still waiting for login."
/>
)
}

const {
authCode: resolvedAuthCode,
resolvedOpaqueToken,
status: authCodeResolutionStatus,
} = authCodeResolution
const { fingerprintId, expiresAt, receivedHash } =
parseAuthCode(resolvedAuthCode)
const { valid, expectedHash: fingerprintHash } = validateAuthCode(
Expand All @@ -117,6 +146,7 @@ const Onboard = async ({ searchParams }: PageProps) => {
authCodeTrimmedLength: authCode.trim().length,
authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode),
isOpaqueAuthCodeToken: isOpaqueCliAuthCodeToken(authCode),
authCodeResolutionStatus,
resolvedAuthCode: resolvedOpaqueToken,
resolvedAuthCodeLength: resolvedAuthCode.length,
userId: user.id,
Expand Down
3 changes: 2 additions & 1 deletion web/src/app/api/auth/cli/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { z } from 'zod/v4'
import {
buildCliAuthCode,
getCliAuthCodeHashPrefix,
getCliAuthCodeTokenIdentifier,
} from '@/app/onboard/_helpers'
import { logger } from '@/util/logger'

Expand Down Expand Up @@ -71,7 +72,7 @@ export async function POST(req: Request) {
const loginToken = randomBytes(32).toString('base64url')

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