mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
* feat(kiro): wire aws identity center device flow into provider oauth (#587)
* feat(kiro): wire aws identity center device flow into provider oauth Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
c3a2bd01b7
commit
b1288c5064
@@ -83,11 +83,6 @@ export default function QuotaTable({ quotas = [], compact = false }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed text-left">
|
||||
<colgroup>
|
||||
<col className="w-[30%]" /> {/* Model Name */}
|
||||
<col className="w-[45%]" /> {/* Limit Progress */}
|
||||
<col className="w-[25%]" /> {/* Reset Time */}
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{quotas.map((quota, index) => {
|
||||
const remaining = quota.remainingPercentage !== undefined
|
||||
@@ -104,7 +99,7 @@ export default function QuotaTable({ quotas = [], compact = false }) {
|
||||
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
{/* Model Name with Status Emoji */}
|
||||
<td className={cellPad}>
|
||||
<td className={`${cellPad} w-[30%]`}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
||||
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
||||
@@ -114,7 +109,7 @@ export default function QuotaTable({ quotas = [], compact = false }) {
|
||||
</td>
|
||||
|
||||
{/* Limit (Progress + Numbers) */}
|
||||
<td className={cellPad}>
|
||||
<td className={`${cellPad} w-[45%]`}>
|
||||
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
||||
{/* Progress bar - always show with border for visibility */}
|
||||
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||
@@ -139,7 +134,7 @@ export default function QuotaTable({ quotas = [], compact = false }) {
|
||||
</td>
|
||||
|
||||
{/* Reset Time */}
|
||||
<td className={cellPad}>
|
||||
<td className={`${cellPad} w-[25%]`}>
|
||||
{countdown !== "-" || resetDisplay ? (
|
||||
<div className="space-y-0.5">
|
||||
{countdown !== "-" && (
|
||||
|
||||
@@ -58,15 +58,25 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
|
||||
const authData = generateAuthData(provider, null);
|
||||
const startUrl = searchParams.get("start_url");
|
||||
const region = searchParams.get("region");
|
||||
const authMethod = searchParams.get("auth_method");
|
||||
const deviceOptions = provider === "kiro"
|
||||
? {
|
||||
...(startUrl ? { startUrl } : {}),
|
||||
...(region ? { region } : {}),
|
||||
...(authMethod ? { authMethod } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Providers that don't use PKCE for device code
|
||||
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy"];
|
||||
let deviceData;
|
||||
if (noPkceDeviceProviders.includes(provider)) {
|
||||
deviceData = await requestDeviceCode(provider);
|
||||
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
|
||||
} else {
|
||||
// Qwen and other PKCE providers
|
||||
deviceData = await requestDeviceCode(provider, authData.codeChallenge);
|
||||
deviceData = await requestDeviceCode(provider, authData.codeChallenge, deviceOptions);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -25,6 +25,28 @@ import {
|
||||
CODEBUDDY_CONFIG,
|
||||
} from "./constants/oauth";
|
||||
|
||||
const BASE64_BLOCK_SIZE = 4;
|
||||
|
||||
/**
|
||||
* Decode JWT access token and extract a stable account identifier for display/upsert.
|
||||
* @param {string} accessToken
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function extractEmailFromAccessToken(accessToken) {
|
||||
try {
|
||||
if (!accessToken || typeof accessToken !== "string") return undefined;
|
||||
const parts = accessToken.split(".");
|
||||
if (parts.length !== 3) return undefined;
|
||||
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
|
||||
const padded = base64 + "=".repeat(missingPadding);
|
||||
const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
||||
return payload.email || payload.preferred_username || payload.sub || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Provider configurations
|
||||
const PROVIDERS = {
|
||||
claude: {
|
||||
@@ -652,9 +674,17 @@ const PROVIDERS = {
|
||||
config: KIRO_CONFIG,
|
||||
flowType: "device_code",
|
||||
// Kiro uses AWS SSO OIDC - requires client registration first
|
||||
requestDeviceCode: async (config) => {
|
||||
requestDeviceCode: async (config, codeChallenge, options = {}) => {
|
||||
const trimmedRegion = typeof options.region === "string" ? options.region.trim() : "";
|
||||
const region = trimmedRegion || "us-east-1";
|
||||
const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : "";
|
||||
const startUrl = trimmedStartUrl || config.startUrl;
|
||||
const authMethod = options.authMethod === "idc" ? "idc" : "builder-id";
|
||||
const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`;
|
||||
const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`;
|
||||
|
||||
// Step 1: Register client with AWS SSO OIDC
|
||||
const registerRes = await fetch(config.registerClientUrl, {
|
||||
const registerRes = await fetch(registerClientUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -677,7 +707,7 @@ const PROVIDERS = {
|
||||
const clientInfo = await registerRes.json();
|
||||
|
||||
// Step 2: Request device authorization
|
||||
const deviceRes = await fetch(config.deviceAuthUrl, {
|
||||
const deviceRes = await fetch(deviceAuthUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -686,7 +716,7 @@ const PROVIDERS = {
|
||||
body: JSON.stringify({
|
||||
clientId: clientInfo.clientId,
|
||||
clientSecret: clientInfo.clientSecret,
|
||||
startUrl: config.startUrl,
|
||||
startUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -708,10 +738,15 @@ const PROVIDERS = {
|
||||
// Store client credentials for token exchange
|
||||
_clientId: clientInfo.clientId,
|
||||
_clientSecret: clientInfo.clientSecret,
|
||||
_region: region,
|
||||
_authMethod: authMethod,
|
||||
_startUrl: startUrl,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
const region = extraData?._region || "us-east-1";
|
||||
const tokenUrl = `https://oidc.${region}.amazonaws.com/token`;
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -745,6 +780,9 @@ const PROVIDERS = {
|
||||
// Store client credentials for refresh
|
||||
_clientId: extraData?._clientId,
|
||||
_clientSecret: extraData?._clientSecret,
|
||||
_region: extraData?._region,
|
||||
_authMethod: extraData?._authMethod,
|
||||
_startUrl: extraData?._startUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -758,14 +796,19 @@ const PROVIDERS = {
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => {
|
||||
const email = extractEmailFromAccessToken(tokens.access_token);
|
||||
const mapped = {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
email,
|
||||
providerSpecificData: {
|
||||
profileArn: tokens?.profile_arn || null,
|
||||
clientId: tokens._clientId,
|
||||
clientSecret: tokens._clientSecret,
|
||||
region: tokens._region || "us-east-1",
|
||||
authMethod: tokens._authMethod || "builder-id",
|
||||
startUrl: tokens._startUrl || KIRO_CONFIG.startUrl,
|
||||
},
|
||||
};
|
||||
return mapped;
|
||||
@@ -1158,12 +1201,12 @@ export async function exchangeTokens(providerName, code, redirectUri, codeVerifi
|
||||
/**
|
||||
* Request device code (for device_code flow)
|
||||
*/
|
||||
export async function requestDeviceCode(providerName, codeChallenge) {
|
||||
export async function requestDeviceCode(providerName, codeChallenge, options) {
|
||||
const provider = getProvider(providerName);
|
||||
if (provider.flowType !== "device_code") {
|
||||
throw new Error(`Provider ${providerName} does not support device code flow`);
|
||||
}
|
||||
return await provider.requestDeviceCode(provider.config, codeChallenge);
|
||||
return await provider.requestDeviceCode(provider.config, codeChallenge, options || {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1213,4 +1256,3 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
|
||||
|
||||
return { success: false, error: result.data.error, errorDescription: result.data.error_description };
|
||||
}
|
||||
|
||||
|
||||
@@ -126,10 +126,10 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* AWS IAM Identity Center (IDC) - HIDDEN */}
|
||||
{/* AWS IAM Identity Center (IDC) */}
|
||||
<button
|
||||
onClick={() => handleMethodSelect("idc")}
|
||||
className="hidden w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary mt-0.5">business</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
* - Localhost: Auto callback via popup message
|
||||
* - Remote: Manual paste callback URL
|
||||
*/
|
||||
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose, oauthMeta }) {
|
||||
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose, oauthMeta, idcConfig }) {
|
||||
const [step, setStep] = useState("waiting"); // waiting | input | success | error
|
||||
const [authData, setAuthData] = useState(null);
|
||||
const [callbackUrl, setCallbackUrl] = useState("");
|
||||
@@ -138,18 +138,30 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
setIsDeviceCode(true);
|
||||
setStep("waiting");
|
||||
|
||||
const res = await fetch(`/api/oauth/${provider}/device-code`);
|
||||
const deviceCodeUrl = new URL(`/api/oauth/${provider}/device-code`, window.location.origin);
|
||||
if (provider === "kiro" && idcConfig?.startUrl) {
|
||||
deviceCodeUrl.searchParams.set("start_url", idcConfig.startUrl);
|
||||
if (idcConfig.region) {
|
||||
deviceCodeUrl.searchParams.set("region", idcConfig.region);
|
||||
}
|
||||
deviceCodeUrl.searchParams.set("auth_method", "idc");
|
||||
}
|
||||
const res = await fetch(deviceCodeUrl.toString());
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
setDeviceData(data);
|
||||
|
||||
// Open verification URL
|
||||
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
||||
if (verifyUrl) window.open(verifyUrl, "_blank");
|
||||
|
||||
// Pass extraData for Kiro (contains _clientId, _clientSecret)
|
||||
const extraData = provider === "kiro" ? { _clientId: data._clientId, _clientSecret: data._clientSecret } : null;
|
||||
const extraData = provider === "kiro"
|
||||
? {
|
||||
_clientId: data._clientId,
|
||||
_clientSecret: data._clientSecret,
|
||||
_region: data._region,
|
||||
_authMethod: data._authMethod,
|
||||
_startUrl: data._startUrl,
|
||||
}
|
||||
: null;
|
||||
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
|
||||
return;
|
||||
}
|
||||
@@ -209,7 +221,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
setError(err.message);
|
||||
setStep("error");
|
||||
}
|
||||
}, [provider, isLocalhost, startPolling]);
|
||||
}, [provider, isLocalhost, startPolling, oauthMeta, idcConfig]);
|
||||
|
||||
// Reset state and start OAuth when modal opens
|
||||
useEffect(() => {
|
||||
@@ -345,6 +357,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
}, [onClose, provider]);
|
||||
|
||||
if (!provider || !providerInfo) return null;
|
||||
const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || "";
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={handleClose} size="lg">
|
||||
@@ -372,18 +385,28 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
<>
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Visit the URL below and enter the code:
|
||||
Visit the login URL below and authorize:
|
||||
</p>
|
||||
<div className="bg-sidebar p-4 rounded-lg mb-4">
|
||||
<p className="text-xs text-text-muted mb-1">Verification URL</p>
|
||||
<p className="text-xs text-text-muted mb-1">Login URL</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm break-all">{deviceData.verification_uri}</code>
|
||||
<code className="flex-1 text-sm break-all">{deviceLoginUrl}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "verify_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceData.verification_uri, "verify_url")}
|
||||
icon={copied === "login_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceLoginUrl, "login_url")}
|
||||
disabled={!deviceLoginUrl}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="open_in_new"
|
||||
onClick={() => window.open(deviceLoginUrl, "_blank", "noopener,noreferrer")}
|
||||
disabled={!deviceLoginUrl}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 p-4 rounded-lg">
|
||||
@@ -494,4 +517,9 @@ OAuthModal.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
/** Extra metadata passed to /authorize and /exchange (e.g. gitlab clientId/baseUrl) */
|
||||
oauthMeta: PropTypes.object,
|
||||
/** Optional Kiro IDC config for AWS IAM Identity Center device flow */
|
||||
idcConfig: PropTypes.shape({
|
||||
startUrl: PropTypes.string,
|
||||
region: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user