link user to idp after creation

This commit is contained in:
Max Peintner
2025-06-11 13:38:38 +02:00
parent 9959581666
commit f31fac4c9e
8 changed files with 171 additions and 94 deletions

View File

@@ -10,6 +10,7 @@ import {
addHuman, addHuman,
addIDPLink, addIDPLink,
getBrandingSettings, getBrandingSettings,
getDefaultOrg,
getIDPByID, getIDPByID,
getLoginSettings, getLoginSettings,
getOrgsByDomain, getOrgsByDomain,
@@ -19,6 +20,7 @@ import {
import { ConnectError, create } from "@zitadel/client"; import { ConnectError, create } from "@zitadel/client";
import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb"; import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { import {
AddHumanUserRequest, AddHumanUserRequest,
AddHumanUserRequestSchema, AddHumanUserRequestSchema,
@@ -28,6 +30,41 @@ import { headers } from "next/headers";
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
async function resolveOrganizationForUser({
organization,
addHumanUser,
serviceUrl,
}: {
organization?: string;
addHumanUser?: { username?: string };
serviceUrl: string;
}): Promise<string | undefined> {
if (organization) return organization;
if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) {
const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username);
const suffix = matched?.[1] ?? "";
const orgs = await getOrgsByDomain({
serviceUrl,
domain: suffix,
});
const orgToCheckForDiscovery =
orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined;
if (orgToCheckForDiscovery) {
const orgLoginSettings = await getLoginSettings({
serviceUrl,
organization: orgToCheckForDiscovery,
});
if (orgLoginSettings?.allowDomainDiscovery) {
return orgToCheckForDiscovery;
}
}
}
return undefined;
}
export default async function Page(props: { export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>; searchParams: Promise<Record<string | number | symbol, string | undefined>>;
params: Promise<{ provider: string }>; params: Promise<{ provider: string }>;
@@ -36,7 +73,7 @@ export default async function Page(props: {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "idp" }); const t = await getTranslations({ locale, namespace: "idp" });
const { id, token, requestId, organization, link } = searchParams; let { id, token, requestId, organization, link } = searchParams;
const { provider } = params; const { provider } = params;
const _headers = await headers(); const _headers = await headers();
@@ -47,6 +84,15 @@ export default async function Page(props: {
organization, organization,
}); });
if (!organization) {
const org: Organization | null = await getDefaultOrg({
serviceUrl,
});
if (org) {
organization = org.id;
}
}
if (!provider || !id || !token) { if (!provider || !id || !token) {
return loginFailed(branding, "IDP context missing"); return loginFailed(branding, "IDP context missing");
} }
@@ -180,32 +226,11 @@ export default async function Page(props: {
let newUser; let newUser;
// automatic creation of a user is allowed and data is complete // automatic creation of a user is allowed and data is complete
if (options?.isAutoCreation && addHumanUser) { if (options?.isAutoCreation && addHumanUser) {
let orgToRegisterOn: string | undefined = organization; const orgToRegisterOn = await resolveOrganizationForUser({
organization,
if ( addHumanUser,
!orgToRegisterOn && serviceUrl,
addHumanUser?.username && // username or email? });
ORG_SUFFIX_REGEX.test(addHumanUser.username)
) {
const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username);
const suffix = matched?.[1] ?? "";
// this just returns orgs where the suffix is set as primary domain
const orgs = await getOrgsByDomain({
serviceUrl,
domain: suffix,
});
const orgToCheckForDiscovery =
orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined;
const orgLoginSettings = await getLoginSettings({
serviceUrl,
organization: orgToCheckForDiscovery,
});
if (orgLoginSettings?.allowDomainDiscovery) {
orgToRegisterOn = orgToCheckForDiscovery;
}
}
let addHumanUserWithOrganization: AddHumanUserRequest; let addHumanUserWithOrganization: AddHumanUserRequest;
if (orgToRegisterOn) { if (orgToRegisterOn) {
@@ -244,14 +269,25 @@ export default async function Page(props: {
} }
} else if (options?.isCreationAllowed) { } else if (options?.isCreationAllowed) {
// if no user was found, we will create a new user manually / redirect to the registration page // if no user was found, we will create a new user manually / redirect to the registration page
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
addHumanUser,
serviceUrl,
});
if (!orgToRegisterOn) {
return loginFailed(branding, "No organization found for registration");
}
return completeIDP({ return completeIDP({
branding, branding,
idpIntent: { idpIntentId: id, idpIntentToken: token }, idpIntent: { idpIntentId: id, idpIntentToken: token },
idpInformation, addHumanUser,
organization, organization: orgToRegisterOn,
requestId, requestId,
userId, idpUserId: idpInformation?.userId,
idpId: idpInformation?.idpId,
idpUserName: idpInformation?.userName,
}); });
} }

View File

@@ -1,3 +1,4 @@
import { Alert } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { RegisterForm } from "@/components/register-form"; import { RegisterForm } from "@/components/register-form";
import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { SignInWithIdp } from "@/components/sign-in-with-idp";
@@ -9,7 +10,6 @@ import {
getLegalAndSupportSettings, getLegalAndSupportSettings,
getLoginSettings, getLoginSettings,
getPasswordComplexitySettings, getPasswordComplexitySettings,
retrieveIDPIntent,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
@@ -21,16 +21,9 @@ export default async function Page(props: {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "register" }); const t = await getTranslations({ locale, namespace: "register" });
const tError = await getTranslations({ locale, namespace: "error" });
let { let { firstname, lastname, email, organization, requestId } = searchParams;
firstname,
lastname,
email,
organization,
requestId,
idpIntentId,
idpIntentToken,
} = searchParams;
const _headers = await headers(); const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers); const { serviceUrl } = getServiceUrlFromHeaders(_headers);
@@ -44,17 +37,6 @@ export default async function Page(props: {
} }
} }
let idpIntent;
if (idpIntentId && idpIntentToken) {
idpIntent = await retrieveIDPIntent({
serviceUrl,
id: idpIntentId,
token: idpIntentToken,
});
const { idpInformation, userId } = idpIntent;
}
const legal = await getLegalAndSupportSettings({ const legal = await getLegalAndSupportSettings({
serviceUrl, serviceUrl,
organization, organization,
@@ -100,7 +82,9 @@ export default async function Page(props: {
<h1>{t("title")}</h1> <h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p> <p className="ztdl-p">{t("description")}</p>
{legal && passwordComplexitySettings && ( {!organization && <Alert>{tError("unknownContext")}</Alert>}
{legal && passwordComplexitySettings && organization && (
<RegisterForm <RegisterForm
idpCount={ idpCount={
!loginSettings?.allowExternalIdp ? 0 : identityProviders.length !loginSettings?.allowExternalIdp ? 0 : identityProviders.length

View File

@@ -1,21 +1,25 @@
import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete"; import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete";
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb"; import { AddHumanUserRequest } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { DynamicTheme } from "../../dynamic-theme"; import { DynamicTheme } from "../../dynamic-theme";
export async function completeIDP({ export async function completeIDP({
userId, idpUserId,
idpInformation, idpId,
idpUserName,
addHumanUser,
requestId, requestId,
organization, organization,
branding, branding,
idpIntent, idpIntent,
}: { }: {
userId: string; idpUserId: string;
idpInformation: IDPInformation; idpId: string;
idpUserName: string;
addHumanUser?: AddHumanUserRequest;
requestId?: string; requestId?: string;
organization?: string; organization: string;
branding?: BrandingSettings; branding?: BrandingSettings;
idpIntent: { idpIntent: {
idpIntentId: string; idpIntentId: string;
@@ -32,8 +36,14 @@ export async function completeIDP({
<p className="ztdl-p">{t("completeRegister.description")}</p> <p className="ztdl-p">{t("completeRegister.description")}</p>
<RegisterFormIDPIncomplete <RegisterFormIDPIncomplete
userId={userId} idpUserId={idpUserId}
idpInformation={idpInformation} idpId={idpId}
idpUserName={idpUserName}
defaultValues={{
email: addHumanUser?.email?.email || "",
firstname: addHumanUser?.profile?.givenName || "",
lastname: addHumanUser?.profile?.familyName || "",
}}
requestId={requestId} requestId={requestId}
organization={organization} organization={organization}
idpIntent={idpIntent} idpIntent={idpIntent}

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { registerUserAndLinkToIDP } from "@/lib/server/register"; import { registerUserAndLinkToIDP } from "@/lib/server/register";
import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@@ -21,31 +20,39 @@ type Inputs =
| FieldValues; | FieldValues;
type Props = { type Props = {
organization?: string; organization: string;
requestId?: string; requestId?: string;
idpIntent: { idpIntent: {
idpIntentId: string; idpIntentId: string;
idpIntentToken: string; idpIntentToken: string;
}; };
idpInformation: IDPInformation; defaultValues?: {
userId: string; firstname?: string;
lastname?: string;
email?: string;
};
idpUserId: string;
idpId: string;
idpUserName: string;
}; };
export function RegisterFormIDPIncomplete({ export function RegisterFormIDPIncomplete({
organization, organization,
requestId, requestId,
idpIntent, idpIntent,
idpInformation, defaultValues,
userId, idpUserId,
idpId,
idpUserName,
}: Props) { }: Props) {
const t = useTranslations("register"); const t = useTranslations("register");
const { register, handleSubmit, formState } = useForm<Inputs>({ const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
email: idpInformation?.rawInformation?.email ?? "", email: defaultValues?.email ?? "",
firstName: idpInformation?.rawInformation?.firstname ?? "", firstname: defaultValues?.firstname ?? "",
lastname: idpInformation?.rawInformation?.lastname ?? "", lastname: defaultValues?.lastname ?? "",
}, },
}); });
@@ -57,7 +64,9 @@ export function RegisterFormIDPIncomplete({
async function submitAndRegister(values: Inputs) { async function submitAndRegister(values: Inputs) {
setLoading(true); setLoading(true);
const response = await registerUserAndLinkToIDP({ const response = await registerUserAndLinkToIDP({
userId: userId, idpId: idpId,
idpUserName: idpUserName,
idpUserId: idpUserId,
email: values.email, email: values.email,
firstName: values.firstname, firstName: values.firstname,
lastName: values.lastname, lastName: values.lastname,

View File

@@ -35,7 +35,7 @@ type Props = {
firstname?: string; firstname?: string;
lastname?: string; lastname?: string;
email?: string; email?: string;
organization?: string; organization: string;
requestId?: string; requestId?: string;
loginSettings?: LoginSettings; loginSettings?: LoginSettings;
idpCount: number; idpCount: number;

View File

@@ -10,7 +10,7 @@ type InviteUserCommand = {
firstName: string; firstName: string;
lastName: string; lastName: string;
password?: string; password?: string;
organization?: string; organization: string;
requestId?: string; requestId?: string;
}; };

View File

@@ -4,7 +4,12 @@ import {
createSessionAndUpdateCookie, createSessionAndUpdateCookie,
createSessionForIdpAndUpdateCookie, createSessionForIdpAndUpdateCookie,
} from "@/lib/server/cookie"; } from "@/lib/server/cookie";
import { addHumanUser, getLoginSettings, getUserByID } from "@/lib/zitadel"; import {
addHumanUser,
addIDPLink,
getLoginSettings,
getUserByID,
} from "@/lib/zitadel";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { import {
@@ -21,7 +26,7 @@ type RegisterUserCommand = {
firstName: string; firstName: string;
lastName: string; lastName: string;
password?: string; password?: string;
organization?: string; organization: string;
requestId?: string; requestId?: string;
}; };
@@ -141,13 +146,15 @@ type RegisterUserAndLinkToIDPommand = {
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
organization?: string; organization: string;
requestId?: string; requestId?: string;
idpIntent: { idpIntent: {
idpIntentId: string; idpIntentId: string;
idpIntentToken: string; idpIntentToken: string;
}; };
userId: string; idpUserId: string;
idpId: string;
idpUserName: string;
}; };
export type registerUserAndLinkToIDPResponse = { export type registerUserAndLinkToIDPResponse = {
@@ -185,9 +192,23 @@ export async function registerUserAndLinkToIDP(
// TODO: addIDPLink to addResponse // TODO: addIDPLink to addResponse
const idpLink = await addIDPLink({
serviceUrl,
idp: {
id: command.idpId,
userId: command.idpUserId,
userName: command.idpUserName,
},
userId: addResponse.userId,
});
if (!idpLink) {
return { error: "Could not link IDP to user" };
}
const session = await createSessionForIdpAndUpdateCookie({ const session = await createSessionForIdpAndUpdateCookie({
requestId: command.requestId, requestId: command.requestId,
userId: command.userId, userId: addResponse.userId, // the user we just created
idpIntent: command.idpIntent, idpIntent: command.idpIntent,
lifetime: loginSettings?.externalLoginCheckLifetime, lifetime: loginSettings?.externalLoginCheckLifetime,
}); });

View File

@@ -1,7 +1,10 @@
import { Client, create, Duration } from "@zitadel/client"; import { Client, create, Duration } from "@zitadel/client";
import { makeReqCtx } from "@zitadel/client/v2"; import { makeReqCtx } from "@zitadel/client/v2";
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb";
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb"; import {
OrganizationSchema,
TextQueryMethod,
} from "@zitadel/proto/zitadel/object/v2/object_pb";
import { import {
CreateCallbackRequest, CreateCallbackRequest,
OIDCService, OIDCService,
@@ -32,6 +35,7 @@ import {
import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { import {
AddHumanUserRequest, AddHumanUserRequest,
AddHumanUserRequestSchema,
ResendEmailCodeRequest, ResendEmailCodeRequest,
ResendEmailCodeRequestSchema, ResendEmailCodeRequestSchema,
SendEmailCodeRequestSchema, SendEmailCodeRequestSchema,
@@ -388,7 +392,7 @@ export type AddHumanUserData = {
lastName: string; lastName: string;
email: string; email: string;
password?: string; password?: string;
organization: string | undefined; organization: string;
}; };
export async function addHumanUser({ export async function addHumanUser({
@@ -404,23 +408,36 @@ export async function addHumanUser({
serviceUrl, serviceUrl,
); );
return userService.addHumanUser({ let addHumanUserRequest: AddHumanUserRequest = create(
email: { AddHumanUserRequestSchema,
email, {
verification: { email: {
case: "isVerified", email,
value: false, verification: {
case: "isVerified",
value: false,
},
}, },
username: email,
profile: { givenName: firstName, familyName: lastName },
passwordType: password
? { case: "password", value: { password } }
: undefined,
}, },
username: email, );
profile: { givenName: firstName, familyName: lastName },
organization: organization if (organization) {
? { org: { case: "orgId", value: organization } } const organizationSchema = create(OrganizationSchema, {
: undefined, org: { case: "orgId", value: organization },
passwordType: password });
? { case: "password", value: { password } }
: undefined, addHumanUserRequest = {
}); ...addHumanUserRequest,
organization: organizationSchema,
};
}
return userService.addHumanUser(addHumanUserRequest);
} }
export async function addHuman({ export async function addHuman({