From c670d3bec31f3ab931282ccd4acc26c4fd34adb4 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:40:16 +0100 Subject: [PATCH 1/3] fix: handle missing OIDC auth request gracefully The GET /login route called getAuthRequest() without error handling. When a request arrived with an expired or invalid authRequest ID, the Zitadel OIDC service returned a NOT_FOUND gRPC error that propagated as an unhandled ConnectError, crashing the route handler with a 500. Wrap the call in try/catch and add a null guard, returning a 400 JSON response instead. This matches the existing pattern used by the SAML branch which already checks for a missing samlRequest. Made-with: Cursor --- apps/login/src/app/login/route.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 450c3e4fd..2b90ca4c1 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -134,10 +134,26 @@ export async function GET(request: NextRequest) { // continue with OIDC if (requestId && requestId.startsWith("oidc_")) { - const { authRequest } = await getAuthRequest({ - serviceUrl, - authRequestId: requestId.replace("oidc_", ""), - }); + let authRequest; + try { + ({ authRequest } = await getAuthRequest({ + serviceUrl, + authRequestId: requestId.replace("oidc_", ""), + })); + } catch (error) { + console.error("Failed to get auth request:", error); + return NextResponse.json( + { error: "Auth request not found or expired" }, + { status: 400 }, + ); + } + + if (!authRequest) { + return NextResponse.json( + { error: "Auth request not found" }, + { status: 400 }, + ); + } let organization = ""; let suffix = ""; From 2be8630610823b86b4d9ac755ab66d13a87982aa Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:23:05 +0100 Subject: [PATCH 2/3] fix: show styled error page instead of raw JSON for login failures Previously, when the /login route encountered errors (expired auth request, missing SAML request, unreachable IDP, no request params), it returned raw JSON like {"error":"Auth request not found or expired"} directly to the browser. Since this route is reached via browser navigation (not a fetch() call), users saw unhelpful raw JSON text. Replace all user-facing NextResponse.json() error responses with redirects to a new /error page that renders inside the existing boxed card layout with a clear title and actionable message. The catch block for getAuthRequest now inspects the ConnectError code from Zitadel to provide specific guidance: expired sessions (NOT_FOUND), permission issues, service unavailability, and timeouts each get a tailored message. The two JSON responses intentionally left unchanged: - RSC check: internal Next.js safeguard, never user-facing - Prompt.NONE: OIDC spec requires no UI for silent auth; the calling application handles this programmatically Made-with: Cursor --- .../src/app/(main)/(boxed)/error/page.tsx | 19 +++++ apps/login/src/app/login/route.ts | 82 ++++++++++++++----- 2 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 apps/login/src/app/(main)/(boxed)/error/page.tsx diff --git a/apps/login/src/app/(main)/(boxed)/error/page.tsx b/apps/login/src/app/(main)/(boxed)/error/page.tsx new file mode 100644 index 000000000..b5a7ada6a --- /dev/null +++ b/apps/login/src/app/(main)/(boxed)/error/page.tsx @@ -0,0 +1,19 @@ +import { Alert, AlertType } from "@/components/alert"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const title = searchParams.title ?? "Something went wrong"; + const message = + searchParams.error ?? "An unexpected error occurred. Please try again."; + + return ( + <> +

{title}

+
+ {message} +
+ + ); +} diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 2b90ca4c1..70f776190 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -16,7 +16,7 @@ import { listSessions, startIdentityProviderFlow, } from "@/lib/zitadel"; -import { create } from "@zitadel/client"; +import { ConnectError, create } from "@zitadel/client"; import { Prompt } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; import { CreateCallbackRequestSchema, @@ -32,6 +32,37 @@ import { DEFAULT_CSP } from "../../../constants/csp"; // NOTE: Route segment configs (dynamic, revalidate, fetchCache) removed for Next.js 15.6+ compatibility // With dynamicIO enabled, route handlers are dynamic by default +const gotoError = ({ + request, + title, + error, +}: { + request: NextRequest; + title: string; + error: string; +}): NextResponse => { + const errorUrl = constructUrl(request, "/error"); + errorUrl.searchParams.set("title", title); + errorUrl.searchParams.set("error", error); + return NextResponse.redirect(errorUrl); +}; + +const getAuthRequestErrorMessage = (error: unknown): string => { + if (error instanceof ConnectError) { + switch (error.code) { + case 5: // NOT_FOUND + return "Your login session has expired. Please return to the application and try signing in again."; + case 7: // PERMISSION_DENIED + return "You don't have permission to access this login request. Please contact your administrator."; + case 14: // UNAVAILABLE + return "The authentication service is temporarily unavailable. Please try again in a few moments."; + case 4: // DEADLINE_EXCEEDED + return "The authentication service took too long to respond. Please try again."; + } + } + return "Your login request could not be found or has expired. Please return to the application and try signing in again."; +}; + const gotoAccounts = ({ request, requestId, @@ -142,17 +173,20 @@ export async function GET(request: NextRequest) { })); } catch (error) { console.error("Failed to get auth request:", error); - return NextResponse.json( - { error: "Auth request not found or expired" }, - { status: 400 }, - ); + return gotoError({ + request, + title: "Login request expired", + error: getAuthRequestErrorMessage(error), + }); } if (!authRequest) { - return NextResponse.json( - { error: "Auth request not found" }, - { status: 400 }, - ); + return gotoError({ + request, + title: "Login request not found", + error: + "Your login request could not be found. Please return to the application and try signing in again.", + }); } let organization = ""; @@ -248,10 +282,12 @@ export async function GET(request: NextRequest) { }); if (!url) { - return NextResponse.json( - { error: "Could not start IDP flow" }, - { status: 500 }, - ); + return gotoError({ + request, + title: "Sign-in unavailable", + error: + "We couldn't connect to your identity provider. Please try again or contact your administrator.", + }); } if (url.startsWith("/")) { @@ -487,10 +523,12 @@ export async function GET(request: NextRequest) { }); if (!samlRequest) { - return NextResponse.json( - { error: "No samlRequest found" }, - { status: 400 }, - ); + return gotoError({ + request, + title: "Login request not found", + error: + "Your SAML login request could not be found or has expired. Please return to the application and try signing in again.", + }); } let selectedSession = await findValidSession({ @@ -574,9 +612,11 @@ export async function GET(request: NextRequest) { } // Device Authorization does not need to start here as it is handled on the /device endpoint else { - return NextResponse.json( - { error: "No authRequest nor samlRequest provided" }, - { status: 500 }, - ); + return gotoError({ + request, + title: "No login request", + error: + "No authentication request was provided. Please start the sign-in process from your application.", + }); } } From 1c3372428d429f3f98a5df26ae6ac91334a70cdc Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:38:31 -0500 Subject: [PATCH 3/3] fix: use duck typing for ConnectError instead of instanceof ConnectError is only re-exported as a type from @zitadel/client (export type { ConnectError }), so it cannot be used with instanceof at runtime. Check for the error shape via property inspection instead. Made-with: Cursor --- apps/login/src/app/login/route.ts | 33 +++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 70f776190..01a2ba2df 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -16,7 +16,7 @@ import { listSessions, startIdentityProviderFlow, } from "@/lib/zitadel"; -import { ConnectError, create } from "@zitadel/client"; +import { create } from "@zitadel/client"; import { Prompt } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; import { CreateCallbackRequestSchema, @@ -48,19 +48,26 @@ const gotoError = ({ }; const getAuthRequestErrorMessage = (error: unknown): string => { - if (error instanceof ConnectError) { - switch (error.code) { - case 5: // NOT_FOUND - return "Your login session has expired. Please return to the application and try signing in again."; - case 7: // PERMISSION_DENIED - return "You don't have permission to access this login request. Please contact your administrator."; - case 14: // UNAVAILABLE - return "The authentication service is temporarily unavailable. Please try again in a few moments."; - case 4: // DEADLINE_EXCEEDED - return "The authentication service took too long to respond. Please try again."; - } + const code = + error != null && + typeof error === "object" && + "code" in error && + typeof (error as { code: unknown }).code === "number" + ? (error as { code: number }).code + : undefined; + + switch (code) { + case 5: // NOT_FOUND + return "Your login session has expired. Please return to the application and try signing in again."; + case 7: // PERMISSION_DENIED + return "You don't have permission to access this login request. Please contact your administrator."; + case 14: // UNAVAILABLE + return "The authentication service is temporarily unavailable. Please try again in a few moments."; + case 4: // DEADLINE_EXCEEDED + return "The authentication service took too long to respond. Please try again."; + default: + return "Your login request could not be found or has expired. Please return to the application and try signing in again."; } - return "Your login request could not be found or has expired. Please return to the application and try signing in again."; }; const gotoAccounts = ({