Skip to content

Commit da0b468

Browse files
committed
feat(kiro): support native external_idp refresh token import and validation
Previously, Microsoft Entra ID (external_idp) users lacked a seamless authentication path in Kiro: - The standard "Import Token" route (POST /api/oauth/kiro/import) strictly validated against AWS OIDC endpoints, rejecting external_idp refresh tokens due to missing enterprise metadata (profile_arn, token_endpoint). - The fallback import-cli-proxy route bypassed token validation entirely, relying on the user to manually construct a complex JSON payload combining SSO cache data and local IDE state. This commit resolves these limitations by introducing native backend support for external_idp refresh tokens, fully respecting existing controller patterns: 1. Unified SSO Cache Resolution: Extracted AWS SSO cache discovery logic into a shared utility (kiroSsoCache.js), allowing targeted refresh token lookups. 2. Native Token Validation: Upgraded KiroService.refreshToken to support native external_idp validation by pinging the Microsoft token endpoint directly, matching the architecture of the background refresh worker. 3. Controller Interception: The "Import Token" route now dynamically intercepts external_idp refresh tokens, extracts the hidden enterprise metadata from the local filesystem, and passes it into KiroService.refreshToken for standard validation. Enterprise users can now submit a refresh token via the Import Token UI. The backend resolves the required metadata, validates the token against the Microsoft endpoint, and establishes the connection without requiring UI redirects or manual JSON construction.
1 parent 9845a17 commit da0b468

6 files changed

Lines changed: 194 additions & 120 deletions

File tree

src/app/api/oauth/kiro/auto-import/route.js

Lines changed: 10 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { NextResponse } from "next/server";
2-
import { readFile, readdir } from "fs/promises";
3-
import { homedir } from "os";
4-
import { join } from "path";
2+
import { resolveKiroCredentialsFromCache } from "@/lib/oauth/kiroSsoCache";
53

64
/**
75
* GET /api/oauth/kiro/auto-import
@@ -11,116 +9,18 @@ import { join } from "path";
119
*/
1210
export async function GET() {
1311
try {
14-
const cachePath = join(homedir(), ".aws/sso/cache");
15-
16-
let files;
17-
try {
18-
files = await readdir(cachePath);
19-
} catch (error) {
20-
return NextResponse.json({
21-
found: false,
22-
error: "AWS SSO cache not found. Please login to Kiro IDE first.",
23-
});
24-
}
25-
26-
let refreshToken = null;
27-
let foundFile = null;
28-
let tokenData = null;
29-
30-
// First try kiro-auth-token.json
31-
const kiroTokenFile = "kiro-auth-token.json";
32-
if (files.includes(kiroTokenFile)) {
33-
try {
34-
const content = await readFile(join(cachePath, kiroTokenFile), "utf-8");
35-
const data = JSON.parse(content);
36-
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
37-
refreshToken = data.refreshToken;
38-
foundFile = kiroTokenFile;
39-
tokenData = data;
40-
}
41-
} catch (error) {
42-
// Continue to search other files
43-
}
44-
}
45-
46-
// If not found, search all .json files
47-
if (!refreshToken) {
48-
for (const file of files) {
49-
if (!file.endsWith(".json")) continue;
50-
try {
51-
const content = await readFile(join(cachePath, file), "utf-8");
52-
const data = JSON.parse(content);
53-
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
54-
refreshToken = data.refreshToken;
55-
foundFile = file;
56-
tokenData = data;
57-
break;
58-
}
59-
} catch (error) {
60-
continue;
61-
}
62-
}
63-
}
64-
65-
if (!refreshToken) {
66-
return NextResponse.json({
67-
found: false,
68-
error: "Kiro token not found in AWS SSO cache. Please login to Kiro IDE first.",
69-
});
70-
}
71-
72-
// For IDC/organization tokens, resolve clientId and clientSecret from
73-
// the linked client registration file (referenced by clientIdHash).
74-
let clientId = null;
75-
let clientSecret = null;
76-
const region = tokenData?.region || null;
77-
const authMethod = tokenData?.authMethod || null;
78-
79-
if (tokenData?.clientIdHash) {
80-
const clientFile = `${tokenData.clientIdHash}.json`;
81-
try {
82-
const clientContent = await readFile(join(cachePath, clientFile), "utf-8");
83-
const clientData = JSON.parse(clientContent);
84-
if (clientData.clientId && clientData.clientSecret) {
85-
clientId = clientData.clientId;
86-
clientSecret = clientData.clientSecret;
87-
}
88-
} catch (error) {
89-
// Client registration file not found - continue without it
90-
}
91-
}
92-
93-
// Read profileArn from Kiro IDE's profile.json.
94-
// Important: the runtime gateway requires us-east-1 in the ARN regardless
95-
// of the IDC region, so we normalize the region in the ARN to us-east-1.
96-
let profileArn = null;
97-
const kiroProfilePaths = [
98-
join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"),
99-
join(homedir(), ".config", "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"),
100-
];
101-
for (const profilePath of kiroProfilePaths) {
102-
try {
103-
const profileContent = await readFile(profilePath, "utf-8");
104-
const profileData = JSON.parse(profileContent);
105-
if (profileData.arn) {
106-
// Normalize region to us-east-1 for the runtime gateway
107-
profileArn = profileData.arn.replace(/arn:aws:codewhisperer:[^:]+:/, "arn:aws:codewhisperer:us-east-1:");
108-
break;
109-
}
110-
} catch (error) {
111-
continue;
112-
}
113-
}
12+
const credentials = await resolveKiroCredentialsFromCache();
11413

11514
return NextResponse.json({
11615
found: true,
117-
refreshToken,
118-
source: foundFile,
119-
clientId,
120-
clientSecret,
121-
region,
122-
authMethod,
123-
profileArn,
16+
refreshToken: credentials.refreshToken,
17+
source: credentials.source,
18+
clientId: credentials.clientId,
19+
clientSecret: credentials.clientSecret,
20+
region: credentials.region,
21+
authMethod: credentials.authMethod,
22+
profileArn: credentials.profileArn,
23+
...(credentials.rawAuth ? { rawAuth: credentials.rawAuth } : {}),
12424
});
12525
} catch (error) {
12626
console.log("Kiro auto-import error:", error);

src/app/api/oauth/kiro/import/route.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NextResponse } from "next/server";
22
import { KiroService } from "@/lib/oauth/services/kiro";
33
import { createProviderConnection } from "@/models";
4+
import { resolveKiroCredentialsFromCache } from "@/lib/oauth/kiroSsoCache";
5+
import { normalizeKiroExternalIdpAuth } from "@/lib/oauth/kiroExternalIdp";
46

57
/**
68
* POST /api/oauth/kiro/import
@@ -22,18 +24,30 @@ export async function POST(request) {
2224
const kiroService = new KiroService();
2325
const isIdc = !!(clientId && clientSecret);
2426

25-
// For IDC tokens, refresh via the regional OIDC endpoint with client credentials.
26-
// For social/builder-id tokens, use the standard social refresh endpoint.
27-
const providerSpecificData = isIdc
27+
let resolvedProviderData = isIdc
2828
? { clientId, clientSecret, region: region || "us-east-1", authMethod: "idc" }
2929
: {};
3030

31-
const tokenData = await kiroService.refreshToken(refreshToken.trim(), providerSpecificData);
31+
let resolvedProfileArn = profileArn || null;
32+
33+
// Try to resolve the token from local SSO cache.
34+
try {
35+
const cacheResult = await resolveKiroCredentialsFromCache(refreshToken.trim());
36+
if (cacheResult.authMethod === "external_idp" && cacheResult.rawAuth) {
37+
const tokenData = normalizeKiroExternalIdpAuth(cacheResult.rawAuth);
38+
resolvedProviderData = tokenData.providerSpecificData;
39+
resolvedProfileArn = tokenData.providerSpecificData.profileArn;
40+
}
41+
} catch (cacheError) {
42+
// Ignore cache errors and proceed with standard flow
43+
}
44+
45+
const tokenData = await kiroService.refreshToken(refreshToken.trim(), resolvedProviderData);
3246

3347
const email = kiroService.extractEmailFromJWT(tokenData.accessToken);
34-
const resolvedAuthMethod = isIdc ? "idc" : "imported";
35-
const providerLabel = isIdc ? "Enterprise" : "Imported";
36-
const resolvedProfileArn = profileArn || tokenData.profileArn || null;
48+
const resolvedAuthMethod = tokenData.providerSpecificData?.authMethod || (isIdc ? "idc" : "imported");
49+
const providerLabel = tokenData.providerSpecificData?.provider || (isIdc ? "Enterprise" : "Imported");
50+
resolvedProfileArn = resolvedProfileArn || tokenData.providerSpecificData?.profileArn || tokenData.profileArn || null;
3751

3852
const connection = await createProviderConnection({
3953
provider: "kiro",
@@ -47,6 +61,7 @@ export async function POST(request) {
4761
authMethod: resolvedAuthMethod,
4862
provider: providerLabel,
4963
...(isIdc ? { clientId, clientSecret, region: region || "us-east-1" } : {}),
64+
...(tokenData.providerSpecificData?.authMethod === "external_idp" ? tokenData.providerSpecificData : {})
5065
},
5166
testStatus: "active",
5267
});

src/lib/oauth/kiroSsoCache.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { readFile, readdir } from "fs/promises";
2+
import { homedir } from "os";
3+
import { join } from "path";
4+
5+
/**
6+
* Check whether an AWS SSO cache entry looks like a Kiro token.
7+
* Accepts Builder ID (aorAAAAAG prefix), external_idp (Microsoft Entra),
8+
* and organization tokens with codewhisperer scopes.
9+
*/
10+
export function isKiroToken(data) {
11+
if (!data?.refreshToken) return false;
12+
if (data.refreshToken.startsWith("aorAAAAAG")) return true;
13+
if (data.authMethod === "external_idp") return true;
14+
if (Array.isArray(data.scopes) && data.scopes.some(s => s.includes("codewhisperer"))) return true;
15+
return false;
16+
}
17+
18+
/**
19+
* Scan AWS SSO cache and resolve full Kiro credentials.
20+
* If targetRefreshToken is provided, it only returns a match for that specific token.
21+
* Otherwise, it returns the first found Kiro token.
22+
*/
23+
export async function resolveKiroCredentialsFromCache(targetRefreshToken = null) {
24+
const cachePath = join(homedir(), ".aws/sso/cache");
25+
let files;
26+
try {
27+
files = await readdir(cachePath);
28+
} catch (error) {
29+
throw new Error("AWS SSO cache not found. Please login to Kiro IDE first.");
30+
}
31+
32+
let refreshToken = null;
33+
let foundFile = null;
34+
let tokenData = null;
35+
36+
const checkData = (data, file) => {
37+
if (isKiroToken(data)) {
38+
if (targetRefreshToken && data.refreshToken !== targetRefreshToken) {
39+
return false;
40+
}
41+
refreshToken = data.refreshToken;
42+
foundFile = file;
43+
tokenData = data;
44+
return true;
45+
}
46+
return false;
47+
};
48+
49+
const kiroTokenFile = "kiro-auth-token.json";
50+
if (files.includes(kiroTokenFile)) {
51+
try {
52+
const content = await readFile(join(cachePath, kiroTokenFile), "utf-8");
53+
checkData(JSON.parse(content), kiroTokenFile);
54+
} catch (error) {}
55+
}
56+
57+
if (!refreshToken) {
58+
for (const file of files) {
59+
if (!file.endsWith(".json")) continue;
60+
try {
61+
const content = await readFile(join(cachePath, file), "utf-8");
62+
if (checkData(JSON.parse(content), file)) break;
63+
} catch (error) {
64+
continue;
65+
}
66+
}
67+
}
68+
69+
if (!refreshToken) {
70+
throw new Error(targetRefreshToken
71+
? "Provided refresh token not found in local AWS SSO cache."
72+
: "Kiro token not found in AWS SSO cache. Please login to Kiro IDE first.");
73+
}
74+
75+
let clientId = null;
76+
let clientSecret = null;
77+
const region = tokenData?.region || null;
78+
const authMethod = tokenData?.authMethod || null;
79+
80+
if (tokenData?.clientIdHash) {
81+
const clientFile = `${tokenData.clientIdHash}.json`;
82+
try {
83+
const clientContent = await readFile(join(cachePath, clientFile), "utf-8");
84+
const clientData = JSON.parse(clientContent);
85+
if (clientData.clientId && clientData.clientSecret) {
86+
clientId = clientData.clientId;
87+
clientSecret = clientData.clientSecret;
88+
}
89+
} catch (error) {}
90+
}
91+
92+
let profileArn = null;
93+
const kiroProfilePaths = [
94+
join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"),
95+
join(homedir(), ".config", "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"),
96+
];
97+
for (const profilePath of kiroProfilePaths) {
98+
try {
99+
const profileContent = await readFile(profilePath, "utf-8");
100+
const profileData = JSON.parse(profileContent);
101+
if (profileData.arn) {
102+
profileArn = profileData.arn.replace(/arn:aws:codewhisperer:[^:]+:/, "arn:aws:codewhisperer:us-east-1:");
103+
break;
104+
}
105+
} catch (error) {
106+
continue;
107+
}
108+
}
109+
110+
const rawAuth = authMethod === "external_idp" ? {
111+
auth_method: tokenData.authMethod,
112+
access_token: tokenData.accessToken,
113+
refresh_token: tokenData.refreshToken,
114+
client_id: tokenData.clientId || clientId,
115+
token_endpoint: tokenData.tokenEndpoint,
116+
scopes: tokenData.scopes,
117+
region: tokenData.region,
118+
profile_arn: profileArn,
119+
...(tokenData.expiresAt ? { expired: tokenData.expiresAt } : {}),
120+
} : undefined;
121+
122+
return {
123+
refreshToken,
124+
source: foundFile,
125+
clientId,
126+
clientSecret,
127+
region,
128+
authMethod,
129+
profileArn,
130+
rawAuth
131+
};
132+
}

src/lib/oauth/services/kiro.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth.js";
2+
import { buildExternalIdpRefreshParams } from "../kiroExternalIdp.js";
23

34
/**
45
* Kiro OAuth Service
@@ -177,6 +178,33 @@ export class KiroService {
177178
async refreshToken(refreshToken, providerSpecificData = {}) {
178179
const { authMethod, clientId, clientSecret, region } = providerSpecificData;
179180

181+
// Microsoft Entra ID (external_idp) refresh
182+
if (authMethod === "external_idp") {
183+
const refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData);
184+
185+
const response = await fetch(refreshRequest.tokenEndpoint, {
186+
method: "POST",
187+
headers: {
188+
"Content-Type": "application/x-www-form-urlencoded",
189+
Accept: "application/json",
190+
},
191+
body: refreshRequest.body,
192+
});
193+
194+
if (!response.ok) {
195+
const errorText = await response.text();
196+
throw new Error(`Token refresh failed for external_idp: ${errorText}`);
197+
}
198+
199+
const data = await response.json();
200+
return {
201+
accessToken: data.access_token,
202+
refreshToken: data.refresh_token || refreshToken,
203+
expiresIn: data.expires_in,
204+
providerSpecificData: refreshRequest.providerSpecificData,
205+
};
206+
}
207+
180208
// AWS SSO OIDC refresh (Builder ID or IDC)
181209
if (clientId && clientSecret) {
182210
const safeRegion = region || "us-east-1";

src/shared/components/KiroAuthModal.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
2121
const [autoDetecting, setAutoDetecting] = useState(false);
2222
const [autoDetected, setAutoDetected] = useState(false);
2323
const [idcCredentials, setIdcCredentials] = useState(null);
24-
2524
// Auto-detect token when import method is selected
2625
useEffect(() => {
2726
if (selectedMethod !== "import" || !isOpen) return;

src/shared/components/KiroOAuthWrapper.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onCl
2727
// Use social login with manual callback
2828
setAuthMethod("social");
2929
setSocialProvider(config.provider);
30-
} else if (method === "import" || method === "api-key") {
31-
// Import / API-key handled in KiroAuthModal, just close
30+
} else if (method === "import" || method === "api-key" || method === "import-cli-proxy") {
31+
// Import / API-key / CLI-proxy handled in KiroAuthModal, just close
3232
onSuccess?.();
3333
}
3434
}, [onSuccess]);

0 commit comments

Comments
 (0)