[web] Get passkeys working on localhost too (#2031)

This commit is contained in:
Manav Rathi
2024-06-06 15:38:49 +05:30
committed by GitHub
16 changed files with 187 additions and 232 deletions

View File

@@ -0,0 +1,9 @@
# Copy this file into `.env.local` and uncomment these to develop against apps
# and server running on localhost.
#
# For details, please see `apps/photos/.env.development`
#NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080
#NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = http://localhost:3002
#NEXT_PUBLIC_ENTE_ACCOUNTS_URL = http://localhost:3001
#NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001

View File

@@ -2,7 +2,27 @@
Code that runs on `accounts.ente.io`.
Passkeys are tied to domains, so this app serves as a common sharing point to
allow the passkey to be tied to the user's Ente account and be used by all our
other apps. For more details, see
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.
For more details about the Passkey flows,
[docs/webauthn-passkeys.md](../../docs/webauthn-passkeys.md).
## Development
To set this up to work with a locally running museum, modify your local
`museum.yaml` to set the relaying party's ID to "localhost" (without any port
number).
```yaml
webauthn:
rpid: "localhost"
rporigins:
- "http://localhost:3001"
```
Note that browsers already treat `localhost` as a secure domain, so Passkey APIs
will work even if our local dev server is using `http`.

View File

@@ -1,3 +1,4 @@
import Page from "@ente/accounts/pages/passkeys/finish";
// See: [Note: Finish passkey flow in the requesting app]
export default Page;

View File

@@ -1,5 +1,6 @@
import log from "@/next/log";
import { clientPackageName } from "@/next/types/app";
import { nullToUndefined } from "@/utils/transform";
import {
CenteredFlex,
VerticallyCentered,
@@ -18,6 +19,7 @@ import { useEffect, useState } from "react";
import {
beginPasskeyAuthentication,
finishPasskeyAuthentication,
isWhitelistedRedirect,
type BeginPasskeyAuthenticationResponse,
} from "services/passkey";
@@ -31,25 +33,16 @@ const PasskeysFlow = () => {
const init = async () => {
const searchParams = new URLSearchParams(window.location.search);
// get redirect from the query params
const redirect = searchParams.get("redirect") as string;
// Extract redirect from the query params.
const redirect = nullToUndefined(searchParams.get("redirect"));
const redirectURL = redirect ? new URL(redirect) : undefined;
const redirectURL = new URL(redirect);
if (process.env.NEXT_PUBLIC_DISABLE_REDIRECT_CHECK !== "true") {
if (
redirect !== "" &&
!(
redirectURL.host.endsWith(".ente.io") ||
redirectURL.host.endsWith(".ente.sh") ||
redirectURL.host.endsWith("bada-frame.pages.dev")
) &&
redirectURL.protocol !== "ente:" &&
redirectURL.protocol !== "enteauth:"
) {
setInvalidInfo(true);
setLoading(false);
return;
}
// Ensure that redirectURL is whitelisted, otherwise show an invalid
// "login" URL error to the user.
if (!redirectURL || !isWhitelistedRedirect(redirectURL)) {
setInvalidInfo(true);
setLoading(false);
return;
}
let pkg = clientPackageName["photos"];
@@ -60,6 +53,7 @@ const PasskeysFlow = () => {
}
setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
// The server needs to know the app on whose behalf we're trying to log in
HTTPService.setHeaders({
"X-Client-Package": pkg,
});

View File

@@ -140,46 +140,44 @@ const Passkeys = () => {
};
return (
<>
<PasskeysContext.Provider
value={{
selectedPasskey,
setSelectedPasskey,
setShowPasskeyDrawer,
refreshPasskeys: init,
}}
>
<CenteredFlex>
<Box maxWidth="20rem">
<Box marginBottom="1rem">
<Typography>{t("PASSKEYS_DESCRIPTION")}</Typography>
</Box>
<FormPaper
style={{
padding: "1rem",
}}
>
<SingleInputForm
fieldType="text"
placeholder={t("ENTER_PASSKEY_NAME")}
buttonText={t("ADD_PASSKEY")}
initialValue={""}
callback={handleSubmit}
submitButtonProps={{
sx: {
marginBottom: 1,
},
}}
/>
</FormPaper>
<Box marginTop="1rem">
<PasskeysList passkeys={passkeys} />
</Box>
<PasskeysContext.Provider
value={{
selectedPasskey,
setSelectedPasskey,
setShowPasskeyDrawer,
refreshPasskeys: init,
}}
>
<CenteredFlex>
<Box maxWidth="20rem">
<Box marginBottom="1rem">
<Typography>{t("PASSKEYS_DESCRIPTION")}</Typography>
</Box>
</CenteredFlex>
<ManagePasskeyDrawer open={showPasskeyDrawer} />
</PasskeysContext.Provider>
</>
<FormPaper
style={{
padding: "1rem",
}}
>
<SingleInputForm
fieldType="text"
placeholder={t("ENTER_PASSKEY_NAME")}
buttonText={t("ADD_PASSKEY")}
initialValue={""}
callback={handleSubmit}
submitButtonProps={{
sx: {
marginBottom: 1,
},
}}
/>
</FormPaper>
<Box marginTop="1rem">
<PasskeysList passkeys={passkeys} />
</Box>
</Box>
</CenteredFlex>
<ManagePasskeyDrawer open={showPasskeyDrawer} />
</PasskeysContext.Provider>
);
};
@@ -191,16 +189,14 @@ interface PasskeysListProps {
const PasskeysList: React.FC<PasskeysListProps> = ({ passkeys }) => {
return (
<>
<MenuItemGroup>
{passkeys.map((passkey, i) => (
<Fragment key={passkey.id}>
<PasskeyListItem passkey={passkey} />
{i < passkeys.length - 1 && <MenuItemDivider />}
</Fragment>
))}
</MenuItemGroup>
</>
<MenuItemGroup>
{passkeys.map((passkey, i) => (
<Fragment key={passkey.id}>
<PasskeyListItem passkey={passkey} />
{i < passkeys.length - 1 && <MenuItemDivider />}
</Fragment>
))}
</MenuItemGroup>
);
};

View File

@@ -1,3 +1,4 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
import { toB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium";
import HTTPService from "@ente/shared/network/HTTPService";
@@ -81,6 +82,18 @@ export const getPasskeyRegistrationOptions = async () => {
}
};
/**
* Return `true` if the given {@link redirectURL} (obtained from the redirect
* query parameter passed around during the passkey verification flow) is one of
* the whitelisted URLs that we allow redirecting to on success.
*/
export const isWhitelistedRedirect = (redirectURL: URL) =>
(isDevBuild && redirectURL.host.endsWith("localhost")) ||
redirectURL.host.endsWith(".ente.io") ||
redirectURL.host.endsWith(".ente.sh") ||
redirectURL.protocol == "ente:" ||
redirectURL.protocol == "enteauth:";
export const finishPasskeyRegistration = async (
friendlyName: string,
credential: Credential,

View File

@@ -54,102 +54,6 @@ body {
height: 100vh;
}
.pswp__button--custom {
width: 48px;
height: 48px;
background: none !important;
background-image: none !important;
color: #fff;
}
.pswp__item video {
width: 100%;
height: 100%;
}
.pswp-item-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
object-fit: contain;
}
.pswp-item-container > * {
position: absolute;
transition: opacity 1s ease;
max-width: 100%;
max-height: 100%;
}
.pswp-item-container > img {
opacity: 1;
}
.pswp-item-container > video {
opacity: 0;
}
.pswp-item-container > div.download-banner {
width: 100%;
height: 16vh;
padding: 2vh 0;
background-color: #151414;
color: #ddd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
opacity: 0.8;
font-size: 20px;
}
.download-banner > a {
width: 130px;
}
.pswp__img {
object-fit: contain;
}
.pswp__button--arrow--left,
.pswp__button--arrow--right {
color: #fff;
background-color: #333333 !important;
border-radius: 50%;
width: 56px;
height: 56px;
}
.pswp__button--arrow--left::before,
.pswp__button--arrow--right::before {
background: none !important;
}
.pswp__button--arrow--left {
margin-left: 20px;
}
.pswp__button--arrow--right {
margin-right: 20px;
}
.pswp-custom-caption-container {
width: 100%;
display: flex;
justify-content: flex-end;
bottom: 56px;
background-color: transparent !important;
}
.pswp__caption--empty {
display: none;
}
.bg-upload-progress-bar {
background-color: #51cd7c;
}
div.otp-input input {
width: 36px !important;
height: 36px;
@@ -168,18 +72,3 @@ div.otp-input input:focus {
transition: 0.5s;
outline: none;
}
.flash-message {
padding: 16px;
display: flex;
align-items: center;
}
@-webkit-keyframes rotation {
from {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(359deg);
}
}

View File

@@ -20,7 +20,7 @@ Add the following to `web/apps/photos/.env.local`:
```env
NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080
NEXT_PUBLIC_ENTE_PAYMENTS_ENDPOINT = http://localhost:3001
NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001
```
Then start it locally

View File

@@ -41,13 +41,13 @@
#
# NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:3000
# The Ente API endpoint for accounts related functionality
# The URL of the accounts app
#
# NEXT_PUBLIC_ENTE_ACCOUNTS_ENDPOINT = http://localhost:3001
# NEXT_PUBLIC_ENTE_ACCOUNTS_URL = http://localhost:3001
# The Ente API endpoint for payments related functionality
# The URL of the payments app
#
# NEXT_PUBLIC_ENTE_PAYMENTS_ENDPOINT = http://localhost:3001
# NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001
# The URL for the shared albums deployment
#
@@ -69,7 +69,7 @@
#
# NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = http://localhost:3002
# The URL of the family plans web app deployment
# The URL of the family plans web app
#
# Currently the source code for the family plan related pages is in a separate
# repository (https://github.com/ente-io/families). The mobile app also uses
@@ -77,7 +77,7 @@
#
# Enhancement: Consider moving that into the app/ folder in this repository.
#
# NEXT_PUBLIC_ENTE_FAMILY_ENDPOINT = http://localhost:3001
# NEXT_PUBLIC_ENTE_FAMILY_URL = http://localhost:3001
# The JSON which describes the expected results of our integration tests. See
# `upload.test.ts` for more details of the expected format.

View File

@@ -11,15 +11,21 @@
#NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080
# If you wish to preview how the shared albums work, you can use `yarn
# dev:albums`. You'll need to run two instances.
# The equivalent CLI commands using env vars would be:
# If you wish to preview how the shared albums work, you can also uncomment
# this, and run two apps:
#
# # For the normal web app
# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 yarn dev:photos
#
# # For the albums app
# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 yarn dev:albums
# - `yarn dev:photos` (the main app)
# - `yarn dev:albums` (the sidecar app, in this case, albums)
#NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = http://localhost:3002
# We also have various sidecar apps. These all run on a separate port, 3001,
# since usually when developing we usually need to run only one of them in
# addition to the main photos app. So you can uncomment this entire set.
#
# You'll also need to create a similar `.env.local` or `.env.development.local`
# in the app you're running (e.g. in apps/accounts), and put an
# `NEXT_PUBLIC_ENTE_ENDPOINT` in there.
#NEXT_PUBLIC_ENTE_ACCOUNTS_URL = http://localhost:3001
#NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001

View File

@@ -22,7 +22,7 @@ import {
generateEncryptionKey,
} from "@ente/shared/crypto/internal/libsodium";
import { useLocalState } from "@ente/shared/hooks/useLocalState";
import { getAccountsURL } from "@ente/shared/network/api";
import { accountsAppURL } from "@ente/shared/network/api";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
import { THEME_COLOR } from "@ente/shared/themes/constants";
import { downloadAsFile } from "@ente/shared/utils";
@@ -507,10 +507,11 @@ const UtilitySection: React.FC<UtilitySectionProps> = ({ closeSidebar }) => {
);
}
// Ente Accounts specific JWT token.
const accountsToken = await getAccountsToken();
window.open(
`${getAccountsURL()}${
`${accountsAppURL()}${
ACCOUNTS_PAGES.ACCOUNT_HANDOFF
}?package=${clientPackageName["photos"]}&token=${accountsToken}`,
);

View File

@@ -342,7 +342,7 @@ credential authentication. We use Accounts as the central WebAuthn hub because
credentials are locked to an FQDN.
```tsx
window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
window.location.href = `${accountsAppURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
```

View File

@@ -18,7 +18,7 @@ import {
} from "@ente/shared/crypto/helpers";
import type { B64EncryptionResult } from "@ente/shared/crypto/types";
import { CustomError } from "@ente/shared/error";
import { getAccountsURL, getEndpoint } from "@ente/shared/network/api";
import { accountsAppURL, apiOrigin } from "@ente/shared/network/api";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import {
LS_KEYS,
@@ -166,7 +166,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.ROOT);
window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
window.location.href = `${accountsAppURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
return undefined;
@@ -313,12 +313,12 @@ const Header_ = styled("div")`
`;
const ConnectionDetails: React.FC = () => {
const apiOrigin = new URL(getEndpoint());
const host = new URL(apiOrigin()).host;
return (
<ConnectionDetails_>
<Typography variant="small" color="text.faint">
{apiOrigin.host}
{host}
</Typography>
</ConnectionDetails_>
);

View File

@@ -6,34 +6,27 @@ import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
import { useRouter } from "next/router";
import React, { useEffect } from "react";
/**
* [Note: Finish passkey flow in the requesting app]
*
* The passkey finish step needs to happen in the context of the client which
* invoked the passkey flow since it needs to save the obtained credentials
* in local storage (which is tied to the current origin).
*/
const Page: React.FC = () => {
const router = useRouter();
const init = async () => {
// get response from query params
useEffect(() => {
// Extract response from query params
const searchParams = new URLSearchParams(window.location.search);
const response = searchParams.get("response");
if (!response) return;
// decode response
const decodedResponse = JSON.parse(atob(response));
saveCredentials(response);
const { keyAttributes, encryptedToken, token, id } = decodedResponse;
setData(LS_KEYS.USER, {
...getData(LS_KEYS.USER),
token,
encryptedToken,
id,
});
setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL);
InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
router.push(redirectURL ?? PAGES.ROOT);
};
useEffect(() => {
init();
}, []);
return (
@@ -44,3 +37,34 @@ const Page: React.FC = () => {
};
export default Page;
/**
* Extract credentials from a successful passkey flow "response" query parameter
* and save them to local storage for use by subsequent steps (or normal
* functioning) of the app.
*
* @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));
// Only one of `encryptedToken` or `token` will be present depending on the
// account's lifetime:
//
// - The plaintext "token" will be passed during fresh signups, where we
// don't yet have keys to encrypt it, the account itself is being created
// as we go through this flow.
//
// - The encrypted `encryptedToken` will be present otherwise (i.e. if the
// user is signing into an existing account).
const { keyAttributes, encryptedToken, token, id } = decodedResponse;
setData(LS_KEYS.USER, {
...getData(LS_KEYS.USER),
token,
encryptedToken,
id,
});
setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
};

View File

@@ -10,7 +10,7 @@ import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
import { ApiError } from "@ente/shared/error";
import { getAccountsURL } from "@ente/shared/network/api";
import { accountsAppURL } from "@ente/shared/network/api";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import localForage from "@ente/shared/storage/localForage";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
@@ -85,7 +85,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
setIsFirstLogin(true);
window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
window.location.href = `${accountsAppURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
router.push(PAGES.CREDENTIALS);

View File

@@ -34,13 +34,15 @@ export const getUploadEndpoint = () => {
return `https://uploader.ente.io`;
};
export const getAccountsURL = () => {
const accountsURL = process.env.NEXT_PUBLIC_ENTE_ACCOUNTS_ENDPOINT;
if (accountsURL) {
return accountsURL;
}
return `https://accounts.ente.io`;
};
/**
* Return the URL of the Ente Accounts app.
*
* Defaults to our production instance, "https://accounts.ente.io", but can be
* overridden by setting the `NEXT_PUBLIC_ENTE_ACCOUNTS_URL` environment
* variable to a non-empty value.
*/
export const accountsAppURL = () =>
process.env.NEXT_PUBLIC_ENTE_ACCOUNTS_URL || `https://accounts.ente.io`;
export const getAlbumsURL = () => {
const albumsURL = process.env.NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT;
@@ -55,7 +57,7 @@ export const getAlbumsURL = () => {
* family plans.
*/
export const getFamilyPortalURL = () => {
const familyURL = process.env.NEXT_PUBLIC_ENTE_FAMILY_ENDPOINT;
const familyURL = process.env.NEXT_PUBLIC_ENTE_FAMILY_URL;
if (familyURL) {
return familyURL;
}
@@ -66,7 +68,7 @@ export const getFamilyPortalURL = () => {
* Return the URL for the host that handles payment related functionality.
*/
export const getPaymentsURL = () => {
const paymentsURL = process.env.NEXT_PUBLIC_ENTE_PAYMENTS_ENDPOINT;
const paymentsURL = process.env.NEXT_PUBLIC_ENTE_PAYMENTS_URL;
if (paymentsURL) {
return paymentsURL;
}