fix(kiro): replace broken social OAuth with device flow (#2471) (#2524)

Integrated into release/v3.8.2
This commit is contained in:
Container
2026-05-21 23:09:20 +02:00
committed by GitHub
parent f4534c12e6
commit c293bc773d
5 changed files with 161 additions and 179 deletions

View File

@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { generatePKCE } from "@/lib/oauth/utils/pkce";
import { KiroService } from "@/lib/oauth/services/kiro";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
const KIRO_AUTH_SERVICE = "https://prod.us-east-1.auth.desktop.kiro.dev";
/**
* 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
* Initiate Google/GitHub social login via device flow.
* Returns a verification URL for the user to open in their browser.
*/
export async function GET(request) {
if ((await isAuthRequired(request)) && !(await isAuthenticated(request))) {
@@ -15,7 +15,7 @@ export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider"); // "google" or "github"
const provider = searchParams.get("provider");
if (!provider || !["google", "github"].includes(provider)) {
return NextResponse.json(
@@ -24,17 +24,30 @@ export async function GET(request) {
);
}
// Generate PKCE for social auth
const { codeVerifier, codeChallenge, state } = generatePKCE();
const loginProvider = provider === "google" ? "Google" : "Github";
const kiroService = new KiroService();
const authUrl = kiroService.buildSocialLoginUrl(provider, codeChallenge, state);
const response = await fetch(`${KIRO_AUTH_SERVICE}/oauth/device/authorization`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: "kiro-cli",
loginProvider,
}),
});
if (!response.ok) {
const error = await response.text();
return NextResponse.json({ error: `Device authorization failed: ${error}` }, { status: 502 });
}
const data = await response.json();
return NextResponse.json({
authUrl,
state,
codeVerifier,
codeChallenge,
authUrl: data.verificationUriComplete,
deviceCode: data.deviceCode,
userCode: data.userCode,
expiresIn: Math.floor((data.expiresInMilliseconds || 300000) / 1000),
interval: Math.floor((data.intervalInMilliseconds || 5000) / 1000),
provider,
});
} catch (error) {

View File

@@ -3,14 +3,14 @@ import { KiroService } from "@/lib/oauth/services/kiro";
import { createProviderConnection, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { kiroSocialExchangeSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
const KIRO_AUTH_SERVICE = "https://prod.us-east-1.auth.desktop.kiro.dev";
/**
* 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
* Poll device code for tokens (Google/GitHub social login device flow).
* Frontend calls this repeatedly until authorization completes.
*/
export async function POST(request: Request) {
if ((await isAuthRequired(request)) && !(await isAuthenticated(request))) {
@@ -33,61 +33,57 @@ export async function POST(request: Request) {
}
try {
const validation = validateBody(kiroSocialExchangeSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
const { deviceCode, provider } = rawBody;
if (!deviceCode || !provider) {
return NextResponse.json({ error: "Missing deviceCode or provider" }, { status: 400 });
}
const response = await fetch(`${KIRO_AUTH_SERVICE}/oauth/device/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode, clientId: "kiro-cli" }),
});
const data = await response.json();
if (!response.ok || data.error === "authorization_pending" || data.error === "slow_down") {
return NextResponse.json({
pending: true,
error: data.error || "authorization_pending",
});
}
if (!data.accessToken && !data.refreshToken) {
return NextResponse.json({
pending: true,
error: data.error || "no_tokens",
});
}
const { code, codeVerifier, provider } = validation.data;
const kiroService = new KiroService();
const email = kiroService.extractEmailFromJWT(data.accessToken);
// Exchange code for tokens (redirect_uri handled internally)
const tokenData = await kiroService.exchangeSocialCode(code, codeVerifier);
const providerSpecificData: Record<string, any> = {
authMethod: "imported",
provider: provider.charAt(0).toUpperCase() + provider.slice(1),
};
// Register an independent OIDC client for this connection so multiple Kiro accounts
// do not share a single backend session (#2328). Failure is non-fatal; the
// connection will degrade to the shared social-auth refresh path.
let oidcRegistration: {
clientId?: string;
clientSecret?: string;
clientSecretExpiresAt?: number;
} = {};
try {
oidcRegistration = await kiroService.registerClient();
} catch (err) {
console.warn("[kiro social-exchange] registerClient failed, continuing without it:", err);
if (data.profileArn) {
providerSpecificData.profileArn = data.profileArn;
}
// Extract email from JWT if available
const email = kiroService.extractEmailFromJWT(tokenData.accessToken);
// Save to database
const connection: any = await createProviderConnection({
provider: "kiro",
authType: "oauth",
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresAt: new Date(Date.now() + (data.expiresIn || 3600) * 1000).toISOString(),
email: email || null,
providerSpecificData: {
profileArn: tokenData.profileArn,
authMethod: provider, // "google" or "github"
provider: provider.charAt(0).toUpperCase() + provider.slice(1),
...(oidcRegistration.clientId
? {
clientId: oidcRegistration.clientId,
clientSecret: oidcRegistration.clientSecret,
region: "us-east-1",
...(oidcRegistration.clientSecretExpiresAt
? { clientSecretExpiresAt: oidcRegistration.clientSecretExpiresAt }
: {}),
}
: {}),
},
providerSpecificData,
testStatus: "active",
});
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
return NextResponse.json({
@@ -104,14 +100,10 @@ export async function POST(request: Request) {
}
}
/**
* 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) {

View File

@@ -69,7 +69,7 @@ export async function POST(request: Request) {
// Re-hydrate the in-memory Global System Prompt config — the migration writes it to
// the DB but the in-memory state would stay stale until a restart otherwise (#2470).
const importedSettings = getSettings();
const importedSettings = await getSettings();
if (importedSettings.systemPrompt) {
setSystemPromptConfig(importedSettings.systemPrompt);
}

View File

@@ -174,25 +174,21 @@ export default function KiroAuthModal({
</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">Google Account</h3>
<p className="text-sm text-text-muted">
Login with your Google account (manual callback).
</p>
<p className="text-sm text-text-muted">Login with your Google account.</p>
</div>
</div>
</button>
{/* GitHub Social Login - HIDDEN */}
{/* GitHub Social Login */}
<button
onClick={() => handleMethodSelect("social-github")}
className="hidden w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
onClick={() => handleSocialLogin("github")}
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">code</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">GitHub Account</h3>
<p className="text-sm text-text-muted">
Login with your GitHub account (manual callback).
</p>
<p className="text-sm text-text-muted">Login with your GitHub account.</p>
</div>
</div>
</button>

View File

@@ -1,10 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import Modal from "./Modal";
import Button from "./Button";
import Input from "./Input";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
type KiroSocialOAuthModalProps = {
isOpen: boolean;
@@ -14,10 +12,6 @@ type KiroSocialOAuthModalProps = {
onClose: () => void;
};
/**
* Kiro Social OAuth Modal (Google/GitHub)
* Handles manual callback URL flow for social login
*/
export default function KiroSocialOAuthModal({
isOpen,
provider,
@@ -25,14 +19,12 @@ export default function KiroSocialOAuthModal({
onSuccess,
onClose,
}: KiroSocialOAuthModalProps) {
const [step, setStep] = useState("loading"); // loading | input | success | error
const [step, setStep] = useState<"loading" | "polling" | "success" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const [userCode, setUserCode] = useState("");
const [authUrl, setAuthUrl] = useState("");
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
const [error, setError] = useState(null);
const { copied, copy } = useCopyToClipboard();
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Initialize auth flow
useEffect(() => {
if (!isOpen || !provider) return;
@@ -45,69 +37,55 @@ export default function KiroSocialOAuthModal({
const data = await res.json();
if (!res.ok) {
throw new Error(data.error);
throw new Error(data.error || "Failed to start authorization");
}
setAuthData(data);
setAuthUrl(data.authUrl);
setStep("input");
setUserCode(data.userCode || "");
setAuthUrl(data.authUrl || "");
setStep("polling");
// Auto-open browser
window.open(data.authUrl, "kiro_social_auth");
} catch (err) {
const interval = (data.interval || 5) * 1000;
pollRef.current = setInterval(async () => {
try {
const pollRes = await fetch("/api/oauth/kiro/social-exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode: data.deviceCode, provider }),
});
const pollData = await pollRes.json();
if (pollData.success) {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
setStep("success");
onSuccess?.();
}
} catch {
// Network error, keep polling
}
}, interval);
} catch (err: any) {
setError(err.message);
setStep("error");
}
};
initAuth();
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [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 handleClose = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
onClose();
};
const providerName = provider === "google" ? "Google" : "GitHub";
@@ -116,11 +94,10 @@ export default function KiroSocialOAuthModal({
<Modal
isOpen={isOpen}
title={`Connect ${providerLabel} via ${providerName}`}
onClose={onClose}
onClose={handleClose}
size="lg"
>
<div className="flex flex-col gap-4">
{/* Loading */}
{step === "loading" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
@@ -133,50 +110,58 @@ export default function KiroSocialOAuthModal({
</div>
)}
{/* Manual Input Step */}
{step === "input" && (
<>
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
<div className="flex gap-2">
<Input value={authUrl} readOnly className="flex-1 font-mono text-xs" />
<Button
variant="secondary"
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authUrl, "auth_url")}
{step === "polling" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-pulse">
open_in_browser
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Open this link in an Incognito window</h3>
<p className="text-sm text-text-muted mb-3">
Use an Incognito/Private window to avoid session conflicts with existing accounts.
</p>
{authUrl && (
<div className="mb-4">
<div className="flex items-center gap-2 justify-center">
<a
href={authUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-primary underline break-all max-w-md inline-block"
>
Copy
</Button>
{authUrl.length > 80 ? authUrl.slice(0, 80) + "..." : authUrl}
</a>
<button
onClick={() => navigator.clipboard.writeText(authUrl)}
className="shrink-0 p-1 rounded hover:bg-sidebar"
title="Copy link"
>
<span className="material-symbols-outlined text-base">content_copy</span>
</button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
<p className="text-xs text-text-muted mb-2">
After authorization, copy the full URL from your browser address bar.
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="kiro://kiro.kiroAgent/authenticate-success?code=..."
className="font-mono text-xs"
/>
)}
{userCode && (
<div className="mb-4">
<p className="text-xs text-text-muted mb-1">Verification code</p>
<p className="font-mono text-2xl font-bold tracking-widest">{userCode}</p>
</div>
)}
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined text-base animate-spin">
progress_activity
</span>
Waiting for authorization...
</div>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
Connect
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
<div className="mt-6">
<Button onClick={handleClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</>
</div>
)}
{/* Success */}
{step === "success" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
@@ -188,13 +173,12 @@ export default function KiroSocialOAuthModal({
<p className="text-sm text-text-muted mb-4">
Your {providerLabel} account via {providerName} has been connected.
</p>
<Button onClick={onClose} fullWidth>
<Button onClick={handleClose} fullWidth>
Done
</Button>
</div>
)}
{/* Error */}
{step === "error" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
@@ -203,11 +187,8 @@ export default function KiroSocialOAuthModal({
<h3 className="text-lg font-semibold mb-2">Connection Failed</h3>
<p className="text-sm text-red-600 mb-4">{error}</p>
<div className="flex gap-2">
<Button onClick={() => setStep("input")} variant="secondary" fullWidth>
Try Again
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
<Button onClick={handleClose} variant="ghost" fullWidth>
Close
</Button>
</div>
</div>