From c324ef4a123561b3008a09db4b6af08f177db32b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 13 Apr 2026 11:10:32 -0700 Subject: [PATCH] Better error message when user info fetching fails --- .../oauth/callback/[provider_id]/route.tsx | 22 ++++++-- apps/backend/src/oauth/providers/base.test.ts | 20 ++++++++ apps/backend/src/oauth/providers/base.tsx | 37 +++++++++++++- claude/CLAUDE-KNOWLEDGE.md | 6 +++ packages/stack-shared/src/known-errors.tsx | 11 ++++ .../src/components-page/error-page.tsx | 17 +++++++ .../src/components-page/oauth-callback.tsx | 29 +++++------ packages/template/src/lib/auth.ts | 51 ++++++++++++++++++- 8 files changed, 170 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index baad5c019..5ccbb3219 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -46,12 +46,23 @@ async function createProjectUserOAuthAccountForLink(prisma: PrismaClientTransact }); } -const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, errorRedirectUrl?: string) => { - if (!errorRedirectUrl || (!validateRedirectUrl(errorRedirectUrl, tenancy) && !isAcceptedNativeAppUrl(errorRedirectUrl))) { +const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, options: { + oauthCallbackRedirectUrl?: string, + errorRedirectUrl?: string, +}) => { + const targetRedirectUrl = + options.oauthCallbackRedirectUrl && (validateRedirectUrl(options.oauthCallbackRedirectUrl, tenancy) || isAcceptedNativeAppUrl(options.oauthCallbackRedirectUrl)) + ? options.oauthCallbackRedirectUrl + : options.errorRedirectUrl && (validateRedirectUrl(options.errorRedirectUrl, tenancy) || isAcceptedNativeAppUrl(options.errorRedirectUrl)) + ? options.errorRedirectUrl + : null; + if (!targetRedirectUrl) { throw error; } - const url = new URL(errorRedirectUrl); + const url = new URL(targetRedirectUrl); + url.searchParams.set("error", "server_error"); + url.searchParams.set("error_description", error.message); url.searchParams.set("errorCode", error.errorCode); url.searchParams.set("message", error.message); url.searchParams.set("details", error.details ? JSON.stringify(error.details) : JSON.stringify({})); @@ -113,6 +124,7 @@ const handler = createSmartRouteHandler({ projectUserId, providerScope, errorRedirectUrl, + redirectUri, afterCallbackRedirectUrl, } = outerInfo; @@ -152,7 +164,7 @@ const handler = createSmartRouteHandler({ }); } catch (error) { if (KnownErrors['OAuthProviderAccessDenied'].isInstance(error)) { - redirectOrThrowError(error, tenancy, errorRedirectUrl); + redirectOrThrowError(error, tenancy, { oauthCallbackRedirectUrl: redirectUri, errorRedirectUrl }); } throw error; } @@ -387,7 +399,7 @@ const handler = createSmartRouteHandler({ return oauthResponseToSmartResponse(oauthResponse); } catch (error) { if (KnownError.isKnownError(error)) { - redirectOrThrowError(error, tenancy, errorRedirectUrl); + redirectOrThrowError(error, tenancy, { oauthCallbackRedirectUrl: redirectUri, errorRedirectUrl }); } throw error; } diff --git a/apps/backend/src/oauth/providers/base.test.ts b/apps/backend/src/oauth/providers/base.test.ts index 73d90a3b0..0c82faf07 100644 --- a/apps/backend/src/oauth/providers/base.test.ts +++ b/apps/backend/src/oauth/providers/base.test.ts @@ -31,4 +31,24 @@ describe("isRetryableOAuthUserInfoError", () => { message: "client credentials are invalid", })).toBe(false); }); + + it("returns true for provider temporary-unavailability errors", () => { + expect(isRetryableOAuthUserInfoError({ + error: "temporarily_unavailable", + message: "provider is temporarily unavailable", + })).toBe(true); + }); + + it("returns true for HTTP 5xx and 429 response statuses", () => { + expect(isRetryableOAuthUserInfoError({ + response: { + status: 503, + }, + })).toBe(true); + expect(isRetryableOAuthUserInfoError({ + response: { + status: 429, + }, + })).toBe(true); + }); }); diff --git a/apps/backend/src/oauth/providers/base.tsx b/apps/backend/src/oauth/providers/base.tsx index ff1d5da15..41dac8a38 100644 --- a/apps/backend/src/oauth/providers/base.tsx +++ b/apps/backend/src/oauth/providers/base.tsx @@ -17,6 +17,11 @@ const RETRYABLE_OAUTH_NETWORK_ERROR_CODES = new Set([ "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", ]); +const RETRYABLE_OAUTH_PROVIDER_ERROR_CODES = new Set([ + "server_error", + "temporarily_unavailable", + "timeout", +]); export type TokenSet = { accessToken: string, @@ -40,17 +45,36 @@ function getUnknownProperty(obj: unknown, key: string): unknown { return Reflect.get(obj, key); } +function getNumberProperty(obj: unknown, key: string): number | undefined { + if (typeof obj !== "object" || obj === null || !(key in obj)) { + return undefined; + } + const value = Reflect.get(obj, key); + return typeof value === "number" ? value : undefined; +} + export function isRetryableOAuthUserInfoError(error: unknown): boolean { const code = getStringProperty(error, "code"); if (code && RETRYABLE_OAUTH_NETWORK_ERROR_CODES.has(code)) { return true; } + const providerErrorCode = getStringProperty(error, "error")?.toLowerCase(); + if (providerErrorCode && RETRYABLE_OAUTH_PROVIDER_ERROR_CODES.has(providerErrorCode)) { + return true; + } + const name = getStringProperty(error, "name"); if (name === "AbortError" || name === "TimeoutError") { return true; } + const response = getUnknownProperty(error, "response"); + const responseStatus = getNumberProperty(response, "status"); + if (responseStatus === 429 || (responseStatus != null && responseStatus >= 500)) { + return true; + } + const message = getStringProperty(error, "message")?.toLowerCase(); if (message?.includes("outgoing request timed out")) { return true; @@ -235,6 +259,14 @@ export abstract class OAuthBaseProvider { if (error?.error === 'invalid_client') { throw new StatusError(400, `Invalid client credentials for this OAuth provider. Please ensure the configuration in the Stack Auth dashboard is correct.`); } + if (isRetryableOAuthUserInfoError(error)) { + captureError("inner-oauth-callback-retryable-error", new StackAssertionError("Transient OAuth provider failure during callback exchange.", { + provider: this.constructor.name, + params, + cause: error, + })); + throw new KnownErrors.OAuthProviderTemporarilyUnavailable(); + } if (error?.error === 'unauthorized_scope_error') { const scopeMatch = error?.error_description?.match(/Scope "([^&]+)" is not authorized for your application/); const missingScope = scopeMatch ? scopeMatch[1] : null; @@ -262,11 +294,12 @@ export abstract class OAuthBaseProvider { }); if (userInfoResult.status === "error") { - throw new StackAssertionError("Failed to fetch OAuth user info after retries.", { + captureError("oauth-userinfo-retry-exhausted", new StackAssertionError("Failed to fetch OAuth user info after retries.", { attempts: userInfoResult.attempts, provider: this.constructor.name, cause: userInfoResult.error, - }); + })); + throw new KnownErrors.OAuthProviderTemporarilyUnavailable(); } return { diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index d77751f26..d444359e7 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -178,3 +178,9 @@ A: In `apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route Q: Why shouldn't OAuth callback retries wrap the whole `getCallback` flow? A: The authorization code exchange (`oauthClient.callback` / `oauthCallback`) is effectively one-shot, so retrying the full callback can convert a transient downstream failure into `invalid_grant` on the next attempt. Retries should wrap only post-exchange user-info fetches (`postProcessUserInfo`) and only for transient network/timeout errors. + +Q: How should OAuth callback behave when userinfo retries still fail? +A: After exhausting transient-network retries in `OAuthBaseProvider.getCallback`, capture internal diagnostics (`oauth-userinfo-retry-exhausted`) but throw `KnownErrors.OAuthProviderTemporarilyUnavailable` so clients get a user-recoverable error/redirect flow instead of an internal assertion. + +Q: How should OAuth callback errors be surfaced to handler-based clients? +A: In `apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx`, prefer redirecting known errors to the original OAuth callback URL (`redirectUri`) with `error`, `error_description`, `errorCode`, `message`, and `details` query params (fallback to `errorRedirectUrl` if needed). In template client handling (`packages/template/src/lib/auth.ts` + `components-page/oauth-callback.tsx`), detect those params, reconstruct a `KnownError`, and route to the handler error page so users get actionable UI instead of silent sign-in redirects. diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index a1d5d0a4d..e65a64bb4 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1423,6 +1423,16 @@ const OAuthProviderAccessDenied = createKnownErrorConstructor( () => [] as const, ); +const OAuthProviderTemporarilyUnavailable = createKnownErrorConstructor( + KnownError, + "OAUTH_PROVIDER_TEMPORARILY_UNAVAILABLE", + () => [ + 503, + "The OAuth provider is temporarily unavailable. Please try signing in again.", + ] as const, + () => [] as const, +); + const ContactChannelAlreadyUsedForAuthBySomeoneElse = createKnownErrorConstructor( KnownError, "CONTACT_CHANNEL_ALREADY_USED_FOR_AUTH_BY_SOMEONE_ELSE", @@ -1958,6 +1968,7 @@ export const KnownErrors = { InvalidAppleCredentials, TeamPermissionNotFound, OAuthProviderAccessDenied, + OAuthProviderTemporarilyUnavailable, ContactChannelAlreadyUsedForAuthBySomeoneElse, InvalidPollingCodeError, ApiKeyNotValid, diff --git a/packages/template/src/components-page/error-page.tsx b/packages/template/src/components-page/error-page.tsx index dc839f44b..a3f20671c 100644 --- a/packages/template/src/components-page/error-page.tsx +++ b/packages/template/src/components-page/error-page.tsx @@ -79,5 +79,22 @@ export function ErrorPage(props: { fullPage?: boolean, searchParams: Record stackApp.redirectToSignIn()} + secondaryButtonText={t("Go Home")} + secondaryAction={() => stackApp.redirectToHome()} + > + + {t("The OAuth provider could not complete sign-in right now. Please try again in a moment.")} + + + ); + } + return ; } diff --git a/packages/template/src/components-page/oauth-callback.tsx b/packages/template/src/components-page/oauth-callback.tsx index 44694ee9f..da800b4a0 100644 --- a/packages/template/src/components-page/oauth-callback.tsx +++ b/packages/template/src/components-page/oauth-callback.tsx @@ -1,5 +1,6 @@ 'use client'; +import { KnownError } from "@stackframe/stack-shared"; import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { Spinner, cn } from "@stackframe/stack-ui"; @@ -7,30 +8,33 @@ import { useEffect, useRef, useState } from "react"; import { useStackApp } from ".."; import { MaybeFullPage } from "../components/elements/maybe-full-page"; import { StyledLink } from "../components/link"; -import { envVars } from "../lib/env"; import { useTranslation } from "../lib/translations"; export function OAuthCallback({ fullPage }: { fullPage?: boolean }) { const { t } = useTranslation(); const app = useStackApp(); const called = useRef(false); - const [error, setError] = useState(null); const [showRedirectLink, setShowRedirectLink] = useState(false); useEffect(() => runAsynchronously(async () => { if (called.current) return; called.current = true; - let hasRedirected = false; - let callbackError: unknown = null; try { - hasRedirected = await app.callOAuthCallback(); + const hasRedirected = await app.callOAuthCallback(); + if (!hasRedirected) { + await app.redirectToSignIn({ noRedirectBack: true }); + } } catch (e) { - callbackError = e; + if (KnownError.isKnownError(e)) { + const errorUrl = new URL(app.urls.error, window.location.href); + errorUrl.searchParams.set("errorCode", e.errorCode); + errorUrl.searchParams.set("message", e.message); + errorUrl.searchParams.set("details", JSON.stringify(e.details ?? {})); + window.location.replace(errorUrl.toString()); + return; + } captureError("", e); - setError(e); - } - if (!hasRedirected && (callbackError == null || envVars.NODE_ENV === "production")) { - await app.redirectToSignIn({ noRedirectBack: true }); + window.location.replace(new URL(app.urls.error, window.location.href).toString()); } }), []); @@ -53,11 +57,6 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) { {showRedirectLink ?

{t('If you are not redirected automatically, ')}{t("click here")}

: null} - {error ?
-

{t("Something went wrong while processing the OAuth callback:")}

-
{JSON.stringify(error, null, 2)}
-

{t("This is most likely an error in Stack. Please report it.")}

-
: null} ); diff --git a/packages/template/src/lib/auth.ts b/packages/template/src/lib/auth.ts index be1ed15d9..2a986af6e 100644 --- a/packages/template/src/lib/auth.ts +++ b/packages/template/src/lib/auth.ts @@ -37,9 +37,54 @@ export async function addNewOAuthProviderOrScope( * * Must be synchronous for the logic in callOAuthCallback to work without race conditions. */ -function consumeOAuthCallbackQueryParams() { +type OAuthCallbackConsumptionResult = + | { + type: "oauth-response", + originalUrl: URL, + codeVerifier: string, + state: string, + } + | { + type: "known-error", + error: KnownError, + }; + +function consumeOAuthCallbackQueryParams(): OAuthCallbackConsumptionResult | null { + const oauthErrorParams = ["error", "error_description", "errorCode", "message", "details"] as const; const requiredParams = ["code", "state"]; const originalUrl = new URL(window.location.href); + const knownErrorCode = originalUrl.searchParams.get("errorCode"); + const knownErrorMessage = originalUrl.searchParams.get("message"); + if (knownErrorCode && knownErrorMessage) { + const details = originalUrl.searchParams.get("details"); + let detailsJson = {}; + if (details) { + try { + detailsJson = JSON.parse(details); + } catch (error) { + throw new StackAssertionError("OAuth callback returned malformed known-error details", { + details, + cause: error, + }); + } + } + + const newUrl = new URL(originalUrl); + for (const param of oauthErrorParams) { + newUrl.searchParams.delete(param); + } + window.history.replaceState({}, "", newUrl.toString()); + + return { + type: "known-error", + error: KnownError.fromJson({ + code: knownErrorCode, + message: knownErrorMessage, + details: detailsJson, + }), + }; + } + for (const param of requiredParams) { if (!originalUrl.searchParams.has(param)) { console.warn(new Error(`Missing required query parameter on OAuth callback: ${param}. Maybe you opened or reloaded the oauth-callback page from your history?`)); @@ -83,6 +128,7 @@ function consumeOAuthCallbackQueryParams() { window.history.replaceState({}, "", newUrl.toString()); return { + type: "oauth-response", originalUrl, codeVerifier: cookieResult.codeVerifier, state: expectedState, @@ -98,6 +144,9 @@ export async function callOAuthCallback( // callOAuthCallback is called multiple times in parallel const consumed = consumeOAuthCallbackQueryParams(); if (!consumed) return Result.ok(undefined); + if (consumed.type === "known-error") { + throw consumed.error; + } // the rest can be asynchronous (we now know that we are the // intended recipient of the callback, and the only instance