Files
zitadel/apps/login/src/lib/server/flow-initiation.ts
T
ff4aae7b3f fix(login): improve error classification (#11926)
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 <mail@conblem.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 11:20:56 +02:00

492 lines
15 KiB
TypeScript

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<unknown> => {
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<NextResponse> {
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]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}" />`)
.join("\n");
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${escapeHtml(response.url)}" method="post">
${hiddenInputs}
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</html>
`;
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<NextResponse> {
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 = `
<html>
<body onload="document.forms[0].submit()">
<form action="${escapeHtml(url)}" method="post">
<input type="hidden" name="RelayState" value="${escapeHtml(binding.value.relayState)}" />
<input type="hidden" name="SAMLResponse" value="${escapeHtml(binding.value.samlResponse)}" />
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</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,
});
}