mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-28 10:02:15 +00:00
Closes #11923 # Which Problems Are Solved Client-side gRPC errors (e.g. NotFound, InvalidArgument, PermissionDenied) were being surfaced as HTTP 500 Internal Server Errors, causing false SRE alerts and poor UI feedback. # How the Problems Are Solved - New transport interceptor (`error-classification.ts`): Automatically enriches every ConnectError with httpStatus and isUserError metadata via a `ClassifiedConnectError` wrapper - Route handler protection (`route.ts`, `flow-initiation.ts`): Catches classified errors from `getAuthRequest` / `getSAMLRequest` and returns correct HTTP status codes instead of 500 - Type safety: Replaced all magic number error.code === 9 checks with typed Code.FailedPrecondition + instanceof ConnectError across `oidc.ts`, `saml.ts`, `password.ts`, `zitadel.ts` - Classification-aware logging (`session.ts`): Client/user errors log at warn level, server errors at error level - Observability (`otel.ts`): Spans now include error.is_user_error and http.status_code attributes for alert filtering # Additional Changes Gitignore `next-env.d.ts`: This file is auto-generated by Next.js on every next dev and next build invocation. The two modes write slightly different import paths (.next/dev/types/ vs .next/types/), which causes git diff --exit-code to fail in CI whenever a developer runs next dev locally before committing. Since Next.js regenerates it automatically, there's no need to track it --------- Co-authored-by: Ramon <[email protected]> Co-authored-by: Copilot <[email protected]>
275 lines
9.2 KiB
TypeScript
275 lines
9.2 KiB
TypeScript
"use server";
|
|
|
|
import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies";
|
|
import { createLogger } from "@/lib/logger";
|
|
import {
|
|
createSessionForUserIdAndIdpIntent,
|
|
createSessionFromChecksAndChallenges,
|
|
getSecuritySettings,
|
|
getSession,
|
|
setSession,
|
|
} from "@/lib/zitadel";
|
|
import { ConnectError, Duration, timestampMs } from "@zitadel/client";
|
|
import { CredentialsCheckError, CredentialsCheckErrorSchema, ErrorDetail } from "@zitadel/proto/zitadel/message_pb";
|
|
import { Challenges, RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
|
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
|
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
|
import { headers } from "next/headers";
|
|
import { getServiceConfig } from "../service-url";
|
|
|
|
const logger = createLogger("cookie");
|
|
|
|
type CustomCookieData = {
|
|
id: string;
|
|
token: string;
|
|
loginName: string;
|
|
organization?: string;
|
|
creationTs: string;
|
|
expirationTs: string;
|
|
changeTs: string;
|
|
requestId?: string; // if its linked to an OIDC flow
|
|
};
|
|
|
|
const passwordAttemptsHandler = (error: ConnectError) => {
|
|
const details = error.findDetails(CredentialsCheckErrorSchema);
|
|
|
|
if (details[0] && "failedAttempts" in details[0]) {
|
|
const failedAttempts = details[0].failedAttempts;
|
|
throw {
|
|
error: `Failed to authenticate: You had ${failedAttempts} password attempts.`,
|
|
failedAttempts: failedAttempts,
|
|
};
|
|
}
|
|
throw error;
|
|
};
|
|
|
|
export async function createSessionAndUpdateCookie(command: {
|
|
checks: Checks;
|
|
requestId: string | undefined;
|
|
lifetime?: Duration;
|
|
challenges?: RequestChallenges;
|
|
}): Promise<{ session: Session; sessionCookie: CustomCookieData; challenges?: Challenges }> {
|
|
const _headers = await headers();
|
|
const { serviceConfig } = getServiceConfig(_headers);
|
|
|
|
let sessionLifetime = command.lifetime;
|
|
|
|
if (!sessionLifetime || !sessionLifetime.seconds) {
|
|
logger.warn("No session lifetime provided, using default of 24 hours");
|
|
|
|
sessionLifetime = {
|
|
seconds: BigInt(24 * 60 * 60), // 24 hours
|
|
nanos: 0,
|
|
} as Duration; // for usecases where the lifetime is not specified (user discovery)
|
|
}
|
|
|
|
const createdSession = await createSessionFromChecksAndChallenges({
|
|
serviceConfig,
|
|
checks: command.checks,
|
|
lifetime: sessionLifetime,
|
|
challenges: command.challenges,
|
|
});
|
|
|
|
if (createdSession) {
|
|
return getSession({
|
|
serviceConfig,
|
|
sessionId: createdSession.sessionId,
|
|
sessionToken: createdSession.sessionToken,
|
|
}).then(async (response) => {
|
|
if (response?.session && response.session?.factors?.user?.loginName) {
|
|
const sessionCookie: CustomCookieData = {
|
|
id: createdSession.sessionId,
|
|
token: createdSession.sessionToken,
|
|
creationTs: response.session.creationDate ? `${timestampMs(response.session.creationDate)}` : "",
|
|
expirationTs: response.session.expirationDate ? `${timestampMs(response.session.expirationDate)}` : "",
|
|
changeTs: response.session.changeDate ? `${timestampMs(response.session.changeDate)}` : "",
|
|
loginName: response.session.factors.user.loginName ?? "",
|
|
};
|
|
|
|
if (command.requestId) {
|
|
sessionCookie.requestId = command.requestId;
|
|
}
|
|
|
|
if (response.session.factors.user.organizationId) {
|
|
sessionCookie.organization = response.session.factors.user.organizationId;
|
|
}
|
|
|
|
const securitySettings = await getSecuritySettings({ serviceConfig });
|
|
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
|
|
|
|
await addSessionToCookie({ session: sessionCookie, iFrameEnabled });
|
|
|
|
return { session: response.session as Session, sessionCookie, challenges: createdSession.challenges };
|
|
} else {
|
|
throw new Error("could not get session or session does not have loginName");
|
|
}
|
|
});
|
|
} else {
|
|
throw new Error("Could not create session");
|
|
}
|
|
}
|
|
|
|
export async function createSessionForIdpAndUpdateCookie({
|
|
userId,
|
|
idpIntent,
|
|
requestId,
|
|
lifetime,
|
|
}: {
|
|
userId: string;
|
|
idpIntent: {
|
|
idpIntentId?: string | undefined;
|
|
idpIntentToken?: string | undefined;
|
|
};
|
|
requestId: string | undefined;
|
|
lifetime?: Duration;
|
|
}): Promise<Session> {
|
|
const _headers = await headers();
|
|
const { serviceConfig } = getServiceConfig(_headers);
|
|
|
|
let sessionLifetime = lifetime;
|
|
|
|
if (!sessionLifetime || !sessionLifetime.seconds) {
|
|
logger.warn("No IDP session lifetime provided, using default of 24 hours");
|
|
|
|
sessionLifetime = {
|
|
seconds: BigInt(24 * 60 * 60), // 24 hours
|
|
nanos: 0,
|
|
} as Duration;
|
|
}
|
|
|
|
const createdSession = await createSessionForUserIdAndIdpIntent({
|
|
serviceConfig,
|
|
userId,
|
|
idpIntent,
|
|
lifetime: sessionLifetime,
|
|
}).catch((error: ErrorDetail | CredentialsCheckError) => {
|
|
logger.error("Could not set session", { error });
|
|
if ("failedAttempts" in error && error.failedAttempts) {
|
|
throw {
|
|
error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`,
|
|
failedAttempts: error.failedAttempts,
|
|
};
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
if (!createdSession) {
|
|
throw new Error("Could not create session");
|
|
}
|
|
|
|
const { session } = await getSession({
|
|
serviceConfig,
|
|
sessionId: createdSession.sessionId,
|
|
sessionToken: createdSession.sessionToken,
|
|
});
|
|
|
|
if (!session || !session.factors?.user?.loginName) {
|
|
throw new Error("Could not retrieve session");
|
|
}
|
|
|
|
const sessionCookie: CustomCookieData = {
|
|
id: createdSession.sessionId,
|
|
token: createdSession.sessionToken,
|
|
creationTs: session.creationDate ? `${timestampMs(session.creationDate)}` : "",
|
|
expirationTs: session.expirationDate ? `${timestampMs(session.expirationDate)}` : "",
|
|
changeTs: session.changeDate ? `${timestampMs(session.changeDate)}` : "",
|
|
loginName: session.factors.user.loginName ?? "",
|
|
organization: session.factors.user.organizationId ?? "",
|
|
};
|
|
|
|
if (requestId) {
|
|
sessionCookie.requestId = requestId;
|
|
}
|
|
|
|
if (session.factors.user.organizationId) {
|
|
sessionCookie.organization = session.factors.user.organizationId;
|
|
}
|
|
|
|
const securitySettings = await getSecuritySettings({ serviceConfig });
|
|
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
|
|
|
|
return addSessionToCookie({ session: sessionCookie, iFrameEnabled }).then(() => {
|
|
return session as Session;
|
|
});
|
|
}
|
|
|
|
export type SessionWithChallenges = Session & {
|
|
challenges: Challenges | undefined;
|
|
};
|
|
|
|
export async function setSessionAndUpdateCookie(command: {
|
|
recentCookie: CustomCookieData;
|
|
checks?: Checks;
|
|
challenges?: RequestChallenges;
|
|
requestId?: string;
|
|
lifetime: Duration;
|
|
}) {
|
|
const _headers = await headers();
|
|
const { serviceConfig } = getServiceConfig(_headers);
|
|
|
|
return setSession({
|
|
serviceConfig,
|
|
sessionId: command.recentCookie.id,
|
|
sessionToken: command.recentCookie.token,
|
|
challenges: command.challenges,
|
|
checks: command.checks,
|
|
lifetime: command.lifetime,
|
|
})
|
|
.then((updatedSession) => {
|
|
if (updatedSession) {
|
|
const sessionCookie: CustomCookieData = {
|
|
id: command.recentCookie.id,
|
|
token: updatedSession.sessionToken,
|
|
creationTs: command.recentCookie.creationTs,
|
|
expirationTs: command.recentCookie.expirationTs,
|
|
// just overwrite the changeDate with the new one
|
|
changeTs: updatedSession.details?.changeDate ? `${timestampMs(updatedSession.details.changeDate)}` : "",
|
|
loginName: command.recentCookie.loginName,
|
|
organization: command.recentCookie.organization,
|
|
};
|
|
|
|
if (command.requestId) {
|
|
sessionCookie.requestId = command.requestId;
|
|
}
|
|
|
|
return getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }).then(
|
|
async (response) => {
|
|
if (!response?.session || !response.session.factors?.user?.loginName) {
|
|
throw new Error("could not get session or session does not have loginName");
|
|
}
|
|
|
|
const { session } = response;
|
|
const newCookie: CustomCookieData = {
|
|
id: sessionCookie.id,
|
|
token: updatedSession.sessionToken,
|
|
creationTs: sessionCookie.creationTs,
|
|
expirationTs: sessionCookie.expirationTs,
|
|
// just overwrite the changeDate with the new one
|
|
changeTs: updatedSession.details?.changeDate ? `${timestampMs(updatedSession.details.changeDate)}` : "",
|
|
loginName: session.factors?.user?.loginName ?? "",
|
|
organization: session.factors?.user?.organizationId ?? "",
|
|
};
|
|
|
|
if (sessionCookie.requestId) {
|
|
newCookie.requestId = sessionCookie.requestId;
|
|
}
|
|
|
|
const securitySettings = await getSecuritySettings({ serviceConfig });
|
|
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
|
|
|
|
return updateSessionCookie({
|
|
id: sessionCookie.id,
|
|
session: newCookie,
|
|
iFrameEnabled,
|
|
}).then(() => {
|
|
return { challenges: updatedSession.challenges, ...session };
|
|
});
|
|
},
|
|
);
|
|
} else {
|
|
throw new Error("Session could not be set");
|
|
}
|
|
})
|
|
.catch(passwordAttemptsHandler);
|
|
}
|