fix(login): enable explicit IDP linking using existing session id (#11225)

Closes #11191 

# Which Problems Are Solved

This PR fixes the IDP linking for accounts with no authentication
method.
We strictly validate session ownership and generate a hash of the
sessionId + the user's fingerprintId cookie. This hash is passed to the
IDP as linkFingerprint.

This PR addtionally addresses TypeScript errors regarding unknown types
in catch blocks within the IDP intent processing logic. It ensures that
errors occurring during IDP linking are safely handled, and that
meaningful error messages are propagated to the client-side redirect
URLs.

# How the Problems Are Solved

- Safely handle unknown errors: Updated catch blocks in
`processIDPCallback` to check if the caught object is an instance of
Error before accessing error.message.
- Fix "IDP Taken" check: Implemented a safe check for the gRPC error
code 6 (Already Exists) by casting the error, resolving the Property
'code' does not exist on type 'unknown' build error.
- Error Propagation: The specific error message is now passed to the
linking-failed page params, allowing for better user feedback instead of
generic errors.
This commit is contained in:
Max Peintner
2025-12-30 14:32:37 +00:00
committed by GitHub
parent d9ecaec6bb
commit 7963cdd713
14 changed files with 1319 additions and 1036 deletions
+2 -2
View File
@@ -59,8 +59,8 @@
"@testing-library/react": "^16.3.0",
"@types/ms": "2.1.0",
"@types/node": "^22.14.1",
"@types/react": "19.1.2",
"@types/react-dom": "19.1.2",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -41,50 +41,44 @@ export default async function Page(props: { searchParams: Promise<Record<string
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function getAuthMethodsAndUser(
serviceUrl: string,
session?: Session,
) {
async function getAuthMethodsAndUser(session?: Session) {
const userId = session?.factors?.user?.id;
if (!userId) {
throw Error("Could not get user id from session");
}
return listAuthenticationMethodTypes({ serviceConfig, userId,
}).then((methods) => {
return getUserByID({ serviceConfig, userId }).then((user) => {
const methods = await listAuthenticationMethodTypes({ serviceConfig, userId });
const user = await getUserByID({ serviceConfig, userId });
const humanUser = user.user?.type.case === "human" ? user.user?.type.value : undefined;
return {
id: session?.id,
factors: session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: humanUser?.phone?.isVerified ?? false,
emailVerified: humanUser?.email?.isVerified ?? false,
expirationDate: session?.expirationDate,
};
});
});
}
async function loadSessionByLoginname(loginName?: string, organization?: string) {
return loadMostRecentSession({ serviceConfig, sessionParams: {
const session = await loadMostRecentSession({
serviceConfig,
sessionParams: {
loginName,
organization,
},
}).then((session) => {
return getAuthMethodsAndUser(serviceConfig.baseUrl, session);
});
return getAuthMethodsAndUser(session);
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ serviceConfig, sessionId: recent.id,
sessionToken: recent.token,
}).then((sessionResponse) => {
return getAuthMethodsAndUser(serviceConfig.baseUrl, sessionResponse.session);
});
const sessionResponse = await getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token });
return getAuthMethodsAndUser(sessionResponse.session);
}
if (!sessionWithData || !sessionWithData.factors || !sessionWithData.factors.user) {
@@ -95,10 +89,11 @@ export default async function Page(props: { searchParams: Promise<Record<string
);
}
const branding = await getBrandingSettings({ serviceConfig, organization: sessionWithData.factors.user?.organizationId,
});
const branding = await getBrandingSettings({ serviceConfig, organization: sessionWithData.factors.user?.organizationId });
const loginSettings = await getLoginSettings({ serviceConfig, organization: sessionWithData.factors.user?.organizationId,
const loginSettings = await getLoginSettings({
serviceConfig,
organization: sessionWithData.factors.user?.organizationId,
});
// check if user was verified recently
@@ -122,10 +117,10 @@ export default async function Page(props: { searchParams: Promise<Record<string
redirect(`/verify?` + params);
}
const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: sessionWithData.factors?.user?.organizationId,
const { identityProviders } = await getActiveIdentityProviders({
serviceConfig,
orgId: sessionWithData.factors?.user?.organizationId,
linking_allowed: true,
}).then((resp) => {
return resp.identityProviders;
});
const params = new URLSearchParams({
@@ -185,7 +180,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
identityProviders={identityProviders}
requestId={requestId}
organization={sessionWithData.factors?.user?.organizationId}
linkOnly={true} // tell the callback function to just link the IDP and not login, to get an error when user is already available
sessionId={sessionWithData.id} // tell the callback function to link the IDP
></SignInWithIdp>
</>
)}
@@ -17,17 +17,16 @@ export default async function LinkingFailedPage(props: {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const branding = await getBrandingSettings({ serviceConfig, organization,
});
const branding = await getBrandingSettings({ serviceConfig, organization });
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="linkingFailed.title" namespace="idp" />
<Translated i18nKey="title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="linkingFailed.description" namespace="idp" />
<p className="ztdl-p text-center">
<Translated i18nKey="errors.linkingFailed" namespace="idp" />
</p>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
@@ -13,7 +13,7 @@ export default async function ProcessPage(props: {
const searchParams = await props.searchParams;
const { provider } = params;
const { id, token, requestId, organization, link, postErrorRedirectUrl } = searchParams;
const { id, token, requestId, organization, link, postErrorRedirectUrl, linkToSessionId, linkFingerprint } = searchParams;
// Validate required parameters before passing to client component
if (!id || !token) {
@@ -28,6 +28,8 @@ export default async function ProcessPage(props: {
requestId={requestId}
organization={organization}
link={link}
sessionId={linkToSessionId}
linkFingerprint={linkFingerprint}
postErrorRedirectUrl={postErrorRedirectUrl}
/>
);
@@ -1,6 +1,6 @@
"use client";
import { RadioGroup } from "@headlessui/react";
import { Label, Radio, RadioGroup } from "@headlessui/react";
import { Translated } from "./translated";
export enum AuthenticationMethod {
@@ -8,10 +8,7 @@ export enum AuthenticationMethod {
Password = "password",
}
export const methods = [
AuthenticationMethod.Passkey,
AuthenticationMethod.Password,
];
export const methods = [AuthenticationMethod.Passkey, AuthenticationMethod.Password];
export function AuthenticationMethodRadio({
selected,
@@ -24,27 +21,21 @@ export function AuthenticationMethodRadio({
<div className="w-full">
<div className="mx-auto w-full max-w-md">
<RadioGroup value={selected} onChange={selectionChanged}>
<RadioGroup.Label className="sr-only">Server size</RadioGroup.Label>
<Label className="sr-only">Server size</Label>
<div className="flex flex-row space-x-4">
{methods.map((method) => (
<RadioGroup.Option
<Radio
key={method}
value={method}
data-testid={method + "-radio"}
className={({ active, checked }) =>
`${
active
? "ring-2 ring-primary-light-500 ring-opacity-60 dark:ring-white/20"
: ""
} ${
className={({ focus, checked }) =>
`${focus ? "ring-2 ring-primary-light-500 ring-opacity-60 dark:ring-white/20" : ""} ${
checked
? "bg-background-light-400 ring-2 ring-primary-light-500 dark:bg-background-dark-400 dark:ring-primary-dark-500"
: "bg-background-light-400 dark:bg-background-dark-400"
} boder-divider-light relative flex h-full flex-1 cursor-pointer rounded-lg border px-5 py-4 hover:shadow-lg focus:outline-none dark:border-divider-dark dark:hover:bg-white/10`
}
>
{({ checked }) => (
<>
<div className="flex w-full flex-col items-center text-sm">
{method === "passkey" && (
<svg
@@ -63,36 +54,22 @@ export function AuthenticationMethodRadio({
</svg>
)}
{method === "password" && (
<svg
className="mb-3 h-8 w-8 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<svg className="mb-3 h-8 w-8 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>form-textbox-password</title>
<path d="M17,7H22V17H17V19A1,1 0 0,0 18,20H20V22H17.5C16.95,22 16,21.55 16,21C16,21.55 15.05,22 14.5,22H12V20H14A1,1 0 0,0 15,19V5A1,1 0 0,0 14,4H12V2H14.5C15.05,2 16,2.45 16,3C16,2.45 16.95,2 17.5,2H20V4H18A1,1 0 0,0 17,5V7M2,7H13V9H4V15H13V17H2V7M20,15V9H17V15H20M8.5,12A1.5,1.5 0 0,0 7,10.5A1.5,1.5 0 0,0 5.5,12A1.5,1.5 0 0,0 7,13.5A1.5,1.5 0 0,0 8.5,12M13,10.89C12.39,10.33 11.44,10.38 10.88,11C10.32,11.6 10.37,12.55 11,13.11C11.55,13.63 12.43,13.63 13,13.11V10.89Z" />
</svg>
)}
<RadioGroup.Label
as="p"
className={`font-medium ${checked ? "" : ""}`}
>
<Label>
{method === AuthenticationMethod.Passkey && (
<Translated
i18nKey="methods.passkey"
namespace="register"
/>
<Translated i18nKey="methods.passkey" namespace="register" />
)}
{method === AuthenticationMethod.Password && (
<Translated
i18nKey="methods.password"
namespace="register"
/>
<Translated i18nKey="methods.password" namespace="register" />
)}
</RadioGroup.Label>
</Label>
</div>
</>
)}
</RadioGroup.Option>
</Radio>
))}
</div>
</RadioGroup>
@@ -14,6 +14,8 @@ type Props = {
requestId?: string;
organization?: string;
link?: string;
sessionId?: string;
linkFingerprint?: string;
postErrorRedirectUrl?: string;
};
@@ -21,7 +23,17 @@ type Props = {
* 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) {
export function IdpProcessHandler({
provider,
id,
token,
requestId,
organization,
link,
sessionId,
linkFingerprint,
postErrorRedirectUrl,
}: Props) {
const t = useTranslations("idp");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -44,7 +56,8 @@ export function IdpProcessHandler({ provider, id, token, requestId, organization
token,
requestId,
organization,
link,
sessionId,
linkFingerprint,
postErrorRedirectUrl,
})
.then((result) => {
@@ -69,7 +82,7 @@ export function IdpProcessHandler({ provider, id, token, requestId, organization
setError(err instanceof Error ? err.message : t("processing.unexpectedError"));
setLoading(false);
});
}, [provider, id, token, requestId, organization, link, postErrorRedirectUrl, router]);
}, [provider, id, token, requestId, organization, link, sessionId, postErrorRedirectUrl, router]);
return (
<div className="flex min-h-screen items-center justify-center">
+2 -3
View File
@@ -141,12 +141,11 @@ export function SessionItem({ session, reload, requestId }: { session: Session;
<XCircleIcon
className="h-5 w-5 opacity-50 transition-all hover:opacity-100 group-hover:block sm:hidden"
onClick={(event) => {
onClick={async (event) => {
event.preventDefault();
event.stopPropagation();
clearSessionId(session.id).then(() => {
await clearSessionId(session.id);
reload();
});
}}
/>
</div>
@@ -19,7 +19,7 @@ export interface SignInWithIDPProps {
identityProviders: IdentityProvider[];
requestId?: string;
organization?: string;
linkOnly?: boolean;
sessionId?: string;
postErrorRedirectUrl?: string;
showLabel?: boolean;
}
@@ -28,7 +28,7 @@ export function SignInWithIdp({
identityProviders,
requestId,
organization,
linkOnly,
sessionId,
postErrorRedirectUrl,
showLabel = true,
}: Readonly<SignInWithIDPProps>) {
@@ -59,7 +59,7 @@ export function SignInWithIdp({
<input type="hidden" name="provider" value={idpTypeToSlug(type)} />
<input type="hidden" name="requestId" value={requestId} />
<input type="hidden" name="organization" value={organization} />
<input type="hidden" name="linkOnly" value={linkOnly ? "true" : "false"} />
{sessionId && <input type="hidden" name="sessionId" value={sessionId} />}
{postErrorRedirectUrl && <input type="hidden" name="postErrorRedirectUrl" value={postErrorRedirectUrl} />}
<Component key={id} name={name} />
</form>
+59 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { processIDPCallback } from "./idp-intent";
import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import crypto from "crypto";
// Mock all the dependencies
vi.mock("next/headers", () => ({
@@ -9,6 +10,16 @@ vi.mock("next/headers", () => ({
vi.mock("@zitadel/client", () => ({
create: vi.fn((schema: any, data: any) => data),
ConnectError: class extends Error {
code: any;
constructor(message: string, code: any) {
super(message);
this.code = code;
}
},
Code: {
AlreadyExists: 6,
},
}));
vi.mock("../service-url", () => ({
@@ -27,16 +38,25 @@ vi.mock("../zitadel", () => ({
getActiveIdentityProviders: vi.fn(),
getUserByID: vi.fn(),
getDefaultOrg: vi.fn(),
getSession: vi.fn(),
}));
vi.mock("./idp", () => ({
createNewSessionFromIdpIntent: vi.fn(),
}));
vi.mock("@/lib/cookies", () => ({
getSessionCookieById: vi.fn(),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
vi.mock("../fingerprint", () => ({
getFingerprintIdCookie: vi.fn(),
}));
describe("processIDPCallback", () => {
// Mock modules
let mockHeaders: any;
@@ -52,7 +72,10 @@ describe("processIDPCallback", () => {
let mockGetActiveIdentityProviders: any;
let mockGetUserByID: any;
let mockGetDefaultOrg: any;
let mockGetSession: any;
let mockCreateNewSessionFromIdpIntent: any;
let mockGetSessionCookieById: any;
let mockGetFingerprintIdCookie: any;
const defaultParams = {
provider: "google",
@@ -124,8 +147,11 @@ describe("processIDPCallback", () => {
getActiveIdentityProviders,
getUserByID,
getDefaultOrg,
getSession,
} = await import("../zitadel");
const { createNewSessionFromIdpIntent } = await import("./idp");
const { getSessionCookieById } = await import("@/lib/cookies");
const { getFingerprintIdCookie } = await import("../fingerprint");
// Setup mocks
mockHeaders = vi.mocked(headers);
@@ -141,7 +167,11 @@ describe("processIDPCallback", () => {
mockGetActiveIdentityProviders = vi.mocked(getActiveIdentityProviders);
mockGetUserByID = vi.mocked(getUserByID);
mockGetDefaultOrg = vi.mocked(getDefaultOrg);
mockGetSession = vi.mocked(getSession);
mockCreateNewSessionFromIdpIntent = vi.mocked(createNewSessionFromIdpIntent);
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
mockGetFingerprintIdCookie = vi.mocked(getFingerprintIdCookie);
// Default mock implementations
mockHeaders.mockResolvedValue({} as any);
@@ -334,11 +364,37 @@ describe("processIDPCallback", () => {
});
describe("CASE 2: Link IDP to existing user", () => {
const sessionId = "session123";
const fingerprintValue = "fp123";
const linkFingerprint = crypto
.createHash("sha256")
.update(sessionId + fingerprintValue)
.digest("hex");
const linkParams = {
...defaultParams,
link: "true",
sessionId,
linkFingerprint,
};
beforeEach(() => {
mockGetFingerprintIdCookie.mockResolvedValue({ name: "fingerprintId", value: fingerprintValue });
mockRetrieveIDPIntent.mockResolvedValue({
...defaultIntent,
userId: undefined,
});
mockGetSessionCookieById.mockResolvedValue({ id: sessionId, token: "token123" });
mockGetSession.mockResolvedValue({
session: {
factors: {
user: {
id: "user123",
},
},
},
});
});
test("should link IDP and create session when linking is allowed", async () => {
mockGetIDPByID.mockResolvedValue({
...defaultIdp,
@@ -352,6 +408,8 @@ describe("processIDPCallback", () => {
const result = await processIDPCallback(linkParams);
expect(mockGetSessionCookieById).toHaveBeenCalledWith({ sessionId: "session123" });
expect(mockAddIDPLink).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
idp: {
+505 -312
View File
@@ -4,7 +4,6 @@ import { getServiceConfig } from "@/lib/service-url";
import {
retrieveIDPIntent,
getIDPByID,
updateHuman,
addIDPLink,
listUsers,
addHuman,
@@ -13,10 +12,11 @@ import {
getActiveIdentityProviders,
getUserByID,
getDefaultOrg,
updateHuman,
ServiceConfig,
} from "@/lib/zitadel";
import { headers } from "next/headers";
import { create } from "@zitadel/client";
import { Code, ConnectError, create } from "@zitadel/client";
import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb";
import {
@@ -24,8 +24,12 @@ import {
AddHumanUserRequestSchema,
UpdateHumanUserRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getSession } from "@/lib/zitadel";
import { getSessionCookieById } from "@/lib/cookies";
import { createNewSessionFromIdpIntent } from "./idp";
import { getTranslations } from "next-intl/server";
import crypto from "crypto";
import { getFingerprintIdCookie } from "../fingerprint";
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
@@ -44,13 +48,11 @@ async function resolveOrganizationForUser({
const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username);
const suffix = matched?.[1] ?? "";
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix,
});
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix });
const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined;
if (orgToCheckForDiscovery) {
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery,
});
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery });
if (orgLoginSettings?.allowDomainDiscovery) {
return orgToCheckForDiscovery;
}
@@ -69,7 +71,9 @@ async function resolveOrganizationForUser({
* 2. The specific IDP is activated for the organization
*
*/
export async function validateIDPLinkingPermissions({ serviceConfig, userOrganizationId,
export async function validateIDPLinkingPermissions({
serviceConfig,
userOrganizationId,
idpId,
}: {
serviceConfig: ServiceConfig;
@@ -77,17 +81,14 @@ export async function validateIDPLinkingPermissions({ serviceConfig, userOrganiz
idpId: string;
}): Promise<boolean> {
// Check organization login settings
const loginSettings = await getLoginSettings({ serviceConfig, organization: userOrganizationId,
});
const loginSettings = await getLoginSettings({ serviceConfig, organization: userOrganizationId });
if (!loginSettings?.allowExternalIdp) {
return false;
}
// Check if the IDP is activated for the organization and allows linking
const activeIDPs = await getActiveIdentityProviders({ serviceConfig, orgId: userOrganizationId,
linking_allowed: true,
});
const activeIDPs = await getActiveIdentityProviders({ serviceConfig, orgId: userOrganizationId, linking_allowed: true });
const isIDPActive = activeIDPs.identityProviders?.some((idp) => idp.id === idpId);
@@ -98,6 +99,457 @@ export async function validateIDPLinkingPermissions({ serviceConfig, userOrganiz
return true;
}
type IDPIntentResult = Awaited<ReturnType<typeof retrieveIDPIntent>>;
type IDPConfig = Awaited<ReturnType<typeof getIDPByID>>;
interface IDPHandlerContext {
serviceConfig: ServiceConfig;
t: (key: string) => string;
intent: IDPIntentResult;
idp: NonNullable<IDPConfig>;
options: NonNullable<NonNullable<IDPConfig>["config"]>["options"];
params: {
provider: string;
id: string;
token: string;
requestId?: string;
organization?: string;
postErrorRedirectUrl?: string;
sessionId?: string;
linkFingerprint?: string;
};
buildRedirectParams: (additionalParams?: Record<string, string>, includeToken?: boolean) => string;
}
type IDPHandlerResult = { redirect?: string; error?: string } | null;
/**
* CASE 1: Explicit Linking (via sessionId)
* This happens when a logged-in user initiates an IDP flow to link it to their account.
*/
async function resolveUserIdFromSession({
sessionId,
serviceConfig,
provider,
}: {
sessionId: string;
serviceConfig: ServiceConfig;
provider: string;
}) {
try {
const sessionCookie = await getSessionCookieById({ sessionId });
if (!sessionCookie) {
console.warn("[IDP Process] Session for linking not found or invalid");
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
}
const sessionResp = await getSession({
serviceConfig,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
const session = sessionResp.session;
if (!session?.factors?.user?.id) {
console.warn("[IDP Process] Session found but no userId associated for linking.");
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
}
return { userId: session.factors.user.id };
} catch (error) {
console.warn("[IDP Process] Error retrieving session for linking:", error);
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
}
}
async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { sessionId, linkFingerprint, provider } = ctx.params;
const { userId } = ctx.intent;
const { options, serviceConfig, intent, t, buildRedirectParams } = ctx;
if (sessionId && !userId) {
// Intent should not have a userId if it is a linking intent
// 1. Security Check: Verify Fingerprint
const fingerprintCookie = await getFingerprintIdCookie();
if (!linkFingerprint || !fingerprintCookie?.value) {
console.warn("[IDP Process] Missing fingerprint information for linking verification");
return { redirect: `/idp/${provider}/linking-failed?error=session_mismatch` };
}
const expectedHash = crypto
.createHash("sha256")
.update(sessionId + fingerprintCookie.value)
.digest("hex");
if (linkFingerprint !== expectedHash) {
console.warn("[IDP Process] Session linking fingerprint mismatch");
return { redirect: `/idp/${provider}/linking-failed?error=session_mismatch` };
}
// 2. Retrieve Session & Resolve User
const { userId: resolvedUserId, redirect: sessionRedirect } = await resolveUserIdFromSession({
sessionId,
serviceConfig,
provider,
});
if (sessionRedirect || !resolvedUserId) {
return { redirect: sessionRedirect || `/idp/${provider}/linking-failed?error=session_invalid` };
}
console.log("[IDP Process] Resolved userId from session link:", resolvedUserId);
// 3. Perform Linking Logic
if (!options?.isLinkingAllowed) {
console.error("[IDP Process] Linking not allowed by IDP configuration");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=linking_not_allowed` };
}
try {
const targetUser = await getUserByID({ serviceConfig, userId: resolvedUserId });
if (!targetUser || !targetUser.details?.resourceOwner) {
console.error("[IDP Process] User not found or missing organization");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=user_not_found` };
}
const isAllowed = await validateIDPLinkingPermissions({
serviceConfig,
userOrganizationId: targetUser.details.resourceOwner,
idpId: intent.idpInformation!.idpId,
});
if (!isAllowed) {
console.error("[IDP Process] IDP linking validation failed");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` };
}
await addIDPLink({
serviceConfig,
idp: {
id: intent.idpInformation!.idpId,
userId: intent.idpInformation!.userId,
userName: intent.idpInformation!.userName,
},
userId: resolvedUserId,
});
console.log("[IDP Process] IDP linked successfully, creating session");
const sessionResult = await createNewSessionFromIdpIntent({
userId: resolvedUserId,
idpIntent: {
idpIntentId: ctx.params.id,
idpIntentToken: ctx.params.token,
},
requestId: ctx.params.requestId,
organization: ctx.params.organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error linking IDP:", error);
const errorMessage = error instanceof Error ? error.message : t("errors.unknownError");
let params = buildRedirectParams({ error: errorMessage });
if (error instanceof ConnectError && error.code === Code.AlreadyExists) {
params = buildRedirectParams({ error: "external_idp_taken" });
}
return { redirect: `/idp/${provider}/linking-failed?${params}` };
}
}
return null;
}
/**
* CASE 2: User exists and should sign in
*/
async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { sessionId } = ctx.params;
const { userId, updateHumanUser } = ctx.intent;
const { options, serviceConfig, t } = ctx;
if (userId && !sessionId) {
// Auto-update user if enabled
if (options?.isAutoUpdate && updateHumanUser) {
try {
console.log("[IDP Process] Auto-updating user profile");
await updateHuman({
serviceConfig,
request: create(UpdateHumanUserRequestSchema, {
userId,
profile: updateHumanUser.profile,
email: updateHumanUser.email,
phone: updateHumanUser.phone,
}),
});
} catch (error) {
console.warn("[IDP Process] Failed to auto-update user:", error);
// Continue with login even if update fails
}
}
// Create session and handle redirect
console.log("[IDP Process] Creating session for existing user");
const sessionResult = await createNewSessionFromIdpIntent({
userId,
idpIntent: {
idpIntentId: ctx.params.id,
idpIntentToken: ctx.params.token,
},
requestId: ctx.params.requestId,
organization: ctx.params.organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
}
return null;
}
/**
* CASE 3: Auto-linking (search for user and link)
*/
async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { options, intent, serviceConfig, buildRedirectParams, t } = ctx;
const { addHumanUser, idpInformation } = intent;
const { organization, provider } = ctx.params;
if (options?.autoLinking) {
let foundUser;
const email = addHumanUser?.email?.email;
if (options.autoLinking === AutoLinkingOption.EMAIL && email) {
foundUser = await listUsers({ serviceConfig, email, organizationId: organization }).then((response) => {
return response.result ? response.result[0] : null;
});
} else if (options.autoLinking === AutoLinkingOption.USERNAME) {
foundUser = await listUsers({
serviceConfig,
userName: idpInformation!.userName,
organizationId: organization,
}).then((response) => {
return response.result ? response.result[0] : null;
});
} else {
foundUser = await listUsers({
serviceConfig,
userName: idpInformation!.userName,
email,
organizationId: organization,
}).then((response) => {
return response.result ? response.result[0] : null;
});
}
if (foundUser) {
try {
if (!foundUser.details?.resourceOwner) {
console.error("[IDP Process] Found user missing organization information");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=missing_organization` };
}
// Validate IDP linking permissions
const isAllowed = await validateIDPLinkingPermissions({
serviceConfig,
userOrganizationId: foundUser.details.resourceOwner,
idpId: idpInformation!.idpId,
});
if (!isAllowed) {
console.error("[IDP Process] Auto-linking validation failed");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` };
}
await addIDPLink({
serviceConfig,
idp: {
id: idpInformation!.idpId,
userId: idpInformation!.userId,
userName: idpInformation!.userName,
},
userId: foundUser.userId,
});
console.log("[IDP Process] User auto-linked successfully, creating session");
// Create session after auto-linking
const sessionResult = await createNewSessionFromIdpIntent({
userId: foundUser.userId,
idpIntent: {
idpIntentId: ctx.params.id,
idpIntentToken: ctx.params.token,
},
requestId: ctx.params.requestId,
organization: ctx.params.organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error auto-linking user:", error);
const errorMessage = error instanceof Error ? error.message : t("errors.unknownError");
const params = buildRedirectParams({ error: errorMessage });
return { redirect: `/idp/${provider}/linking-failed?${params}` };
}
}
}
return null;
}
/**
* CASE 4: Auto-creation of user
*/
async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { options, intent, serviceConfig, buildRedirectParams, t } = ctx;
const { addHumanUser } = intent;
const { organization, provider } = ctx.params;
if (options?.isAutoCreation && addHumanUser) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
addHumanUser,
serviceConfig,
});
if (!orgToRegisterOn) {
console.error("[IDP Process] Could not determine organization for auto-creation (no default org available)");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=no_organization_context` };
}
const organizationSchema = create(OrganizationSchema, {
org: { case: "orgId", value: orgToRegisterOn },
});
const addHumanUserWithOrganization = create(AddHumanUserRequestSchema, {
...addHumanUser,
organization: organizationSchema,
});
try {
const newUser = await addHuman({ serviceConfig, request: addHumanUserWithOrganization });
console.log("[IDP Process] User auto-created successfully, creating session");
// Create session for newly created user
const sessionResult = await createNewSessionFromIdpIntent({
userId: newUser.userId,
idpIntent: {
idpIntentId: ctx.params.id,
idpIntentToken: ctx.params.token,
},
requestId: ctx.params.requestId,
organization: ctx.params.organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error: unknown) {
console.error("[IDP Process] Error auto-creating user:", error);
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=user_creation_failed` };
}
}
return null;
}
/**
* CASE 5: Manual user creation allowed
*/
async function handleManualCreation(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { options, intent, serviceConfig, buildRedirectParams } = ctx;
const { addHumanUser, idpInformation } = intent;
const { organization, provider } = ctx.params;
if (options?.isCreationAllowed && addHumanUser) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
addHumanUser,
serviceConfig,
});
if (!orgToRegisterOn) {
console.error("[IDP Process] Could not determine organization for registration (no default org available)");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/registration-failed?${params}` };
}
// Store user data for manual registration form
// Note: includeToken=true because the session hasn't been created yet
// The token will be needed when registerUserAndLinkToIDP creates the session
const params = buildRedirectParams(
{
organization: orgToRegisterOn,
idpId: idpInformation!.idpId,
idpUserId: idpInformation!.userId || "",
idpUserName: idpInformation!.userName || "",
// User data for pre-filling form
givenName: addHumanUser.profile?.givenName || "",
familyName: addHumanUser.profile?.familyName || "",
email: addHumanUser.email?.email || "",
},
true,
); // includeToken=true
return { redirect: `/idp/${provider}/complete-registration?${params}` };
}
return null;
}
/**
* CASE 6: No user found and creation not allowed
*/
async function handleNoUserFound(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { buildRedirectParams } = ctx;
console.log("[IDP Process] No matching user and creation not allowed");
const params = buildRedirectParams();
return { redirect: `/idp/${ctx.params.provider}/account-not-found?${params}` };
}
/**
* Server action to process IDP callback and handle ALL business logic.
* This action:
@@ -111,17 +563,20 @@ export async function processIDPCallback({
token,
requestId,
organization,
link,
postErrorRedirectUrl,
sessionId,
linkFingerprint,
}: {
provider: string;
id: string;
token: string;
requestId?: string;
organization?: string;
link?: string;
postErrorRedirectUrl?: string;
sessionId?: string;
linkFingerprint?: string;
}): Promise<{ redirect?: string; error?: string }> {
// ... (headers and config retrieval) ...
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
@@ -146,22 +601,20 @@ export async function processIDPCallback({
});
// Consume the single-use token ONCE
const intent = await retrieveIDPIntent({ serviceConfig, id,
token,
});
const intent = await retrieveIDPIntent({ serviceConfig, id, token });
console.log("[IDP Process] Intent retrieved successfully, processing business logic");
const { idpInformation, userId, addHumanUser, updateHumanUser } = intent;
const { idpInformation } = intent;
// Verify we have IDP info early on
if (!idpInformation) {
console.error("[IDP Process] IDP information missing");
return { redirect: `/idp/${provider}/failure?error=missing_idp_info` };
}
// Get IDP configuration
const idp = await getIDPByID({ serviceConfig, id: idpInformation.idpId,
});
const idp = await getIDPByID({ serviceConfig, id: idpInformation.idpId });
if (!idp) {
return { error: t("errors.idpNotFound") };
@@ -177,6 +630,7 @@ export async function processIDPCallback({
if (requestId) params.set("requestId", requestId);
if (organization) params.set("organization", organization);
if (postErrorRedirectUrl) params.set("postErrorRedirectUrl", postErrorRedirectUrl);
if (sessionId) params.set("linkToSessionId", sessionId); // Include sessionId in redirects
if (additionalParams) {
Object.entries(additionalParams).forEach(([key, value]) => {
@@ -187,304 +641,43 @@ export async function processIDPCallback({
return params.toString();
};
// ============================================
// CASE 1: User exists and should sign in
// ============================================
if (userId && !link) {
// Auto-update user if enabled
if (options?.isAutoUpdate && updateHumanUser) {
try {
await updateHuman({ serviceConfig, request: create(UpdateHumanUserRequestSchema, {
userId: userId,
profile: updateHumanUser.profile,
email: updateHumanUser.email,
phone: updateHumanUser.phone,
}),
});
console.log("[IDP Process] User auto-updated successfully");
} catch (error: unknown) {
console.warn("[IDP Process] Error auto-updating user:", error);
}
}
// Create session and handle redirect
console.log("[IDP Process] Creating session for existing user");
const sessionResult = await createNewSessionFromIdpIntent({
userId,
idpIntent: {
idpIntentId: id,
idpIntentToken: token,
},
requestId,
organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
}
// ============================================
// CASE 2: Link IDP to existing user
// ============================================
if (link && userId) {
if (!options?.isLinkingAllowed) {
console.error("[IDP Process] Linking not allowed by IDP configuration");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=linking_not_allowed` };
}
try {
// Get user to retrieve their organization
const targetUser = await getUserByID({ serviceConfig, userId });
if (!targetUser || !targetUser.details?.resourceOwner) {
console.error("[IDP Process] User not found or missing organization");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=user_not_found` };
}
// Validate IDP linking permissions
const isAllowed = await validateIDPLinkingPermissions({ serviceConfig, userOrganizationId: targetUser.details.resourceOwner,
idpId: idpInformation.idpId,
});
if (!isAllowed) {
console.error("[IDP Process] IDP linking validation failed");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` };
}
await addIDPLink({ serviceConfig, idp: {
id: idpInformation.idpId,
userId: idpInformation.userId,
userName: idpInformation.userName,
},
userId,
});
console.log("[IDP Process] IDP linked successfully, creating session");
// Create session after linking
const sessionResult = await createNewSessionFromIdpIntent({
userId,
idpIntent: {
idpIntentId: id,
idpIntentToken: token,
},
requestId,
organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error linking IDP:", error);
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}` };
}
}
// ============================================
// CASE 3: Auto-linking (search for user and link)
// ============================================
if (options?.autoLinking) {
let foundUser;
const email = addHumanUser?.email?.email;
if (options.autoLinking === AutoLinkingOption.EMAIL && email) {
foundUser = await listUsers({ serviceConfig, email, organizationId: organization }).then((response) => {
return response.result ? response.result[0] : null;
});
} else if (options.autoLinking === AutoLinkingOption.USERNAME) {
foundUser = await listUsers({ serviceConfig, userName: idpInformation.userName,
organizationId: organization,
}).then((response) => {
return response.result ? response.result[0] : null;
});
} else {
foundUser = await listUsers({ serviceConfig, userName: idpInformation.userName,
email,
organizationId: organization,
}).then((response) => {
return response.result ? response.result[0] : null;
});
}
if (foundUser) {
try {
if (!foundUser.details?.resourceOwner) {
console.error("[IDP Process] Found user missing organization information");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=missing_organization` };
}
// Validate IDP linking permissions
const isAllowed = await validateIDPLinkingPermissions({ serviceConfig, userOrganizationId: foundUser.details.resourceOwner,
idpId: idpInformation.idpId,
});
if (!isAllowed) {
console.error("[IDP Process] Auto-linking validation failed");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` };
}
await addIDPLink({ serviceConfig, idp: {
id: idpInformation.idpId,
userId: idpInformation.userId,
userName: idpInformation.userName,
},
userId: foundUser.userId,
});
console.log("[IDP Process] User auto-linked successfully, creating session");
// Create session after auto-linking
const sessionResult = await createNewSessionFromIdpIntent({
userId: foundUser.userId,
idpIntent: {
idpIntentId: id,
idpIntentToken: token,
},
requestId,
organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error auto-linking user:", error);
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/linking-failed?${params}` };
}
}
}
// ============================================
// CASE 4: Auto-creation of user
// ============================================
if (options?.isAutoCreation && addHumanUser) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
addHumanUser,
const ctx: IDPHandlerContext = {
serviceConfig,
});
if (!orgToRegisterOn) {
console.error("[IDP Process] Could not determine organization for auto-creation (no default org available)");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=no_organization_context` };
}
const organizationSchema = create(OrganizationSchema, {
org: { case: "orgId", value: orgToRegisterOn },
});
const addHumanUserWithOrganization = create(AddHumanUserRequestSchema, {
...addHumanUser,
organization: organizationSchema,
});
try {
const newUser = await addHuman({ serviceConfig, request: addHumanUserWithOrganization,
});
console.log("[IDP Process] User auto-created successfully, creating session");
// Create session for newly created user
const sessionResult = await createNewSessionFromIdpIntent({
userId: newUser.userId,
idpIntent: {
idpIntentId: id,
idpIntentToken: token,
},
t,
intent,
idp,
options,
params: {
provider,
id,
token,
requestId,
organization,
});
if ("error" in sessionResult && sessionResult.error) {
console.error("[IDP Process] Error creating session:", sessionResult.error);
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect);
return { redirect: sessionResult.redirect };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error: unknown) {
console.error("[IDP Process] Error auto-creating user:", error);
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=user_creation_failed` };
}
}
// ============================================
// CASE 5: Manual user creation allowed
// ============================================
if (options?.isCreationAllowed && addHumanUser) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
addHumanUser,
serviceConfig,
});
if (!orgToRegisterOn) {
console.error("[IDP Process] Could not determine organization for registration (no default org available)");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/registration-failed?${params}` };
}
// Store user data for manual registration form
// Note: includeToken=true because the session hasn't been created yet
// The token will be needed when registerUserAndLinkToIDP creates the session
const params = buildRedirectParams(
{
organization: orgToRegisterOn,
idpId: idpInformation.idpId,
idpUserId: idpInformation.userId || "",
idpUserName: idpInformation.userName || "",
// User data for pre-filling form
givenName: addHumanUser.profile?.givenName || "",
familyName: addHumanUser.profile?.familyName || "",
email: addHumanUser.email?.email || "",
postErrorRedirectUrl,
sessionId,
linkFingerprint,
},
true,
); // includeToken=true
return { redirect: `/idp/${provider}/complete-registration?${params}` };
buildRedirectParams,
};
const handlers = [
handleExplicitLinking,
handleUserExists,
handleAutoLinking,
handleAutoCreation,
handleManualCreation,
handleNoUserFound,
];
for (const handler of handlers) {
const result = await handler(ctx);
if (result) {
return result;
}
}
// ============================================
// CASE 6: No user found and creation not allowed
// ============================================
console.log("[IDP Process] No matching user and creation not allowed");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/account-not-found?${params}` };
// Should theoretically be unreachable if handleNoUserFound covers the rest
return { error: t("errors.unknown") };
} catch (error: unknown) {
console.error("[IDP Process] Error processing intent:", error);
+25 -3
View File
@@ -12,6 +12,10 @@ vi.mock("next/navigation", () => ({
}),
}));
vi.mock("@/lib/cookies", () => ({
getSessionCookieById: vi.fn(),
}));
vi.mock("../service-url", () => ({
getServiceConfig: vi.fn(),
}));
@@ -25,6 +29,11 @@ vi.mock("../zitadel", () => ({
startIdentityProviderFlow: vi.fn(),
}));
vi.mock("../fingerprint", () => ({
getFingerprintIdCookie: vi.fn(),
getOrSetFingerprintId: vi.fn(),
}));
describe("redirectToIdp", () => {
let mockHeaders: any;
let mockGetServiceUrlFromHeaders: any;
@@ -40,6 +49,8 @@ describe("redirectToIdp", () => {
const { getServiceConfig } = await import("../service-url");
const { getInstanceHost, getPublicHost } = await import("./host");
const { startIdentityProviderFlow } = await import("../zitadel");
const { getFingerprintIdCookie, getOrSetFingerprintId } = await import("../fingerprint");
const { getSessionCookieById } = await import("@/lib/cookies");
// Setup mocks
mockHeaders = vi.mocked(headers);
@@ -47,8 +58,14 @@ describe("redirectToIdp", () => {
mockGetInstanceHost = vi.mocked(getInstanceHost);
mockGetPublicHost = vi.mocked(getPublicHost);
mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow);
const mockGetFingerprintIdCookie = vi.mocked(getFingerprintIdCookie);
const mockGetOrSetFingerprintId = vi.mocked(getOrSetFingerprintId);
const mockGetSessionCookieById = vi.mocked(getSessionCookieById);
// Default mock implementations
mockGetFingerprintIdCookie.mockResolvedValue({ name: "fingerprintId", value: "fp123" });
mockGetOrSetFingerprintId.mockResolvedValue("fp123");
mockGetSessionCookieById.mockResolvedValue({} as any);
mockHeaders.mockResolvedValue({} as any);
mockGetServiceUrlFromHeaders.mockReturnValue({
serviceConfig: { baseUrl: "https://api.example.com" },
@@ -203,7 +220,7 @@ describe("redirectToIdp", () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
formData.append("linkOnly", "true");
formData.append("sessionId", "session123");
formData.append("postErrorRedirectUrl", "/custom-error");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
@@ -220,9 +237,14 @@ describe("redirectToIdp", () => {
const failureUrl = callArgs.urls.failureUrl;
// Both parameters should be present
expect(successUrl).toContain("link=true");
// Both parameters should be present
expect(successUrl).toContain("linkToSessionId=session123");
// We can't easily predict the exact fingerprint hash without mocking crypto in this test,
// but we can check if the parameter key exists
expect(successUrl).toContain("linkFingerprint=");
expect(successUrl).toContain("postErrorRedirectUrl=%2Fcustom-error");
expect(failureUrl).toContain("link=true");
expect(failureUrl).toContain("linkToSessionId=session123");
expect(failureUrl).toContain("linkFingerprint=");
expect(failureUrl).toContain("postErrorRedirectUrl=%2Fcustom-error");
});
+24 -2
View File
@@ -8,9 +8,12 @@ import {
startLDAPIdentityProviderFlow,
ServiceConfig,
} from "@/lib/zitadel";
import crypto from "crypto";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { completeFlowOrGetUrl } from "../client";
import { getSessionCookieById } from "../cookies";
import { getOrSetFingerprintId } from "../fingerprint";
import { getServiceConfig } from "../service-url";
import { checkEmailVerification, checkMFAFactors } from "../verify-helper";
import { createSessionForIdpAndUpdateCookie } from "./cookie";
@@ -25,14 +28,33 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For
const params = new URLSearchParams();
const linkOnly = formData.get("linkOnly") === "true";
const sessionId = formData.get("sessionId") as string;
const requestId = formData.get("requestId") as string;
const organization = formData.get("organization") as string;
const idpId = formData.get("id") as string;
const provider = formData.get("provider") as string;
const postErrorRedirectUrl = formData.get("postErrorRedirectUrl") as string;
if (linkOnly) params.set("link", "true");
if (sessionId) {
try {
// Validate that the requestor owns the session they are trying to link
await getSessionCookieById({ sessionId });
// Get fingerprint (ensure it exists)
const fingerprintId = await getOrSetFingerprintId();
// Create hash to verify intent upon return
const linkFingerprint = crypto
.createHash("sha256")
.update(sessionId + fingerprintId)
.digest("hex");
params.set("linkToSessionId", sessionId);
params.set("linkFingerprint", linkFingerprint);
} catch {
return { error: "Invalid session for linking" };
}
}
if (requestId) params.set("requestId", requestId);
if (organization) params.set("organization", organization);
if (postErrorRedirectUrl) params.set("postErrorRedirectUrl", postErrorRedirectUrl);
+4 -1
View File
@@ -775,7 +775,10 @@ export async function startIdentityProviderFlow({
urls: RedirectURLsJson;
}>): Promise<string | null> {
// Use empty publicHost to avoid issues with redirect URIs pointing to the login UI instead of the zitadel API
const userService: Client<typeof UserService> = await createServiceForHost(UserService, {...serviceConfig, publicHost: ''});
const userService: Client<typeof UserService> = await createServiceForHost(UserService, {
...serviceConfig,
publicHost: "",
});
return userService
.startIdentityProviderIntent({
+603 -603
View File
File diff suppressed because it is too large Load Diff