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>
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
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 { 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 });
@@ -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 { 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");
+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 { 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,
+20 -2
View File
@@ -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({
+3 -2
View File
@@ -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,
+3 -2
View File
@@ -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,
+6 -6
View File
@@ -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);
+21 -2
View File
@@ -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<NextResponse> {
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<NextResponse> {
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 });
+3 -2
View File
@@ -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<IDPHandler
logger.error("Error linking IDP", { error });
const errorMessage = error instanceof Error ? error.message : t("errors.unknownError");
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" });
}
return { redirect: `/idp/${provider}/linking-failed?${params}` };
+2 -1
View File
@@ -1,5 +1,6 @@
"use server";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
@@ -273,7 +274,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
checks,
requestId: command.requestId,
}).catch((error) => {
if (error?.rawMessage === "Errors.User.NotActive (SESSION-Gj4ko)") {
if (isClassifiedError(error) && error.message?.includes("Errors.User.NotActive")) {
return { error: t("errors.userNotActive") };
}
throw error;
+3 -1
View File
@@ -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",
+2 -2
View File
@@ -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" };
+6 -1
View File
@@ -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;
});
+4 -3
View File
@@ -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],
});
}