diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index f6b21ff6..bd633707 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -192,11 +192,71 @@ export async function validateOidcCallback( state }); - const tokens = await client.validateAuthorizationCode( - ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), - code, - codeVerifier - ); + let tokens: arctic.OAuth2Tokens; + try { + tokens = await client.validateAuthorizationCode( + ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), + code, + codeVerifier + ); + } catch (err: unknown) { + if (err instanceof arctic.OAuth2RequestError) { + logger.warn("OIDC provider rejected the authorization code", { + error: err.code, + description: err.description, + uri: err.uri, + state: err.state + }); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + err.description || + `OIDC provider rejected the request (${err.code})` + ) + ); + } + + if (err instanceof arctic.UnexpectedResponseError) { + logger.error( + "OIDC provider returned an unexpected response during token exchange", + { status: err.status } + ); + return next( + createHttpError( + HttpCode.BAD_GATEWAY, + "Received an unexpected response from the identity provider while exchanging the authorization code." + ) + ); + } + + if (err instanceof arctic.UnexpectedErrorResponseBodyError) { + logger.error( + "OIDC provider returned an unexpected error payload during token exchange", + { status: err.status, data: err.data } + ); + return next( + createHttpError( + HttpCode.BAD_GATEWAY, + "Identity provider returned an unexpected error payload while exchanging the authorization code." + ) + ); + } + + if (err instanceof arctic.ArcticFetchError) { + logger.error( + "Failed to reach OIDC provider while exchanging authorization code", + { error: err.message } + ); + return next( + createHttpError( + HttpCode.BAD_GATEWAY, + "Unable to reach the identity provider while exchanging the authorization code. Please try again." + ) + ); + } + + throw err; + } const idToken = tokens.idToken(); logger.debug("ID token", { idToken }); diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx index a2432e3e..811f4402 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -14,8 +14,11 @@ export const dynamic = "force-dynamic"; export default async function Page(props: { params: Promise<{ orgId: string; idpId: string }>; searchParams: Promise<{ - code: string; - state: string; + code?: string; + state?: string; + error?: string; + error_description?: string; + error_uri?: string; }>; }) { const params = await props.params; @@ -59,6 +62,14 @@ export default async function Page(props: { } } + const providerError = searchParams.error + ? { + error: searchParams.error, + description: searchParams.error_description, + uri: searchParams.error_uri + } + : undefined; + return ( <> ); diff --git a/src/components/ValidateOidcToken.tsx b/src/components/ValidateOidcToken.tsx index d4d9678d..900a99ac 100644 --- a/src/components/ValidateOidcToken.tsx +++ b/src/components/ValidateOidcToken.tsx @@ -26,6 +26,11 @@ type ValidateOidcTokenParams = { stateCookie: string | undefined; idp: { name: string }; loginPageId?: number; + providerError?: { + error: string; + description?: string | null; + uri?: string | null; + }; }; export default function ValidateOidcToken(props: ValidateOidcTokenParams) { @@ -35,14 +40,65 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [isProviderError, setIsProviderError] = useState(false); const { licenseStatus, isLicenseViolation } = useLicenseStatusContext(); const t = useTranslations(); useEffect(() => { - async function validate() { + let isCancelled = false; + + async function runValidation() { setLoading(true); + setIsProviderError(false); + + if (props.providerError?.error) { + const providerMessage = + props.providerError.description || + t("idpErrorOidcProviderRejected", { + error: props.providerError.error, + defaultValue: + "The identity provider returned an error: {error}." + }); + const suffix = props.providerError.uri + ? ` (${props.providerError.uri})` + : ""; + if (!isCancelled) { + setIsProviderError(true); + setError(`${providerMessage}${suffix}`); + setLoading(false); + } + return; + } + + if (!props.code) { + if (!isCancelled) { + setIsProviderError(false); + setError( + t("idpErrorOidcMissingCode", { + defaultValue: + "The identity provider did not return an authorization code." + }) + ); + setLoading(false); + } + return; + } + + if (!props.expectedState || !props.stateCookie) { + if (!isCancelled) { + setIsProviderError(false); + setError( + t("idpErrorOidcMissingState", { + defaultValue: + "The login request is missing state information. Please restart the login process." + }) + ); + setLoading(false); + } + return; + } console.log(t("idpOidcTokenValidating"), { code: props.code, @@ -57,22 +113,28 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { try { const response = await validateOidcUrlCallbackProxy( props.idpId, - props.code || "", - props.expectedState || "", - props.stateCookie || "", + props.code, + props.expectedState, + props.stateCookie, props.loginPageId ); if (response.error) { - setError(response.message); - setLoading(false); + if (!isCancelled) { + setIsProviderError(false); + setError(response.message); + setLoading(false); + } return; } const data = response.data; if (!data) { - setError("Unable to validate OIDC token"); - setLoading(false); + if (!isCancelled) { + setIsProviderError(false); + setError("Unable to validate OIDC token"); + setLoading(false); + } return; } @@ -82,8 +144,11 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { router.push(env.app.dashboardUrl); } - setLoading(false); - await new Promise((resolve) => setTimeout(resolve, 100)); + if (!isCancelled) { + setIsProviderError(false); + setLoading(false); + await new Promise((resolve) => setTimeout(resolve, 100)); + } if (redirectUrl.startsWith("http")) { window.location.href = data.redirectUrl; // this is validated by the parent using this component @@ -92,18 +157,39 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { } } catch (e: any) { console.error(e); - setError( - t("idpErrorOidcTokenValidating", { - defaultValue: "An unexpected error occurred. Please try again." - }) - ); + if (!isCancelled) { + setIsProviderError(false); + setError( + t("idpErrorOidcTokenValidating", { + defaultValue: + "An unexpected error occurred. Please try again." + }) + ); + } } finally { - setLoading(false); + if (!isCancelled) { + setLoading(false); + } } } - validate(); - }, []); + runValidation(); + + return () => { + isCancelled = true; + }; + }, [ + env.app.dashboardUrl, + isLicenseViolation, + props.code, + props.expectedState, + props.idpId, + props.loginPageId, + props.providerError, + props.stateCookie, + router, + t + ]); return (
@@ -133,12 +219,16 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { - - {t("idpErrorConnectingTo", { - name: props.idp.name - })} + + {isProviderError + ? error + : t("idpErrorConnectingTo", { + name: props.idp.name + })} - {error} + {!isProviderError && ( + {error} + )} )}