mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 16:16:29 +00:00
fix(web-server): make kiro auth routes method-aware
- validate kiroMethod on /start and /start-url with clear errors - route Kiro management start-url requests with upstream method query - accept state-first responses and keep auth_url optional - add start-url guard tests for Kiro social and aws-authcode paths Refs #552 Refs #233
This commit is contained in:
@@ -37,6 +37,11 @@ import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
|
|||||||
import {
|
import {
|
||||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||||
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||||
|
isKiroAuthMethod,
|
||||||
|
isKiroDeviceCodeMethod,
|
||||||
|
KiroAuthMethod,
|
||||||
|
normalizeKiroAuthMethod,
|
||||||
|
toKiroManagementMethod,
|
||||||
} from '../../cliproxy/auth/auth-types';
|
} from '../../cliproxy/auth/auth-types';
|
||||||
import { getOAuthFlowType } from '../../cliproxy/provider-capabilities';
|
import { getOAuthFlowType } from '../../cliproxy/provider-capabilities';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
@@ -47,7 +52,38 @@ const router = Router();
|
|||||||
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
||||||
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
||||||
|
|
||||||
export function getStartUrlUnsupportedReason(provider: CLIProxyProvider): string | null {
|
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') {
|
if (getOAuthFlowType(provider) === 'device_code') {
|
||||||
return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`;
|
return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`;
|
||||||
}
|
}
|
||||||
@@ -351,9 +387,14 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons
|
|||||||
*/
|
*/
|
||||||
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
|
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
|
||||||
const { provider } = req.params;
|
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)
|
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
||||||
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
|
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
|
||||||
|
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||||
|
|
||||||
// Validate provider
|
// Validate provider
|
||||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||||
@@ -361,6 +402,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
return;
|
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
|
// For kiro/ghcp: nickname is required
|
||||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
|
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
|
||||||
if (!nickname) {
|
if (!nickname) {
|
||||||
@@ -408,6 +457,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
add: true, // Always add mode from UI
|
add: true, // Always add mode from UI
|
||||||
headless: false, // Force interactive mode
|
headless: false, // Force interactive mode
|
||||||
nickname: nickname || undefined,
|
nickname: nickname || undefined,
|
||||||
|
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
||||||
fromUI: true, // Enable project selection prompt in UI
|
fromUI: true, // Enable project selection prompt in UI
|
||||||
noIncognito, // Kiro: use normal browser if enabled
|
noIncognito, // Kiro: use normal browser if enabled
|
||||||
});
|
});
|
||||||
@@ -552,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> => {
|
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
|
||||||
const { provider } = req.params;
|
const { provider } = req.params;
|
||||||
|
const { kiroMethod: kiroMethodRaw } = req.body ?? {};
|
||||||
|
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||||
|
|
||||||
// Check remote mode
|
// Check remote mode
|
||||||
const target = getProxyTarget();
|
const target = getProxyTarget();
|
||||||
@@ -565,7 +617,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
|||||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unsupportedReason = getStartUrlUnsupportedReason(provider as CLIProxyProvider);
|
|
||||||
|
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) {
|
if (unsupportedReason) {
|
||||||
res.status(400).json({ error: unsupportedReason });
|
res.status(400).json({ error: unsupportedReason });
|
||||||
return;
|
return;
|
||||||
@@ -574,11 +637,15 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
|||||||
try {
|
try {
|
||||||
const authUrlProvider =
|
const authUrlProvider =
|
||||||
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
|
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
|
// Call CLIProxyAPI to start OAuth and get auth URL
|
||||||
// CLIProxyAPI management routes are under /v0/management prefix
|
// CLIProxyAPI management routes are under /v0/management prefix
|
||||||
const response = await fetch(
|
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) }
|
{ headers: buildManagementHeaders(target) }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -588,18 +655,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
|||||||
return;
|
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;
|
const authUrl = data.url || data.auth_url;
|
||||||
|
|
||||||
if (!authUrl) {
|
// Some upstream flows return state first and provide auth_url in subsequent status polling.
|
||||||
res.status(500).json({ error: 'No authorization URL received from CLIProxyAPI' });
|
if (!authUrl && !data.state) {
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
authUrl,
|
authUrl: authUrl || null,
|
||||||
state: data.state,
|
state: data.state || null,
|
||||||
|
method: data.method || null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to start OAuth';
|
const message = error instanceof Error ? error.message : 'Failed to start OAuth';
|
||||||
|
|||||||
@@ -3,11 +3,22 @@ import { getStartUrlUnsupportedReason } from '../../../src/web-server/routes/cli
|
|||||||
|
|
||||||
describe('cliproxy-auth-routes start-url guard', () => {
|
describe('cliproxy-auth-routes start-url guard', () => {
|
||||||
it('rejects device code providers', () => {
|
it('rejects device code providers', () => {
|
||||||
expect(getStartUrlUnsupportedReason('kiro')).toContain("Provider 'kiro' uses Device Code flow");
|
expect(getStartUrlUnsupportedReason('kiro')).toContain("Kiro method 'aws' uses Device Code flow");
|
||||||
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
|
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
|
||||||
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' 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', () => {
|
it('allows authorization code providers', () => {
|
||||||
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
|
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
|
||||||
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
|
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user