Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions open-sse/services/tokenRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,11 +513,7 @@ export async function refreshKiroToken(
// AWS SSO OIDC (Builder ID or IDC)
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
if (clientId && clientSecret) {
const isIDC = authMethod === "idc";
const endpoint =
isIDC && region
? `https://oidc.${region}.amazonaws.com/token`
: "https://oidc.us-east-1.amazonaws.com/token";
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;

const response = await runWithProxyContext(proxyConfig, () =>
fetch(endpoint, {
Expand Down
21 changes: 20 additions & 1 deletion src/app/api/oauth/[provider]/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export async function GET(
}

const authData = generateAuthData(provider, null);
const startUrl = searchParams.get("startUrl");
const region = searchParams.get("region") || "us-east-1";

// Resolve proxy for this provider (provider-level → global → direct)
const proxy = await resolveProxyForProvider(provider);
Expand All @@ -95,7 +97,24 @@ export async function GET(
provider === "kilocode"
) {
// GitHub, Kiro/Amazon Q, Kimi Coding, and KiloCode don't use PKCE for device code
deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider));
if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
const providerOverrideConfig = {
...providerData.config,
startUrl,
region,
skipIssuerUrlForRegistration: true,
registerClientUrl: `https://oidc.${region}.amazonaws.com/client/register`,
deviceAuthUrl: `https://oidc.${region}.amazonaws.com/device_authorization`,
tokenUrl: `https://oidc.${region}.amazonaws.com/token`,
ssoOidcEndpoint: `https://oidc.${region}.amazonaws.com`,
};

deviceData = await runWithProxyContext(proxy, () =>
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
);
} else {
deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider));
}
} else {
// Qwen and other providers use PKCE
deviceData = await runWithProxyContext(proxy, () =>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/oauth/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,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, configOverride = null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function parameters in this TypeScript file are missing type definitions. Adding explicit types will improve maintainability and allow for the removal of any casts in the API route handlers.

Suggested change
export async function requestDeviceCode(providerName, codeChallenge, configOverride = null) {
export async function requestDeviceCode(providerName: string, codeChallenge: string | null, configOverride: any = null) {

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(configOverride || provider.config, codeChallenge);
}

/**
Expand Down
37 changes: 29 additions & 8 deletions src/lib/oauth/providers/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,35 @@ export const kiro = {
config: KIRO_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const regionMatch = String(config.tokenUrl || "").match(/oidc\.([a-z0-9-]+)\.amazonaws\.com/i);
const resolvedRegion = regionMatch?.[1] || "us-east-1";
Comment on lines +7 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Deriving the region via regex from tokenUrl is fragile. Since the region is explicitly passed in the config object from the route handler, it is safer and more direct to use config.region with a fallback.

Suggested change
const regionMatch = String(config.tokenUrl || "").match(/oidc\.([a-z0-9-]+)\.amazonaws\.com/i);
const resolvedRegion = regionMatch?.[1] || "us-east-1";
const resolvedRegion = config.region || "us-east-1";

const registerPayload: {
clientName: string;
clientType: string;
scopes: string[];
grantTypes: string[];
issuerUrl?: string;
} = {
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
};

// For enterprise IDC custom startUrl flows, issuerUrl can differ per tenant.
// Sending a fixed issuerUrl often causes invalid_request during device auth.
if (config.issuerUrl && !config.skipIssuerUrlForRegistration) {
registerPayload.issuerUrl = config.issuerUrl;
}

// 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,
}),
body: JSON.stringify(registerPayload),
});

if (!registerRes.ok) {
Expand Down Expand Up @@ -57,10 +72,14 @@ export const kiro = {
interval: deviceData.interval || 5,
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
_region: resolvedRegion,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const response = await fetch(config.tokenUrl, {
const tokenRegion = extraData?._region || "us-east-1";
const tokenUrl = `https://oidc.${tokenRegion}.amazonaws.com/token`;

const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down Expand Up @@ -91,6 +110,7 @@ export const kiro = {
expires_in: data.expiresIn,
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
_region: tokenRegion,
},
};
}
Expand All @@ -110,6 +130,7 @@ export const kiro = {
providerSpecificData: {
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
region: tokens._region,
},
}),
};
10 changes: 6 additions & 4 deletions src/shared/components/KiroAuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,17 +138,19 @@ export default function KiroAuthModal({
</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>
<div className="flex-1">
<h3 className="font-semibold mb-1">AWS IAM Identity Center</h3>
<h3 className="font-semibold mb-1">
Your Organization (AWS IAM Identity Center)
</h3>
<p className="text-sm text-text-muted">
For enterprise users with custom AWS IAM Identity Center.
Use your company SSO start URL (example: https://your-org.awsapps.com/start).
</p>
</div>
</div>
Expand Down
86 changes: 58 additions & 28 deletions src/shared/components/OAuthModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import PropTypes from "prop-types";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
Expand Down Expand Up @@ -47,33 +47,36 @@ export default function OAuthModal({
const deviceVerificationUrl =
deviceData?.verification_uri_complete || deviceData?.verification_uri || "";

// State for client-only values to avoid hydration mismatch
const [isLocalhost, setIsLocalhost] = useState(false);
const [placeholderUrl, setPlaceholderUrl] = useState("/callback?code=...");
const callbackProcessedRef = useRef(false);
const flowStartedRef = useRef(false);

// Detect if running on true localhost vs LAN IP (client-side only)
// - True localhost (127.0.0.1/localhost): popup auto-callback works
// - LAN IPs (192.168.x, 10.x, 172.x): redirect URI uses localhost but callback
// won't resolve back to the VPS, so use manual paste mode
const [isTrueLocalhost, setIsTrueLocalhost] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
const hostname = window.location.hostname;
const isLocal =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1";
setIsLocalhost(isLocal);
setIsTrueLocalhost(isTrulyLocal);
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
// Client-only runtime values
const runtimeLocation = useMemo(() => {
if (typeof window === "undefined") {
return {
isLocalhost: false,
isTrueLocalhost: false,
placeholderUrl: "/callback?code=...",
};
}

const hostname = window.location.hostname;
const isLocal =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1";

return {
isLocalhost: isLocal,
isTrueLocalhost: isTrulyLocal,
placeholderUrl: `${window.location.origin}/callback?code=...`,
};
}, []);

const { isLocalhost, isTrueLocalhost, placeholderUrl } = runtimeLocation;
const callbackProcessedRef = useRef(false);
const flowStartedRef = useRef(false);

// Define all useCallback hooks BEFORE the useEffects that reference them

// Exchange tokens
Expand Down Expand Up @@ -217,7 +220,22 @@ export default function OAuthModal({
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" || provider === "amazon-q") &&
idcConfig &&
typeof idcConfig === "object"
) {
const idc = idcConfig as { startUrl?: string; region?: string };
if (typeof idc.startUrl === "string" && idc.startUrl.trim()) {
deviceCodeUrl.searchParams.set("startUrl", idc.startUrl.trim());
}
if (typeof idc.region === "string" && idc.region.trim()) {
deviceCodeUrl.searchParams.set("region", idc.region.trim());
}
}

const res = await fetch(deviceCodeUrl.toString());
const data = await res.json();
if (!res.ok) {
const errMsg =
Expand All @@ -237,7 +255,11 @@ export default function OAuthModal({
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
const extraData =
provider === "kiro" || provider === "amazon-q"
? { _clientId: data._clientId, _clientSecret: data._clientSecret }
? {
_clientId: data._clientId,
_clientSecret: data._clientSecret,
_region: data._region,
}
: null;
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
return;
Expand Down Expand Up @@ -379,7 +401,15 @@ export default function OAuthModal({
setError(err.message);
setStep("error");
}
}, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess, reauthConnection]);
}, [
provider,
isLocalhost,
isTrueLocalhost,
startPolling,
onSuccess,
reauthConnection,
idcConfig,
]);

// Reset guard when modal closes
useEffect(() => {
Expand Down
Loading