mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Integrated into release/v3.8.2
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user