mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-12 09:05:12 +00:00
fix(login): Prevent double execution of IDP callback token and improve architecture (#10948)
Closes #10828 # Which Problems Are Solved The IDP callback flow was calling retrieveIDPIntent() twice, causing single-use token failures with error: "Intent Token is invalid". This occurred due to Next.js 15's dynamicIO feature triggering double renders # How the Problems Are Solved Completely refactored the IDP callback architecture to ensure single-use tokens are consumed exactly once: - Centralized Business Logic: Moved all IDP callback logic into a single server action (processIDPCallback) that: - Consumes the token once - Handles all 6 business scenarios (login, linking, auto-linking, auto-creation, manual registration, account not found) - Integrates session creation in the same action - Returns `{ redirect?: string; error?: string }` for client-side navigation - Client Component Invocation: Created `IdpProcessHandler` client component that: - Calls the server action from browser context (enables cookie modification) - Prevents double execution with useRef - Handles loading states and error display - Clean Architecture: - Removed 403-line success page with complex logic - Removed component files from `/components/idps/pages/` folder - Moved all UI directly into server pages - Created dedicated result pages with minimal params # Additional Changes - Added translations to all 8 supported languages --------- Co-authored-by: Ramon <[email protected]>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { processIDPCallback } from "@/lib/server/idp-intent";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { Spinner } from "./spinner";
|
||||
|
||||
type Props = {
|
||||
provider: string;
|
||||
id: string;
|
||||
token: string;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
link?: string;
|
||||
postErrorRedirectUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client component that handles IDP callback processing.
|
||||
* Must be client-side to allow cookie modifications via server actions.
|
||||
*/
|
||||
export function IdpProcessHandler({ provider, id, token, requestId, organization, link, postErrorRedirectUrl }: Props) {
|
||||
const t = useTranslations("idp");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const executedRef = useRef(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent double execution in React Strict Mode
|
||||
if (executedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
executedRef.current = true;
|
||||
|
||||
console.log("[IDP Process Handler] Starting IDP callback processing from client");
|
||||
|
||||
processIDPCallback({
|
||||
provider,
|
||||
id,
|
||||
token,
|
||||
requestId,
|
||||
organization,
|
||||
link,
|
||||
postErrorRedirectUrl,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.error) {
|
||||
console.error("[IDP Process Handler] Error:", result.error);
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.redirect) {
|
||||
console.log("[IDP Process Handler] Redirecting to:", result.redirect);
|
||||
router.push(result.redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(t("processing.noRedirect"));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[IDP Process Handler] Unexpected error:", err);
|
||||
setError(err instanceof Error ? err.message : t("processing.unexpectedError"));
|
||||
setLoading(false);
|
||||
});
|
||||
}, [provider, id, token, requestId, organization, link, postErrorRedirectUrl, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Spinner className="h-8 w-8" />
|
||||
<p className="text-sm text-gray-600">{t("processing.message")}</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="max-w-md py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CreateNewSessionCommand, createNewSessionFromIdpIntent } from "@/lib/server/idp";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { Spinner } from "./spinner";
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
// organization: string;
|
||||
idpIntent: {
|
||||
idpIntentId: string;
|
||||
idpIntentToken: string;
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function IdpSignin({ userId, idpIntent: { idpIntentId, idpIntentToken }, requestId }: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const executedRef = useRef(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent double execution in React Strict Mode
|
||||
if (executedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
executedRef.current = true;
|
||||
let request: CreateNewSessionCommand = {
|
||||
userId,
|
||||
idpIntent: {
|
||||
idpIntentId,
|
||||
idpIntentToken,
|
||||
},
|
||||
};
|
||||
|
||||
if (requestId) {
|
||||
request = { ...request, requestId: requestId };
|
||||
}
|
||||
|
||||
createNewSessionFromIdpIntent(request)
|
||||
.then((response) => {
|
||||
if (response && "error" in response && response?.error) {
|
||||
setError(response?.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response?.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError("An internal error occurred");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
{loading && <Spinner className="h-5 w-5" />}
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete";
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { AddHumanUserRequest } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function completeIDP({
|
||||
idpUserId,
|
||||
idpId,
|
||||
idpUserName,
|
||||
addHumanUser,
|
||||
requestId,
|
||||
organization,
|
||||
branding,
|
||||
idpIntent,
|
||||
}: {
|
||||
idpUserId: string;
|
||||
idpId: string;
|
||||
idpUserName: string;
|
||||
addHumanUser?: AddHumanUserRequest;
|
||||
requestId?: string;
|
||||
organization: string;
|
||||
branding?: BrandingSettings;
|
||||
idpIntent: {
|
||||
idpIntentId: string;
|
||||
idpIntentToken: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="completeRegister.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="completeRegister.description" namespace="idp" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<RegisterFormIDPIncomplete
|
||||
idpUserId={idpUserId}
|
||||
idpId={idpId}
|
||||
idpUserName={idpUserName}
|
||||
defaultValues={{
|
||||
email: addHumanUser?.email?.email || "",
|
||||
firstname: addHumanUser?.profile?.givenName || "",
|
||||
lastname: addHumanUser?.profile?.familyName || "",
|
||||
}}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
idpIntent={idpIntent}
|
||||
/>
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { Alert, AlertType } from "../../alert";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function linkingFailed(branding?: BrandingSettings, error?: string) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="linkingError.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="linkingError.description" namespace="idp" />
|
||||
</p>
|
||||
{error && <div className="w-full">{<Alert type={AlertType.ALERT}>{error}</Alert>}</div>}
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { IdpSignin } from "../../idp-signin";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function linkingSuccess(
|
||||
userId: string,
|
||||
idpIntent: { idpIntentId: string; idpIntentToken: string },
|
||||
requestId?: string,
|
||||
branding?: BrandingSettings,
|
||||
) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="linkingSuccess.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="linkingSuccess.description" namespace="idp" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<IdpSignin userId={userId} idpIntent={idpIntent} requestId={requestId} />
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { Alert, AlertType } from "../../alert";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function loginFailed(branding?: BrandingSettings, error?: string) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="loginError.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="loginError.description" namespace="idp" />
|
||||
</p>
|
||||
{error && <div className="w-full">{<Alert type={AlertType.ALERT}>{error}</Alert>}</div>}
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { IdpSignin } from "../../idp-signin";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function loginSuccess(
|
||||
userId: string,
|
||||
idpIntent: { idpIntentId: string; idpIntentToken: string },
|
||||
requestId?: string,
|
||||
branding?: BrandingSettings,
|
||||
) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="loginSuccess.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="loginSuccess.description" namespace="idp" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<IdpSignin userId={userId} idpIntent={idpIntent} requestId={requestId} />
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user