Added handling for user canceling the oauth process (#260)

* added oauth cancel handling

* added translation

* fixed tests

* fixed reviews
This commit is contained in:
Zai Shi 2024-09-20 22:45:06 +02:00 committed by GitHub
parent e3a8b43fd5
commit 8ea8ff383e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1795 additions and 1579 deletions

View File

@ -20,7 +20,11 @@ const redirectOrThrowError = (error: KnownError, project: ProjectsCrud["Admin"][
throw error;
}
redirect(`${errorRedirectUrl}?errorCode=${error.errorCode}&message=${error.message}&details=${error.details}`);
const url = new URL(errorRedirectUrl);
url.searchParams.set("errorCode", error.errorCode);
url.searchParams.set("message", error.message);
url.searchParams.set("details", error.details ? JSON.stringify(error.details) : JSON.stringify({}));
redirect(url.toString());
};
const handler = createSmartRouteHandler({
@ -92,14 +96,24 @@ const handler = createSmartRouteHandler({
}
const providerObj = await getProvider(provider);
const { userInfo, tokenSet } = await providerObj.getCallback({
codeVerifier: innerCodeVerifier,
state: innerState,
callbackParams: {
...query,
...body,
},
});
let callbackResult: Awaited<ReturnType<typeof providerObj.getCallback>>;
try {
callbackResult = await providerObj.getCallback({
codeVerifier: innerCodeVerifier,
state: innerState,
callbackParams: {
...query,
...body,
},
});
} catch (error) {
if (error instanceof KnownErrors['OAuthProviderAccessDenied']) {
redirectOrThrowError(error, project, errorRedirectUrl);
}
throw error;
}
const { userInfo, tokenSet } = callbackResult;
if (type === "link") {
if (!projectUserId) {

View File

@ -154,6 +154,9 @@ export abstract class OAuthBaseProvider {
captureError("inner-oauth-callback", error);
throw new KnownErrors.InvalidAuthorizationCode();
}
if (error?.error === 'access_denied') {
throw new KnownErrors.OAuthProviderAccessDenied();
}
throw new StackAssertionError(`Inner OAuth callback failed due to error: ${error}`, undefined, { cause: error });
}

View File

@ -128,7 +128,7 @@ it("should redirect to error callback url when inner callback has invalid author
NiceResponse {
"status": 307,
"headers": Headers {
"location": "http://stack-test.localhost/some-callback-url/callback-error?errorCode=INVALID_AUTHORIZATION_CODE&message=The%20given%20authorization%20code%20is%20invalid.&details=undefined",
"location": "http://stack-test.localhost/some-callback-url/callback-error?errorCode=INVALID_AUTHORIZATION_CODE&message=The+given+authorization+code+is+invalid.&details=%7B%7D",
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
<some fields may have been hidden>,
},

View File

@ -1119,6 +1119,16 @@ const InvalidAuthorizationCode = createKnownErrorConstructor(
() => [] as const,
);
const OAuthProviderAccessDenied = createKnownErrorConstructor(
KnownError,
"OAUTH_PROVIDER_ACCESS_DENIED",
() => [
400,
"The OAuth provider denied access to the user.",
] as const,
() => [] as const,
);
export type KnownErrors = {
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
};
@ -1209,6 +1219,7 @@ export const KnownErrors = {
InvalidStandardOAuthProviderId,
InvalidAuthorizationCode,
TeamPermissionNotFound,
OAuthProviderAccessDenied,
} satisfies Record<string, KnownErrorConstructor<any, any>>;

View File

@ -18,13 +18,14 @@ export function ErrorPage(props: { fullPage?: boolean, searchParams: Record<stri
const unknownErrorCard = <PredefinedMessageCard type='unknownError' fullPage={!!props.fullPage} />;
if (!errorCode || !message || !details) {
if (!errorCode || !message) {
return unknownErrorCard;
}
let error;
try {
error = KnownError.fromJson({ code: errorCode, message, details });
const detailJson = details ? JSON.parse(details) : {};
error = KnownError.fromJson({ code: errorCode, message, details: detailJson });
} catch (e) {
return unknownErrorCard;
}
@ -49,9 +50,9 @@ export function ErrorPage(props: { fullPage?: boolean, searchParams: Record<stri
// TODO: add "Connect again" button
return (
<MessageCard
title="Failed to connect account"
title={t("Failed to connect account")}
fullPage={!!props.fullPage}
primaryButtonText="Go to Home"
primaryButtonText={t("Go to Home")}
primaryAction={() => stackApp.redirectToHome()}
>
<Typography>
@ -61,5 +62,22 @@ export function ErrorPage(props: { fullPage?: boolean, searchParams: Record<stri
);
}
if (error instanceof KnownErrors.OAuthProviderAccessDenied) {
return (
<MessageCard
title={t("OAuth provider access denied")}
fullPage={!!props.fullPage}
primaryButtonText={t("Sign in again")}
primaryAction={() => stackApp.redirectToSignIn()}
secondaryButtonText={t("Go to Home")}
secondaryAction={() => stackApp.redirectToHome()}
>
<Typography>
{t("The sign-in operation has been cancelled. Please try again. [access_denied]")}
</Typography>
</MessageCard>
);
}
return <KnownErrorMessageCard error={error} fullPage={!!props.fullPage} />;
}

View File

@ -3,6 +3,7 @@
import { Typography } from "@stackframe/stack-ui";
import { useStackApp } from "../..";
import { MessageCard } from "./message-card";
import { useTranslation } from "../../lib/translations";
export function PredefinedMessageCard({
type,
@ -12,6 +13,7 @@ export function PredefinedMessageCard({
fullPage?: boolean,
}) {
const stackApp = useStackApp();
const { t } = useTranslation();
let title: string;
let message: string | null = null;
@ -22,53 +24,53 @@ export function PredefinedMessageCard({
switch (type) {
case 'signedIn': {
title = "You are already signed in";
title = t("You are already signed in");
primaryAction = () => stackApp.redirectToHome();
secondaryAction = () => stackApp.redirectToSignOut();
primaryButton = "Go to home";
secondaryButton = "Sign out";
primaryButton = t("Go to home");
secondaryButton = t("Sign out");
break;
}
case 'signedOut': {
title = "You are not currently signed in.";
title = t("You are not currently signed in.");
primaryAction = () => stackApp.redirectToSignIn();
primaryButton = "Sign in";
primaryButton = t("Sign in");
break;
}
case 'signUpDisabled': {
title = "Sign up for new users is not enabled at the moment.";
title = t("Sign up for new users is not enabled at the moment.");
primaryAction = () => stackApp.redirectToHome();
secondaryAction = () => stackApp.redirectToSignIn();
primaryButton = "Go to home";
secondaryButton = "Sign in";
primaryButton = t("Go to home");
secondaryButton = t("Sign in");
break;
}
case 'emailSent': {
title = "Email sent!";
message = 'If the user with this e-mail address exists, an e-mail was sent to your inbox. Make sure to check your spam folder.';
title = t("Email sent!");
message = t("If the user with this e-mail address exists, an e-mail was sent to your inbox. Make sure to check your spam folder.");
primaryAction = () => stackApp.redirectToHome();
primaryButton = "Go to home";
primaryButton = t("Go to home");
break;
}
case 'passwordReset': {
title = "Password reset successfully!";
message = 'Your password has been reset. You can now sign in with your new password.';
title = t("Password reset successfully!");
message = t("Your password has been reset. You can now sign in with your new password.");
primaryAction = () => stackApp.redirectToSignIn({ noRedirectBack: true });
primaryButton = "Sign in";
primaryButton = t("Sign in");
break;
}
case 'emailVerified': {
title = "Email verified!";
message = 'Your have successfully verified your email.';
title = t("Email verified!");
message = t("Your have successfully verified your email.");
primaryAction = () => stackApp.redirectToSignIn({ noRedirectBack: true });
primaryButton = "Sign in";
primaryButton = t("Sign in");
break;
}
case 'unknownError': {
title = "An unknown error occurred";
message = 'Please try again and if the problem persists, contact support.';
title = t("An unknown error occurred");
message = t("Please try again and if the problem persists, contact support.");
primaryAction = () => stackApp.redirectToHome();
primaryButton = "Go to home";
primaryButton = t("Go to home");
break;
}
}

View File

@ -307,7 +307,7 @@ export function OAuthButton({
<div className='flex items-center w-full gap-4'>
{style.icon}
<span className='flex-1'>
{type === 'sign-up' ? t('Sign up with ') : t('Sign in with ')}{style.name}
{type === 'sign-up' ? t('Sign up with') : t('Sign in with')} {style.name}
</span>
</div>
</Button>

File diff suppressed because it is too large Load Diff