From 8606aac473c462eaa7db104121140adf58ba3189 Mon Sep 17 00:00:00 2001 From: Dataflow Dev Date: Tue, 1 Sep 2026 04:00:43 +0530 Subject: [PATCH 1/2] feat: bind free sessions to accounts to prevent multi-account abuse Prevent users from switching accounts mid-session to abuse free-tier limits. When a freebuff session becomes active, it is now bound to the authenticating user's ID. Multiple guard rails enforce this: - /logout blocked during active session (use /end-session or --force) - Login rejected if new user differs from session-bound user - Startup guard ends session if credentials changed externally - Session binding cleared on ended/none/superseded transitions The server already enforces one free session per account; this adds client-side enforcement so the same machine can't cycle through multiple accounts to farm free sessions. --- cli/src/commands/command-registry.ts | 24 +++++++++++-- cli/src/data/slash-commands.ts | 2 +- cli/src/hooks/use-auth-state.ts | 20 +++++++++-- cli/src/hooks/use-freebuff-session.ts | 46 ++++++++++++++++++++++++- cli/src/state/freebuff-session-store.ts | 10 ++++++ 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index b308c7bb91..9ddc4e6fdd 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -13,7 +13,10 @@ 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 { useThemeStore } from '../hooks/use-theme' import { LOGIN_WEBSITE_URL, WEBSITE_URL } from '../login/constants' import { startNewChat } from '../project-files' @@ -291,10 +294,25 @@ 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') const { resetLoginState } = useLoginStore.getState() diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index c6e187a0c4..bb89235605 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -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, }, diff --git a/cli/src/hooks/use-auth-state.ts b/cli/src/hooks/use-auth-state.ts index 80819b587e..d439c633dc 100644 --- a/cli/src/hooks/use-auth-state.ts +++ b/cli/src/hooks/use-auth-state.ts @@ -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' @@ -94,6 +95,21 @@ export const useAuthState = ({ // Handle successful login const handleLoginSuccess = useCallback( (loggedInUser: User) => { + // 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', + ) + clearUserCredentials() + return + } + // 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) { diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index 448ca7b215..3b02bc7121 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -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 { @@ -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 @@ -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 | null = null @@ -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) diff --git a/cli/src/state/freebuff-session-store.ts b/cli/src/state/freebuff-session-store.ts index e7d31bb716..3d4e2c57c6 100644 --- a/cli/src/state/freebuff-session-store.ts +++ b/cli/src/state/freebuff-session-store.ts @@ -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((set) => ({ session: null, failure: null, + sessionBoundUserId: null, setSession: (session) => set({ session }), setFailure: (failure) => set({ failure }), + setSessionBoundUserId: (userId) => set({ sessionBoundUserId: userId }), })) From 792fcc15a6096140f7a656e62f577ad84d9d7172 Mon Sep 17 00:00:00 2001 From: Dataflow Dev Date: Tue, 1 Sep 2026 04:42:18 +0530 Subject: [PATCH 2/2] feat: improve session-account binding with UX, cleanup, analytics, and tests - Login modal now shows clear error message when rejected due to account mismatch instead of silently reverting to the login screen - /logout --force now releases the server-side session slot and clears the binding before clearing credentials - Added ACCOUNT_SWITCH_BLOCKED analytics event to track abuse attempts - Added unit tests for session binding store and helper functions Addresses review feedback on PR #1171 for tighter abuse prevention. --- cli/src/commands/command-registry.ts | 9 +++ cli/src/components/login-modal.tsx | 14 ++++- .../__tests__/session-bound-user.test.ts | 30 ++++++++++ cli/src/hooks/use-auth-state.ts | 12 +++- .../__tests__/freebuff-session-store.test.ts | 60 +++++++++++++++++++ common/src/constants/analytics-events.ts | 1 + 6 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 cli/src/hooks/__tests__/session-bound-user.test.ts create mode 100644 cli/src/state/__tests__/freebuff-session-store.test.ts diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 9ddc4e6fdd..c05c642dfc 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -17,6 +17,8 @@ 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' @@ -315,6 +317,13 @@ const ALL_COMMANDS: CommandDefinition[] = [ 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: () => { diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index a03716985b..097060be2c 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -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 } @@ -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( @@ -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) + } }, }) }, []) diff --git a/cli/src/hooks/__tests__/session-bound-user.test.ts b/cli/src/hooks/__tests__/session-bound-user.test.ts new file mode 100644 index 0000000000..c772a6adaf --- /dev/null +++ b/cli/src/hooks/__tests__/session-bound-user.test.ts @@ -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() + }) +}) diff --git a/cli/src/hooks/use-auth-state.ts b/cli/src/hooks/use-auth-state.ts index d439c633dc..59a481c4d6 100644 --- a/cli/src/hooks/use-auth-state.ts +++ b/cli/src/hooks/use-auth-state.ts @@ -92,9 +92,10 @@ 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() @@ -106,8 +107,12 @@ export const useAuthState = ({ }, '[auth] Login rejected: session bound to different user', ) + trackEvent(AnalyticsEvent.ACCOUNT_SWITCH_BLOCKED, { + sessionBoundUserId: boundUserId, + attemptedUserId: loggedInUser.id ?? 'unknown', + }) clearUserCredentials() - return + 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 @@ -134,6 +139,7 @@ export const useAuthState = ({ setInputFocused(true) setUser(loggedInUser) setIsAuthenticated(true) + return null }, [resetChatStore, resetLoginState, setInputFocused], ) diff --git a/cli/src/state/__tests__/freebuff-session-store.test.ts b/cli/src/state/__tests__/freebuff-session-store.test.ts new file mode 100644 index 0000000000..13dccf75ca --- /dev/null +++ b/cli/src/state/__tests__/freebuff-session-store.test.ts @@ -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', + ) + }) + }) +}) diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index 3328003ec0..0cfa3ca2f4 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -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',