mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
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:
co-authored by
Ramon
Copilot
parent
8e41d288ff
commit
ff4aae7b3f
@@ -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
|
||||
|
||||
Vendored
-6
@@ -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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user