diff --git a/apps/login/sentry.server.config.ts b/apps/login/sentry.server.config.ts index 324c0a24e..961f5d401 100644 --- a/apps/login/sentry.server.config.ts +++ b/apps/login/sentry.server.config.ts @@ -12,6 +12,13 @@ Sentry.init({ replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, + ignoreErrors: [ + // Malformed RSC requests from bots/crawlers sending invalid Next-Router-State-Tree headers + "The router state header was sent but could not be parsed", + // Node.js TransformStream race condition during RSC streaming (node:internal/webstreams bug) + "transformAlgorithm is not a function", + ], + // Enable sending user PII (Personally Identifiable Information) // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii sendDefaultPii: true, diff --git a/apps/login/src/app/(main)/(boxed)/accounts/page.tsx b/apps/login/src/app/(main)/(boxed)/accounts/page.tsx index 6c4ae9c16..d9820a568 100644 --- a/apps/login/src/app/(main)/(boxed)/accounts/page.tsx +++ b/apps/login/src/app/(main)/(boxed)/accounts/page.tsx @@ -2,6 +2,7 @@ import { SessionsList } from "@/components/sessions-list"; import { Translated } from "@/components/translated"; import { getAllSessionCookieIds } from "@/lib/cookies"; import { generateRouteMetadata } from "@/lib/metadata"; +import { toPlainObject } from "@/lib/serialize"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getBrandingSettings, @@ -55,7 +56,7 @@ export default async function Page(props: { } } - let sessions = await loadSessions({ serviceUrl }); + let sessions = toPlainObject(await loadSessions({ serviceUrl })); const branding = await getBrandingSettings({ serviceUrl, diff --git a/apps/login/src/app/(main)/(boxed)/idp/[provider]/success/page.tsx b/apps/login/src/app/(main)/(boxed)/idp/[provider]/success/page.tsx index 56abe81bb..bf5f2514b 100644 --- a/apps/login/src/app/(main)/(boxed)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(main)/(boxed)/idp/[provider]/success/page.tsx @@ -146,11 +146,27 @@ export default async function Page(props: { return loginFailed("IDP context missing"); } - const intent = await retrieveIDPIntent({ - serviceUrl, - id, - token, - }); + let intent; + try { + intent = await retrieveIDPIntent({ + serviceUrl, + id, + token, + }); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === 9 /* FAILED_PRECONDITION */ + ) { + // Intent was already consumed by the createNewSessionFromIdpIntent + // server action. This re-render is expected — the client is already + // processing the redirect, so return a benign loading state. + return loginFailed("Session is being created, please wait…"); + } + throw error; + } const { idpInformation, userId: intentUserId } = intent; const resolvedUserId = intentUserId || qpUserId; diff --git a/apps/login/src/app/(main)/(boxed)/logout/page.tsx b/apps/login/src/app/(main)/(boxed)/logout/page.tsx index 37bd8f878..84b85f140 100644 --- a/apps/login/src/app/(main)/(boxed)/logout/page.tsx +++ b/apps/login/src/app/(main)/(boxed)/logout/page.tsx @@ -2,6 +2,7 @@ import { SessionsClearList } from "@/components/sessions-clear-list"; import { Translated } from "@/components/translated"; import { getAllSessionCookieIds } from "@/lib/cookies"; import { generateRouteMetadata } from "@/lib/metadata"; +import { toPlainObject } from "@/lib/serialize"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getDefaultOrg, listSessions } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; @@ -47,7 +48,7 @@ export default async function Page(props: { } } - let sessions = await loadSessions({ serviceUrl }); + let sessions = toPlainObject(await loadSessions({ serviceUrl })); const params = new URLSearchParams(); diff --git a/apps/login/src/components/session-item.tsx b/apps/login/src/components/session-item.tsx index 0282827c6..1b46c98d1 100644 --- a/apps/login/src/components/session-item.tsx +++ b/apps/login/src/components/session-item.tsx @@ -66,7 +66,9 @@ export function SessionItem({ if (valid && session?.factors?.user) { try { const resp = await continueWithSession({ - ...session, + sessionId: session.id, + loginName: session.factors?.user?.loginName, + organizationId: session.factors?.user?.organizationId, requestId: requestId, }); diff --git a/apps/login/src/lib/serialize.ts b/apps/login/src/lib/serialize.ts new file mode 100644 index 000000000..7f4c53dcd --- /dev/null +++ b/apps/login/src/lib/serialize.ts @@ -0,0 +1,11 @@ +/** + * Converts protobuf message objects into plain JSON-serializable objects + * suitable for passing across the React Server Component → Client Component + * boundary. Protobuf-ES v2 messages can contain bigint values and + * null-prototype objects that RSC serialization rejects. + */ +export function toPlainObject(obj: T): T { + return JSON.parse( + JSON.stringify(obj, (_, v) => (typeof v === "bigint" ? Number(v) : v)), + ); +} diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 0eeb1adf1..309384b13 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -10,7 +10,6 @@ import { } from "@/lib/zitadel"; import { Duration } from "@zitadel/client"; import { 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 { getNextUrl } from "../client"; @@ -71,38 +70,50 @@ export async function skipMFAAndContinueWithNextUrl({ export async function continueWithSession({ requestId, - ...session -}: Session & { requestId?: string }) { + sessionId, + loginName, + organizationId, +}: { + requestId?: string; + sessionId: string; + loginName?: string; + organizationId?: string; +}) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); const loginSettings = await getLoginSettings({ serviceUrl, - organization: session.factors?.user?.organizationId, + organization: organizationId, }); const url = - requestId && session.id && session.factors?.user + requestId && sessionId ? await getNextUrl( { - sessionId: session.id, - requestId: requestId, - organization: session.factors.user.organizationId, + sessionId, + requestId, + organization: organizationId, }, loginSettings?.defaultRedirectUri, ) - : session.factors?.user + : loginName ? await getNextUrl( { - loginName: session.factors.user.loginName, - organization: session.factors.user.organizationId, + loginName, + organization: organizationId, }, loginSettings?.defaultRedirectUri, ) : null; if (!url) { - console.error("Could not get next url", { requestId, session }); + console.error("Could not get next url", { + requestId, + sessionId, + loginName, + organizationId, + }); } return url ? { redirect: url } : null;