mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +00:00
Merge pull request #561 from kaitranntt/fix/552-review-followups
fix(cliproxy): harden Kiro device-code auth flow consistency
This commit is contained in:
@@ -60,7 +60,7 @@ Want to run the dashboard in Docker? See `docker/README.md`.
|
||||
The dashboard provides visual management for all account types:
|
||||
|
||||
- **Claude Accounts**: Create isolated instances (work, personal, client)
|
||||
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity
|
||||
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
|
||||
- **API Profiles**: Configure GLM, Kimi with your keys
|
||||
- **Health Monitor**: Real-time status across all profiles
|
||||
|
||||
@@ -93,7 +93,7 @@ The dashboard provides visual management for all account types:
|
||||
| **Codex** | OAuth | `ccs codex` | Code generation |
|
||||
| **Copilot** | OAuth | `ccs copilot` or `ccs ghcp` | GitHub Copilot models |
|
||||
| **Cursor IDE** | Local Token | `ccs cursor` | Cursor subscription models via local daemon |
|
||||
| **Kiro** | OAuth | `ccs kiro` | AWS CodeWhisperer (Claude-powered) |
|
||||
| **Kiro** | OAuth (AWS default) | `ccs kiro` | AWS CodeWhisperer (Claude-powered) |
|
||||
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
|
||||
| **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API |
|
||||
| **Ollama** | Local | `ccs ollama` | Local open-source models, privacy |
|
||||
@@ -141,6 +141,19 @@ ccs ollama # Local Ollama (no API key needed)
|
||||
ccs glm # GLM (API key)
|
||||
```
|
||||
|
||||
### Kiro Auth Methods
|
||||
|
||||
`ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts).
|
||||
|
||||
```bash
|
||||
ccs kiro --auth --kiro-auth-method aws # AWS Builder ID device code (default)
|
||||
ccs kiro --auth --kiro-auth-method aws-authcode # AWS Builder ID auth code
|
||||
ccs kiro --auth --kiro-auth-method google # Google OAuth
|
||||
ccs kiro --auth --kiro-auth-method github # Dashboard management OAuth flow
|
||||
```
|
||||
|
||||
Dashboard parity: `ccs config` -> Accounts -> Add Kiro account -> choose `Auth Method`.
|
||||
|
||||
### Cursor IDE Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -86,7 +86,7 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi
|
||||
v
|
||||
1. CLIProxy Hardcoded ----+---> gemini, codex, agy, kiro, ghcp
|
||||
(OAuth-based) | Zero-config OAuth providers
|
||||
| (kiro: Auth Code, ghcp: Device Code)
|
||||
| (kiro: method-aware, ghcp: Device Code)
|
||||
|
|
||||
2. CLIProxy Variants -----+---> config.cliproxy section
|
||||
(User-defined) | Custom provider configurations
|
||||
@@ -570,10 +570,10 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi
|
||||
| Authentication Flow |
|
||||
+===========================================================================+
|
||||
|
||||
OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY, Kiro)
|
||||
--------------------------------------------------------------------
|
||||
OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY)
|
||||
--------------------------------------------------------------
|
||||
|
||||
1. User runs: ccs gemini (or ccs kiro)
|
||||
1. User runs: ccs gemini
|
||||
|
|
||||
v
|
||||
2. Check token cache (~/.ccs/cliproxy/auth/)
|
||||
@@ -584,7 +584,6 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi
|
||||
|
|
||||
v
|
||||
3. Open browser for OAuth (localhost:PORT callback)
|
||||
| - Kiro uses port 9876
|
||||
v
|
||||
4. Callback with auth code
|
||||
|
|
||||
@@ -595,6 +594,21 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi
|
||||
6. Cache token locally
|
||||
|
||||
|
||||
Kiro OAuth - Method-Aware Flow (CLI + Dashboard parity)
|
||||
-------------------------------------------------------
|
||||
|
||||
Supported methods:
|
||||
- aws: Device Code (default, AWS org friendly)
|
||||
- aws-authcode: Authorization Code via CLI flow
|
||||
- google: Social OAuth via management API
|
||||
- github: Social OAuth via management API (Dashboard flow)
|
||||
|
||||
Key behavior:
|
||||
- Device Code method uses /start route (no callback port)
|
||||
- Callback/social methods use /start-url + status polling
|
||||
- Some management flows return state first, auth_url later
|
||||
|
||||
|
||||
OAuth Providers - Device Code Flow (GitHub Copilot/ghcp)
|
||||
--------------------------------------------------------
|
||||
|
||||
|
||||
@@ -7,6 +7,74 @@
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { AccountInfo } from '../account-manager';
|
||||
|
||||
/**
|
||||
* Kiro authentication methods supported by CLIProxyAPIPlus.
|
||||
* - aws: AWS Builder ID via Device Code flow
|
||||
* - aws-authcode: AWS Builder ID via Authorization Code flow (CLI flag only)
|
||||
* - google: Social OAuth via Google
|
||||
* - github: Social OAuth via GitHub (management API only)
|
||||
*/
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
|
||||
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
|
||||
|
||||
/** CLI binary supports these Kiro methods directly via flags. */
|
||||
export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google'] as const;
|
||||
export type KiroCLIAuthMethod = (typeof KIRO_CLI_AUTH_METHODS)[number];
|
||||
|
||||
/** Default Kiro method for CCS UX and AWS Organization support. */
|
||||
export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws';
|
||||
|
||||
export function isKiroAuthMethod(value: string): value is KiroAuthMethod {
|
||||
return KIRO_AUTH_METHODS.includes(value as KiroAuthMethod);
|
||||
}
|
||||
|
||||
export function isKiroCLIAuthMethod(value: string): value is KiroCLIAuthMethod {
|
||||
return KIRO_CLI_AUTH_METHODS.includes(value as KiroCLIAuthMethod);
|
||||
}
|
||||
|
||||
export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
|
||||
if (!value) return DEFAULT_KIRO_AUTH_METHOD;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
|
||||
}
|
||||
|
||||
export function isKiroDeviceCodeMethod(method: KiroAuthMethod): boolean {
|
||||
return method === 'aws';
|
||||
}
|
||||
|
||||
export function getKiroCallbackPort(method: KiroAuthMethod): number | null {
|
||||
return isKiroDeviceCodeMethod(method) ? null : 9876;
|
||||
}
|
||||
|
||||
export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string {
|
||||
switch (method) {
|
||||
case 'aws':
|
||||
return '--kiro-aws-login';
|
||||
case 'aws-authcode':
|
||||
return '--kiro-aws-authcode';
|
||||
case 'google':
|
||||
return '--kiro-google-login';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiro method for CLIProxyAPI management endpoint:
|
||||
* GET /v0/management/kiro-auth-url?method=<value>
|
||||
*/
|
||||
export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' {
|
||||
switch (method) {
|
||||
case 'google':
|
||||
return 'google';
|
||||
case 'github':
|
||||
return 'github';
|
||||
case 'aws-authcode':
|
||||
return 'aws';
|
||||
case 'aws':
|
||||
default:
|
||||
return 'aws';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
||||
* See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth
|
||||
@@ -15,19 +83,19 @@ import { AccountInfo } from '../account-manager';
|
||||
* - Gemini: Authorization Code Flow with local callback server on port 8085
|
||||
* - Codex: Authorization Code Flow with local callback server on port 1455
|
||||
* - Agy: Authorization Code Flow with local callback server on port 51121
|
||||
* - Kiro: Authorization Code Flow with local callback server on port 9876
|
||||
* - iFlow: Authorization Code Flow with local callback server on port 11451
|
||||
* - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth)
|
||||
* - Kiro: Device Code Flow (polling-based, NO callback port needed)
|
||||
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
|
||||
* - GHCP: Device Code Flow (polling-based, NO callback port needed)
|
||||
*/
|
||||
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
|
||||
gemini: 8085,
|
||||
kiro: 9876,
|
||||
codex: 1455,
|
||||
agy: 51121,
|
||||
iflow: 11451,
|
||||
claude: 54545,
|
||||
// kiro: Device Code Flow - no callback port
|
||||
// qwen: Device Code Flow - no callback port
|
||||
// ghcp: Device Code Flow - no callback port
|
||||
};
|
||||
@@ -113,7 +181,9 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
|
||||
displayName: 'Kiro (AWS)',
|
||||
authUrl: 'https://oidc.us-east-1.amazonaws.com',
|
||||
scopes: ['codewhisperer:completions', 'codewhisperer:conversations'],
|
||||
authFlag: '--kiro-login',
|
||||
// Default to AWS Builder ID device code flow for better compatibility.
|
||||
// Other Kiro methods are selected at runtime via OAuthOptions.kiroMethod.
|
||||
authFlag: '--kiro-aws-login',
|
||||
},
|
||||
ghcp: {
|
||||
provider: 'ghcp',
|
||||
@@ -213,6 +283,8 @@ export interface OAuthOptions {
|
||||
account?: string;
|
||||
add?: boolean;
|
||||
nickname?: string;
|
||||
/** Kiro auth method override (CLI + Dashboard parity). */
|
||||
kiroMethod?: KiroAuthMethod;
|
||||
/** If true, triggered from Web UI (enables project selection prompt) */
|
||||
fromUI?: boolean;
|
||||
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
|
||||
|
||||
@@ -29,10 +29,15 @@ import {
|
||||
} from '../../management/oauth-port-diagnostics';
|
||||
import {
|
||||
OAuthOptions,
|
||||
OAUTH_CALLBACK_PORTS,
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroCallbackPort,
|
||||
getKiroCLIAuthFlag,
|
||||
isKiroCLIAuthMethod,
|
||||
isKiroDeviceCodeMethod,
|
||||
getOAuthConfig,
|
||||
ProviderOAuthConfig,
|
||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||
normalizeKiroAuthMethod,
|
||||
} from './auth-types';
|
||||
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
||||
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||
@@ -414,6 +419,8 @@ export async function triggerOAuth(
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
|
||||
let { nickname } = options;
|
||||
const resolvedKiroMethod =
|
||||
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
|
||||
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
@@ -444,10 +451,28 @@ export async function triggerOAuth(
|
||||
return null;
|
||||
}
|
||||
|
||||
const callbackPort = OAUTH_PORTS[provider];
|
||||
if (provider === 'kiro' && resolvedKiroMethod === 'github') {
|
||||
console.log(fail('Kiro GitHub login is only available in Dashboard management OAuth flow.'));
|
||||
console.log(' Use: ccs config -> Accounts -> Add Kiro account -> Method: GitHub OAuth');
|
||||
return null;
|
||||
}
|
||||
|
||||
const callbackPort =
|
||||
provider === 'kiro' ? getKiroCallbackPort(resolvedKiroMethod) : OAUTH_PORTS[provider];
|
||||
const isCLI = !fromUI;
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
const isDeviceCodeFlow = callbackPort === null;
|
||||
const isDeviceCodeFlow =
|
||||
provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod) : callbackPort === null;
|
||||
|
||||
let authFlag = oauthConfig.authFlag;
|
||||
if (provider === 'kiro') {
|
||||
if (!isKiroCLIAuthMethod(resolvedKiroMethod)) {
|
||||
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
|
||||
console.log(' Use Dashboard management OAuth for this method.');
|
||||
return null;
|
||||
}
|
||||
authFlag = getKiroCLIAuthFlag(resolvedKiroMethod);
|
||||
}
|
||||
|
||||
// Interactive mode selection for headless environments
|
||||
// Skip if explicit mode flag provided or device code flow (no callback needed)
|
||||
@@ -493,7 +518,7 @@ export async function triggerOAuth(
|
||||
const { binaryPath, tokenDir, configPath } = prepared;
|
||||
|
||||
// Free callback port if needed (only for authorization code flows)
|
||||
const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
|
||||
const localCallbackPort = callbackPort;
|
||||
if (localCallbackPort) {
|
||||
const killed = killProcessOnPort(localCallbackPort, verbose);
|
||||
if (killed && verbose) {
|
||||
@@ -502,7 +527,7 @@ export async function triggerOAuth(
|
||||
}
|
||||
|
||||
// Build args
|
||||
const args = ['--config', configPath, oauthConfig.authFlag];
|
||||
const args = ['--config', configPath, authFlag];
|
||||
if (headless) {
|
||||
args.push('--no-browser');
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { installImageAnalyzerHook } from '../../utils/hooks';
|
||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
|
||||
|
||||
// Import modular components
|
||||
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
||||
@@ -307,6 +308,33 @@ export async function execClaudeWithCLIProxy(
|
||||
setNickname = argsWithoutProxy[nicknameIdx + 1];
|
||||
}
|
||||
|
||||
// Parse --kiro-auth-method flag
|
||||
let kiroAuthMethod: KiroAuthMethod | undefined;
|
||||
const kiroMethodIdx = argsWithoutProxy.indexOf('--kiro-auth-method');
|
||||
if (kiroMethodIdx !== -1) {
|
||||
const rawMethod = argsWithoutProxy[kiroMethodIdx + 1];
|
||||
if (!rawMethod || rawMethod.startsWith('-')) {
|
||||
console.error(fail('--kiro-auth-method requires a value'));
|
||||
console.error(' Supported values: aws, aws-authcode, google, github');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const normalized = rawMethod.trim().toLowerCase();
|
||||
if (!isKiroAuthMethod(normalized)) {
|
||||
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
|
||||
console.error(' Supported values: aws, aws-authcode, google, github');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
|
||||
}
|
||||
|
||||
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
||||
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse --thinking / --effort flags (aliases; first occurrence wins)
|
||||
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
|
||||
if (thinkingParse.error) {
|
||||
@@ -465,6 +493,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const authSuccess = await triggerOAuth(provider, {
|
||||
verbose,
|
||||
import: true,
|
||||
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
});
|
||||
if (!authSuccess) {
|
||||
@@ -495,6 +524,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const authSuccess = await triggerOAuth(p, {
|
||||
verbose,
|
||||
add: addAccount,
|
||||
...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}),
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
...(noIncognito ? { noIncognito: true } : {}),
|
||||
@@ -535,6 +565,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const authSuccess = await triggerOAuth(provider, {
|
||||
verbose,
|
||||
add: addAccount,
|
||||
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
...(noIncognito ? { noIncognito: true } : {}),
|
||||
@@ -854,6 +885,7 @@ export async function execClaudeWithCLIProxy(
|
||||
'--accounts',
|
||||
'--use',
|
||||
'--nickname',
|
||||
'--kiro-auth-method',
|
||||
'--thinking',
|
||||
'--effort',
|
||||
'--1m',
|
||||
@@ -872,6 +904,7 @@ export async function execClaudeWithCLIProxy(
|
||||
if (
|
||||
argsWithoutProxy[idx - 1] === '--use' ||
|
||||
argsWithoutProxy[idx - 1] === '--nickname' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-auth-method' ||
|
||||
argsWithoutProxy[idx - 1] === '--thinking' ||
|
||||
argsWithoutProxy[idx - 1] === '--effort'
|
||||
)
|
||||
|
||||
@@ -193,6 +193,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs <provider> --logout', 'Clear authentication'],
|
||||
['ccs <provider> --headless', 'Headless auth (for SSH)'],
|
||||
['ccs <provider> --port-forward', 'Force port-forwarding mode (skip prompt)'],
|
||||
['ccs kiro --auth --kiro-auth-method aws', 'Kiro via AWS Builder ID (device code)'],
|
||||
['ccs kiro --auth --kiro-auth-method aws-authcode', 'Kiro via AWS auth code flow'],
|
||||
['ccs kiro --auth --kiro-auth-method google', 'Kiro via Google OAuth'],
|
||||
['ccs kiro --auth --kiro-auth-method github', 'Kiro via GitHub OAuth (Dashboard flow)'],
|
||||
['ccs kiro --import', 'Import token from Kiro IDE'],
|
||||
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
|
||||
['ccs codex "explain code"', 'Use with prompt'],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Codex: 1455
|
||||
* - Agy: 51121
|
||||
* - iFlow: 11451
|
||||
* - Kiro: 9876
|
||||
* - Kiro: Device Code Flow (no port needed)
|
||||
* - Claude: 54545
|
||||
* - Qwen: Device Code Flow (no port needed)
|
||||
* - GHCP: Device Code Flow (no port needed)
|
||||
@@ -26,40 +26,44 @@ import {
|
||||
} from '../utils/port-utils';
|
||||
import { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { CLIPROXY_PROFILES } from '../auth/profile-detector';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getOAuthCallbackPort,
|
||||
getOAuthFlowType,
|
||||
type OAuthFlowType as ProviderOAuthFlowType,
|
||||
} from '../cliproxy/provider-capabilities';
|
||||
|
||||
/**
|
||||
* OAuth callback ports for each provider
|
||||
* Extracted from CLIProxyAPI source
|
||||
* Build provider-indexed records from canonical provider capabilities.
|
||||
* Keeps diagnostics in sync with runtime OAuth flow metadata.
|
||||
*/
|
||||
export const OAUTH_CALLBACK_PORTS: Record<CLIProxyProvider, number | null> = {
|
||||
gemini: 8085,
|
||||
codex: 1455,
|
||||
agy: 51121,
|
||||
qwen: null, // Device Code Flow - no callback port
|
||||
iflow: 11451, // Authorization Code Flow
|
||||
kiro: 9876, // Authorization Code Flow
|
||||
ghcp: null, // Device Code Flow - no callback port
|
||||
claude: 54545, // Authorization Code Flow (Anthropic OAuth)
|
||||
};
|
||||
function buildProviderMap<T>(
|
||||
valueFor: (provider: CLIProxyProvider) => T
|
||||
): Record<CLIProxyProvider, T> {
|
||||
return CLIPROXY_PROVIDER_IDS.reduce(
|
||||
(acc, provider) => {
|
||||
acc[provider] = valueFor(provider);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<CLIProxyProvider, T>
|
||||
);
|
||||
}
|
||||
|
||||
export const OAUTH_CALLBACK_PORTS: Record<CLIProxyProvider, number | null> = buildProviderMap(
|
||||
(provider) => getOAuthCallbackPort(provider)
|
||||
);
|
||||
|
||||
/**
|
||||
* OAuth flow types
|
||||
*/
|
||||
export type OAuthFlowType = 'authorization_code' | 'device_code';
|
||||
export type OAuthFlowType = ProviderOAuthFlowType;
|
||||
|
||||
/**
|
||||
* OAuth flow type per provider
|
||||
*/
|
||||
export const OAUTH_FLOW_TYPES: Record<CLIProxyProvider, OAuthFlowType> = {
|
||||
gemini: 'authorization_code',
|
||||
codex: 'authorization_code',
|
||||
agy: 'authorization_code',
|
||||
qwen: 'device_code',
|
||||
iflow: 'authorization_code',
|
||||
kiro: 'authorization_code',
|
||||
ghcp: 'device_code',
|
||||
claude: 'authorization_code',
|
||||
};
|
||||
export const OAUTH_FLOW_TYPES: Record<CLIProxyProvider, OAuthFlowType> = buildProviderMap(
|
||||
(provider) => getOAuthFlowType(provider)
|
||||
);
|
||||
|
||||
/**
|
||||
* Port diagnostic result
|
||||
|
||||
@@ -37,7 +37,13 @@ import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
|
||||
import {
|
||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||
isKiroAuthMethod,
|
||||
isKiroDeviceCodeMethod,
|
||||
KiroAuthMethod,
|
||||
normalizeKiroAuthMethod,
|
||||
toKiroManagementMethod,
|
||||
} from '../../cliproxy/auth/auth-types';
|
||||
import { getOAuthFlowType } from '../../cliproxy/provider-capabilities';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
|
||||
@@ -46,6 +52,44 @@ const router = Router();
|
||||
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
||||
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
||||
|
||||
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
|
||||
if (raw === undefined || raw === null) {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: false };
|
||||
}
|
||||
if (typeof raw !== 'string') {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: true };
|
||||
}
|
||||
if (raw.trim() === '') {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: false };
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (!isKiroAuthMethod(normalized)) {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: true };
|
||||
}
|
||||
return { method: normalizeKiroAuthMethod(normalized), invalid: false };
|
||||
}
|
||||
|
||||
export function getStartUrlUnsupportedReason(
|
||||
provider: CLIProxyProvider,
|
||||
options?: { kiroMethod?: KiroAuthMethod }
|
||||
): string | null {
|
||||
if (provider === 'kiro') {
|
||||
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
|
||||
if (kiroMethod === 'aws-authcode') {
|
||||
return "Kiro method 'aws-authcode' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead.";
|
||||
}
|
||||
if (isKiroDeviceCodeMethod(kiroMethod)) {
|
||||
return "Kiro method 'aws' uses Device Code flow. Use /api/cliproxy/auth/kiro/start instead.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (getOAuthFlowType(provider) === 'device_code') {
|
||||
return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
||||
@@ -343,9 +387,14 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons
|
||||
*/
|
||||
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
|
||||
const { provider } = req.params;
|
||||
const { nickname: nicknameRaw, noIncognito: noIncognitoBody } = req.body;
|
||||
const {
|
||||
nickname: nicknameRaw,
|
||||
noIncognito: noIncognitoBody,
|
||||
kiroMethod: kiroMethodRaw,
|
||||
} = req.body;
|
||||
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
||||
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
|
||||
// Validate provider
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
@@ -353,6 +402,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'kiro' && invalidKiroMethod) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
|
||||
code: 'INVALID_KIRO_METHOD',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For kiro/ghcp: nickname is required
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
|
||||
if (!nickname) {
|
||||
@@ -400,6 +457,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
add: true, // Always add mode from UI
|
||||
headless: false, // Force interactive mode
|
||||
nickname: nickname || undefined,
|
||||
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
||||
fromUI: true, // Enable project selection prompt in UI
|
||||
noIncognito, // Kiro: use normal browser if enabled
|
||||
});
|
||||
@@ -544,6 +602,8 @@ 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 } = req.body ?? {};
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
|
||||
// Check remote mode
|
||||
const target = getProxyTarget();
|
||||
@@ -558,14 +618,34 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'kiro' && invalidKiroMethod) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
|
||||
code: 'INVALID_KIRO_METHOD',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const unsupportedReason = getStartUrlUnsupportedReason(provider as CLIProxyProvider, {
|
||||
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
||||
});
|
||||
if (unsupportedReason) {
|
||||
res.status(400).json({ error: unsupportedReason });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authUrlProvider =
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
|
||||
const kiroQuery =
|
||||
provider === 'kiro'
|
||||
? `&method=${encodeURIComponent(toKiroManagementMethod(kiroMethod))}`
|
||||
: '';
|
||||
|
||||
// Call CLIProxyAPI to start OAuth and get auth URL
|
||||
// CLIProxyAPI management routes are under /v0/management prefix
|
||||
const response = await fetch(
|
||||
buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true`),
|
||||
buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`),
|
||||
{ headers: buildManagementHeaders(target) }
|
||||
);
|
||||
|
||||
@@ -575,18 +655,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { url?: string; auth_url?: string; state?: string };
|
||||
const data = (await response.json()) as {
|
||||
url?: string;
|
||||
auth_url?: string;
|
||||
state?: string;
|
||||
method?: string;
|
||||
};
|
||||
const authUrl = data.url || data.auth_url;
|
||||
|
||||
if (!authUrl) {
|
||||
res.status(500).json({ error: 'No authorization URL received from CLIProxyAPI' });
|
||||
// Some upstream flows return state first and provide auth_url in subsequent status polling.
|
||||
if (!authUrl && !data.state) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
authUrl,
|
||||
state: data.state,
|
||||
authUrl: authUrl || null,
|
||||
state: data.state || null,
|
||||
method: data.method || null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to start OAuth';
|
||||
|
||||
@@ -2,11 +2,24 @@ import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getOAuthCallbackPort,
|
||||
getOAuthFlowType,
|
||||
getProviderDisplayName,
|
||||
getProvidersByOAuthFlow,
|
||||
isCLIProxyProvider,
|
||||
mapExternalProviderName,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import {
|
||||
OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS,
|
||||
OAUTH_FLOW_TYPES,
|
||||
} from '../../../src/management/oauth-port-diagnostics';
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroCallbackPort,
|
||||
getKiroCLIAuthFlag,
|
||||
normalizeKiroAuthMethod,
|
||||
OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS,
|
||||
toKiroManagementMethod,
|
||||
} from '../../../src/cliproxy/auth/auth-types';
|
||||
|
||||
describe('provider-capabilities', () => {
|
||||
it('keeps canonical provider IDs backward-compatible', () => {
|
||||
@@ -56,4 +69,38 @@ describe('provider-capabilities', () => {
|
||||
expect(getOAuthCallbackPort('gemini')).toBe(8085);
|
||||
expect(getProviderDisplayName('agy')).toBe('AntiGravity');
|
||||
});
|
||||
|
||||
it('keeps diagnostics flow metadata in sync with provider capabilities', () => {
|
||||
for (const provider of CLIPROXY_PROVIDER_IDS) {
|
||||
expect(OAUTH_FLOW_TYPES[provider]).toBe(getOAuthFlowType(provider));
|
||||
expect(DIAGNOSTIC_CALLBACK_PORTS[provider]).toBe(getOAuthCallbackPort(provider));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not define callback ports for device code providers in auth constants', () => {
|
||||
for (const provider of getProvidersByOAuthFlow('device_code')) {
|
||||
expect(AUTH_CALLBACK_PORTS[provider]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps Kiro auth methods to upstream CLI/management contracts', () => {
|
||||
expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws');
|
||||
expect(normalizeKiroAuthMethod()).toBe('aws');
|
||||
expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google');
|
||||
expect(normalizeKiroAuthMethod('not-valid')).toBe('aws');
|
||||
|
||||
expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login');
|
||||
expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode');
|
||||
expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login');
|
||||
|
||||
expect(getKiroCallbackPort('aws')).toBeNull();
|
||||
expect(getKiroCallbackPort('google')).toBe(9876);
|
||||
expect(getKiroCallbackPort('github')).toBe(9876);
|
||||
expect(getKiroCallbackPort('aws-authcode')).toBe(9876);
|
||||
|
||||
expect(toKiroManagementMethod('aws')).toBe('aws');
|
||||
expect(toKiroManagementMethod('aws-authcode')).toBe('aws');
|
||||
expect(toKiroManagementMethod('google')).toBe('google');
|
||||
expect(toKiroManagementMethod('github')).toBe('github');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getStartUrlUnsupportedReason } from '../../../src/web-server/routes/cliproxy-auth-routes';
|
||||
|
||||
describe('cliproxy-auth-routes start-url guard', () => {
|
||||
it('rejects device code providers', () => {
|
||||
expect(getStartUrlUnsupportedReason('kiro')).toContain("Kiro method 'aws' uses Device Code flow");
|
||||
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
|
||||
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow");
|
||||
});
|
||||
|
||||
it('allows Kiro social methods on start-url', () => {
|
||||
expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'google' })).toBeNull();
|
||||
expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'github' })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects Kiro aws-authcode method on start-url', () => {
|
||||
expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'aws-authcode' })).toContain(
|
||||
"Kiro method 'aws-authcode' uses CLI auth flow"
|
||||
);
|
||||
});
|
||||
|
||||
it('allows authorization code providers', () => {
|
||||
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
|
||||
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
|
||||
expect(getStartUrlUnsupportedReason('claude')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Add Account Dialog Component
|
||||
* Uses /start-url to get OAuth URL + polls for completion via management API.
|
||||
* For Device Code flows (ghcp, qwen): Uses /start endpoint which spawns CLIProxy
|
||||
* For Device Code flows (ghcp, qwen, kiro): Uses /start endpoint which spawns CLIProxy
|
||||
* binary and emits WebSocket events. DeviceCodeDialog handles user code display.
|
||||
* Shows auth URL + callback paste field. Polling auto-closes on success.
|
||||
* For Kiro: Also shows "Import from IDE" option.
|
||||
@@ -18,11 +18,25 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react';
|
||||
import { useKiroImport } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||
import { isDeviceCodeProvider } from '@/lib/provider-config';
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroAuthMethodOption,
|
||||
isDeviceCodeProvider,
|
||||
isNicknameRequiredProvider,
|
||||
KIRO_AUTH_METHOD_OPTIONS,
|
||||
} from '@/lib/provider-config';
|
||||
import type { KiroAuthMethod } from '@/lib/provider-config';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AddAccountDialogProps {
|
||||
@@ -44,18 +58,27 @@ export function AddAccountDialog({
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [callbackUrl, setCallbackUrl] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
|
||||
const wasAuthenticatingRef = useRef(false);
|
||||
const authFlow = useCliproxyAuthFlow();
|
||||
const kiroImportMutation = useKiroImport();
|
||||
|
||||
const isKiro = provider === 'kiro';
|
||||
const isDeviceCode = isDeviceCodeProvider(provider);
|
||||
const defaultDeviceCode = isDeviceCodeProvider(provider);
|
||||
const requiresNickname = isNicknameRequiredProvider(provider);
|
||||
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
|
||||
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
|
||||
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
|
||||
const nicknameTrimmed = nickname.trim();
|
||||
const errorMessage = localError || authFlow.error;
|
||||
|
||||
const resetAndClose = () => {
|
||||
setNickname('');
|
||||
setCallbackUrl('');
|
||||
setCopied(false);
|
||||
setLocalError(null);
|
||||
setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD);
|
||||
wasAuthenticatingRef.current = false;
|
||||
onClose();
|
||||
};
|
||||
@@ -103,13 +126,23 @@ export function AddAccountDialog({
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate via /start-url + polling only.
|
||||
* Does NOT call /start (which spawns a local CLIProxy binary that kills running instances).
|
||||
* /start-url uses the management API to get auth URL, then polls for completion.
|
||||
* Start auth flow using provider capabilities.
|
||||
* - Device code providers use /start and rely on WebSocket events for code display.
|
||||
* - Authorization code providers use /start-url and polling.
|
||||
*/
|
||||
const handleAuthenticate = () => {
|
||||
if (requiresNickname && !nicknameTrimmed) {
|
||||
setLocalError(`Nickname is required for ${displayName} accounts.`);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, { nickname: nickname.trim() || undefined });
|
||||
authFlow.startAuth(provider, {
|
||||
nickname: nicknameTrimmed || undefined,
|
||||
kiroMethod: isKiro ? kiroAuthMethod : undefined,
|
||||
flowType: isKiro ? kiroMethodOption.flowType : undefined,
|
||||
startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKiroImport = () => {
|
||||
@@ -146,7 +179,7 @@ export function AddAccountDialog({
|
||||
<DialogTitle>Add {displayName} Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isKiro
|
||||
? 'Authenticate via browser or import an existing token from Kiro IDE.'
|
||||
? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.'
|
||||
: isDeviceCode
|
||||
? 'Click Authenticate. A verification code will appear for you to enter on the provider website.'
|
||||
: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
|
||||
@@ -154,23 +187,56 @@ export function AddAccountDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Kiro auth method */}
|
||||
{isKiro && !showAuthUI && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-auth-method">Auth Method</Label>
|
||||
<Select
|
||||
value={kiroAuthMethod}
|
||||
onValueChange={(value) => {
|
||||
setKiroAuthMethod(value as KiroAuthMethod);
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="kiro-auth-method">
|
||||
<SelectValue placeholder="Select Kiro auth method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KIRO_AUTH_METHOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{kiroMethodOption.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nickname input - only show before auth starts */}
|
||||
{!showAuthUI && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">Nickname (optional)</Label>
|
||||
<Label htmlFor="nickname">
|
||||
{requiresNickname ? 'Nickname (required)' : 'Nickname (optional)'}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setNickname(e.target.value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
placeholder="e.g., work, personal"
|
||||
disabled={isPending}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this account. Auto-generated from email if left empty.
|
||||
{requiresNickname
|
||||
? 'Required for this provider. Use a unique friendly name (e.g., work, personal).'
|
||||
: 'A friendly name to identify this account. Auto-generated from email if left empty.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -191,11 +257,6 @@ export function AddAccountDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{authFlow.error && !authFlow.authUrl && (
|
||||
<p className="text-xs text-center text-destructive">{authFlow.error}</p>
|
||||
)}
|
||||
|
||||
{/* Auth URL section - only for Authorization Code flows, NOT Device Code */}
|
||||
{authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
||||
<div className="space-y-3">
|
||||
@@ -270,9 +331,18 @@ export function AddAccountDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Preparing sign-in URL...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Persist error visibility outside auth-only UI states */}
|
||||
{errorMessage && <p className="text-xs text-center text-destructive">{errorMessage}</p>}
|
||||
|
||||
{/* Kiro import loading */}
|
||||
{kiroImportMutation.isPending && (
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
@@ -302,7 +372,10 @@ export function AddAccountDialog({
|
||||
</Button>
|
||||
)}
|
||||
{!showAuthUI && (
|
||||
<Button onClick={handleAuthenticate} disabled={isPending}>
|
||||
<Button
|
||||
onClick={handleAuthenticate}
|
||||
disabled={isPending || (requiresNickname && !nicknameTrimmed)}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Authenticate
|
||||
</Button>
|
||||
|
||||
@@ -19,12 +19,15 @@ interface AuthFlowState {
|
||||
oauthState: string | null;
|
||||
/** Whether callback is being submitted */
|
||||
isSubmittingCallback: boolean;
|
||||
/** Whether this is a device code flow (ghcp, qwen) - dialog handled separately via WebSocket */
|
||||
/** Whether this is a device code flow (ghcp, qwen, kiro) - dialog handled separately via WebSocket */
|
||||
isDeviceCodeFlow: boolean;
|
||||
}
|
||||
|
||||
interface StartAuthOptions {
|
||||
nickname?: string;
|
||||
kiroMethod?: string;
|
||||
flowType?: 'authorization_code' | 'device_code';
|
||||
startEndpoint?: 'start' | 'start-url';
|
||||
}
|
||||
|
||||
/** Polling interval for OAuth status check (3 seconds) */
|
||||
@@ -49,6 +52,7 @@ export function useCliproxyAuthFlow() {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollStartRef = useRef<number>(0);
|
||||
const openedAuthUrlRef = useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Clear polling
|
||||
@@ -64,6 +68,7 @@ export function useCliproxyAuthFlow() {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
@@ -85,14 +90,46 @@ export function useCliproxyAuthFlow() {
|
||||
const response = await fetch(
|
||||
`/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as {
|
||||
status?: string;
|
||||
error?: string;
|
||||
url?: string;
|
||||
auth_url?: string;
|
||||
verification_url?: string;
|
||||
user_code?: string;
|
||||
};
|
||||
|
||||
if (data.status === 'ok') {
|
||||
stopPolling();
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
} else if (data.status === 'auth_url') {
|
||||
const authUrl = data.url || data.auth_url;
|
||||
if (authUrl) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
authUrl,
|
||||
}));
|
||||
if (!openedAuthUrlRef.current) {
|
||||
openedAuthUrlRef.current = true;
|
||||
window.open(authUrl, '_blank');
|
||||
}
|
||||
}
|
||||
} else if (data.status === 'device_code') {
|
||||
stopPolling();
|
||||
const details =
|
||||
data.user_code && data.verification_url
|
||||
? `Open ${data.verification_url} and enter code: ${data.user_code}`
|
||||
: 'Switch to Device Code method and try again.';
|
||||
toast.error('Provider returned Device Code flow in callback mode');
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAuthenticating: false,
|
||||
error: details,
|
||||
}));
|
||||
} else if (data.status === 'error') {
|
||||
stopPolling();
|
||||
const errorMsg = data.error || 'Authentication failed';
|
||||
@@ -103,7 +140,7 @@ export function useCliproxyAuthFlow() {
|
||||
error: errorMsg,
|
||||
}));
|
||||
}
|
||||
// status === 'pending' means continue polling
|
||||
// status === 'wait' (or pending) means continue polling
|
||||
} catch {
|
||||
// Network error - continue polling
|
||||
}
|
||||
@@ -124,12 +161,21 @@ export function useCliproxyAuthFlow() {
|
||||
// Abort any in-progress auth
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
|
||||
// Create fresh controller and capture locally to avoid race with cancelAuth
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
const deviceCodeFlow = isDeviceCodeProvider(provider);
|
||||
const flowType =
|
||||
options?.flowType ||
|
||||
(isDeviceCodeProvider(provider) ? 'device_code' : 'authorization_code');
|
||||
const deviceCodeFlow = flowType === 'device_code';
|
||||
const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url');
|
||||
const payload = {
|
||||
nickname: options?.nickname,
|
||||
kiroMethod: options?.kiroMethod,
|
||||
};
|
||||
|
||||
setState({
|
||||
provider,
|
||||
@@ -142,14 +188,13 @@ export function useCliproxyAuthFlow() {
|
||||
});
|
||||
|
||||
try {
|
||||
if (deviceCodeFlow) {
|
||||
// Device Code Flow: Call /start endpoint which spawns CLIProxyAPI binary.
|
||||
// This emits WebSocket events with userCode that DeviceCodeDialog will display.
|
||||
// The /start endpoint blocks until completion, so we don't await it here.
|
||||
if (startEndpoint === 'start') {
|
||||
// /start spawns CLIProxy binary and blocks until completion.
|
||||
// For Device Code flows, userCode is delivered via WebSocket.
|
||||
fetch(`/api/cliproxy/auth/${provider}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nickname: options?.nickname }),
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
@@ -159,6 +204,7 @@ export function useCliproxyAuthFlow() {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
// Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast
|
||||
// via deviceCodeCompleted WebSocket event to avoid duplicate toasts
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
} else {
|
||||
const errorMsg = data.error || 'Authentication failed';
|
||||
@@ -183,13 +229,13 @@ export function useCliproxyAuthFlow() {
|
||||
error: message,
|
||||
}));
|
||||
});
|
||||
// Don't await - let the request run in background while DeviceCodeDialog handles UI
|
||||
// Don't await - keeps UI responsive while backend auth is in progress
|
||||
} else {
|
||||
// Authorization Code Flow: Call /start-url to get auth URL immediately (non-blocking)
|
||||
// /start-url uses management API to bootstrap callback/social flows.
|
||||
const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nickname: options?.nickname }),
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -202,12 +248,13 @@ export function useCliproxyAuthFlow() {
|
||||
// Update state with auth URL
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
authUrl: data.authUrl,
|
||||
authUrl: data.authUrl || null,
|
||||
oauthState: data.state,
|
||||
}));
|
||||
|
||||
// Auto-open auth URL in new browser tab (fallback URL still shown in dialog)
|
||||
if (data.authUrl) {
|
||||
openedAuthUrlRef.current = true;
|
||||
window.open(data.authUrl, '_blank');
|
||||
}
|
||||
|
||||
@@ -221,6 +268,7 @@ export function useCliproxyAuthFlow() {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
return;
|
||||
}
|
||||
@@ -240,6 +288,7 @@ export function useCliproxyAuthFlow() {
|
||||
const currentProvider = state.provider;
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
// Also cancel on backend
|
||||
if (currentProvider) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Listens for WebSocket device code events and manages dialog state.
|
||||
* Similar to useProjectSelection but for Device Code OAuth flows
|
||||
* (GitHub Copilot, Qwen, etc.)
|
||||
* (GitHub Copilot, Qwen, Kiro, etc.)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
@@ -26,6 +26,7 @@ interface DeviceCodeState {
|
||||
/** Provider display names for user-friendly messages */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
ghcp: 'GitHub Copilot',
|
||||
kiro: 'Kiro (AWS)',
|
||||
qwen: 'Qwen Code',
|
||||
};
|
||||
|
||||
|
||||
@@ -82,3 +82,75 @@ export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen'
|
||||
export function isDeviceCodeProvider(provider: string): boolean {
|
||||
return DEVICE_CODE_PROVIDERS.includes(provider as CLIProxyProvider);
|
||||
}
|
||||
|
||||
/** Providers that require nickname because token payload may not include email. */
|
||||
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
|
||||
|
||||
/** Check if provider requires user-supplied nickname in auth flow */
|
||||
export function isNicknameRequiredProvider(provider: string): boolean {
|
||||
return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider);
|
||||
}
|
||||
|
||||
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
|
||||
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
|
||||
|
||||
export type KiroFlowType = 'authorization_code' | 'device_code';
|
||||
export type KiroStartEndpoint = 'start' | 'start-url';
|
||||
|
||||
export interface KiroAuthMethodOption {
|
||||
id: KiroAuthMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
flowType: KiroFlowType;
|
||||
startEndpoint: KiroStartEndpoint;
|
||||
}
|
||||
|
||||
/** UX-first default for issue #233: AWS Builder ID device flow. */
|
||||
export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws';
|
||||
|
||||
export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [
|
||||
{
|
||||
id: 'aws',
|
||||
label: 'AWS Builder ID (Recommended)',
|
||||
description: 'Device code flow for AWS organizations and Builder ID accounts.',
|
||||
flowType: 'device_code',
|
||||
startEndpoint: 'start',
|
||||
},
|
||||
{
|
||||
id: 'aws-authcode',
|
||||
label: 'AWS Builder ID (Auth Code)',
|
||||
description: 'Authorization code flow via CLI binary.',
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
label: 'Google OAuth',
|
||||
description: 'Social OAuth flow with callback URL support.',
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start-url',
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
label: 'GitHub OAuth',
|
||||
description: 'Social OAuth flow via management API callback.',
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start-url',
|
||||
},
|
||||
];
|
||||
|
||||
export function isKiroAuthMethod(value: string): value is KiroAuthMethod {
|
||||
return KIRO_AUTH_METHODS.includes(value as KiroAuthMethod);
|
||||
}
|
||||
|
||||
export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
|
||||
if (!value) return DEFAULT_KIRO_AUTH_METHOD;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
|
||||
}
|
||||
|
||||
export function getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption {
|
||||
const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method);
|
||||
return option || KIRO_AUTH_METHOD_OPTIONS[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user