From fa7a46475e5faa5daeeb4659b8f71ddafcb82b87 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:59:35 +0100 Subject: [PATCH 1/2] fix: fix errors from sentry --- apps/login/sentry.server.config.ts | 7 +++ .../src/app/(main)/(boxed)/accounts/page.tsx | 3 +- .../(boxed)/idp/[provider]/success/page.tsx | 26 +++++++++-- .../src/app/(main)/(boxed)/logout/page.tsx | 3 +- apps/login/src/components/session-item.tsx | 4 +- apps/login/src/lib/serialize.ts | 11 +++++ apps/login/src/lib/server/session.ts | 35 +++++++++----- pr-summary.md | 46 +++++++++++++++++++ 8 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 apps/login/src/lib/serialize.ts create mode 100644 pr-summary.md 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; diff --git a/pr-summary.md b/pr-summary.md new file mode 100644 index 000000000..6c4d953a5 --- /dev/null +++ b/pr-summary.md @@ -0,0 +1,46 @@ +# Fix Sentry errors: RSC serialization, IDP intent re-render, and noise suppression + +## Summary + +This PR fixes three categories of production Sentry errors on the login UI: + +1. **RSC serialization failure on `/accounts` and `/logout`** — Protobuf `Session` objects passed as props to client components +2. **IDP intent "failed_precondition" on `/idp/[provider]/success`** — Re-render after server action re-fetches a consumed intent +3. **Noisy framework-level errors** — Suppressed via Sentry `ignoreErrors` + +## Changes + +### 1. Fix protobuf Session serialization across RSC boundary + +**Error:** `Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.` + +**Root cause:** The `/accounts` and `/logout` server components fetch `Session[]` protobuf objects (which contain `bigint` timestamps and null-prototype internal fields) and pass them directly as props to `"use client"` components. React Server Components cannot serialize these across the boundary. + +**Fix:** + +- **`apps/login/src/lib/serialize.ts`** (new) — `toPlainObject()` helper that deep-clones via `JSON.parse(JSON.stringify())` with BigInt-to-Number conversion, stripping null prototypes and non-serializable fields +- **`apps/login/src/app/(main)/(boxed)/accounts/page.tsx`** — Wrap `loadSessions()` result with `toPlainObject()` before passing to `` +- **`apps/login/src/app/(main)/(boxed)/logout/page.tsx`** — Same fix for `` +- **`apps/login/src/lib/server/session.ts`** — Refactored `continueWithSession` to accept `{ sessionId, loginName, organizationId, requestId }` instead of the full `Session` protobuf object, avoiding serialization issues on the client-to-server-action boundary +- **`apps/login/src/components/session-item.tsx`** — Updated `continueWithSession` call site to pass only the needed fields + +### 2. Handle consumed IDP intent on re-render + +**Error:** `ConnectError: [failed_precondition] Intent has not succeeded` + +**Root cause:** After the IDP success page renders and the `` client component calls the `createNewSessionFromIdpIntent` server action (which consumes the intent), Next.js re-renders the server component as part of the action response. This re-render calls `retrieveIDPIntent` again with the same `id`/`token`, but the intent has already been consumed. + +**Fix:** + +- **`apps/login/src/app/(main)/(boxed)/idp/[provider]/success/page.tsx`** — Wrapped `retrieveIDPIntent` in a try-catch. If the error is `FAILED_PRECONDITION` (code 9), it returns a benign message via `loginFailed()` instead of throwing. Other errors are re-thrown. + +### 3. Suppress framework-level Sentry noise + +**Errors:** + +- `The router state header was sent but could not be parsed` — Malformed RSC requests from bots/crawlers sending invalid `Next-Router-State-Tree` headers +- `transformAlgorithm is not a function` — Node.js 20.x `TransformStream` race condition during RSC streaming (client disconnect mid-stream) + +**Fix:** + +- **`apps/login/sentry.server.config.ts`** — Added both patterns to `ignoreErrors` From 9569c1fd1a0599eab624315e9dd33ac07b237f40 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:02:24 +0100 Subject: [PATCH 2/2] chore: remove pr summary --- pr-summary.md | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 pr-summary.md diff --git a/pr-summary.md b/pr-summary.md deleted file mode 100644 index 6c4d953a5..000000000 --- a/pr-summary.md +++ /dev/null @@ -1,46 +0,0 @@ -# Fix Sentry errors: RSC serialization, IDP intent re-render, and noise suppression - -## Summary - -This PR fixes three categories of production Sentry errors on the login UI: - -1. **RSC serialization failure on `/accounts` and `/logout`** — Protobuf `Session` objects passed as props to client components -2. **IDP intent "failed_precondition" on `/idp/[provider]/success`** — Re-render after server action re-fetches a consumed intent -3. **Noisy framework-level errors** — Suppressed via Sentry `ignoreErrors` - -## Changes - -### 1. Fix protobuf Session serialization across RSC boundary - -**Error:** `Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.` - -**Root cause:** The `/accounts` and `/logout` server components fetch `Session[]` protobuf objects (which contain `bigint` timestamps and null-prototype internal fields) and pass them directly as props to `"use client"` components. React Server Components cannot serialize these across the boundary. - -**Fix:** - -- **`apps/login/src/lib/serialize.ts`** (new) — `toPlainObject()` helper that deep-clones via `JSON.parse(JSON.stringify())` with BigInt-to-Number conversion, stripping null prototypes and non-serializable fields -- **`apps/login/src/app/(main)/(boxed)/accounts/page.tsx`** — Wrap `loadSessions()` result with `toPlainObject()` before passing to `` -- **`apps/login/src/app/(main)/(boxed)/logout/page.tsx`** — Same fix for `` -- **`apps/login/src/lib/server/session.ts`** — Refactored `continueWithSession` to accept `{ sessionId, loginName, organizationId, requestId }` instead of the full `Session` protobuf object, avoiding serialization issues on the client-to-server-action boundary -- **`apps/login/src/components/session-item.tsx`** — Updated `continueWithSession` call site to pass only the needed fields - -### 2. Handle consumed IDP intent on re-render - -**Error:** `ConnectError: [failed_precondition] Intent has not succeeded` - -**Root cause:** After the IDP success page renders and the `` client component calls the `createNewSessionFromIdpIntent` server action (which consumes the intent), Next.js re-renders the server component as part of the action response. This re-render calls `retrieveIDPIntent` again with the same `id`/`token`, but the intent has already been consumed. - -**Fix:** - -- **`apps/login/src/app/(main)/(boxed)/idp/[provider]/success/page.tsx`** — Wrapped `retrieveIDPIntent` in a try-catch. If the error is `FAILED_PRECONDITION` (code 9), it returns a benign message via `loginFailed()` instead of throwing. Other errors are re-thrown. - -### 3. Suppress framework-level Sentry noise - -**Errors:** - -- `The router state header was sent but could not be parsed` — Malformed RSC requests from bots/crawlers sending invalid `Next-Router-State-Tree` headers -- `transformAlgorithm is not a function` — Node.js 20.x `TransformStream` race condition during RSC streaming (client disconnect mid-stream) - -**Fix:** - -- **`apps/login/sentry.server.config.ts`** — Added both patterns to `ignoreErrors`