Files
zitadel/apps/login/src/lib/server/idp-intent.ts
T
324cc54d9a fix(console): support Zitadel Support IDP (#12619)
# Which Problems Are Solved

The backend and the new login already support the dedicated ZITADEL IdP
template
(`AddZitadelProvider`/`UpdateZitadelProvider`, `PROVIDER_TYPE_ZITADEL`,
`InstanceRolesInfo`),
but the Console does not expose it:

- A ZITADEL provider cannot be created or edited from the Console,
neither at instance nor at
organization level — it is missing from the "add provider" list in the
IdP settings.
- Providers created through the API are listed in the IdP table as
`coming soon` (the type
`@switch` has no `PROVIDER_TYPE_ZITADEL` case) and the row has no detail
route, so an existing
  provider cannot be opened.
- `InstanceRolesInfo` — the organization ID/domain pairs that drive
instance-role synchronization
  for instance-level providers — is not configurable outside the API.

# How the Problems Are Solved

- Added `ProviderZitadelComponent`
(`console/src/app/modules/providers/provider-zitadel/`),
  modeled on the generic OIDC provider component:
- Form for `name`, `issuer`, `clientId`, `clientSecret` and `scopesList`
(chip input, defaulting
to `openid`, `profile`, `email`), plus the shared
`cnsl-provider-options` block.
- On edit the client secret is optional and is only sent when the
"update client secret"
    checkbox is set.
- Reuses `cnsl-provider-next` for the callback URL, activation and "what
now" flow.
- Added the instance role mapping section, rendered only for
instance-level providers
(`serviceType === ADMIN`), since the API rejects `instance_roles_info`
on organization-scoped
ZITADEL providers. Entries are added as organization ID + organization
domain pairs and are
submitted as `InstanceRolesInfo`; both values are required and are
matched together on login.
- Added `addZitadelProvider`/`updateZitadelProvider` to `AdminService`
and `ManagementService`
  wrapping the existing gRPC calls.
- Declared the component in `providers.module.ts` and mapped
`PROVIDER_TYPE_ZITADEL` to the
`zitadel` path in `providers-routing.module.ts`, so
`/instance/provider/zitadel/...` and
  `/org/provider/zitadel/...` resolve for both create and edit.
- Added a ZITADEL tile to the "add provider" list in the IdP settings
and a
`PROVIDER_TYPE_ZITADEL` case to the IdP table;
`IdpTableComponent.routerLinkForRow` now returns
  the detail route for ZITADEL rows.

# Additional Changes

- Added the new i18n keys `IDP.CREATE.ZITADEL.TITLE`/`DESCRIPTION`,
`IDP.INSTANCEROLESINFO`,
`IDP.INSTANCEROLESINFO_DESC`, `IDP.ORGANIZATIONID` and
`IDP.ORGANIZATIONDOMAIN` to all 22
Console locale files with actual translations instead of English
fallbacks.
- The provider tile, table entry and detail header use the colored
ZITADEL logo assets
(`zitadel-logo-solo-light.svg` in the dark theme,
`zitadel-logo-solo-dark.svg` in the light
theme), consistent with the header, rather than the monochrome
`*-solo-*-icon.svg` variants.
- Additional sync roles trigger for the support IDP to behave correctly
on first login after a disable/enable cycle — where auto-linking jumps
in — but not required for the link to be established.

# Additional Context

- Closes #11825
- Part of the "Zitadel as an Identity Provider" epic #5127
- Console counterpart to the API work in #12018, #12056 (add), #12371,
#12378, #12384 (update),
#12394 (get/list), #12396 (delete), #12422 (active IdPs) and to the
login support in #12469,
  #12530 and #12568

---------

Co-authored-by: Livio Spring <[email protected]>
2026-08-24 07:00:02 +02:00

899 lines
31 KiB
TypeScript

"use server";
import { getSessionCookieById } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { getServiceConfig } from "@/lib/service-url";
import {
addIDPLink,
createUser,
getActiveIdentityProviders,
getDefaultOrg,
getIDPByID,
getLoginSettings,
getOrgsByDomain,
getSession,
getUserByID,
listUsers,
retrieveIDPIntent,
ServiceConfig,
updateUser,
} from "@/lib/zitadel";
import { Code, create } from "@zitadel/client";
import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import { SetHumanEmail } from "@zitadel/proto/zitadel/user/v2/email_pb";
import { SetHumanProfile } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
CreateUserRequest,
CreateUserRequestSchema,
UpdateUserRequest,
UpdateUserRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import crypto from "crypto";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { getFingerprintIdCookie } from "../fingerprint";
import { createNewSessionFromIdpIntent } from "./idp";
import { syncInstanceRolesFromIdpIntent } from "./instance-roles";
const logger = createLogger("idp-intent");
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
type IDPIntentResult = Awaited<ReturnType<typeof retrieveIDPIntent>>;
/**
* Flattened view of the "create user" information contained in an IDP intent response,
* used for reads (organization resolution, required-field checks and form pre-filling).
*/
interface ResolvedCreateUser {
username?: string;
profile?: SetHumanProfile;
email?: SetHumanEmail;
}
/**
* Reads the "create user" information from an IDP intent response.
*
* Prefers the new `user_action.create_user` oneof (CreateUserRequest, which supports metadata).
* Falls back to the deprecated `add_human_user` field so that responses from older API versions
* keep working during the transition.
*/
function resolveCreateUser(intent: IDPIntentResult): ResolvedCreateUser | undefined {
if (intent.userAction?.case === "createUser") {
const request = intent.userAction.value;
const human = request.userType?.case === "human" ? request.userType.value : undefined;
return {
username: request.username,
profile: human?.profile,
email: human?.email,
};
}
// Fallback: deprecated add_human_user (does not support the new top-level metadata).
if (intent.addHumanUser) {
return {
username: intent.addHumanUser.username,
profile: intent.addHumanUser.profile,
email: intent.addHumanUser.email,
};
}
return undefined;
}
/**
* Builds the request for the non-deprecated CreateUser endpoint from an IDP intent response.
*
* Prefers the new `user_action.create_user` oneof: the action's request is passed through as-is
* and only the resolved organization is injected. Falls back to mapping the deprecated
* `add_human_user` (flat) fields into the new nested shape for older API responses.
*/
function buildCreateUserRequest(intent: IDPIntentResult, organizationId: string): CreateUserRequest | undefined {
if (intent.userAction?.case === "createUser") {
return create(CreateUserRequestSchema, {
...intent.userAction.value,
organizationId,
});
}
// Fallback: map the deprecated flat add_human_user payload into the CreateUserRequest shape.
if (intent.addHumanUser) {
const add = intent.addHumanUser;
return create(CreateUserRequestSchema, {
organizationId,
username: add.username,
userType: {
case: "human",
value: {
profile: add.profile,
email: add.email,
phone: add.phone,
idpLinks: add.idpLinks,
},
},
// SetMetadataEntry and Metadata share the same {key, value} shape; map explicitly.
metadata: add.metadata?.map((entry) => ({ key: entry.key, value: entry.value })),
});
}
return undefined;
}
/**
* Builds the request for the non-deprecated UpdateUser endpoint from an IDP intent response.
*
* Prefers the new `user_action.update_user` oneof (UpdateUserRequest, which supports metadata).
* Falls back to the deprecated `update_human_user` field for older API responses.
*
* Note: we intentionally sync only profile, email, phone and metadata here (not the username) to
* preserve the existing auto-update behaviour and avoid invalidating sessions on every login.
*/
function buildUpdateUserRequest(intent: IDPIntentResult, userId: string): UpdateUserRequest | undefined {
if (intent.userAction?.case === "updateUser") {
const request = intent.userAction.value;
// UpdateUserRequest.user_type is optional and also supports machine updates. Only sync a
// human profile (IDP users are human); for any other case we still carry the metadata over,
// since metadata is a top-level field independent of the user type.
const human = request.userType?.case === "human" ? request.userType.value : undefined;
// Nothing to apply: neither human profile data nor metadata. Skip the update entirely.
if (!human && (request.metadata?.length ?? 0) === 0) {
return undefined;
}
return create(UpdateUserRequestSchema, {
userId,
userType: human
? {
case: "human",
value: {
profile: human.profile,
email: human.email,
phone: human.phone,
},
}
: undefined,
metadata: request.metadata,
});
}
// Fallback: deprecated update_human_user (has no metadata support).
if (intent.updateHumanUser) {
const update = intent.updateHumanUser;
return create(UpdateUserRequestSchema, {
userId,
userType: {
case: "human",
value: {
// The deprecated profile is a SetHumanProfile; map it to the Update profile shape.
profile: update.profile
? {
givenName: update.profile.givenName,
familyName: update.profile.familyName,
nickName: update.profile.nickName,
displayName: update.profile.displayName,
preferredLanguage: update.profile.preferredLanguage,
gender: update.profile.gender,
}
: undefined,
email: update.email,
phone: update.phone,
},
},
});
}
return undefined;
}
async function resolveOrganizationForUser({
organization,
createUserData,
serviceConfig,
}: {
organization?: string;
createUserData?: ResolvedCreateUser;
serviceConfig: ServiceConfig;
}): Promise<string | undefined> {
if (organization) return organization;
if (createUserData?.username && ORG_SUFFIX_REGEX.test(createUserData.username)) {
const matched = ORG_SUFFIX_REGEX.exec(createUserData.username);
const suffix = matched?.[1] ?? "";
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 });
if (orgLoginSettings?.allowDomainDiscovery) {
return orgToCheckForDiscovery;
}
}
}
// Fallback to default organization if no org was resolved through discovery
const defaultOrg = await getDefaultOrg({ serviceConfig });
return defaultOrg?.id;
}
/**
* Validates if IDP linking is allowed for a user's organization.
* Checks:
* 1. Organization allows external IDP login (allowExternalIdp)
* 2. The specific IDP is activated for the organization
*
*/
export async function validateIDPLinkingPermissions({
serviceConfig,
userOrganizationId,
idpId,
}: {
serviceConfig: ServiceConfig;
userOrganizationId: string;
idpId: string;
}): Promise<boolean> {
// Check organization login settings
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 isIDPActive = activeIDPs.identityProviders?.some((idp) => idp.id === idpId);
if (!isIDPActive) {
return false;
}
return true;
}
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;
samlData?: { url: string; fields: Record<string, 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) {
logger.warn("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) {
logger.warn("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) {
logger.warn("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) {
logger.warn("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) {
logger.warn("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` };
}
logger.debug("Resolved userId from session link", { userId: resolvedUserId });
// 3. Perform Linking Logic
if (!options?.isLinkingAllowed) {
logger.error("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) {
logger.error("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) {
logger.error("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,
});
logger.info("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) {
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
logger.error("Error linking IDP", { error });
const errorMessage = error instanceof Error ? error.message : t("errors.unknownError");
let params = buildRedirectParams({ error: errorMessage });
if (isClassifiedError(error) && 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 } = ctx.intent;
const { options, serviceConfig, t } = ctx;
if (userId && !sessionId) {
// Auto-update user if enabled. Uses the non-deprecated UpdateUser endpoint so that
// metadata provided by the action response is applied in the same request.
const updateUserRequest = options?.isAutoUpdate ? buildUpdateUserRequest(ctx.intent, userId) : undefined;
if (updateUserRequest) {
try {
logger.debug("Auto-updating user profile");
await updateUser({ serviceConfig, request: updateUserRequest });
} catch (error) {
logger.warn("Failed to auto-update user", { error });
// Continue with login even if update fails
}
}
// Synchronize instance member roles for ZITADEL IdPs with instanceRolesInfo
// configured (e.g. support access). Merge-only and never blocks the login.
await syncInstanceRolesFromIdpIntent({ serviceConfig, intent: ctx.intent, userId });
// Create session and handle redirect
logger.debug("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) {
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
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 { idpInformation } = intent;
const createUserData = resolveCreateUser(intent);
const { organization, provider } = ctx.params;
if (options?.autoLinking) {
let foundUser;
const email = createUserData?.email?.email;
const emailVerified =
createUserData?.email?.verification?.case === "isVerified" && createUserData?.email?.verification?.value;
if (options.autoLinking === AutoLinkingOption.EMAIL && email && emailVerified) {
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;
});
}
if (foundUser) {
try {
if (!foundUser.details?.resourceOwner) {
logger.error("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) {
logger.error("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,
});
logger.info("User auto-linked successfully, creating session");
// Synchronize instance member roles for ZITADEL IdPs with instanceRolesInfo
// configured (e.g. support access). Merge-only and never blocks the login.
// Required here as well: a user that is auto-linked on this login would
// otherwise only receive its roles on the next one (via the existing-user path).
await syncInstanceRolesFromIdpIntent({ serviceConfig, intent, userId: foundUser.userId });
// 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) {
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
logger.error("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 { idpInformation } = intent;
const createUserData = resolveCreateUser(intent);
const { organization, provider } = ctx.params;
if (options?.isAutoCreation && createUserData) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
createUserData,
serviceConfig,
});
if (!orgToRegisterOn) {
logger.error("Could not determine organization for auto-creation (no default org available)");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=no_organization_context` };
}
// Check if required profile fields are present
if (!createUserData.profile?.givenName || !createUserData.profile?.familyName) {
logger.info("Missing required profile fields (givenName or familyName), redirecting to complete registration");
if (!idpInformation!.userId) {
logger.error("IDP intent missing userId, cannot redirect to complete registration");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=missing_idp_user_info` };
}
const params = buildRedirectParams(
{
organization: orgToRegisterOn,
idpId: idpInformation!.idpId,
idpUserId: idpInformation!.userId,
idpUserName: idpInformation!.userName || "",
// User data for pre-filling form
givenName: createUserData.profile?.givenName || "",
familyName: createUserData.profile?.familyName || "",
email: createUserData.email?.email || "",
},
true,
);
return { redirect: `/idp/${provider}/complete-registration?${params}` };
}
const createUserRequest = buildCreateUserRequest(intent, orgToRegisterOn);
if (!createUserRequest) {
logger.error("Could not build create user request from intent");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=user_creation_failed` };
}
try {
const newUser = await createUser({ serviceConfig, request: createUserRequest });
logger.info("User auto-created successfully, creating session");
// Synchronize instance member roles for ZITADEL IdPs with instanceRolesInfo
// configured (e.g. support access). Merge-only and never blocks the login.
await syncInstanceRolesFromIdpIntent({ serviceConfig, intent, userId: newUser.id });
// Create session for newly created user
const sessionResult = await createNewSessionFromIdpIntent({
userId: newUser.id,
idpIntent: {
idpIntentId: ctx.params.id,
idpIntentToken: ctx.params.token,
},
requestId: ctx.params.requestId,
organization: ctx.params.organization,
});
if ("error" in sessionResult && sessionResult.error) {
logger.error("Error creating session", { error: sessionResult.error });
return { error: sessionResult.error };
}
if ("redirect" in sessionResult && sessionResult.redirect) {
logger.debug("Session created, redirecting", { redirect: sessionResult.redirect });
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
logger.info("Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error: unknown) {
logger.error("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 { idpInformation } = intent;
const createUserData = resolveCreateUser(intent);
const { organization, provider } = ctx.params;
if (options?.isCreationAllowed && createUserData) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
createUserData,
serviceConfig,
});
if (!orgToRegisterOn) {
logger.error("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
if (!idpInformation!.userId) {
logger.error("IDP intent missing userId, cannot redirect to complete registration");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=missing_idp_user_info` };
}
const params = buildRedirectParams(
{
organization: orgToRegisterOn,
idpId: idpInformation!.idpId,
idpUserId: idpInformation!.userId,
idpUserName: idpInformation!.userName || "",
// User data for pre-filling form
givenName: createUserData.profile?.givenName || "",
familyName: createUserData.profile?.familyName || "",
email: createUserData.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;
logger.debug("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:
* 1. Consumes the single-use token once
* 2. Performs all IDP-related operations (auto-update, auto-linking, auto-creation)
* 3. Returns redirect URL or error for client-side navigation
*/
export async function processIDPCallback({
provider,
id,
token,
requestId,
organization,
postErrorRedirectUrl,
sessionId,
linkFingerprint,
}: {
provider: string;
id: string;
token: string;
requestId?: string;
organization?: string;
postErrorRedirectUrl?: string;
sessionId?: string;
linkFingerprint?: string;
}): Promise<{ redirect?: string; error?: string; samlData?: { url: string; fields: Record<string, string> } }> {
// ... (headers and config retrieval) ...
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("idp");
// Validate required parameters
if (!provider || !id || !token) {
logger.error("Missing required parameters", { provider, id, hasToken: !!token });
const errorParams = new URLSearchParams();
if (requestId) errorParams.set("requestId", requestId);
if (organization) errorParams.set("organization", organization);
if (postErrorRedirectUrl) errorParams.set("postErrorRedirectUrl", postErrorRedirectUrl);
return { redirect: `/idp/${provider}/failure?${errorParams.toString()}` };
}
try {
// Consume the single-use token ONCE
const intent = await retrieveIDPIntent({ serviceConfig, id, token });
logger.debug("Intent retrieved successfully, processing business logic");
const { idpInformation } = intent;
// Verify we have IDP info early on
if (!idpInformation) {
logger.error("IDP information missing");
return { redirect: `/idp/${provider}/failure?error=missing_idp_info` };
}
// Get IDP configuration
const idp = await getIDPByID({ serviceConfig, id: idpInformation.idpId });
if (!idp) {
return { error: t("errors.idpNotFound") };
}
const options = idp?.config?.options;
// Build base redirect params
const buildRedirectParams = (additionalParams?: Record<string, string>, includeToken: boolean = false) => {
const params = new URLSearchParams();
params.set("id", id);
if (includeToken) params.set("token", token);
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]) => {
if (value) params.set(key, value);
});
}
return params.toString();
};
const ctx: IDPHandlerContext = {
serviceConfig,
t,
intent,
idp,
options,
params: {
provider,
id,
token,
requestId,
organization,
postErrorRedirectUrl,
sessionId,
linkFingerprint,
},
buildRedirectParams,
};
const handlers = [
handleExplicitLinking,
handleUserExists,
handleAutoLinking,
handleAutoCreation,
handleManualCreation,
handleNoUserFound,
];
for (const handler of handlers) {
const result = await handler(ctx);
if (result) {
return result;
}
}
// Should theoretically be unreachable if handleNoUserFound covers the rest
return { error: t("errors.unknown") };
} catch (error: unknown) {
logger.error("Error processing intent", { error });
const errorParams = new URLSearchParams();
if (requestId) errorParams.set("requestId", requestId);
if (organization) errorParams.set("organization", organization);
if (postErrorRedirectUrl) errorParams.set("postErrorRedirectUrl", postErrorRedirectUrl);
errorParams.set("error", error instanceof Error ? error.message : t("errors.unknownError"));
return { redirect: `/idp/${provider}/failure?${errorParams.toString()}` };
}
}