Skip to content
6 changes: 5 additions & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,10 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [authState, authContext, setAuthState]);

const handleShowAuthSelection = useCallback(() => {
setAuthState(AuthState.Updating);
}, [setAuthState]);

const {
proQuotaRequest,
handleProQuotaChoice,
Expand All @@ -766,7 +770,7 @@ export const AppContainer = (props: AppContainerProps) => {
paidTier,
settings,
setModelSwitchedFromQuotaError,
onShowAuthSelection: () => setAuthState(AuthState.Updating),
onShowAuthSelection: handleShowAuthSelection,
errorVerbosity: settings.merged.ui.errorVerbosity,
});

Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/ui/auth/AuthDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import {
type Mock,
} from 'vitest';
import { AuthDialog } from './AuthDialog.js';
import { AuthType, type Config, debugLogger } from '@google/gemini-cli-core';
import {
AuthType,
clearCachedCredentialFile,
type Config,
debugLogger,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { AuthState } from '../types.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
Expand Down Expand Up @@ -371,6 +376,25 @@ describe('AuthDialog', () => {
vi.useRealTimers();
unmount();
});

it('catches errors thrown during onSelect and forwards them to onAuthError', async () => {
mockedValidateAuthMethod.mockResolvedValue(null);
props.settings.merged.security.auth.selectedType = AuthType.USE_GEMINI;
vi.mocked(clearCachedCredentialFile).mockRejectedValueOnce(
new Error('Failed to clear credentials'),
);

const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);

expect(props.onAuthError).toHaveBeenCalledWith(null);
expect(props.onAuthError).toHaveBeenCalledWith(
'Failed to clear credentials',
);
unmount();
});
});

it('displays authError when provided', async () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/ui/auth/AuthDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,12 @@ export function AuthDialog({
if (error) {
onAuthError(error);
} else {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
onSelect(authMethod, SettingScope.User);
onAuthError(null);
try {
await onSelect(authMethod, SettingScope.User);
} catch (e) {
onAuthError(e instanceof Error ? e.message : String(e));
}
}
};

Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/ui/auth/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,5 +331,21 @@ describe('useAuth', () => {
expect(result.current.authError).not.toContain('Failed to login');
expect(result.current.authState).toBe(AuthState.Updating);
});

it('should transition to Updating without setting authError on ChangeAuthRequestedError or ValidationCancelledError', async () => {
const { ChangeAuthRequestedError } = await import(
'@google/gemini-cli-core'
);
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);

await act(async () => {
deferredRefreshAuth.reject(new ChangeAuthRequestedError());
});

expect(result.current.authError).toBeNull();
expect(result.current.authState).toBe(AuthState.Updating);
});
});
});
10 changes: 10 additions & 0 deletions packages/cli/src/ui/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
debugLogger,
isAccountSuspendedError,
ProjectIdRequiredError,
ChangeAuthRequestedError,
ValidationCancelledError,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
Expand Down Expand Up @@ -141,6 +143,14 @@ export const useAuthCommand = (
setAuthError(null);
setAuthState(AuthState.Authenticated);
} catch (e) {
if (
e instanceof ChangeAuthRequestedError ||
e instanceof ValidationCancelledError
) {
setAuthError(null);
setAuthState(AuthState.Updating);
return;
}
const suspendedError = isAccountSuspendedError(e);
if (suspendedError) {
setAccountSuspensionInfo({
Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/ui/components/ValidationDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,62 @@ describe('ValidationDialog', () => {
expect(lastFrame()).toContain('Waiting for verification...');
unmount();
});

it('should resolve verify after Enter in waiting state without resetting timer on re-render', async () => {
const { lastFrame, waitUntilReady, rerender, unmount } = await render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);

const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
await waitUntilReady();

expect(lastFrame()).toContain('Waiting for verification...');

vi.useFakeTimers();
try {
// Press Enter to confirm verification in browser is complete
await act(async () => {
mockKeypressHandler({
name: 'enter',
ctrl: false,
shift: false,
alt: false,
cmd: false,
insertable: false,
sequence: '\r',
});
});

// Advance 300ms, then simulate parent re-render with a new inline onChoice callback
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
const newOnChoice = vi.fn();
rerender(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={newOnChoice}
/>,
);

// Advance remaining 200ms — timer should fire at 500ms total without having been reset
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});

expect(newOnChoice).toHaveBeenCalledTimes(1);
expect(newOnChoice).toHaveBeenCalledWith('verify');
} finally {
vi.useRealTimers();
}
unmount();
});
});

describe('headless mode', () => {
Expand Down
19 changes: 13 additions & 6 deletions packages/cli/src/ui/components/ValidationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import type React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
Expand Down Expand Up @@ -36,6 +36,9 @@ export function ValidationDialog({
const keyMatchers = useKeyMatchers();
const [state, setState] = useState<DialogState>('choosing');
const [errorMessage, setErrorMessage] = useState<string>('');
const onChoiceRef = useRef(onChoice);
onChoiceRef.current = onChoice;
const hasCompletedRef = useRef(false);

const items = [
{
Expand All @@ -54,7 +57,7 @@ export function ValidationDialog({
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
onChoice('cancel');
onChoiceRef.current('cancel');
return true;
} else if (state === 'waiting' && keyMatchers[Command.RETURN](key)) {
// User confirmed verification is complete - transition to 'complete' state
Expand All @@ -66,16 +69,20 @@ export function ValidationDialog({
{ isActive: state !== 'complete' },
);

// When state becomes 'complete', show success message briefly then proceed
// When state becomes 'complete', show success message briefly then proceed.
// Use onChoiceRef so parent re-renders do not reset the 500ms timer.
useEffect(() => {
if (state === 'complete') {
if (state === 'complete' && !hasCompletedRef.current) {
const timer = setTimeout(() => {
onChoice('verify');
if (!hasCompletedRef.current) {
hasCompletedRef.current = true;
onChoiceRef.current('verify');
}
}, 500);
return () => clearTimeout(timer);
}
return undefined;
}, [state, onChoice]);
}, [state]);

const handleSelect = useCallback(
async (choice: ValidationIntent) => {
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/agents/auth-provider/oauth2-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,26 @@ describe('OAuth2AuthProvider', () => {
/requires authorization_url and token_url/,
);
});

it('should deduplicate concurrent interactive auth requests', async () => {
const provider = new OAuth2AuthProvider(createConfig(), 'test-agent');
const storage = getTokenStorage();
storage.getCredentials.mockResolvedValue(null);

await provider.initialize();

const [headers1, headers2, headers3] = await Promise.all([
provider.headers(),
provider.headers(),
provider.headers(),
]);

expect(headers1).toEqual({ Authorization: 'Bearer new-access-token' });
expect(headers2).toEqual({ Authorization: 'Bearer new-access-token' });
expect(headers3).toEqual({ Authorization: 'Bearer new-access-token' });
expect(vi.mocked(startCallbackServer)).toHaveBeenCalledTimes(1);
expect(vi.mocked(exchangeCodeForToken)).toHaveBeenCalledTimes(1);
});
});

describe('shouldRetryWithHeaders', () => {
Expand Down Expand Up @@ -482,6 +502,81 @@ describe('OAuth2AuthProvider', () => {
const result = await provider.shouldRetryWithHeaders({}, res401);
expect(result).toBeDefined();
});

it('should reset retry count when cached token is used on subsequent headers() call', async () => {
const provider = new OAuth2AuthProvider(createConfig(), 'test-agent');
const res401 = new Response(null, { status: 401 });

// Consume both retries
await provider.shouldRetryWithHeaders({}, res401);
await provider.shouldRetryWithHeaders({}, res401);

// A subsequent normal request uses the valid cached token, resetting the retry counter
await provider.headers();

// A later 401 should be allowed to retry again
const result = await provider.shouldRetryWithHeaders({}, res401);
expect(result).toBeDefined();
});

it('should reset retry count when a new token is acquired on subsequent headers() call', async () => {
const provider = new OAuth2AuthProvider(createConfig(), 'test-agent');
const storage = getTokenStorage();
const res401 = new Response(null, { status: 401 });

// Consume both retries
await provider.shouldRetryWithHeaders({}, res401);
await provider.shouldRetryWithHeaders({}, res401);

// Simulate token expiring before the next normal request
storage.isTokenExpired.mockReturnValueOnce(true);

// Acquiring a fresh token via headers() resets the retry counter
await provider.headers();

// A later 401 should be allowed to retry again
const result = await provider.shouldRetryWithHeaders({}, res401);
expect(result).toBeDefined();
});

it('should not increment retry count or delete credentials again when concurrent requests fail with the same stale token', async () => {
const provider = new OAuth2AuthProvider(createConfig(), 'test-agent');
const storage = getTokenStorage();

storage.getCredentials.mockResolvedValue({
serverName: 'test-agent',
token: { accessToken: 'stale-token', tokenType: 'Bearer' },
updatedAt: Date.now(),
});
storage.isTokenExpired.mockReturnValue(false);

await provider.initialize();

const staleReq = {
headers: { Authorization: 'Bearer stale-token' },
};
const res401 = new Response(null, { status: 401 });

// Three concurrent requests that all used 'stale-token' fail with 401
const [retry1, retry2, retry3] = await Promise.all([
provider.shouldRetryWithHeaders(staleReq, res401),
provider.shouldRetryWithHeaders(staleReq, res401),
provider.shouldRetryWithHeaders(staleReq, res401),
]);

expect(retry1).toEqual({ Authorization: 'Bearer new-access-token' });
expect(retry2).toEqual({ Authorization: 'Bearer new-access-token' });
expect(retry3).toEqual({ Authorization: 'Bearer new-access-token' });
expect(storage.deleteCredentials).toHaveBeenCalledTimes(1);

// Because the 3 concurrent failures were for the same stale token, only 1 retry
// was consumed, so another subsequent 401 with the new token can still retry.
const nextRetry = await provider.shouldRetryWithHeaders(
{ headers: { Authorization: 'Bearer new-access-token' } },
res401,
);
expect(nextRetry).toBeDefined();
});
});

describe('token persistence', () => {
Expand Down
Loading
Loading