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 <[email protected]>
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Max Peintner
2026-03-31 11:20:56 +02:00
committed by GitHub
co-authored by Ramon Copilot
parent 8e41d288ff
commit ff4aae7b3f
18 changed files with 332 additions and 36 deletions
+3
View File
@@ -107,3 +107,6 @@ CLAUDE.md
docs_old docs_old
apps/docs/package-lock.json apps/docs/package-lock.json
# Auto-generated by Next.js (rewrites on every dev/build)
next-env.d.ts
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+13 -2
View File
@@ -1,5 +1,6 @@
import { isRSCRequest, validateAuthRequest } from "@/lib/auth-utils"; import { isRSCRequest, validateAuthRequest } from "@/lib/auth-utils";
import { getAllSessions } from "@/lib/cookies"; import { getAllSessions } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { FlowInitiationParams, handleOIDCFlowInitiation, handleSAMLFlowInitiation } from "@/lib/server/flow-initiation"; import { FlowInitiationParams, handleOIDCFlowInitiation, handleSAMLFlowInitiation } from "@/lib/server/flow-initiation";
import { getServiceConfig } from "@/lib/service-url"; import { getServiceConfig } from "@/lib/service-url";
import { listSessions, ServiceConfig } from "@/lib/zitadel"; import { listSessions, ServiceConfig } from "@/lib/zitadel";
@@ -45,9 +46,19 @@ export async function GET(request: NextRequest) {
const flowParams: FlowInitiationParams = { serviceConfig, requestId, sessions, sessionCookies, request }; const flowParams: FlowInitiationParams = { serviceConfig, requestId, sessions, sessionCookies, request };
if (requestId.startsWith("oidc_")) { if (requestId.startsWith("oidc_")) {
return handleOIDCFlowInitiation(flowParams); try {
return await handleOIDCFlowInitiation(flowParams);
} catch (error) {
const status = isClassifiedError(error) ? error.httpStatus : 500;
return NextResponse.json({ error: "Authentication flow failed" }, { status });
}
} else if (requestId.startsWith("saml_")) { } else if (requestId.startsWith("saml_")) {
return handleSAMLFlowInitiation(flowParams); try {
return await handleSAMLFlowInitiation(flowParams);
} catch (error) {
const status = isClassifiedError(error) ? error.httpStatus : 500;
return NextResponse.json({ error: "SAML flow failed" }, { status });
}
} else if (requestId.startsWith("device_")) { } else if (requestId.startsWith("device_")) {
// Device Authorization does not need to start here as it is handled on the /device endpoint // Device Authorization does not need to start here as it is handled on the /device endpoint
return NextResponse.json({ error: "Device authorization should use /device endpoint" }, { status: 400 }); return NextResponse.json({ error: "Device authorization should use /device endpoint" }, { status: 400 });
@@ -0,0 +1,101 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { describe, expect, it } from "vitest";
import { ClassifiedConnectError, grpcCodeToHttpStatus, isClassifiedError } from "./error-classification";
describe("grpcCodeToHttpStatus", () => {
const cases: [Code, number][] = [
[Code.InvalidArgument, 400],
[Code.FailedPrecondition, 400],
[Code.OutOfRange, 400],
[Code.Unauthenticated, 401],
[Code.PermissionDenied, 403],
[Code.NotFound, 404],
[Code.AlreadyExists, 409],
[Code.Aborted, 409],
[Code.ResourceExhausted, 429],
[Code.Canceled, 499],
[Code.Unimplemented, 501],
[Code.Unavailable, 503],
[Code.DeadlineExceeded, 504],
[Code.Internal, 500],
[Code.DataLoss, 500],
[Code.Unknown, 500],
];
it.each(cases)("maps gRPC code %i to HTTP status %i", (grpcCode, expectedHttp) => {
expect(grpcCodeToHttpStatus(grpcCode)).toBe(expectedHttp);
});
it("defaults to 500 for unmapped codes", () => {
expect(grpcCodeToHttpStatus(999 as Code)).toBe(500);
});
});
describe("ClassifiedConnectError", () => {
it("preserves original error properties", () => {
const source = new ConnectError("not found", Code.NotFound);
const classified = new ClassifiedConnectError(source);
expect(classified.message).toContain("not found");
expect(classified.code).toBe(Code.NotFound);
expect(classified.name).toBe("ClassifiedConnectError");
});
it("sets httpStatus from gRPC code", () => {
const source = new ConnectError("permission denied", Code.PermissionDenied);
const classified = new ClassifiedConnectError(source);
expect(classified.httpStatus).toBe(403);
});
it("marks client errors correctly", () => {
const clientError = new ClassifiedConnectError(new ConnectError("bad input", Code.InvalidArgument));
const serverError = new ClassifiedConnectError(new ConnectError("internal", Code.Internal));
expect(clientError.isUserError).toBe(true);
expect(serverError.isUserError).toBe(false);
});
it("is detectable via isClassifiedError type guard", () => {
const classified = new ClassifiedConnectError(new ConnectError("test", Code.NotFound));
expect(isClassifiedError(classified)).toBe(true);
});
it("marks FailedPrecondition as client error", () => {
const classified = new ClassifiedConnectError(new ConnectError("precondition", Code.FailedPrecondition));
expect(classified.isUserError).toBe(true);
expect(classified.httpStatus).toBe(400);
});
it("marks Unavailable as server error", () => {
const classified = new ClassifiedConnectError(new ConnectError("unavailable", Code.Unavailable));
expect(classified.isUserError).toBe(false);
expect(classified.httpStatus).toBe(503);
});
});
describe("isClassifiedError", () => {
it("returns true for ClassifiedConnectError", () => {
const classified = new ClassifiedConnectError(new ConnectError("test", Code.NotFound));
expect(isClassifiedError(classified)).toBe(true);
});
it("returns false for plain ConnectError", () => {
const plain = new ConnectError("test", Code.NotFound);
expect(isClassifiedError(plain)).toBe(false);
});
it("returns false for plain Error", () => {
expect(isClassifiedError(new Error("test"))).toBe(false);
});
it("returns false for non-error values", () => {
expect(isClassifiedError(null)).toBe(false);
expect(isClassifiedError(undefined)).toBe(false);
expect(isClassifiedError("string")).toBe(false);
expect(isClassifiedError({ code: 5 })).toBe(false);
});
});
@@ -0,0 +1,129 @@
/**
* Error Classification Interceptor
*
* Enriches ConnectError instances with HTTP status code equivalents and
* client/server classification. This interceptor runs at the transport level,
* so every gRPC/Connect call automatically gets classified errors so we can properly catch them in client code.
*
* Purpose:
* - Prevent client-side gRPC errors (4xx equivalents) from being surfaced as HTTP 500s
* - Provide correct HTTP status codes for route handler error responses
*
* @see https://cloud.google.com/apis/design/errors#handling_errors
*/
import { Code, ConnectError, Interceptor } from "@connectrpc/connect";
/** Unique brand symbol for ClassifiedConnectError type guard detection */
const CLASSIFIED_BRAND = Symbol.for("ClassifiedConnectError");
/** Canonical gRPC → HTTP status code mapping */
const GRPC_TO_HTTP: Readonly<Record<number, number>> = {
[Code.InvalidArgument]: 400,
[Code.FailedPrecondition]: 400,
[Code.OutOfRange]: 400,
[Code.Unauthenticated]: 401,
[Code.PermissionDenied]: 403,
[Code.NotFound]: 404,
[Code.AlreadyExists]: 409,
[Code.Aborted]: 409,
[Code.ResourceExhausted]: 429,
[Code.Canceled]: 499,
[Code.Unimplemented]: 501,
[Code.Unavailable]: 503,
[Code.DeadlineExceeded]: 504,
[Code.DataLoss]: 500,
[Code.Internal]: 500,
[Code.Unknown]: 500,
};
/** gRPC codes that represent user input errors (not genuine server failures) */
const CLIENT_ERROR_CODES: ReadonlySet<Code> = new Set([
Code.InvalidArgument,
Code.FailedPrecondition,
Code.OutOfRange,
Code.Unauthenticated,
Code.PermissionDenied,
Code.NotFound,
Code.AlreadyExists,
Code.Aborted,
Code.ResourceExhausted,
Code.Canceled,
]);
/**
* A ConnectError enriched with HTTP status classification.
*
* All ConnectErrors thrown by service calls through the classified transport
* will be instances of this class, allowing callers to inspect `httpStatus`
* and `isUserError` without manual mapping.
*/
export class ClassifiedConnectError extends ConnectError {
/** The equivalent HTTP status code for this gRPC error */
readonly httpStatus: number;
/** Whether this error represents a user input error (true) or a server failure (false) */
readonly isUserError: boolean;
/** @internal Brand symbol for type guard detection */
readonly [CLASSIFIED_BRAND] = true as const;
constructor(source: ConnectError) {
super(source.rawMessage, source.code, source.metadata, undefined, source.cause);
// ConnectError's constructor resets the prototype chain via Object.setPrototypeOf.
// We must restore it so instanceof ClassifiedConnectError works correctly.
Object.setPrototypeOf(this, ClassifiedConnectError.prototype);
this.name = "ClassifiedConnectError";
// Preserve the original stack trace so debugging/alert triage can see the RPC call site.
if (source.stack) {
this.stack = source.stack;
}
this.httpStatus = GRPC_TO_HTTP[source.code] ?? 500;
this.isUserError = CLIENT_ERROR_CODES.has(source.code);
// Copy details from the source error (avoids OutgoingDetail/IncomingDetail type mismatch)
if (source.details.length > 0) {
Object.defineProperty(this, "details", { value: source.details, writable: false });
}
// Preserve the raw message from the original error
if ("rawMessage" in source) {
Object.defineProperty(this, "rawMessage", { value: source.rawMessage, writable: false });
}
}
}
/**
* Type guard for ClassifiedConnectError.
* Use this in catch blocks to safely access httpStatus/isUserError.
*/
export function isClassifiedError(error: unknown): error is ClassifiedConnectError {
return error !== null && typeof error === "object" && CLASSIFIED_BRAND in error;
}
/**
* Maps a gRPC Code to its HTTP status equivalent.
* Useful when you have the code but not a full ClassifiedConnectError instance.
*/
export function grpcCodeToHttpStatus(code: Code): number {
return GRPC_TO_HTTP[code] ?? 500;
}
/**
* Transport-level interceptor that catches ConnectError and re-throws
* it as a ClassifiedConnectError with httpStatus and isUserError metadata.
*
* Plug this into the transport's interceptor chain to automatically classify
* every error from every service call.
*/
export const errorClassificationInterceptor: Interceptor = (next) =>
async function classifiedCall(req) {
try {
return await next(req);
} catch (err) {
if (err instanceof ConnectError) {
throw new ClassifiedConnectError(err);
}
throw err;
}
};
@@ -7,6 +7,7 @@ import { createClientFor } from "@zitadel/client";
import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { errorClassificationInterceptor } from "./error-classification";
import { otelGrpcInterceptor } from "./otel"; import { otelGrpcInterceptor } from "./otel";
const exporter = new InMemorySpanExporter(); const exporter = new InMemorySpanExporter();
@@ -77,7 +78,7 @@ describe("otelGrpcInterceptor", () => {
}, },
{ {
transport: { transport: {
interceptors: [otelGrpcInterceptor], interceptors: [otelGrpcInterceptor, errorClassificationInterceptor],
}, },
}, },
); );
@@ -95,7 +96,11 @@ describe("otelGrpcInterceptor", () => {
// code prefix gets added by connect // code prefix gets added by connect
message: "[not_found] Session not found", message: "[not_found] Session not found",
}, },
attributes: { "rpc.grpc.status_code": 5 }, attributes: {
"rpc.grpc.status_code": 5,
"error.is_user_error": true,
"http.status_code": 404,
},
}); });
expect(spans[0].events).toHaveLength(1); expect(spans[0].events).toHaveLength(1);
expect(spans[0].events[0].name).toBe("exception"); expect(spans[0].events[0].name).toBe("exception");
+6 -2
View File
@@ -19,8 +19,9 @@
* ``` * ```
*/ */
import { ConnectError, Interceptor } from "@connectrpc/connect"; import { Interceptor } from "@connectrpc/connect";
import { context, propagation, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { context, propagation, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
import { isClassifiedError } from "./error-classification";
const TRACER_NAME = "zitadel-login-grpc" as const; const TRACER_NAME = "zitadel-login-grpc" as const;
@@ -65,14 +66,17 @@ export const otelGrpcInterceptor: Interceptor = (next) =>
try { try {
const response = await next(req); const response = await next(req);
span.setAttribute("rpc.grpc.status_code", 0); // Code.OK
span.setStatus({ code: SpanStatusCode.OK }); span.setStatus({ code: SpanStatusCode.OK });
return response; return response;
} catch (err) { } catch (err) {
const exception = err instanceof Error ? err : new Error(String(err)); const exception = err instanceof Error ? err : new Error(String(err));
span.recordException(exception); span.recordException(exception);
if (exception instanceof ConnectError) { if (isClassifiedError(exception)) {
span.setAttribute("rpc.grpc.status_code", exception.code); span.setAttribute("rpc.grpc.status_code", exception.code);
span.setAttribute("error.is_user_error", exception.isUserError);
span.setAttribute("http.status_code", exception.httpStatus);
} }
span.setStatus({ span.setStatus({
code: SpanStatusCode.ERROR, code: SpanStatusCode.ERROR,
+20 -2
View File
@@ -7,6 +7,22 @@ import * as zitadelModule from "./zitadel";
vi.mock("./session"); vi.mock("./session");
vi.mock("./zitadel"); vi.mock("./zitadel");
vi.mock("./server/loginname"); vi.mock("./server/loginname");
vi.mock("@/lib/grpc/interceptors/error-classification", () => ({
isClassifiedError: (error: unknown): boolean =>
typeof error === "object" && error !== null && "code" in error && typeof (error as any).code === "number",
}));
vi.mock("@zitadel/client", () => ({
Code: { FailedPrecondition: 9 },
ConnectError: class MockConnectError extends Error {
code: number;
constructor(msg: string, code: number) {
super(msg);
this.code = code;
}
},
create: vi.fn(),
}));
describe("loginWithOIDCAndSession", () => { describe("loginWithOIDCAndSession", () => {
const mockServiceUrl = "https://zitadel.example.com"; const mockServiceUrl = "https://zitadel.example.com";
@@ -115,8 +131,9 @@ describe("loginWithOIDCAndSession", () => {
}); });
it("should handle error code 9 with default redirect", async () => { it("should handle error code 9 with default redirect", async () => {
const { ConnectError } = await import("@zitadel/client");
vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true); vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true);
vi.mocked(zitadelModule.createCallback).mockRejectedValue({ code: 9 }); vi.mocked(zitadelModule.createCallback).mockRejectedValue(new ConnectError("already handled", 9));
vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({
defaultRedirectUri: "https://default.example.com", defaultRedirectUri: "https://default.example.com",
} as any); } as any);
@@ -133,8 +150,9 @@ describe("loginWithOIDCAndSession", () => {
}); });
it("should redirect to /signedin when error code 9 and no default URI", async () => { it("should redirect to /signedin when error code 9 and no default URI", async () => {
const { ConnectError } = await import("@zitadel/client");
vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true); vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true);
vi.mocked(zitadelModule.createCallback).mockRejectedValue({ code: 9 }); vi.mocked(zitadelModule.createCallback).mockRejectedValue(new ConnectError("already handled", 9));
vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({} as any); vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({} as any);
const result = await loginWithOIDCAndSession({ const result = await loginWithOIDCAndSession({
+3 -2
View File
@@ -1,7 +1,8 @@
import { Cookie } from "@/lib/cookies"; import { Cookie } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { createCallback, getLoginSettings, ServiceConfig } from "@/lib/zitadel"; import { createCallback, getLoginSettings, ServiceConfig } from "@/lib/zitadel";
import { create } from "@zitadel/client"; import { Code, create } from "@zitadel/client";
import { CreateCallbackRequestSchema, SessionSchema } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { CreateCallbackRequestSchema, SessionSchema } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { isSessionValid } from "./session"; import { isSessionValid } from "./session";
@@ -70,7 +71,7 @@ export async function loginWithOIDCAndSession({
} catch (error: unknown) { } catch (error: unknown) {
// handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.)
console.error(error); console.error(error);
if (error && typeof error === "object" && "code" in error && error?.code === 9) { if (isClassifiedError(error) && error.code === Code.FailedPrecondition) {
const loginSettings = await getLoginSettings({ const loginSettings = await getLoginSettings({
serviceConfig, serviceConfig,
organization: selectedSession.factors?.user?.organizationId, organization: selectedSession.factors?.user?.organizationId,
+3 -2
View File
@@ -1,7 +1,8 @@
import { Cookie } from "@/lib/cookies"; import { Cookie } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { createResponse, getLoginSettings, ServiceConfig } from "@/lib/zitadel"; import { createResponse, getLoginSettings, ServiceConfig } from "@/lib/zitadel";
import { create } from "@zitadel/client"; import { Code, create } from "@zitadel/client";
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { isSessionValid } from "./session"; import { isSessionValid } from "./session";
@@ -89,7 +90,7 @@ export async function loginWithSAMLAndSession({
// handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.)
console.error(error); console.error(error);
if (error && typeof error === "object" && "code" in error && error?.code === 9) { if (isClassifiedError(error) && error.code === Code.FailedPrecondition) {
const loginSettings = await getLoginSettings({ const loginSettings = await getLoginSettings({
serviceConfig, serviceConfig,
organization: selectedSession.factors?.user?.organizationId, organization: selectedSession.factors?.user?.organizationId,
+6 -6
View File
@@ -101,11 +101,11 @@ export async function createSessionAndUpdateCookie(command: {
return { session: response.session as Session, sessionCookie, challenges: createdSession.challenges }; return { session: response.session as Session, sessionCookie, challenges: createdSession.challenges };
} else { } else {
throw "could not get session or session does not have loginName"; throw new Error("could not get session or session does not have loginName");
} }
}); });
} else { } else {
throw "Could not create session"; throw new Error("Could not create session");
} }
} }
@@ -154,7 +154,7 @@ export async function createSessionForIdpAndUpdateCookie({
}); });
if (!createdSession) { if (!createdSession) {
throw "Could not create session"; throw new Error("Could not create session");
} }
const { session } = await getSession({ const { session } = await getSession({
@@ -164,7 +164,7 @@ export async function createSessionForIdpAndUpdateCookie({
}); });
if (!session || !session.factors?.user?.loginName) { if (!session || !session.factors?.user?.loginName) {
throw "Could not retrieve session"; throw new Error("Could not retrieve session");
} }
const sessionCookie: CustomCookieData = { const sessionCookie: CustomCookieData = {
@@ -235,7 +235,7 @@ export async function setSessionAndUpdateCookie(command: {
return getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }).then( return getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }).then(
async (response) => { async (response) => {
if (!response?.session || !response.session.factors?.user?.loginName) { if (!response?.session || !response.session.factors?.user?.loginName) {
throw "could not get session or session does not have loginName"; throw new Error("could not get session or session does not have loginName");
} }
const { session } = response; const { session } = response;
@@ -267,7 +267,7 @@ export async function setSessionAndUpdateCookie(command: {
}, },
); );
} else { } else {
throw "Session not be set"; throw new Error("Session could not be set");
} }
}) })
.catch(passwordAttemptsHandler); .catch(passwordAttemptsHandler);
+21 -2
View File
@@ -1,5 +1,6 @@
import { getValidLocaleFromUILocales } from "@/lib/auth-utils"; import { getValidLocaleFromUILocales } from "@/lib/auth-utils";
import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies"; import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { shouldUILocalesOverrideCookie } from "@/lib/i18n"; import { shouldUILocalesOverrideCookie } from "@/lib/i18n";
import { idpTypeToSlug } from "@/lib/idp"; import { idpTypeToSlug } from "@/lib/idp";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
@@ -86,7 +87,16 @@ export interface FlowInitiationParams {
export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> { export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> {
const { serviceConfig, requestId, sessions, sessionCookies, request } = params; const { serviceConfig, requestId, sessions, sessionCookies, request } = params;
const { authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", "") }); 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); const locale = getValidLocaleFromUILocales(authRequest?.uiLocales);
if (locale) { if (locale) {
@@ -386,7 +396,16 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> { export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> {
const { serviceConfig, requestId, sessions, sessionCookies, request } = params; const { serviceConfig, requestId, sessions, sessionCookies, request } = params;
const { samlRequest } = await getSAMLRequest({ serviceConfig, samlRequestId: requestId.replace("saml_", "") }); 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) { if (!samlRequest) {
return NextResponse.json({ error: "No samlRequest found" }, { status: 400 }); return NextResponse.json({ error: "No samlRequest found" }, { status: 400 });
+3 -2
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getSessionCookieById } from "@/lib/cookies"; import { getSessionCookieById } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { getServiceConfig } from "@/lib/service-url"; import { getServiceConfig } from "@/lib/service-url";
import { import {
@@ -18,7 +19,7 @@ import {
ServiceConfig, ServiceConfig,
updateHuman, updateHuman,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { Code, ConnectError, create } from "@zitadel/client"; import { Code, 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 { import {
@@ -276,7 +277,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
logger.error("Error linking IDP", { error }); logger.error("Error linking IDP", { error });
const errorMessage = error instanceof Error ? error.message : t("errors.unknownError"); const errorMessage = error instanceof Error ? error.message : t("errors.unknownError");
let params = buildRedirectParams({ error: errorMessage }); let params = buildRedirectParams({ error: errorMessage });
if (error instanceof ConnectError && error.code === Code.AlreadyExists) { if (isClassifiedError(error) && error.code === Code.AlreadyExists) {
params = buildRedirectParams({ error: "external_idp_taken" }); params = buildRedirectParams({ error: "external_idp_taken" });
} }
return { redirect: `/idp/${provider}/linking-failed?${params}` }; return { redirect: `/idp/${provider}/linking-failed?${params}` };
+2 -1
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
@@ -273,7 +274,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
checks, checks,
requestId: command.requestId, requestId: command.requestId,
}).catch((error) => { }).catch((error) => {
if (error?.rawMessage === "Errors.User.NotActive (SESSION-Gj4ko)") { if (isClassifiedError(error) && error.message?.includes("Errors.User.NotActive")) {
return { error: t("errors.userNotActive") }; return { error: t("errors.userNotActive") };
} }
throw error; throw error;
+3 -1
View File
@@ -9,6 +9,7 @@ vi.mock("next/headers", () => ({
vi.mock("@zitadel/client", () => ({ vi.mock("@zitadel/client", () => ({
create: vi.fn(), create: vi.fn(),
Code: { FailedPrecondition: 9 },
ConnectError: class extends Error { ConnectError: class extends Error {
code: number; code: number;
constructor(msg: string, code: number) { constructor(msg: string, code: number) {
@@ -272,7 +273,8 @@ describe("checkSessionAndSetPassword", () => {
}); });
test("should handle setPassword failure with failed precondition", async () => { test("should handle setPassword failure with failed precondition", async () => {
mockSetPassword.mockRejectedValue({ code: 9, message: "User is not yet initialized" }); const { ConnectError } = await import("@zitadel/client");
mockSetPassword.mockRejectedValue(new ConnectError("User is not yet initialized", 9));
const result = await checkSessionAndSetPassword({ const result = await checkSessionAndSetPassword({
sessionId: "session123", sessionId: "session123",
+2 -2
View File
@@ -15,7 +15,7 @@ import {
setPassword, setPassword,
setUserPassword, setUserPassword,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { create, Duration } from "@zitadel/client"; import { Code, ConnectError, create, Duration } from "@zitadel/client";
import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
@@ -585,7 +585,7 @@ export async function checkSessionAndSetPassword({
return setPassword({ serviceConfig, payload }).catch((error) => { return setPassword({ serviceConfig, payload }).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized) // throw error if failed precondition (ex. User is not yet initialized)
if (error.code === 9 && error.message) { if (error instanceof ConnectError && error.code === Code.FailedPrecondition && error.message) {
return { error: t("errors.failedPrecondition") }; return { error: t("errors.failedPrecondition") };
} }
return { error: "Could not set password" }; return { error: "Could not set password" };
+6 -1
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie"; import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie";
import { import {
@@ -152,7 +153,11 @@ export async function updateOrCreateSession(options: UpdateSessionCommand) {
challenges, challenges,
requestId, requestId,
}).catch((error) => { }).catch((error) => {
logger.error("Could not create session", { error }); if (isClassifiedError(error) && error.isUserError) {
logger.warn("Could not create session (client error)", { grpcCode: error.code, httpStatus: error.httpStatus });
} else {
logger.error("Could not create session (server error)", { error });
}
return undefined; return undefined;
}); });
+4 -3
View File
@@ -34,8 +34,9 @@ import { getTranslations } from "next-intl/server";
import { getUserAgent } from "./fingerprint"; import { getUserAgent } from "./fingerprint";
import { createLogger } from "./logger"; import { createLogger } from "./logger";
import { errorClassificationInterceptor } from "@/lib/grpc/interceptors/error-classification";
import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel"; import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel";
import { Interceptor } from "@connectrpc/connect"; import { Code, ConnectError, Interceptor } from "@connectrpc/connect";
import { createServiceForHost } from "./service"; import { createServiceForHost } from "./service";
const logger = createLogger("zitadel"); const logger = createLogger("zitadel");
@@ -1154,7 +1155,7 @@ export async function setUserPassword({
return userService.setPassword(payload, {}).catch((error) => { return userService.setPassword(payload, {}).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized) // throw error if failed precondition (ex. User is not yet initialized)
if (error.code === 9 && error.message) { if (error instanceof ConnectError && error.code === Code.FailedPrecondition && error.message) {
return { error: error.message }; return { error: error.message };
} else { } else {
throw error; throw error;
@@ -1378,6 +1379,6 @@ export function createServerTransport(token: string, serviceConfig: ServiceConfi
return createConnectTransport({ return createConnectTransport({
httpVersion: "1.1", httpVersion: "1.1",
baseUrl: serviceConfig.baseUrl, baseUrl: serviceConfig.baseUrl,
interceptors: [otelGrpcInterceptor, authorizationInterceptor, headerInterceptor], interceptors: [otelGrpcInterceptor, errorClassificationInterceptor, authorizationInterceptor, headerInterceptor],
}); });
} }