[web] Passkey - Show a redirect again option on passkeys (#2113)
This commit is contained in:
@@ -2,11 +2,15 @@
|
||||
|
||||
Code that runs on `accounts.ente.io`.
|
||||
|
||||
Primarily, this serves a common domain where our clients (mobile and web / auth
|
||||
and photos) can create and authenticate using shared passkeys tied to the user's
|
||||
Ente account. Passkeys can be shared by multiple domains, so we didn't strictly
|
||||
need a separate web origin for sharing passkeys across our web clients, but we
|
||||
do need a web origin to handle the passkey flow for the mobile clients.
|
||||
Primarily, this serves a common domain where our clients can create and
|
||||
authenticate using shared passkeys tied to the user's Ente account.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Passkeys can be shared by multiple subdomains, so we didn't strictly need a
|
||||
> separate web origin for sharing passkeys between our (photos and auth) web
|
||||
> clients, but we do need a web origin to handle the passkey flow for the
|
||||
> desktop and mobile clients.
|
||||
|
||||
For more details about the Passkey flows,
|
||||
[docs/webauthn-passkeys.md](../../docs/webauthn-passkeys.md).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import log from "@/next/log";
|
||||
import type { TwoFactorAuthorizationResponse } from "@/next/types/credentials";
|
||||
import { ensure } from "@/utils/ensure";
|
||||
import { nullToUndefined } from "@/utils/transform";
|
||||
import { VerticallyCentered } from "@ente/shared/components/Container";
|
||||
import EnteButton from "@ente/shared/components/EnteButton";
|
||||
@@ -13,8 +14,8 @@ import {
|
||||
finishPasskeyAuthentication,
|
||||
isWebAuthnSupported,
|
||||
isWhitelistedRedirect,
|
||||
redirectAfterPasskeyAuthentication,
|
||||
redirectToPasskeyRecoverPage,
|
||||
redirectURLWithPasskeyAuthentication,
|
||||
signChallenge,
|
||||
} from "services/passkey";
|
||||
|
||||
@@ -30,10 +31,19 @@ const Page = () => {
|
||||
| "unknownRedirect" /* Unrecoverable error */
|
||||
| "unrecoverableFailure" /* Unrocevorable error - generic */
|
||||
| "failed" /* Recoverable error */
|
||||
| "waitingForUser"; /* ...to authenticate with their passkey */
|
||||
| "waitingForUser" /* ...to authenticate with their passkey */
|
||||
| "redirectingWeb" /* Redirect back to the requesting app (HTTP) */
|
||||
| "redirectingApp"; /* Other redirects (mobile / desktop redirect) */
|
||||
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
|
||||
// The URL we're redirecting to on success.
|
||||
//
|
||||
// This will only be set when status is "redirecting*".
|
||||
const [redirectURLWithData, setRedirectURLWithData] = useState<
|
||||
URL | undefined
|
||||
>();
|
||||
|
||||
/** (re)start the authentication flow */
|
||||
const authenticate = async () => {
|
||||
if (!isWebAuthnSupported()) {
|
||||
@@ -107,12 +117,13 @@ const Page = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Conceptually we can `setStatus("done")` at this point, but we'll
|
||||
// leave this page anyway, so no need to tickle React.
|
||||
setStatus(isHTTP(redirectURL) ? "redirectingWeb" : "redirectingApp");
|
||||
|
||||
await redirectAfterPasskeyAuthentication(
|
||||
redirectURL,
|
||||
authorizationResponse,
|
||||
setRedirectURLWithData(
|
||||
await redirectURLWithPasskeyAuthentication(
|
||||
redirectURL,
|
||||
authorizationResponse,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -120,6 +131,10 @@ const Page = () => {
|
||||
void authenticate();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectURLWithData) redirectToURL(redirectURLWithData);
|
||||
}, [redirectURLWithData]);
|
||||
|
||||
const handleRetry = () => void authenticate();
|
||||
|
||||
const handleRecover = () => {
|
||||
@@ -133,6 +148,9 @@ const Page = () => {
|
||||
redirectToPasskeyRecoverPage(new URL(recover));
|
||||
};
|
||||
|
||||
const handleRedirectAgain = () =>
|
||||
redirectToURL(ensure(redirectURLWithData));
|
||||
|
||||
const components: Record<Status, React.ReactNode> = {
|
||||
loading: <Loading />,
|
||||
unknownRedirect: <UnknownRedirect />,
|
||||
@@ -142,6 +160,8 @@ const Page = () => {
|
||||
<RetriableFailed onRetry={handleRetry} onRecover={handleRecover} />
|
||||
),
|
||||
waitingForUser: <WaitingForUser />,
|
||||
redirectingWeb: <RedirectingWeb />,
|
||||
redirectingApp: <RedirectingApp onRetry={handleRedirectAgain} />,
|
||||
};
|
||||
|
||||
return components[status];
|
||||
@@ -149,6 +169,14 @@ const Page = () => {
|
||||
|
||||
export default Page;
|
||||
|
||||
// Not 100% accurate, but good enough for our purposes.
|
||||
const isHTTP = (url: URL) => url.protocol.startsWith("http");
|
||||
|
||||
const redirectToURL = (url: URL) => {
|
||||
log.info(`Redirecting to ${url.href}`);
|
||||
window.location.href = url.href;
|
||||
};
|
||||
|
||||
const Loading: React.FC = () => {
|
||||
return (
|
||||
<VerticallyCentered>
|
||||
@@ -286,3 +314,57 @@ const WaitingImgContainer = styled("div")`
|
||||
justify-content: center;
|
||||
margin-block-start: 1rem;
|
||||
`;
|
||||
|
||||
const RedirectingWeb: React.FC = () => {
|
||||
return (
|
||||
<Content>
|
||||
<InfoIcon color="accent" fontSize="large" />
|
||||
<Typography variant="h3">{t("passkey_verified")}</Typography>
|
||||
<Typography color="text.muted">
|
||||
{t("redirecting_back_to_app")}
|
||||
</Typography>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
interface RedirectingAppProps {
|
||||
/** Called when the user presses the button to redirect again */
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const RedirectingApp: React.FC<RedirectingAppProps> = ({ onRetry }) => {
|
||||
const handleClose = window.close;
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<InfoIcon color="accent" fontSize="large" />
|
||||
<Typography variant="h3">{t("passkey_verified")}</Typography>
|
||||
<Typography color="text.muted">
|
||||
{t("redirecting_back_to_app")}
|
||||
</Typography>
|
||||
<Typography color="text.muted">
|
||||
{t("redirect_close_instructions")}
|
||||
</Typography>
|
||||
<ButtonStack>
|
||||
<EnteButton
|
||||
onClick={handleClose}
|
||||
fullWidth
|
||||
color="secondary"
|
||||
type="button"
|
||||
variant="contained"
|
||||
>
|
||||
{t("CLOSE")}
|
||||
</EnteButton>
|
||||
<EnteButton
|
||||
onClick={onRetry}
|
||||
fullWidth
|
||||
color="primary"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t("redirect_again")}
|
||||
</EnteButton>
|
||||
</ButtonStack>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -521,25 +521,24 @@ const authenticatorAssertionResponse = (credential: Credential) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Redirect back to the calling app that initiated the passkey authentication
|
||||
* flow with the result of the authentication.
|
||||
* Create a redirection URL to get back to the calling app that initiated the
|
||||
* passkey authentication flow with the result of the authentication.
|
||||
*
|
||||
* @param redirectURL The URL to redirect to. Provided by the calling app that
|
||||
* initiated the passkey authentication.
|
||||
* @param redirectURL The base URL to redirect to. Provided by the calling app
|
||||
* that initiated the passkey authentication.
|
||||
*
|
||||
* @param twoFactorAuthorizationResponse The result of
|
||||
* {@link finishPasskeyAuthentication} returned by the backend.
|
||||
*/
|
||||
export const redirectAfterPasskeyAuthentication = async (
|
||||
export const redirectURLWithPasskeyAuthentication = async (
|
||||
redirectURL: URL,
|
||||
twoFactorAuthorizationResponse: TwoFactorAuthorizationResponse,
|
||||
) => {
|
||||
const encodedResponse = await toB64URLSafeNoPaddingString(
|
||||
JSON.stringify(twoFactorAuthorizationResponse),
|
||||
);
|
||||
|
||||
redirectURL.searchParams.set("response", encodedResponse);
|
||||
window.location.href = redirectURL.href;
|
||||
return redirectURL;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -627,6 +627,10 @@
|
||||
"TRY_AGAIN": "Try again",
|
||||
"passkey_login_instructions": "Follow the steps from your browser to continue logging in.",
|
||||
"passkey_login": "Login with passkey",
|
||||
"passkey_verified": "Passkey verified",
|
||||
"redirecting_back_to_app": "Redirecting you back to the app...",
|
||||
"redirect_close_instructions": "You can close this window after the app opens.",
|
||||
"redirect_again": "Redirect again",
|
||||
"autogenerated_first_album_name": "My First Album",
|
||||
"autogenerated_default_album_name": "New Album"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user