mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
Closes #11923 # Which Problems Are Solved Client-side gRPC errors (e.g. NotFound, InvalidArgument, PermissionDenied) were being surfaced as HTTP 500 Internal Server Errors, causing false SRE alerts and poor UI feedback. # How the Problems Are Solved - New transport interceptor (`error-classification.ts`): Automatically enriches every ConnectError with httpStatus and isUserError metadata via a `ClassifiedConnectError` wrapper - Route handler protection (`route.ts`, `flow-initiation.ts`): Catches classified errors from `getAuthRequest` / `getSAMLRequest` and returns correct HTTP status codes instead of 500 - Type safety: Replaced all magic number error.code === 9 checks with typed Code.FailedPrecondition + instanceof ConnectError across `oidc.ts`, `saml.ts`, `password.ts`, `zitadel.ts` - Classification-aware logging (`session.ts`): Client/user errors log at warn level, server errors at error level - Observability (`otel.ts`): Spans now include error.is_user_error and http.status_code attributes for alert filtering # Additional Changes Gitignore `next-env.d.ts`: This file is auto-generated by Next.js on every next dev and next build invocation. The two modes write slightly different import paths (.next/dev/types/ vs .next/types/), which causes git diff --exit-code to fail in CI whenever a developer runs next dev locally before committing. Since Next.js regenerates it automatically, there's no need to track it --------- Co-authored-by: Ramon <[email protected]> Co-authored-by: Copilot <[email protected]>
715 lines
23 KiB
TypeScript
715 lines
23 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 {
|
|
addHuman,
|
|
addIDPLink,
|
|
getActiveIdentityProviders,
|
|
getDefaultOrg,
|
|
getIDPByID,
|
|
getLoginSettings,
|
|
getOrgsByDomain,
|
|
getSession,
|
|
getUserByID,
|
|
listUsers,
|
|
retrieveIDPIntent,
|
|
ServiceConfig,
|
|
updateHuman,
|
|
} from "@/lib/zitadel";
|
|
import { Code, 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 {
|
|
AddHumanUserRequest,
|
|
AddHumanUserRequestSchema,
|
|
UpdateHumanUserRequestSchema,
|
|
} 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";
|
|
|
|
const logger = createLogger("idp-intent");
|
|
|
|
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
|
|
|
|
async function resolveOrganizationForUser({
|
|
organization,
|
|
addHumanUser,
|
|
serviceConfig,
|
|
}: {
|
|
organization?: string;
|
|
addHumanUser?: AddHumanUserRequest;
|
|
serviceConfig: ServiceConfig;
|
|
}): 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({ 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 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;
|
|
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, updateHumanUser } = ctx.intent;
|
|
const { options, serviceConfig, t } = ctx;
|
|
|
|
if (userId && !sessionId) {
|
|
// Auto-update user if enabled
|
|
if (options?.isAutoUpdate && updateHumanUser) {
|
|
try {
|
|
logger.debug("Auto-updating user profile");
|
|
await updateHuman({
|
|
serviceConfig,
|
|
request: create(UpdateHumanUserRequestSchema, {
|
|
userId,
|
|
profile: updateHumanUser.profile,
|
|
email: updateHumanUser.email,
|
|
phone: updateHumanUser.phone,
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
logger.warn("Failed to auto-update user", { error });
|
|
// Continue with login even if update fails
|
|
}
|
|
}
|
|
|
|
// 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 { 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) {
|
|
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");
|
|
|
|
// 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 { addHumanUser } = intent;
|
|
const { organization, provider } = ctx.params;
|
|
|
|
if (options?.isAutoCreation && addHumanUser) {
|
|
const orgToRegisterOn = await resolveOrganizationForUser({
|
|
organization,
|
|
addHumanUser,
|
|
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` };
|
|
}
|
|
|
|
const organizationSchema = create(OrganizationSchema, {
|
|
org: { case: "orgId", value: orgToRegisterOn },
|
|
});
|
|
|
|
const addHumanUserWithOrganization = create(AddHumanUserRequestSchema, {
|
|
...addHumanUser,
|
|
organization: organizationSchema,
|
|
});
|
|
|
|
try {
|
|
const newUser = await addHuman({ serviceConfig, request: addHumanUserWithOrganization });
|
|
logger.info("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) {
|
|
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 { addHumanUser, idpInformation } = intent;
|
|
const { organization, provider } = ctx.params;
|
|
|
|
if (options?.isCreationAllowed && addHumanUser) {
|
|
const orgToRegisterOn = await resolveOrganizationForUser({
|
|
organization,
|
|
addHumanUser,
|
|
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
|
|
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;
|
|
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()}` };
|
|
}
|
|
}
|