Feat Kiro OAuth, Fix Codex

This commit is contained in:
decolua
2026-01-15 18:29:47 +07:00
parent c208f244ee
commit 26b61e5fbb
25 changed files with 1856 additions and 78 deletions
+11 -17
View File
@@ -38,7 +38,8 @@ export async function GET(request, { params }) {
// For providers that don't use PKCE (like GitHub), don't pass codeChallenge
let deviceData;
if (provider === "github") {
if (provider === "github" || provider === "kiro") {
// GitHub and Kiro don't use PKCE for device code
deviceData = await requestDeviceCode(provider);
} else {
// Qwen and other providers use PKCE
@@ -101,16 +102,19 @@ export async function POST(request, { params }) {
}
if (action === "poll") {
const { deviceCode, codeVerifier } = body;
const { deviceCode, codeVerifier, extraData } = body;
if (!deviceCode) {
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
}
// For providers that don't use PKCE (like GitHub), don't pass codeVerifier
// For providers that don't use PKCE (like GitHub, Kiro), don't pass codeVerifier
let result;
if (provider === "github") {
result = await pollForToken(provider, deviceCode);
} else if (provider === "kiro") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await pollForToken(provider, deviceCode, null, extraData);
} else {
// Qwen and other providers use PKCE
if (!codeVerifier) {
@@ -143,24 +147,14 @@ export async function POST(request, { params }) {
});
}
// Still pending or error
if (!result.pending) {
// Save error to database for actual errors (not pending)
await createProviderConnection({
provider,
authType: "oauth",
testStatus: "error",
lastError: result.errorDescription,
errorCode: result.error,
lastErrorAt: new Date().toISOString(),
});
}
// Still pending or error - don't create connection for pending states
const isPending = result.pending || result.error === "authorization_pending" || result.error === "slow_down";
return NextResponse.json({
success: false,
error: result.error,
errorDescription: result.errorDescription,
pending: result.pending || result.error === "authorization_pending",
pending: isPending,
});
}
+19
View File
@@ -113,6 +113,24 @@ export const GITHUB_CONFIG = {
editorPluginVersion: "copilot-chat/0.26.7",
};
// Kiro OAuth Configuration (AWS SSO OIDC Device Code Flow)
export const KIRO_CONFIG = {
// AWS SSO OIDC endpoints for Builder ID
ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com",
registerClientUrl: "https://oidc.us-east-1.amazonaws.com/client/register",
deviceAuthUrl: "https://oidc.us-east-1.amazonaws.com/device_authorization",
tokenUrl: "https://oidc.us-east-1.amazonaws.com/token",
refreshTokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
// AWS Builder ID start URL
startUrl: "https://view.awsapps.com/start",
// Client registration params
clientName: "kiro-cli",
clientType: "public",
scopes: ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"],
grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6",
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -126,4 +144,5 @@ export const PROVIDERS = {
ANTIGRAVITY: "antigravity",
OPENAI: "openai",
GITHUB: "github",
KIRO: "kiro",
};
+126 -2
View File
@@ -12,6 +12,7 @@ import {
IFLOW_CONFIG,
ANTIGRAVITY_CONFIG,
GITHUB_CONFIG,
KIRO_CONFIG,
} from "./constants/oauth";
// Provider configurations
@@ -536,6 +537,125 @@ const PROVIDERS = {
},
}),
},
kiro: {
config: KIRO_CONFIG,
flowType: "device_code",
// Kiro uses AWS SSO OIDC - requires client registration first
requestDeviceCode: async (config) => {
// Step 1: Register client with AWS SSO OIDC
const registerRes = await fetch(config.registerClientUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
issuerUrl: config.issuerUrl,
}),
});
if (!registerRes.ok) {
const error = await registerRes.text();
throw new Error(`Client registration failed: ${error}`);
}
const clientInfo = await registerRes.json();
// Step 2: Request device authorization
const deviceRes = await fetch(config.deviceAuthUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
startUrl: config.startUrl,
}),
});
if (!deviceRes.ok) {
const error = await deviceRes.text();
throw new Error(`Device authorization failed: ${error}`);
}
const deviceData = await deviceRes.json();
// Return combined data for polling
return {
device_code: deviceData.deviceCode,
user_code: deviceData.userCode,
verification_uri: deviceData.verificationUri,
verification_uri_complete: deviceData.verificationUriComplete,
expires_in: deviceData.expiresIn,
interval: deviceData.interval || 5,
// Store client credentials for token exchange
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: extraData?._clientId,
clientSecret: extraData?._clientSecret,
deviceCode: deviceCode,
grantType: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// AWS SSO OIDC returns camelCase
if (data.accessToken) {
return {
ok: true,
data: {
access_token: data.accessToken,
refresh_token: data.refreshToken,
expires_in: data.expiresIn,
// Store client credentials for refresh
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
},
};
}
return {
ok: false,
data: {
error: data.error || "authorization_pending",
error_description: data.error_description || data.message,
},
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
},
}),
},
};
/**
@@ -614,14 +734,18 @@ export async function requestDeviceCode(providerName, codeChallenge) {
/**
* Poll for token (for device_code flow)
* @param {string} providerName - Provider name
* @param {string} deviceCode - Device code from requestDeviceCode
* @param {string} codeVerifier - PKCE code verifier (optional for some providers)
* @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro)
*/
export async function pollForToken(providerName, deviceCode, codeVerifier) {
export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) {
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier);
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData);
if (result.ok) {
// For device code flows, success is only when we have an access token
+7 -6
View File
@@ -147,8 +147,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
try {
setError(null);
// Device code flow (GitHub, Qwen)
if (provider === "github" || provider === "qwen") {
// Device code flow (GitHub, Qwen, Kiro)
if (provider === "github" || provider === "qwen" || provider === "kiro") {
setIsDeviceCode(true);
setStep("waiting");
@@ -162,8 +162,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const verifyUrl = data.verification_uri_complete || data.verification_uri;
if (verifyUrl) window.open(verifyUrl, "_blank");
// Start polling
startPolling(data.device_code, data.codeVerifier, data.interval || 5);
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
const extraData = provider === "kiro" ? { _clientId: data._clientId, _clientSecret: data._clientSecret } : null;
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
return;
}
@@ -209,7 +210,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
};
// Poll for device code token
const startPolling = async (deviceCode, codeVerifier, interval) => {
const startPolling = async (deviceCode, codeVerifier, interval, extraData) => {
setPolling(true);
const maxAttempts = 60;
@@ -220,7 +221,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const res = await fetch(`/api/oauth/${provider}/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode, codeVerifier }),
body: JSON.stringify({ deviceCode, codeVerifier, extraData }),
});
const data = await res.json();
+1
View File
@@ -9,6 +9,7 @@ export const OAUTH_PROVIDERS = {
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
};
export const APIKEY_PROVIDERS = {