diff --git a/public/providers/antigravity.png b/public/providers/antigravity.png index b567e54..bb2f256 100644 Binary files a/public/providers/antigravity.png and b/public/providers/antigravity.png differ diff --git a/public/providers/claude.png b/public/providers/claude.png index 5a38939..15b3663 100644 Binary files a/public/providers/claude.png and b/public/providers/claude.png differ diff --git a/public/providers/cline.png b/public/providers/cline.png index 67161e9..215efe8 100644 Binary files a/public/providers/cline.png and b/public/providers/cline.png differ diff --git a/public/providers/codex.png b/public/providers/codex.png index 2f0ecb9..b22f2c2 100644 Binary files a/public/providers/codex.png and b/public/providers/codex.png differ diff --git a/public/providers/copilot.png b/public/providers/copilot.png index e4f69c1..bedbab1 100644 Binary files a/public/providers/copilot.png and b/public/providers/copilot.png differ diff --git a/public/providers/cursor.png b/public/providers/cursor.png index a0f1fa8..234bd66 100644 Binary files a/public/providers/cursor.png and b/public/providers/cursor.png differ diff --git a/public/providers/gemini-cli.png b/public/providers/gemini-cli.png index 2f26055..c226db2 100644 Binary files a/public/providers/gemini-cli.png and b/public/providers/gemini-cli.png differ diff --git a/public/providers/github.png b/public/providers/github.png index e4f69c1..bedbab1 100644 Binary files a/public/providers/github.png and b/public/providers/github.png differ diff --git a/public/providers/iflow.png b/public/providers/iflow.png index abf4657..9ca74ca 100644 Binary files a/public/providers/iflow.png and b/public/providers/iflow.png differ diff --git a/public/providers/kiro.png b/public/providers/kiro.png index 249667a..86bd864 100644 Binary files a/public/providers/kiro.png and b/public/providers/kiro.png differ diff --git a/public/providers/qwen.png b/public/providers/qwen.png index d773698..b4b7f58 100644 Binary files a/public/providers/qwen.png and b/public/providers/qwen.png differ diff --git a/public/providers/roo.png b/public/providers/roo.png index 582c0b7..8dd0c33 100644 Binary files a/public/providers/roo.png and b/public/providers/roo.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 85eec0f..25d2bdd 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -195,7 +195,7 @@ export default function ClaudeToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} />
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index ecb1f05..80df981 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -168,7 +168,7 @@ wire_api = "responses"
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} />
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index ad58149..ad985c2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -235,8 +235,8 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba alt={tool.name} width={40} height={40} - className="size-12 object-contain rounded-xl bg-gray-500" - style={{ width: "auto", height: "auto" }} + className="size-12 object-contain rounded-xl bg-gray-500 max-w-[48px] max-h-[48px]" + sizes="48px" onError={(e) => { e.target.style.display = "none"; }} /> ); @@ -250,8 +250,8 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba alt={tool.name} width={40} height={40} - className="size-10 object-contain rounded-xl" - style={{ width: "auto", height: "auto" }} + className="size-10 object-contain rounded-xl max-w-[40px] max-h-[40px]" + sizes="40px" onError={(e) => { e.target.style.display = "none"; }} /> ); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index b4bee0c..cfe37df 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import { useParams } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, Toggle } from "@/shared/components"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -275,8 +275,8 @@ export default function ProviderDetailPage() { alt={providerInfo.name} width={48} height={48} - className="object-contain rounded-lg" - style={{ width: "auto", height: "auto" }} + className="object-contain rounded-lg max-w-[48px] max-h-[48px]" + sizes="48px" onError={(e) => { e.currentTarget.style.display = "none"; }} />
@@ -344,13 +344,22 @@ export default function ProviderDetailPage() { {/* Modals */} - setShowOAuthModal(false)} - /> + {providerId === "kiro" ? ( + setShowOAuthModal(false)} + /> + ) : ( + setShowOAuthModal(false)} + /> + )} setImgError(true)} /> )} diff --git a/src/app/api/oauth/kiro/import/route.js b/src/app/api/oauth/kiro/import/route.js new file mode 100644 index 0000000..c1db94b --- /dev/null +++ b/src/app/api/oauth/kiro/import/route.js @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +/** + * POST /api/oauth/kiro/import + * Import and validate refresh token from Kiro IDE + */ +export async function POST(request) { + try { + const { refreshToken } = await request.json(); + + if (!refreshToken || typeof refreshToken !== "string") { + return NextResponse.json( + { error: "Refresh token is required" }, + { status: 400 } + ); + } + + const kiroService = new KiroService(); + + // Validate and refresh token + const tokenData = await kiroService.validateImportToken(refreshToken.trim()); + + // Extract email from JWT if available + const email = kiroService.extractEmailFromJWT(tokenData.accessToken); + + // Save to database + const connection = await createProviderConnection({ + provider: "kiro", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: tokenData.profileArn, + authMethod: "imported", + provider: "Imported", + }, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.log("Kiro import token error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after Kiro import:", error); + } +} diff --git a/src/app/api/oauth/kiro/social-authorize/route.js b/src/app/api/oauth/kiro/social-authorize/route.js new file mode 100644 index 0000000..e5e1281 --- /dev/null +++ b/src/app/api/oauth/kiro/social-authorize/route.js @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { generatePKCE } from "@/lib/oauth/utils/pkce"; +import { KiroService } from "@/lib/oauth/services/kiro"; + +/** + * GET /api/oauth/kiro/social-authorize + * Generate Google/GitHub social login URL for manual callback flow + * Uses kiro:// custom protocol as required by AWS Cognito + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); // "google" or "github" + + if (!provider || !["google", "github"].includes(provider)) { + return NextResponse.json( + { error: "Invalid provider. Use 'google' or 'github'" }, + { status: 400 } + ); + } + + // Generate PKCE for social auth + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + const kiroService = new KiroService(); + const authUrl = kiroService.buildSocialLoginUrl( + provider, + codeChallenge, + state + ); + + return NextResponse.json({ + authUrl, + state, + codeVerifier, + codeChallenge, + provider, + }); + } catch (error) { + console.log("Kiro social authorize error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/kiro/social-exchange/route.js b/src/app/api/oauth/kiro/social-exchange/route.js new file mode 100644 index 0000000..5e91618 --- /dev/null +++ b/src/app/api/oauth/kiro/social-exchange/route.js @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +/** + * POST /api/oauth/kiro/social-exchange + * Exchange authorization code for tokens (Google/GitHub social login) + * Callback URL will be in format: kiro://kiro.kiroAgent/authenticate-success?code=XXX&state=YYY + */ +export async function POST(request) { + try { + const { code, codeVerifier, provider } = await request.json(); + + if (!code || !codeVerifier) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + if (!provider || !["google", "github"].includes(provider)) { + return NextResponse.json( + { error: "Invalid provider" }, + { status: 400 } + ); + } + + const kiroService = new KiroService(); + + // Exchange code for tokens (redirect_uri handled internally) + const tokenData = await kiroService.exchangeSocialCode( + code, + codeVerifier + ); + + // Extract email from JWT if available + const email = kiroService.extractEmailFromJWT(tokenData.accessToken); + + // Save to database + const connection = await createProviderConnection({ + provider: "kiro", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: tokenData.profileArn, + authMethod: provider, // "google" or "github" + provider: provider.charAt(0).toUpperCase() + provider.slice(1), + }, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.log("Kiro social exchange error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after Kiro OAuth:", error); + } +} diff --git a/src/app/landing/components/FlowAnimation.js b/src/app/landing/components/FlowAnimation.js index 08d53ce..482e7a1 100644 --- a/src/app/landing/components/FlowAnimation.js +++ b/src/app/landing/components/FlowAnimation.js @@ -48,7 +48,8 @@ export default function FlowAnimation() { alt={tool.name} width={48} height={48} - className="object-contain rounded-xl" + className="object-contain rounded-xl max-w-[48px] max-h-[48px]" + sizes="48px" />
diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index d557dba..d08a221 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -113,22 +113,33 @@ export const GITHUB_CONFIG = { editorPluginVersion: "copilot-chat/0.26.7", }; -// Kiro OAuth Configuration (AWS SSO OIDC Device Code Flow) +// Kiro OAuth Configuration +// Supports multiple auth methods: +// 1. AWS Builder ID (Device Code Flow) +// 2. AWS IAM Identity Center/IDC (Device Code Flow with custom startUrl/region) +// 3. Google/GitHub Social Login (Authorization Code Flow - manual callback) +// 4. Import Token (paste refresh token from Kiro IDE) export const KIRO_CONFIG = { - // AWS SSO OIDC endpoints for Builder ID + // AWS SSO OIDC endpoints for Builder ID/IDC (Device Code Flow) 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 + // AWS Builder ID default start URL startUrl: "https://view.awsapps.com/start", // Client registration params - clientName: "kiro-cli", + clientName: "kiro-oauth-client", 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", + // Social auth endpoints (Google/GitHub via AWS Cognito) + socialAuthEndpoint: "https://prod.us-east-1.auth.desktop.kiro.dev", + socialLoginUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/login", + socialTokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/oauth/token", + socialRefreshUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", + // Auth methods + authMethods: ["builder-id", "idc", "google", "github", "import"], }; // OAuth timeout (5 minutes) diff --git a/src/lib/oauth/services/index.js b/src/lib/oauth/services/index.js index b3d8284..3912864 100644 --- a/src/lib/oauth/services/index.js +++ b/src/lib/oauth/services/index.js @@ -11,4 +11,5 @@ export { IFlowService } from "./iflow.js"; export { AntigravityService } from "./antigravity.js"; export { OpenAIService } from "./openai.js"; export { GitHubService } from "./github.js"; +export { KiroService } from "./kiro.js"; diff --git a/src/lib/oauth/services/kiro.js b/src/lib/oauth/services/kiro.js new file mode 100644 index 0000000..e65b0ab --- /dev/null +++ b/src/lib/oauth/services/kiro.js @@ -0,0 +1,276 @@ +import { KIRO_CONFIG } from "../constants/oauth.js"; + +/** + * Kiro OAuth Service + * Supports multiple authentication methods: + * 1. AWS Builder ID (Device Code Flow) + * 2. AWS IAM Identity Center/IDC (Device Code Flow) + * 3. Google/GitHub Social Login (Authorization Code Flow + Manual Callback) + * 4. Import Token (Manual refresh token paste) + */ + +const KIRO_AUTH_SERVICE = "https://prod.us-east-1.auth.desktop.kiro.dev"; + +export class KiroService { + /** + * Register OIDC client with AWS SSO + * Returns clientId and clientSecret for device code flow + */ + async registerClient(region = "us-east-1") { + const endpoint = `https://oidc.${region}.amazonaws.com/client/register`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientName: KIRO_CONFIG.clientName, + clientType: KIRO_CONFIG.clientType, + scopes: KIRO_CONFIG.scopes, + grantTypes: KIRO_CONFIG.grantTypes, + issuerUrl: KIRO_CONFIG.issuerUrl, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to register client: ${error}`); + } + + const data = await response.json(); + return { + clientId: data.clientId, + clientSecret: data.clientSecret, + clientSecretExpiresAt: data.clientSecretExpiresAt, + }; + } + + /** + * Start device authorization for AWS Builder ID or IDC + */ + async startDeviceAuthorization(clientId, clientSecret, startUrl, region = "us-east-1") { + const endpoint = `https://oidc.${region}.amazonaws.com/device_authorization`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientId, + clientSecret, + startUrl, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to start device authorization: ${error}`); + } + + const data = await response.json(); + return { + deviceCode: data.deviceCode, + userCode: data.userCode, + verificationUri: data.verificationUri, + verificationUriComplete: data.verificationUriComplete, + expiresIn: data.expiresIn, + interval: data.interval || 5, + }; + } + + /** + * Poll for token using device code (AWS Builder ID/IDC) + */ + async pollDeviceToken(clientId, clientSecret, deviceCode, region = "us-east-1") { + const endpoint = `https://oidc.${region}.amazonaws.com/token`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientId, + clientSecret, + deviceCode, + grantType: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = await response.json(); + + // Handle pending/slow_down/errors + if (!response.ok || data.error) { + return { + success: false, + error: data.error, + errorDescription: data.error_description, + pending: data.error === "authorization_pending" || data.error === "slow_down", + }; + } + + return { + success: true, + tokens: { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresIn: data.expiresIn, + tokenType: data.tokenType, + }, + }; + } + + /** + * Build Google/GitHub social login URL + * Returns authorization URL for manual callback flow + * Uses kiro:// custom protocol as required by AWS Cognito whitelist + */ + buildSocialLoginUrl(provider, codeChallenge, state) { + const idp = provider === "google" ? "Google" : "Github"; + // AWS Cognito only whitelists kiro:// protocol, not localhost + const redirectUri = "kiro://kiro.kiroAgent/authenticate-success"; + return `${KIRO_AUTH_SERVICE}/login?idp=${idp}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${codeChallenge}&code_challenge_method=S256&state=${state}&prompt=select_account`; + } + + /** + * Exchange authorization code for tokens (Social Login) + * Must use same redirect_uri as authorization request + */ + async exchangeSocialCode(code, codeVerifier) { + // Must match the redirect_uri used in buildSocialLoginUrl + const redirectUri = "kiro://kiro.kiroAgent/authenticate-success"; + + const response = await fetch(`${KIRO_AUTH_SERVICE}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + profileArn: data.profileArn, + expiresIn: data.expiresIn || 3600, + }; + } + + /** + * Refresh token using refresh token + */ + async refreshToken(refreshToken, providerSpecificData = {}) { + const { authMethod, clientId, clientSecret, region } = providerSpecificData; + + // AWS SSO OIDC refresh (Builder ID or IDC) + if (clientId && clientSecret) { + const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientId, + clientSecret, + refreshToken, + grantType: "refresh_token", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken || refreshToken, + expiresIn: data.expiresIn, + }; + } + + // Social auth refresh (Google/GitHub) + const response = await fetch(`${KIRO_AUTH_SERVICE}/refreshToken`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refreshToken, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken || refreshToken, + profileArn: data.profileArn, + expiresIn: data.expiresIn || 3600, + }; + } + + /** + * Validate and import refresh token + */ + async validateImportToken(refreshToken) { + // Validate token format + if (!refreshToken.startsWith("aorAAAAAG")) { + throw new Error("Invalid token format. Token should start with aorAAAAAG..."); + } + + // Try to refresh to validate + try { + const result = await this.refreshToken(refreshToken); + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken || refreshToken, + profileArn: result.profileArn, + expiresIn: result.expiresIn, + authMethod: "imported", + }; + } catch (error) { + throw new Error(`Token validation failed: ${error.message}`); + } + } + + /** + * Fetch user email from access token (optional, for display) + */ + extractEmailFromJWT(accessToken) { + try { + const parts = accessToken.split("."); + if (parts.length !== 3) return null; + + // Decode payload (add padding if needed) + let payload = parts[1]; + while (payload.length % 4) { + payload += "="; + } + + const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))); + return decoded.email || decoded.preferred_username || decoded.sub; + } catch { + return null; + } + } +} diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 88bba08..d805f20 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -94,8 +94,8 @@ export default function Header({ onMenuClick, showMenuButton = true }) { alt={crumb.label} width={28} height={28} - className="object-contain rounded" - style={{ width: "auto", height: "auto" }} + className="object-contain rounded max-w-[28px] max-h-[28px]" + sizes="28px" onError={(e) => { e.currentTarget.style.display = "none"; }} /> )} diff --git a/src/shared/components/KiroAuthModal.js b/src/shared/components/KiroAuthModal.js new file mode 100644 index 0000000..9e9789a --- /dev/null +++ b/src/shared/components/KiroAuthModal.js @@ -0,0 +1,325 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +/** + * Kiro Auth Method Selection Modal + * Allows user to choose between multiple Kiro authentication methods: + * 1. AWS Builder ID (Device Code) + * 2. AWS IAM Identity Center/IDC (Device Code with custom startUrl/region) + * 3. Google Social Login (Manual callback) + * 4. GitHub Social Login (Manual callback) + * 5. Import Token (Paste refresh token) + */ +export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { + const [selectedMethod, setSelectedMethod] = useState(null); + const [idcStartUrl, setIdcStartUrl] = useState(""); + const [idcRegion, setIdcRegion] = useState("us-east-1"); + const [refreshToken, setRefreshToken] = useState(""); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const { copied, copy } = useCopyToClipboard(); + + const handleMethodSelect = (method) => { + setSelectedMethod(method); + setError(null); + }; + + const handleBack = () => { + setSelectedMethod(null); + setError(null); + }; + + const handleImportToken = async () => { + if (!refreshToken.trim()) { + setError("Please enter a refresh token"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/kiro/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: refreshToken.trim() }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + // Success - close modal + onClose(); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + + const handleIdcContinue = () => { + if (!idcStartUrl.trim()) { + setError("Please enter your IDC start URL"); + return; + } + onMethodSelect("idc", { startUrl: idcStartUrl.trim(), region: idcRegion }); + }; + + const handleSocialLogin = (provider) => { + onMethodSelect("social", { provider }); + }; + + return ( + +
+ {/* Method Selection */} + {!selectedMethod && ( +
+

+ Choose your authentication method: +

+ + {/* AWS Builder ID */} + + + {/* AWS IAM Identity Center (IDC) - HIDDEN */} + + + {/* Google Social Login - HIDDEN */} + + + {/* GitHub Social Login - HIDDEN */} + + + {/* Import Token */} + +
+ )} + + {/* IDC Configuration */} + {selectedMethod === "idc" && ( +
+
+ + setIdcStartUrl(e.target.value)} + placeholder="https://your-org.awsapps.com/start" + className="font-mono text-sm" + /> +

+ Your organization's AWS IAM Identity Center URL +

+
+ +
+ + setIdcRegion(e.target.value)} + placeholder="us-east-1" + className="font-mono text-sm" + /> +

+ AWS region for your Identity Center (default: us-east-1) +

+
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+ )} + + {/* Social Login Info (Google) */} + {selectedMethod === "social-google" && ( +
+
+
+ info +
+

+ Manual Callback Required +

+

+ After login, you'll need to copy the callback URL from your browser and paste it back here. +

+
+
+
+ +
+ + +
+
+ )} + + {/* Social Login Info (GitHub) */} + {selectedMethod === "social-github" && ( +
+
+
+ info +
+

+ Manual Callback Required +

+

+ After login, you'll need to copy the callback URL from your browser and paste it back here. +

+
+
+
+ +
+ + +
+
+ )} + + {/* Import Token */} + {selectedMethod === "import" && ( +
+
+

+ 💡 Please login to Kiro IDE first. +

+
+ +
+ + setRefreshToken(e.target.value)} + placeholder="aorAAAAAG..." + className="font-mono text-sm" + type="password" + /> +

+ Find it in Kiro IDE at: ~/.aws/sso/cache/kiro-auth-token.json +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+
+ )} +
+
+ ); +} + +KiroAuthModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onMethodSelect: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/KiroOAuthWrapper.js b/src/shared/components/KiroOAuthWrapper.js new file mode 100644 index 0000000..be53ccd --- /dev/null +++ b/src/shared/components/KiroOAuthWrapper.js @@ -0,0 +1,101 @@ +"use client"; + +import { useState, useCallback } from "react"; +import PropTypes from "prop-types"; +import OAuthModal from "./OAuthModal"; +import KiroAuthModal from "./KiroAuthModal"; +import KiroSocialOAuthModal from "./KiroSocialOAuthModal"; + +/** + * Kiro OAuth Wrapper + * Orchestrates between method selection, device code flow, and social login flow + */ +export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onClose }) { + const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import" + const [socialProvider, setSocialProvider] = useState(null); // "google" | "github" + const [idcConfig, setIdcConfig] = useState(null); + + const handleMethodSelect = useCallback((method, config) => { + if (method === "builder-id") { + // Use device code flow (AWS Builder ID) + setAuthMethod("builder-id"); + } else if (method === "idc") { + // Use device code flow with IDC config + setAuthMethod("idc"); + setIdcConfig(config); + } else if (method === "social") { + // Use social login with manual callback + setAuthMethod("social"); + setSocialProvider(config.provider); + } else if (method === "import") { + // Import handled in KiroAuthModal, just close + onSuccess?.(); + } + }, [onSuccess]); + + const handleBack = () => { + setAuthMethod(null); + setSocialProvider(null); + setIdcConfig(null); + }; + + const handleSocialSuccess = () => { + setAuthMethod(null); + setSocialProvider(null); + onSuccess?.(); + }; + + const handleDeviceSuccess = () => { + setAuthMethod(null); + setIdcConfig(null); + onSuccess?.(); + }; + + // Show method selection first + if (!authMethod) { + return ( + + ); + } + + // Show device code flow (Builder ID or IDC) + if (authMethod === "builder-id" || authMethod === "idc") { + return ( + + ); + } + + // Show social login flow (Google/GitHub with manual callback) + if (authMethod === "social" && socialProvider) { + return ( + + ); + } + + return null; +} + +KiroOAuthWrapper.propTypes = { + isOpen: PropTypes.bool.isRequired, + providerInfo: PropTypes.shape({ + name: PropTypes.string, + }), + onSuccess: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/KiroSocialOAuthModal.js b/src/shared/components/KiroSocialOAuthModal.js new file mode 100644 index 0000000..323d4b1 --- /dev/null +++ b/src/shared/components/KiroSocialOAuthModal.js @@ -0,0 +1,205 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +/** + * Kiro Social OAuth Modal (Google/GitHub) + * Handles manual callback URL flow for social login + */ +export default function KiroSocialOAuthModal({ isOpen, provider, onSuccess, onClose }) { + const [step, setStep] = useState("loading"); // loading | input | success | error + const [authUrl, setAuthUrl] = useState(""); + const [authData, setAuthData] = useState(null); + const [callbackUrl, setCallbackUrl] = useState(""); + const [error, setError] = useState(null); + const { copied, copy } = useCopyToClipboard(); + + // Initialize auth flow + useEffect(() => { + if (!isOpen || !provider) return; + + const initAuth = async () => { + try { + setError(null); + setStep("loading"); + + const res = await fetch(`/api/oauth/kiro/social-authorize?provider=${provider}`); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error); + } + + setAuthData(data); + setAuthUrl(data.authUrl); + setStep("input"); + + // Auto-open browser + window.open(data.authUrl, "_blank"); + } catch (err) { + setError(err.message); + setStep("error"); + } + }; + + initAuth(); + }, [isOpen, provider]); + + const handleManualSubmit = async () => { + try { + setError(null); + + // Parse callback URL - can be either kiro:// or http://localhost format + let url; + try { + url = new URL(callbackUrl); + } catch (e) { + // If URL parsing fails, might be malformed + throw new Error("Invalid callback URL format"); + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + + if (errorParam) { + throw new Error(url.searchParams.get("error_description") || errorParam); + } + + if (!code) { + throw new Error("No authorization code found in URL"); + } + + // Exchange code for tokens + const res = await fetch("/api/oauth/kiro/social-exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + codeVerifier: authData.codeVerifier, + provider, + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + + setStep("success"); + onSuccess?.(); + } catch (err) { + setError(err.message); + setStep("error"); + } + }; + + const providerName = provider === "google" ? "Google" : "GitHub"; + + return ( + +
+ {/* Loading */} + {step === "loading" && ( +
+
+ + progress_activity + +
+

Initializing...

+

+ Setting up {providerName} authentication +

+
+ )} + + {/* Manual Input Step */} + {step === "input" && ( + <> +
+
+

Step 1: Open this URL in your browser

+
+ + +
+
+ +
+

Step 2: Paste the callback URL here

+

+ After authorization, copy the full URL from your browser address bar. +

+ setCallbackUrl(e.target.value)} + placeholder="kiro://kiro.kiroAgent/authenticate-success?code=..." + className="font-mono text-xs" + /> +
+
+ +
+ + +
+ + )} + + {/* Success */} + {step === "success" && ( +
+
+ check_circle +
+

Connected Successfully!

+

+ Your Kiro account via {providerName} has been connected. +

+ +
+ )} + + {/* Error */} + {step === "error" && ( +
+
+ error +
+

Connection Failed

+

{error}

+
+ + +
+
+ )} +
+
+ ); +} + +KiroSocialOAuthModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + provider: PropTypes.oneOf(["google", "github"]).isRequired, + onSuccess: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index cf98333..99d674b 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -18,7 +18,11 @@ export { default as ModelSelectModal } from "./ModelSelectModal"; export { default as ManualConfigModal } from "./ManualConfigModal"; export { default as UsageStats } from "./UsageStats"; export { default as RequestLogger } from "./RequestLogger"; +export { default as KiroAuthModal } from "./KiroAuthModal"; +export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper"; +export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal"; // Layouts export * from "./layouts"; +