[web] Passkey improvements (#2071)
This commit is contained in:
@@ -31,7 +31,7 @@ import {
|
||||
} from "services/passkey";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { showNavBar } = useAppContext();
|
||||
const { showNavBar, setDialogBoxAttributesV2 } = useAppContext();
|
||||
|
||||
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
||||
const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false);
|
||||
@@ -46,6 +46,11 @@ const Page: React.FC = () => {
|
||||
setPasskeys(await getPasskeys());
|
||||
} catch (e) {
|
||||
log.error("Failed to fetch passkeys", e);
|
||||
setDialogBoxAttributesV2({
|
||||
title: t("ERROR"),
|
||||
content: t("passkey_fetch_failed"),
|
||||
close: {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,12 +96,11 @@ const Page: React.FC = () => {
|
||||
// If the user cancels the operation, then an error with name
|
||||
// "NotAllowedError" is thrown.
|
||||
//
|
||||
// Ignore this, but in other cases add an error indicator to the add
|
||||
// passkey text field. The browser is expected to already have shown
|
||||
// an error dialog to the user.
|
||||
// Ignore these, but in other cases add an error indicator to the
|
||||
// add passkey text field. The browser is expected to already have
|
||||
// shown an error dialog to the user.
|
||||
if (!(e instanceof Error && e.name == "NotAllowedError")) {
|
||||
// TODO-PK: localize
|
||||
setFieldError("Could not add passkey");
|
||||
setFieldError(t("passkey_add_failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -232,8 +236,7 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
|
||||
<Stack spacing={"4px"} py={"12px"}>
|
||||
<Titlebar
|
||||
onClose={onClose}
|
||||
// TODO-PK: Localize (more below too)
|
||||
title="Manage Passkey"
|
||||
title={t("manage_passkey")}
|
||||
onRootClose={onClose}
|
||||
/>
|
||||
<InfoItem
|
||||
@@ -251,7 +254,7 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
|
||||
setShowRenameDialog(true);
|
||||
}}
|
||||
startIcon={<EditIcon />}
|
||||
label={"Rename Passkey"}
|
||||
label={t("RENAME_PASSKEY")}
|
||||
/>
|
||||
<MenuItemDivider />
|
||||
<EnteMenuItem
|
||||
@@ -259,7 +262,7 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
startIcon={<DeleteIcon />}
|
||||
label={"Delete Passkey"}
|
||||
label={t("DELETE_PASSKEY")}
|
||||
color="critical"
|
||||
/>
|
||||
</MenuItemGroup>
|
||||
|
||||
@@ -102,11 +102,11 @@ const Page = () => {
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
authorizationResponse = await finishPasskeyAuthentication(
|
||||
authorizationResponse = await finishPasskeyAuthentication({
|
||||
passkeySessionID,
|
||||
ceremonySessionID,
|
||||
credential,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
log.error("Passkey authentication failed", e);
|
||||
setStatus("failed");
|
||||
@@ -116,7 +116,10 @@ const Page = () => {
|
||||
// Conceptually we can `setStatus("done")` at this point, but we'll
|
||||
// leave this page anyway, so no need to tickle React.
|
||||
|
||||
redirectAfterPasskeyAuthentication(redirectURL, authorizationResponse);
|
||||
await redirectAfterPasskeyAuthentication(
|
||||
redirectURL,
|
||||
authorizationResponse,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -152,8 +155,7 @@ const UnknownRedirect: React.FC = () => {
|
||||
};
|
||||
|
||||
const WebAuthnNotSupported: React.FC = () => {
|
||||
// TODO-PK(MR): Translate
|
||||
return <Failed message={"Passkeys are not supported in this browser"} />;
|
||||
return <Failed message={t("passkeys_not_supported")} />;
|
||||
};
|
||||
|
||||
const UnrecoverableFailure: React.FC = () => {
|
||||
|
||||
@@ -6,10 +6,10 @@ import { nullToUndefined } from "@/utils/transform";
|
||||
import {
|
||||
fromB64URLSafeNoPadding,
|
||||
toB64URLSafeNoPadding,
|
||||
toB64URLSafeNoPaddingString,
|
||||
} from "@ente/shared/crypto/internal/libsodium";
|
||||
import { apiOrigin } from "@ente/shared/network/api";
|
||||
import { getToken } from "@ente/shared/storage/localStorage/helpers";
|
||||
import _sodium from "libsodium-wrappers";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Return true if the user's browser supports WebAuthn (Passkeys). */
|
||||
@@ -116,7 +116,11 @@ export const registerPasskey = async (name: string) => {
|
||||
|
||||
// Finish by letting the backend know about these credentials so that it can
|
||||
// save the public key for future authentication.
|
||||
await finishPasskeyRegistration(name, sessionID, credential);
|
||||
await finishPasskeyRegistration({
|
||||
friendlyName: name,
|
||||
sessionID,
|
||||
credential,
|
||||
});
|
||||
};
|
||||
|
||||
interface BeginPasskeyRegistrationResponse {
|
||||
@@ -256,11 +260,17 @@ const binaryToServerB64 = async (b: ArrayBuffer) => {
|
||||
return b64String as unknown as BufferSource;
|
||||
};
|
||||
|
||||
const finishPasskeyRegistration = async (
|
||||
sessionID: string,
|
||||
friendlyName: string,
|
||||
credential: Credential,
|
||||
) => {
|
||||
interface FinishPasskeyRegistrationOptions {
|
||||
sessionID: string;
|
||||
friendlyName: string;
|
||||
credential: Credential;
|
||||
}
|
||||
|
||||
const finishPasskeyRegistration = async ({
|
||||
sessionID,
|
||||
friendlyName,
|
||||
credential,
|
||||
}: FinishPasskeyRegistrationOptions) => {
|
||||
const attestationResponse = authenticatorAttestationResponse(credential);
|
||||
|
||||
const attestationObject = await binaryToServerB64(
|
||||
@@ -269,6 +279,7 @@ const finishPasskeyRegistration = async (
|
||||
const clientDataJSON = await binaryToServerB64(
|
||||
attestationResponse.clientDataJSON,
|
||||
);
|
||||
const transports = attestationResponse.getTransports();
|
||||
|
||||
const params = new URLSearchParams({ friendlyName, sessionID });
|
||||
const url = `${apiOrigin()}/passkeys/registration/finish`;
|
||||
@@ -285,6 +296,7 @@ const finishPasskeyRegistration = async (
|
||||
response: {
|
||||
attestationObject,
|
||||
clientDataJSON,
|
||||
transports,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -423,6 +435,11 @@ export const signChallenge = async (
|
||||
return await navigator.credentials.get({ publicKey });
|
||||
};
|
||||
|
||||
interface FinishPasskeyAuthenticationOptions {
|
||||
passkeySessionID: string;
|
||||
ceremonySessionID: string;
|
||||
credential: Credential;
|
||||
}
|
||||
/**
|
||||
* Finish the authentication by providing the signed assertion to the backend.
|
||||
*
|
||||
@@ -432,11 +449,11 @@ export const signChallenge = async (
|
||||
* @returns The result of successful authentication, a
|
||||
* {@link TwoFactorAuthorizationResponse}.
|
||||
*/
|
||||
export const finishPasskeyAuthentication = async (
|
||||
passkeySessionID: string,
|
||||
ceremonySessionID: string,
|
||||
credential: Credential,
|
||||
) => {
|
||||
export const finishPasskeyAuthentication = async ({
|
||||
passkeySessionID,
|
||||
ceremonySessionID,
|
||||
credential,
|
||||
}: FinishPasskeyAuthenticationOptions) => {
|
||||
const response = authenticatorAssertionResponse(credential);
|
||||
|
||||
const authenticatorData = await binaryToServerB64(
|
||||
@@ -511,14 +528,14 @@ const authenticatorAssertionResponse = (credential: Credential) => {
|
||||
* @param twoFactorAuthorizationResponse The result of
|
||||
* {@link finishPasskeyAuthentication} returned by the backend.
|
||||
*/
|
||||
export const redirectAfterPasskeyAuthentication = (
|
||||
export const redirectAfterPasskeyAuthentication = async (
|
||||
redirectURL: URL,
|
||||
twoFactorAuthorizationResponse: TwoFactorAuthorizationResponse,
|
||||
) => {
|
||||
const encodedResponse = _sodium.to_base64(
|
||||
const encodedResponse = await toB64URLSafeNoPaddingString(
|
||||
JSON.stringify(twoFactorAuthorizationResponse),
|
||||
);
|
||||
|
||||
// TODO-PK: Shouldn't this be URL encoded?
|
||||
window.location.href = `${redirectURL}?response=${encodedResponse}`;
|
||||
redirectURL.searchParams.set("response", encodedResponse);
|
||||
window.location.href = redirectURL.href;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { VerticallyCentered } from "@ente/shared/components/Container";
|
||||
import EnteSpinner from "@ente/shared/components/EnteSpinner";
|
||||
import { fromB64URLSafeNoPaddingString } from "@ente/shared/crypto/internal/libsodium";
|
||||
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
|
||||
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -23,11 +24,11 @@ const Page: React.FC<PageProps> = () => {
|
||||
const response = searchParams.get("response");
|
||||
if (!response) return;
|
||||
|
||||
saveCredentials(response);
|
||||
|
||||
const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL);
|
||||
InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
|
||||
router.push(redirectURL ?? PAGES.CREDENTIALS);
|
||||
saveCredentials(response).then(() => {
|
||||
const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL);
|
||||
InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
|
||||
router.push(redirectURL ?? PAGES.CREDENTIALS);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -47,9 +48,12 @@ export default Page;
|
||||
* @param response The string that is passed as the response query parameter to
|
||||
* us (we're the final "finish" page in the passkey flow).
|
||||
*/
|
||||
const saveCredentials = (response: string) => {
|
||||
// Decode response string.
|
||||
const decodedResponse = JSON.parse(atob(response));
|
||||
const saveCredentials = async (response: string) => {
|
||||
// Decode response string (inverse of the steps we perform in
|
||||
// `redirectAfterPasskeyAuthentication`).
|
||||
const decodedResponse = JSON.parse(
|
||||
await fromB64URLSafeNoPaddingString(response),
|
||||
);
|
||||
|
||||
// Only one of `encryptedToken` or `token` will be present depending on the
|
||||
// account's lifetime:
|
||||
|
||||
@@ -610,6 +610,8 @@
|
||||
"APPLY_CROP": "Apply Crop",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": "At least one transformation or color adjustment must be performed before saving.",
|
||||
"PASSKEYS": "Passkeys",
|
||||
"passkey_fetch_failed": "Could not get your passkeys.",
|
||||
"manage_passkey": "Manage passkey",
|
||||
"DELETE_PASSKEY": "Delete passkey",
|
||||
"DELETE_PASSKEY_CONFIRMATION": "Are you sure you want to delete this passkey? This action is irreversible.",
|
||||
"RENAME_PASSKEY": "Rename passkey",
|
||||
@@ -617,9 +619,11 @@
|
||||
"ENTER_PASSKEY_NAME": "Enter passkey name",
|
||||
"PASSKEYS_DESCRIPTION": "Passkeys are a modern and secure second-factor for your Ente account. They use on-device biometric authentication for convenience and security.",
|
||||
"CREATED_AT": "Created at",
|
||||
"passkey_add_failed": "Could not add passkey",
|
||||
"PASSKEY_LOGIN_FAILED": "Passkey login failed",
|
||||
"PASSKEY_LOGIN_URL_INVALID": "The login URL is invalid.",
|
||||
"PASSKEY_LOGIN_ERRORED": "An error occurred while logging in with passkey.",
|
||||
"passkeys_not_supported": "Passkeys are not supported in this browser",
|
||||
"TRY_AGAIN": "Try again",
|
||||
"PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Follow the steps from your browser to continue logging in.",
|
||||
"LOGIN_WITH_PASSKEY": "Login with passkey",
|
||||
|
||||
@@ -414,6 +414,15 @@ export const toB64URLSafe = async (input: Uint8Array) => {
|
||||
* This differs from {@link toB64URLSafe} in that it does not append any
|
||||
* trailing padding character(s) "=" to make the resultant string's length be an
|
||||
* integer multiple of 4.
|
||||
*
|
||||
* - In some contexts, for example when serializing WebAuthn binary for
|
||||
* transmission over the network, this is the required / recommended
|
||||
* approach.
|
||||
*
|
||||
* - In other cases, for example when trying to pass an arbitrary JSON string
|
||||
* via a URL parameter, this is also convenient so that we do not have to
|
||||
* deal with any ambiguity surrounding the "=" which is also the query
|
||||
* parameter key value separator.
|
||||
*/
|
||||
export const toB64URLSafeNoPadding = async (input: Uint8Array) => {
|
||||
await sodium.ready;
|
||||
@@ -431,6 +440,24 @@ export const fromB64URLSafeNoPadding = async (input: string) => {
|
||||
return sodium.from_base64(input, sodium.base64_variants.URLSAFE_NO_PADDING);
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant of {@link toB64URLSafeNoPadding} that works with {@link strings}. See also
|
||||
* its sibling method {@link fromB64URLSafeNoPaddingString}.
|
||||
*/
|
||||
export const toB64URLSafeNoPaddingString = async (input: string) => {
|
||||
await sodium.ready;
|
||||
return toB64URLSafeNoPadding(sodium.from_string(input));
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant of {@link fromB64URLSafeNoPadding} that works with {@link strings}. See also
|
||||
* its sibling method {@link toB64URLSafeNoPaddingString}.
|
||||
*/
|
||||
export const fromB64URLSafeNoPaddingString = async (input: string) => {
|
||||
await sodium.ready;
|
||||
return sodium.to_string(await fromB64URLSafeNoPadding(input));
|
||||
};
|
||||
|
||||
export async function fromUTF8(input: string) {
|
||||
await sodium.ready;
|
||||
return sodium.from_string(input);
|
||||
|
||||
Reference in New Issue
Block a user