feat(cliproxy): allow optional provider nicknames

- stop requiring manual unique nicknames for no-email providers by default

- preserve explicit nickname validation and same-account reauth semantics

- persist optional nicknames through dashboard manual callback flows
This commit is contained in:
Tam Nhu Tran
2026-03-23 11:02:08 -04:00
parent 185f7f469e
commit bdb7ac2937
15 changed files with 398 additions and 220 deletions
+150 -36
View File
@@ -22,6 +22,8 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../../cliproxy/account-manager';
@@ -33,7 +35,7 @@ import {
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -53,12 +55,55 @@ import {
import { createRouteErrorHelpers } from './route-helpers';
const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
const pendingManualAuthState = new Map<
string,
{ nickname?: string; expectedAccountId?: string; createdAt: number }
>();
// Valid providers list - derived from canonical CLIPROXY_PROFILES
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes');
function pruneExpiredManualAuthState(now = Date.now()): void {
for (const [state, pending] of pendingManualAuthState.entries()) {
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
pendingManualAuthState.delete(state);
}
}
}
function rememberManualAuthState(
state: string,
pending: { nickname?: string; expectedAccountId?: string }
): void {
pruneExpiredManualAuthState();
pendingManualAuthState.set(state, {
...pending,
createdAt: Date.now(),
});
}
function getManualAuthState(
state: string | undefined
): { nickname?: string; expectedAccountId?: string } | null {
if (!state) {
return null;
}
pruneExpiredManualAuthState();
const pending = pendingManualAuthState.get(state);
if (!pending) {
return null;
}
return {
nickname: pending.nickname,
expectedAccountId: pending.expectedAccountId,
};
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -104,6 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
return 'Authentication failed or was cancelled';
}
export function getStartAuthNicknameError(
provider: CLIProxyProvider,
nickname: string | undefined,
existingAccounts: Array<{ id: string; nickname?: string }>,
allowExistingAccountId?: string
): { error: string; code: 'INVALID_NICKNAME' | 'NICKNAME_EXISTS' } | null {
if (!PROVIDERS_WITHOUT_EMAIL.includes(provider) || !nickname) {
return null;
}
const validationError = validateNickname(nickname);
if (validationError) {
return {
error: validationError,
code: 'INVALID_NICKNAME',
};
}
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
return {
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
code: 'NICKNAME_EXISTS',
};
}
return null;
}
/**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
@@ -430,37 +503,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
}
// For kiro/ghcp: nickname is required
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
res.status(400).json({
error: `Nickname is required for ${provider} accounts. Please provide a unique nickname.`,
code: 'NICKNAME_REQUIRED',
});
return;
}
const validationError = validateNickname(nickname);
if (validationError) {
res.status(400).json({
error: validationError,
code: 'INVALID_NICKNAME',
});
return;
}
// Check uniqueness
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNicknames = existingAccounts.map(
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
);
if (existingNicknames.includes(nickname.toLowerCase())) {
res.status(400).json({
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
code: 'NICKNAME_EXISTS',
});
return;
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts,
existingNameMatch?.id
);
if (nicknameError) {
res.status(400).json(nicknameError);
return;
}
// Check Kiro no-incognito setting from config (or request body)
@@ -625,7 +678,12 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
*/
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {};
const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
// Check remote mode
@@ -668,6 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts,
existingNameMatch?.id
);
if (nicknameError) {
res.status(400).json(nicknameError);
return;
}
try {
const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
@@ -696,19 +767,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
method?: string;
};
const authUrl = data.url || data.auth_url;
const oauthState = data.state || parseAuthUrlState(authUrl);
// Some upstream flows return state first and provide auth_url in subsequent status polling.
if (!authUrl && !data.state) {
if (!authUrl && !oauthState) {
res
.status(500)
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
return;
}
if (oauthState) {
rememberManualAuthState(oauthState, {
nickname: nickname || undefined,
expectedAccountId: existingNameMatch?.id,
});
}
res.json({
success: true,
authUrl: authUrl || null,
state: data.state || null,
state: oauthState,
method: data.method || null,
});
} catch (error) {
@@ -768,6 +847,18 @@ function parseCallbackUrl(url: string): { code?: string; state?: string } {
}
}
function parseAuthUrlState(url: string | null | undefined): string | null {
if (!url) {
return null;
}
try {
return new URL(url).searchParams.get('state');
} catch {
return null;
}
}
/**
* POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually
* For cross-browser OAuth flows where callback cannot redirect directly
@@ -800,6 +891,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
res.status(400).json({ error: 'Invalid callback URL: missing code parameter' });
return;
}
const pendingAuth = getManualAuthState(parsed.state);
try {
const callbackProvider =
@@ -824,7 +916,29 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
return;
}
res.json({ success: true });
const account = registerAccountFromToken(
provider as CLIProxyProvider,
getProviderTokenDir(provider as CLIProxyProvider),
pendingAuth?.nickname,
false,
pendingAuth?.expectedAccountId
);
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
res.json({
success: true,
account: account
? {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
}
: null,
});
} catch (error) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
}