diff --git a/.gitignore b/.gitignore
index 4f7233c47b..b553906568 100644
--- a/.gitignore
+++ b/.gitignore
@@ -107,3 +107,6 @@ CLAUDE.md
docs_old
apps/docs/package-lock.json
+
+# Auto-generated by Next.js (rewrites on every dev/build)
+next-env.d.ts
diff --git a/apps/login/next-env.d.ts b/apps/login/next-env.d.ts
deleted file mode 100755
index c4b7818fbb..0000000000
--- a/apps/login/next-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-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.
diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts
index a68fe86d62..48f83382f4 100644
--- a/apps/login/src/app/login/route.ts
+++ b/apps/login/src/app/login/route.ts
@@ -1,5 +1,6 @@
import { isRSCRequest, validateAuthRequest } from "@/lib/auth-utils";
import { getAllSessions } from "@/lib/cookies";
+import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { FlowInitiationParams, handleOIDCFlowInitiation, handleSAMLFlowInitiation } from "@/lib/server/flow-initiation";
import { getServiceConfig } from "@/lib/service-url";
import { listSessions, ServiceConfig } from "@/lib/zitadel";
@@ -45,9 +46,19 @@ export async function GET(request: NextRequest) {
const flowParams: FlowInitiationParams = { serviceConfig, requestId, sessions, sessionCookies, request };
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_")) {
- 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_")) {
// 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 });
diff --git a/apps/login/src/lib/grpc/interceptors/error-classification.test.ts b/apps/login/src/lib/grpc/interceptors/error-classification.test.ts
new file mode 100644
index 0000000000..891adf2ae3
--- /dev/null
+++ b/apps/login/src/lib/grpc/interceptors/error-classification.test.ts
@@ -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);
+ });
+});
diff --git a/apps/login/src/lib/grpc/interceptors/error-classification.ts b/apps/login/src/lib/grpc/interceptors/error-classification.ts
new file mode 100644
index 0000000000..beb7fffcb6
--- /dev/null
+++ b/apps/login/src/lib/grpc/interceptors/error-classification.ts
@@ -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> = {
+ [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 = 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;
+ }
+ };
diff --git a/apps/login/src/lib/grpc/interceptors/otel.test.ts b/apps/login/src/lib/grpc/interceptors/otel.test.ts
index 29e7bc8350..e61ce357b3 100644
--- a/apps/login/src/lib/grpc/interceptors/otel.test.ts
+++ b/apps/login/src/lib/grpc/interceptors/otel.test.ts
@@ -7,6 +7,7 @@ import { createClientFor } from "@zitadel/client";
import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
+import { errorClassificationInterceptor } from "./error-classification";
import { otelGrpcInterceptor } from "./otel";
const exporter = new InMemorySpanExporter();
@@ -77,7 +78,7 @@ describe("otelGrpcInterceptor", () => {
},
{
transport: {
- interceptors: [otelGrpcInterceptor],
+ interceptors: [otelGrpcInterceptor, errorClassificationInterceptor],
},
},
);
@@ -95,7 +96,11 @@ describe("otelGrpcInterceptor", () => {
// code prefix gets added by connect
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[0].name).toBe("exception");
diff --git a/apps/login/src/lib/grpc/interceptors/otel.ts b/apps/login/src/lib/grpc/interceptors/otel.ts
index ec2d32ba2f..8642bafc04 100644
--- a/apps/login/src/lib/grpc/interceptors/otel.ts
+++ b/apps/login/src/lib/grpc/interceptors/otel.ts
@@ -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 { isClassifiedError } from "./error-classification";
const TRACER_NAME = "zitadel-login-grpc" as const;
@@ -65,14 +66,17 @@ export const otelGrpcInterceptor: Interceptor = (next) =>
try {
const response = await next(req);
+ span.setAttribute("rpc.grpc.status_code", 0); // Code.OK
span.setStatus({ code: SpanStatusCode.OK });
return response;
} catch (err) {
const exception = err instanceof Error ? err : new Error(String(err));
span.recordException(exception);
- if (exception instanceof ConnectError) {
+ if (isClassifiedError(exception)) {
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({
code: SpanStatusCode.ERROR,
diff --git a/apps/login/src/lib/oidc.test.ts b/apps/login/src/lib/oidc.test.ts
index cbcc957618..32b522d295 100644
--- a/apps/login/src/lib/oidc.test.ts
+++ b/apps/login/src/lib/oidc.test.ts
@@ -7,6 +7,22 @@ import * as zitadelModule from "./zitadel";
vi.mock("./session");
vi.mock("./zitadel");
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", () => {
const mockServiceUrl = "https://zitadel.example.com";
@@ -115,8 +131,9 @@ describe("loginWithOIDCAndSession", () => {
});
it("should handle error code 9 with default redirect", async () => {
+ const { ConnectError } = await import("@zitadel/client");
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({
defaultRedirectUri: "https://default.example.com",
} as any);
@@ -133,8 +150,9 @@ describe("loginWithOIDCAndSession", () => {
});
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(zitadelModule.createCallback).mockRejectedValue({ code: 9 });
+ vi.mocked(zitadelModule.createCallback).mockRejectedValue(new ConnectError("already handled", 9));
vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({} as any);
const result = await loginWithOIDCAndSession({
diff --git a/apps/login/src/lib/oidc.ts b/apps/login/src/lib/oidc.ts
index 2ab3e2a391..e9ade7ab27 100644
--- a/apps/login/src/lib/oidc.ts
+++ b/apps/login/src/lib/oidc.ts
@@ -1,7 +1,8 @@
import { Cookie } from "@/lib/cookies";
+import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
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 { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { isSessionValid } from "./session";
@@ -70,7 +71,7 @@ export async function loginWithOIDCAndSession({
} catch (error: unknown) {
// handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.)
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({
serviceConfig,
organization: selectedSession.factors?.user?.organizationId,
diff --git a/apps/login/src/lib/saml.ts b/apps/login/src/lib/saml.ts
index 7b1784c154..96b8eb4d06 100644
--- a/apps/login/src/lib/saml.ts
+++ b/apps/login/src/lib/saml.ts
@@ -1,7 +1,8 @@
import { Cookie } from "@/lib/cookies";
+import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
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 { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
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.)
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({
serviceConfig,
organization: selectedSession.factors?.user?.organizationId,
diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts
index edfbc9bbe5..8016540364 100644
--- a/apps/login/src/lib/server/cookie.ts
+++ b/apps/login/src/lib/server/cookie.ts
@@ -101,11 +101,11 @@ export async function createSessionAndUpdateCookie(command: {
return { session: response.session as Session, sessionCookie, challenges: createdSession.challenges };
} 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 {
- throw "Could not create session";
+ throw new Error("Could not create session");
}
}
@@ -154,7 +154,7 @@ export async function createSessionForIdpAndUpdateCookie({
});
if (!createdSession) {
- throw "Could not create session";
+ throw new Error("Could not create session");
}
const { session } = await getSession({
@@ -164,7 +164,7 @@ export async function createSessionForIdpAndUpdateCookie({
});
if (!session || !session.factors?.user?.loginName) {
- throw "Could not retrieve session";
+ throw new Error("Could not retrieve session");
}
const sessionCookie: CustomCookieData = {
@@ -235,7 +235,7 @@ export async function setSessionAndUpdateCookie(command: {
return getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }).then(
async (response) => {
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;
@@ -267,7 +267,7 @@ export async function setSessionAndUpdateCookie(command: {
},
);
} else {
- throw "Session not be set";
+ throw new Error("Session could not be set");
}
})
.catch(passwordAttemptsHandler);
diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts
index 73765ef25d..893da64189 100644
--- a/apps/login/src/lib/server/flow-initiation.ts
+++ b/apps/login/src/lib/server/flow-initiation.ts
@@ -1,5 +1,6 @@
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";
@@ -86,7 +87,16 @@ export interface FlowInitiationParams {
export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise {
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);
if (locale) {
@@ -386,7 +396,16 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise {
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) {
return NextResponse.json({ error: "No samlRequest found" }, { status: 400 });
diff --git a/apps/login/src/lib/server/idp-intent.ts b/apps/login/src/lib/server/idp-intent.ts
index 56fe7fac1d..bb66538485 100644
--- a/apps/login/src/lib/server/idp-intent.ts
+++ b/apps/login/src/lib/server/idp-intent.ts
@@ -1,6 +1,7 @@
"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 {
@@ -18,7 +19,7 @@ import {
ServiceConfig,
updateHuman,
} 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 { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb";
import {
@@ -276,7 +277,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise {
- if (error?.rawMessage === "Errors.User.NotActive (SESSION-Gj4ko)") {
+ if (isClassifiedError(error) && error.message?.includes("Errors.User.NotActive")) {
return { error: t("errors.userNotActive") };
}
throw error;
diff --git a/apps/login/src/lib/server/password.test.ts b/apps/login/src/lib/server/password.test.ts
index 41a256197d..9efb98cab0 100644
--- a/apps/login/src/lib/server/password.test.ts
+++ b/apps/login/src/lib/server/password.test.ts
@@ -9,6 +9,7 @@ vi.mock("next/headers", () => ({
vi.mock("@zitadel/client", () => ({
create: vi.fn(),
+ Code: { FailedPrecondition: 9 },
ConnectError: class extends Error {
code: number;
constructor(msg: string, code: number) {
@@ -272,7 +273,8 @@ describe("checkSessionAndSetPassword", () => {
});
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({
sessionId: "session123",
diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts
index 1097ad00a9..56118a8978 100644
--- a/apps/login/src/lib/server/password.ts
+++ b/apps/login/src/lib/server/password.ts
@@ -15,7 +15,7 @@ import {
setPassword,
setUserPassword,
} 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 { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_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) => {
// 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: "Could not set password" };
diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts
index fff38e2276..60f813a602 100644
--- a/apps/login/src/lib/server/session.ts
+++ b/apps/login/src/lib/server/session.ts
@@ -1,5 +1,6 @@
"use server";
+import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie";
import {
@@ -152,7 +153,11 @@ export async function updateOrCreateSession(options: UpdateSessionCommand) {
challenges,
requestId,
}).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;
});
diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts
index a46232c487..d8b606c5ef 100644
--- a/apps/login/src/lib/zitadel.ts
+++ b/apps/login/src/lib/zitadel.ts
@@ -34,8 +34,9 @@ import { getTranslations } from "next-intl/server";
import { getUserAgent } from "./fingerprint";
import { createLogger } from "./logger";
+import { errorClassificationInterceptor } from "@/lib/grpc/interceptors/error-classification";
import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel";
-import { Interceptor } from "@connectrpc/connect";
+import { Code, ConnectError, Interceptor } from "@connectrpc/connect";
import { createServiceForHost } from "./service";
const logger = createLogger("zitadel");
@@ -1154,7 +1155,7 @@ export async function setUserPassword({
return userService.setPassword(payload, {}).catch((error) => {
// 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 };
} else {
throw error;
@@ -1378,6 +1379,6 @@ export function createServerTransport(token: string, serviceConfig: ServiceConfi
return createConnectTransport({
httpVersion: "1.1",
baseUrl: serviceConfig.baseUrl,
- interceptors: [otelGrpcInterceptor, authorizationInterceptor, headerInterceptor],
+ interceptors: [otelGrpcInterceptor, errorClassificationInterceptor, authorizationInterceptor, headerInterceptor],
});
}