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:
Max Peintner
2025-10-27 14:34:39 +01:00
committed by GitHub
co-authored by Ramon
parent 5d75e41d00
commit 9dc127ddb5
23 changed files with 1589 additions and 652 deletions
@@ -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>
);
}
-75
View File
@@ -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>
);
}