import { getValidLocaleFromUILocales } from "@/lib/auth-utils"; import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies"; import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification"; import { shouldUILocalesOverrideCookie } from "@/lib/i18n"; import { idpTypeToSlug } from "@/lib/idp"; import { createLogger } from "@/lib/logger"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; import { constructUrl } from "@/lib/service-url"; import { findValidSession } from "@/lib/session"; import { createCallback, createResponse, getActiveIdentityProviders, getAuthRequest, getOrgsByDomain, getSAMLRequest, getSecuritySettings, ServiceConfig, startIdentityProviderFlow, } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { Prompt } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; import { CreateCallbackRequestSchema, SessionSchema } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_settings_pb"; import escapeHtml from "escape-html"; import { NextRequest, NextResponse } from "next/server"; import { buildCSP } from "../csp"; const logger = createLogger("flow-initiation"); const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/; const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/; function setCSPHeaders( response: NextResponse, serviceConfig: ServiceConfig, securitySettings: SecuritySettings | undefined, ): void { const iframeOrigins = securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0 ? securitySettings.embeddedIframe.allowedOrigins : undefined; response.headers.set("Content-Security-Policy", buildCSP({ serviceUrl: serviceConfig.baseUrl, iframeOrigins })); if (!iframeOrigins) { response.headers.set("X-Frame-Options", "deny"); } } const gotoAccounts = ({ request, requestId, organization, }: { request: NextRequest; requestId: string; organization?: string; }): NextResponse => { const accountsUrl = constructUrl(request, "/accounts"); if (requestId) { accountsUrl.searchParams.set("requestId", requestId); } if (organization) { accountsUrl.searchParams.set("organization", organization); } return NextResponse.redirect(accountsUrl); }; export interface FlowInitiationParams { serviceConfig: ServiceConfig; requestId: string; sessions: Session[]; sessionCookies: any[]; request: NextRequest; } /** * Handle OIDC flow initiation */ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise { const { serviceConfig, requestId, sessions, sessionCookies, request } = params; let authRequest; try { ({ authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", "") })); } catch (error) { if (isClassifiedError(error) && error.isUserError) { logger.warn("Auth request failed (client error)", { grpcCode: error.code, httpStatus: error.httpStatus }); return NextResponse.json({ error: error.message }, { status: error.httpStatus }); } throw error; } const locale = getValidLocaleFromUILocales(authRequest?.uiLocales); if (locale) { const existingLanguage = await getLanguageCookie(); if (shouldUILocalesOverrideCookie() || !existingLanguage) { await setLanguageCookie(locale); } } let organization = ""; let suffix = ""; let idpId = ""; if (authRequest?.scope) { const orgScope = authRequest.scope.find((s: string) => ORG_SCOPE_REGEX.test(s)); const idpScope = authRequest.scope.find((s: string) => IDP_SCOPE_REGEX.test(s)); if (orgScope) { const matched = ORG_SCOPE_REGEX.exec(orgScope); organization = matched?.[1] ?? ""; } else { const orgDomainScope = authRequest.scope.find((s: string) => ORG_DOMAIN_SCOPE_REGEX.test(s)); if (orgDomainScope) { const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope); const orgDomain = matched?.[1] ?? ""; logger.info("Extracted org domain:", { orgDomain }); if (orgDomain) { const orgs = await getOrgsByDomain({ serviceConfig, domain: orgDomain }); if (orgs.result && orgs.result.length === 1) { organization = orgs.result[0].id ?? ""; suffix = orgDomain; } } } } if (idpScope) { const matched = IDP_SCOPE_REGEX.exec(idpScope); idpId = matched?.[1] ?? ""; const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: organization ? organization : undefined, }).then((resp) => { return resp.identityProviders; }); const idp = identityProviders.find((idp) => idp.id === idpId); if (idp) { const identityProviderType = identityProviders[0].type; if (identityProviderType === IdentityProviderType.LDAP) { const ldapUrl = constructUrl(request, "/ldap"); if (authRequest.id) { ldapUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); } if (organization) { ldapUrl.searchParams.set("organization", organization); } return NextResponse.redirect(ldapUrl); } let provider = idpTypeToSlug(identityProviderType); const params = new URLSearchParams({ requestId: requestId, }); if (organization) { params.set("organization", organization); } const response = await startIdentityProviderFlow({ serviceConfig, idpId, urls: { successUrl: constructUrl(request, `/idp/${provider}/process?${params.toString()}`).toString(), failureUrl: constructUrl(request, `/idp/${provider}/failure?${params.toString()}`).toString(), }, }); if (!response || !response.url) { return NextResponse.json({ error: "Could not start IDP flow" }, { status: 500 }); } if (response.fields) { const hiddenInputs = Object.entries(response.fields) .map(([key, value]) => ``) .join("\n"); const html = `
${hiddenInputs}
`; return new NextResponse(html, { headers: { "Content-Type": "text/html" }, }); } let url = response.url; if (url.startsWith("/")) { url = constructUrl(request, url).toString(); } return NextResponse.redirect(url); } } } if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) { const registerUrl = constructUrl(request, "/register"); registerUrl.searchParams.set("requestId", requestId); if (organization) { registerUrl.searchParams.set("organization", organization); } return NextResponse.redirect(registerUrl); } // use existing session and hydrate it for oidc if (authRequest && sessions.length) { if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { return gotoAccounts({ request, requestId: `oidc_${authRequest.id}`, organization, }); } else if (authRequest.prompt.includes(Prompt.LOGIN)) { if (authRequest.loginHint) { try { let command: SendLoginnameCommand = { loginName: authRequest.loginHint, requestId: authRequest.id, }; if (organization) { command = { ...command, organization }; } const res = await sendLoginname(command); if (res && "redirect" in res && res?.redirect) { const absoluteUrl = constructUrl(request, res.redirect); return NextResponse.redirect(absoluteUrl.toString()); } } catch (error) { logger.error("Failed to execute sendLoginname:", { error }); } } const loginNameUrl = constructUrl(request, "/loginname"); loginNameUrl.searchParams.set("requestId", requestId); if (authRequest.loginHint) { loginNameUrl.searchParams.set("loginName", authRequest.loginHint); } if (organization) { loginNameUrl.searchParams.set("organization", organization); } if (suffix) { loginNameUrl.searchParams.set("suffix", suffix); } return NextResponse.redirect(loginNameUrl); } else if (authRequest.prompt.includes(Prompt.NONE)) { const securitySettings = await getSecuritySettings({ serviceConfig }); const selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, organization }); const noSessionResponse = NextResponse.json({ error: "No active session found" }, { status: 400 }); setCSPHeaders(noSessionResponse, serviceConfig, securitySettings); if (!selectedSession || !selectedSession.id) { return noSessionResponse; } const cookie = sessionCookies.find((cookie) => cookie.id === selectedSession.id); if (!cookie || !cookie.id || !cookie.token) { return noSessionResponse; } const session = { sessionId: cookie.id, sessionToken: cookie.token, }; const { callbackUrl } = await createCallback({ serviceConfig, req: create(CreateCallbackRequestSchema, { authRequestId: requestId.replace("oidc_", ""), callbackKind: { case: "session", value: create(SessionSchema, session), }, }), }); const callbackResponse = NextResponse.redirect(callbackUrl); setCSPHeaders(callbackResponse, serviceConfig, securitySettings); return callbackResponse; } else { let selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, organization }); if (!selectedSession || !selectedSession.id) { return gotoAccounts({ request, requestId: `oidc_${authRequest.id}`, organization, }); } const cookie = sessionCookies.find((cookie) => cookie.id === selectedSession.id); if (!cookie || !cookie.id || !cookie.token) { return gotoAccounts({ request, requestId: `oidc_${authRequest.id}`, organization, }); } const session = { sessionId: cookie.id, sessionToken: cookie.token, }; try { const { callbackUrl } = await createCallback({ serviceConfig, req: create(CreateCallbackRequestSchema, { authRequestId: requestId.replace("oidc_", ""), callbackKind: { case: "session", value: create(SessionSchema, session), }, }), }); if (callbackUrl) { return NextResponse.redirect(callbackUrl); } else { logger.info("could not create callback, redirect user to choose other account"); return gotoAccounts({ request, organization, requestId, }); } } catch (error) { logger.error("Error creating callback:", { error }); return gotoAccounts({ request, requestId, organization, }); } } } else { const loginNameUrl = constructUrl(request, "/loginname"); loginNameUrl.searchParams.set("requestId", requestId); if (authRequest?.loginHint) { loginNameUrl.searchParams.set("loginName", authRequest.loginHint); loginNameUrl.searchParams.set("submit", "true"); } if (organization) { loginNameUrl.searchParams.append("organization", organization); } if (suffix) { loginNameUrl.searchParams.append("suffix", suffix); } return NextResponse.redirect(loginNameUrl); } } /** * Handle SAML flow initiation */ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise { const { serviceConfig, requestId, sessions, sessionCookies, request } = params; let samlRequest; try { ({ samlRequest } = await getSAMLRequest({ serviceConfig, samlRequestId: requestId.replace("saml_", "") })); } catch (error) { if (isClassifiedError(error) && error.isUserError) { logger.warn("SAML request failed (client error)", { grpcCode: error.code, httpStatus: error.httpStatus }); return NextResponse.json({ error: error.message }, { status: error.httpStatus }); } throw error; } if (!samlRequest) { return NextResponse.json({ error: "No samlRequest found" }, { status: 400 }); } // Early return: No sessions available - redirect to login if (sessions.length === 0) { const loginNameUrl = constructUrl(request, "/loginname"); loginNameUrl.searchParams.set("requestId", requestId); return NextResponse.redirect(loginNameUrl); } // Try to find a valid session let selectedSession = await findValidSession({ serviceConfig, sessions, samlRequest }); // Early return: No valid session found - show account selection if (!selectedSession || !selectedSession.id) { return gotoAccounts({ request, requestId, }); } const cookie = sessionCookies.find((cookie) => cookie.id === selectedSession.id); // Early return: No valid cookie/token found - show account selection // Note: We need the session token from the cookie to authenticate API calls if (!cookie || !cookie.id || !cookie.token) { return gotoAccounts({ request, requestId, }); } // Valid session and cookie found - attempt to complete SAML flow const session = { sessionId: cookie.id, sessionToken: cookie.token, }; try { const { url, binding } = await createResponse({ serviceConfig, req: create(CreateResponseRequestSchema, { samlRequestId: requestId.replace("saml_", ""), responseKind: { case: "session", value: session, }, }), }); if (url && binding.case === "redirect") { return NextResponse.redirect(url); } else if (url && binding.case === "post") { const html = `
`; return new NextResponse(html, { headers: { "Content-Type": "text/html" }, }); } } catch (error) { logger.error("SAML createResponse failed:", { error }); } // Final fallback: SAML response creation failed - show account selection return gotoAccounts({ request, requestId, }); }