Skip to content
Open
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
33 changes: 30 additions & 3 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs, build
import { handleReasoningCommand } from './reasoning'
import { runBashCommand } from './router'
import { handleUsageCommand } from './usage'
import { returnToFreebuffLanding } from '../hooks/use-freebuff-session'
import {
returnToFreebuffLanding,
getSessionBoundUserId,
} from '../hooks/use-freebuff-session'
import { releaseFreebuffSlot } from '../utils/freebuff-session-api'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { useThemeStore } from '../hooks/use-theme'
import { LOGIN_WEBSITE_URL, WEBSITE_URL } from '../login/constants'
import { startNewChat } from '../project-files'
Expand Down Expand Up @@ -291,12 +296,34 @@ const ALL_COMMANDS: CommandDefinition[] = [
clearInput(params)
},
}),
defineCommand({
defineCommandWithArgs({
name: 'logout',
aliases: ['signout'],
handler: (params) => {
handler: (params, args) => {
// Check if a session is bound to this account. If so, require --force
// or ask the user to end the session first to prevent multi-account abuse.
const boundUserId = getSessionBoundUserId()
const force = args.trim() === '--force'
if (boundUserId && !force) {
params.setMessages((prev) => [
...prev,
getSystemMessage(
'You have an active session tied to this account. End it first with /end-session, or force logout with /logout --force.',
),
])
clearInput(params)
return
}

stopActiveRun('logout')

// When force-logging out with an active session, release the server-side
// slot and clear the binding before clearing credentials.
if (boundUserId && force) {
releaseFreebuffSlot().catch(() => {})
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
}

const { resetLoginState } = useLoginStore.getState()
params.logoutMutation.mutate(undefined, {
onSettled: () => {
Expand Down
14 changes: 11 additions & 3 deletions cli/src/components/login-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { getLogoBlockColor, getLogoAccentColor } from '../utils/theme-system'
import type { User } from '../utils/auth'

interface LoginModalProps {
onLoginSuccess: (user: User) => void
onLoginSuccess: (user: User) => string | null
hasInvalidCredentials?: boolean | null
}

Expand Down Expand Up @@ -148,7 +148,11 @@ export const LoginModal = ({
const handleLoginSuccess = useCallback((user: User) => {
loginMutationRef.current.mutate(user, {
onSuccess: (validatedUser) => {
onLoginSuccessRef.current(validatedUser)
const rejectionReason = onLoginSuccessRef.current(validatedUser)
if (rejectionReason) {
setError(rejectionReason)
setIsWaitingForEnter(false)
}
},
onError: (error) => {
logger.error(
Expand All @@ -157,7 +161,11 @@ export const LoginModal = ({
},
'❌ Login validation failed, proceeding with raw user',
)
onLoginSuccessRef.current(user)
const rejectionReason = onLoginSuccessRef.current(user)
if (rejectionReason) {
setError(rejectionReason)
setIsWaitingForEnter(false)
}
},
})
}, [])
Expand Down
2 changes: 1 addition & 1 deletion cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
{
id: 'logout',
label: 'logout',
description: 'Sign out of your session',
description: 'Sign out of your session (--force to bypass active session check)',
aliases: ['signout'],
implicitCommand: true,
},
Expand Down
30 changes: 30 additions & 0 deletions cli/src/hooks/__tests__/session-bound-user.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'bun:test'

import { useFreebuffSessionStore } from '../../state/freebuff-session-store'

// Re-implement the helper to test it independently (avoids importing the
// full use-freebuff-session module which has side effects and React deps).
function getSessionBoundUserId(): string | null {
return useFreebuffSessionStore.getState().sessionBoundUserId
}

describe('getSessionBoundUserId', () => {
beforeEach(() => {
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
})

it('returns null when no binding exists', () => {
expect(getSessionBoundUserId()).toBeNull()
})

it('returns the bound user id when set', () => {
useFreebuffSessionStore.getState().setSessionBoundUserId('user-abc')
expect(getSessionBoundUserId()).toBe('user-abc')
})

it('returns null after binding is cleared', () => {
useFreebuffSessionStore.getState().setSessionBoundUserId('user-abc')
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
expect(getSessionBoundUserId()).toBeNull()
})
})
30 changes: 26 additions & 4 deletions cli/src/hooks/use-auth-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
import { useCallback, useEffect, useState } from 'react'

import { useAuthQuery, useLogoutMutation } from './use-auth-query'
import { getSessionBoundUserId } from './use-freebuff-session'
import { useLoginStore } from '../state/login-store'
import { identifyUser, trackEvent } from '../utils/analytics'
import { getUserCredentials } from '../utils/auth'
import { clearUserCredentials, getUserCredentials } from '../utils/auth'
import { resetCodebuffClient } from '../utils/codebuff-client'
import { IS_FREEBUFF } from '../utils/constants'
import { loggerContext } from '../utils/logger'
import { logger, loggerContext } from '../utils/logger'

import type { MultilineInputHandle } from '../components/multiline-input'
import type { User } from '../utils/auth'
Expand Down Expand Up @@ -91,9 +92,29 @@ export const useAuthState = ({
}
}, [authQuery.isSuccess, authQuery.isError, authQuery.data, user])

// Handle successful login
// Handle successful login. Returns a rejection reason if the login was
// blocked (e.g. session bound to a different account), or null on success.
const handleLoginSuccess = useCallback(
(loggedInUser: User) => {
(loggedInUser: User): string | null => {
// Prevent multi-account abuse: if a session is bound to a different user,
// reject the login and clear the just-saved credentials.
const boundUserId = getSessionBoundUserId()
if (boundUserId && loggedInUser.id !== boundUserId) {
logger.warn(
{
sessionBoundUserId: boundUserId,
attemptedUserId: loggedInUser.id,
},
'[auth] Login rejected: session bound to different user',
)
trackEvent(AnalyticsEvent.ACCOUNT_SWITCH_BLOCKED, {
sessionBoundUserId: boundUserId,
attemptedUserId: loggedInUser.id ?? 'unknown',
})
clearUserCredentials()
return 'This session is tied to another account. End the current session first, or log out with /logout --force.'
}

// Identify first (aliases the pre-login anonymous history to the real
// user id) so the login event below is attributed to the user.
if (loggedInUser.id && loggedInUser.email) {
Expand All @@ -118,6 +139,7 @@ export const useAuthState = ({
setInputFocused(true)
setUser(loggedInUser)
setIsAuthenticated(true)
return null
},
[resetChatStore, resetLoginState, setInputFocused],
)
Expand Down
46 changes: 45 additions & 1 deletion cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from '../state/freebuff-model-store'
import { useChatStore } from '../state/chat-store'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { getAuthTokenDetails } from '../utils/auth'
import { getAuthTokenDetails, getUserCredentials } from '../utils/auth'
import { stopActiveRun } from '../utils/active-run'
import { IS_FREEBUFF } from '../utils/constants'
import {
Expand Down Expand Up @@ -141,6 +141,13 @@ export function getFreebuffInstanceId(): string | undefined {
return 'instanceId' in current ? current.instanceId : undefined
}

/** Read the user ID bound to the current session. Returns `null` when no
* session is active or the session has no binding. Used by the logout
* guard and login validation to enforce single-account-per-session. */
export function getSessionBoundUserId(): string | null {
return useFreebuffSessionStore.getState().sessionBoundUserId
}

/** True when the session represents a server-side slot the caller is
* holding (active, or in the post-expiry grace window with a live
* instance id). Chat requests are only admissible in these states — once
Expand Down Expand Up @@ -416,6 +423,29 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
return
}

// Startup guard: if a session is bound to a different user, end it
// immediately to prevent multi-account abuse on the same machine.
const { sessionBoundUserId } = useFreebuffSessionStore.getState()
const currentUser = getUserCredentials()
if (sessionBoundUserId && currentUser?.id !== sessionBoundUserId) {
logger.warn(
{
sessionBoundUserId,
currentUserId: currentUser?.id,
},
'[freebuff-session] Session bound to different user; ending session',
)
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
releaseFreebuffSlot().catch(() => {})
setSession({
status: 'ended',
accessTier: undefined,
rateLimitsByModel: undefined,
subscription: undefined,
})
return
}

let cancelled = false
let abortController = new AbortController()
let timer: ReturnType<typeof setTimeout> | null = null
Expand Down Expand Up @@ -443,6 +473,20 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
}
if (next.status === 'active') {
recordFreebuffInstanceOwner(next.instanceId)
// Bind the session to the current user to prevent multi-account abuse.
const credentials = getUserCredentials()
if (credentials?.id) {
useFreebuffSessionStore
.getState()
.setSessionBoundUserId(credentials.id)
}
} else if (
next.status === 'ended' ||
next.status === 'none' ||
next.status === 'superseded'
) {
// Clear the binding when the session is no longer active.
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
}
setSession(next)
setFailure(null)
Expand Down
60 changes: 60 additions & 0 deletions cli/src/state/__tests__/freebuff-session-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach } from 'bun:test'

import { useFreebuffSessionStore } from '../freebuff-session-store'

describe('FreebuffSessionStore — session binding', () => {
beforeEach(() => {
useFreebuffSessionStore.getState().setSession(null)
useFreebuffSessionStore.getState().setFailure(null)
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
})

describe('sessionBoundUserId', () => {
it('should default to null', () => {
expect(useFreebuffSessionStore.getState().sessionBoundUserId).toBeNull()
})

it('should set the bound user id', () => {
useFreebuffSessionStore.getState().setSessionBoundUserId('user-123')

expect(useFreebuffSessionStore.getState().sessionBoundUserId).toBe(
'user-123',
)
})

it('should clear the bound user id', () => {
useFreebuffSessionStore.getState().setSessionBoundUserId('user-123')
useFreebuffSessionStore.getState().setSessionBoundUserId(null)

expect(useFreebuffSessionStore.getState().sessionBoundUserId).toBeNull()
})

it('should overwrite previous binding', () => {
useFreebuffSessionStore.getState().setSessionBoundUserId('user-123')
useFreebuffSessionStore.getState().setSessionBoundUserId('user-456')

expect(useFreebuffSessionStore.getState().sessionBoundUserId).toBe(
'user-456',
)
})
})

describe('setSession', () => {
it('should not affect sessionBoundUserId when setting session', () => {
useFreebuffSessionStore.getState().setSessionBoundUserId('user-123')

useFreebuffSessionStore.getState().setSession({
status: 'active',
instanceId: 'inst-1',
model: 'test-model',
expiresAt: Date.now() + 60_000,
remainingMs: 60_000,
accessTier: 'full',
})

expect(useFreebuffSessionStore.getState().sessionBoundUserId).toBe(
'user-123',
)
})
})
})
10 changes: 10 additions & 0 deletions cli/src/state/freebuff-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,24 @@ export type FreebuffSessionFailure =
interface FreebuffSessionStore {
session: FreebuffSessionResponse | null
failure: FreebuffSessionFailure | null
/**
* The user ID that owns the current active session. Set when the session
* becomes `active`, cleared when the session ends. Used to prevent
* account-switching abuse — if a session is bound to user A, user B
* cannot log in on the same machine without ending the session first.
*/
sessionBoundUserId: string | null

setSession: (session: FreebuffSessionResponse | null) => void
setFailure: (failure: FreebuffSessionFailure | null) => void
setSessionBoundUserId: (userId: string | null) => void
}

export const useFreebuffSessionStore = create<FreebuffSessionStore>((set) => ({
session: null,
failure: null,
sessionBoundUserId: null,
setSession: (session) => set({ session }),
setFailure: (failure) => set({ failure }),
setSessionBoundUserId: (userId) => set({ sessionBoundUserId: userId }),
}))
1 change: 1 addition & 0 deletions common/src/constants/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum AnalyticsEvent {
LOGIN_FAILED = 'cli.login_failed',
LOGIN_TIMEOUT = 'cli.login_timeout',
LOGIN_ABORTED = 'cli.login_aborted',
ACCOUNT_SWITCH_BLOCKED = 'cli.account_switch_blocked',
SLASH_MENU_ACTIVATED = 'cli.slash_menu_activated',
SLASH_COMMAND_USED = 'cli.slash_command_used',
TERMINAL_BROKER_SPAWN_FAILED = 'cli.terminal_broker_spawn_failed',
Expand Down
Loading