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], }); }