Add OIDC authentication error response support

This commit is contained in:
David Reed
2025-12-10 11:13:04 -08:00
parent 74dd3fdc9f
commit 78369b6f6a
3 changed files with 192 additions and 30 deletions

View File

@@ -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 });

View File

@@ -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 (
<>
<ValidateOidcToken
@@ -69,6 +80,7 @@ export default async function Page(props: {
expectedState={searchParams.state}
stateCookie={stateCookie}
idp={{ name: foundIdp.name }}
providerError={providerError}
/>
</>
);

View File

@@ -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<string | null>(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 (
<div className="flex items-center justify-center min-h-screen">
@@ -133,12 +219,16 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>
{t("idpErrorConnectingTo", {
name: props.idp.name
})}
<span className="text-sm font-medium">
{isProviderError
? error
: t("idpErrorConnectingTo", {
name: props.idp.name
})}
</span>
<span className="text-xs">{error}</span>
{!isProviderError && (
<span className="text-xs">{error}</span>
)}
</AlertDescription>
</Alert>
)}