Skip to content

Commit

Permalink
build: agent-js v0.13.2 with idb breaking change (#1247)
Browse files Browse the repository at this point in the history
# Motivation

agent-js `v0.13.x` introduces a breaking change. It uses indexeddb instead of local storage to save the session delegation and identity.

Indexddb is awesome and it's the most convenient way to access these information from web workers which we are already using for the cron that checks the delegation and which we might use for more features in the future.

# ⚠️ Important Note ⚠️ 

- on logout, local storage is not going cleared anymore but idb instead
- we don't explicitly clear idb but defer the work to agent-js which do so
- instead of syncing the auth on local storage changes, the identity will be checked in the auth cron

#  Dependencies

fake-indexeddb (https://github.com/dumbmatter/fakeIndexedDB) has been added to mock idb for jest tests happening in the nodejs context

# PRs required

- [X] ic-js PR dfinity/ic-js#177
- [x] agent-js PR dfinity/agent-js#611
- [x] agent-js PR dfinity/agent-js#612

# Changes

- in web worker, read delegation from idb instead of localstorage to check the validity of the delegation
- in web worker, also check the auth is authenticated
- on logout, do not clear local storage
- mock idb for test with new dev dependency `fake-indexeddb`
- remove auth sync on local storage changes
- bump agent-js `v0.13.2`
- bump most recent nns-js, sns-js and utils-js
  • Loading branch information
peterpeterparker committed Aug 24, 2022
1 parent 6d636c2 commit 50f52ee
Show file tree
Hide file tree
Showing 15 changed files with 691 additions and 552 deletions.
1 change: 1 addition & 0 deletions frontend/jest.config.cjs
Expand Up @@ -23,4 +23,5 @@ module.exports = {
transformIgnorePatterns: [
"<rootDir>/node_modules/(?!(@dfinity/gix-components))",
],
setupFiles: ["fake-indexeddb/auto"],
};
944 changes: 539 additions & 405 deletions frontend/package-lock.json

Large diffs are not rendered by default.

15 changes: 8 additions & 7 deletions frontend/package.json
Expand Up @@ -16,7 +16,7 @@
"test": "TZ=UTC jest",
"test:watch": "TZ=UTC npm run test -- --watchAll",
"update:next": "npm update @dfinity/nns @dfinity/sns @dfinity/utils",
"update:agent": "npm rm @dfinity/agent @dfinity/auth-client @dfinity/authentication @dfinity/candid @dfinity/identity @dfinity/principal && npm i @dfinity/agent @dfinity/auth-client @dfinity/authentication @dfinity/candid @dfinity/identity @dfinity/principal",
"update:agent": "npm rm @dfinity/agent @dfinity/auth-client @dfinity/authentication @dfinity/candid @dfinity/identity @dfinity/principal @dfinity/nns @dfinity/sns @dfinity/utils && npm i @dfinity/agent @dfinity/auth-client @dfinity/authentication @dfinity/candid @dfinity/identity @dfinity/principal @dfinity/nns@next @dfinity/sns@next @dfinity/utils@next",
"update:gix": "npm update @dfinity/gix-components"
},
"devDependencies": {
Expand All @@ -38,6 +38,7 @@
"autoprefixer": "^10.4.7",
"eslint": "^8.15.0",
"eslint-plugin-svelte3": "^4.0.0",
"fake-indexeddb": "^4.0.0",
"jest": "^28.1.0",
"jest-environment-jsdom": "^28.1.0",
"jest-mock-extended": "^2.0.6",
Expand All @@ -62,14 +63,14 @@
"typescript": "^4.6.4"
},
"dependencies": {
"@dfinity/agent": "^0.12.2",
"@dfinity/auth-client": "^0.12.2",
"@dfinity/authentication": "^0.12.2",
"@dfinity/candid": "^0.12.2",
"@dfinity/agent": "^0.13.2",
"@dfinity/auth-client": "^0.13.2",
"@dfinity/authentication": "^0.13.2",
"@dfinity/candid": "^0.13.2",
"@dfinity/gix-components": "next",
"@dfinity/identity": "^0.12.2",
"@dfinity/identity": "^0.13.2",
"@dfinity/nns": "next",
"@dfinity/principal": "^0.12.2",
"@dfinity/principal": "^0.13.2",
"@dfinity/sns": "next",
"@dfinity/utils": "next",
"@ledgerhq/hw-transport-node-hid-noevents": "^6.27.1",
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/lib/components/common/Guard.svelte
Expand Up @@ -14,12 +14,8 @@
};
</script>

<!-- storage: on every change in local storage we sync the auth state -->
<!-- popstate: browser back button has been clicked, we reflect the new browser url to the route -->
<svelte:window
on:storage={async () => await authStore.sync()}
on:popstate={() => routeStore.update({ path: routePath() })}
/>
<svelte:window on:popstate={() => routeStore.update({ path: routePath() })} />

{#await syncAuthStore()}
<Spinner />
Expand Down
9 changes: 3 additions & 6 deletions frontend/src/lib/services/auth.services.ts
@@ -1,7 +1,6 @@
import type { Identity } from "@dfinity/agent";
import { get } from "svelte/store";
import { authStore } from "../stores/auth.store";
import { themeStore } from "../stores/theme.store";
import { toastsStore } from "../stores/toasts.store";
import type { ToastLevel, ToastMsg } from "../types/toast";
import { replaceHistory } from "../utils/route.utils";
Expand All @@ -20,12 +19,10 @@ export const logout = async ({
appendMsgToUrl(msg);
}

// We preserve the anonymous theme information only so that user sign-in with same theme next time
const { theme: storageTheme }: Storage = localStorage;
// Auth: Delegation and identity are cleared from indexedDB by agent-js so, we do not need to clear these

window.localStorage.clear();

themeStore.select(storageTheme);
// Preferences: We do not clear local storage as well. It contains anonymous information such as the selected theme.
// Information the user want to preserve across sign-in. e.g. if I select the light theme, logout and sign-in again, I am happy if the dapp still uses the light theme.

// We reload the page to make sure all the states are cleared
window.location.reload();
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/lib/services/worker.services.ts
@@ -1,6 +1,5 @@
import type { AuthStore } from "../stores/auth.store";
import type { PostMessageEventData } from "../types/post-messages";
import { localStorageAuth } from "../utils/auth.utils";
import { logout } from "./auth.services";

const initWorker = () => {
Expand Down Expand Up @@ -31,7 +30,6 @@ const initWorker = () => {

worker.postMessage({
msg: "nnsStartIdleTimer",
data: await localStorageAuth(),
});
},
};
Expand Down
18 changes: 3 additions & 15 deletions frontend/src/lib/stores/auth.store.ts
@@ -1,28 +1,16 @@
import type { Identity } from "@dfinity/agent";
import { AuthClient } from "@dfinity/auth-client";
import type { AuthClient } from "@dfinity/auth-client";
import { writable } from "svelte/store";
import {
AUTH_SESSION_DURATION,
IDENTITY_SERVICE_URL,
} from "../constants/identity.constants";
import { createAuthClient } from "../utils/auth.utils";

export interface AuthStore {
identity: Identity | undefined | null;
}

/**
* Create an AuthClient to manage authentication and identity.
* - Session duration is 30min (AUTH_SESSION_DURATION).
* - Disable idle manager that sign-out in case of inactivity after default 10min to avoid UX issues if multiple tabs are used as we observe the storage and sync the delegation on any changes
*/
const createAuthClient = (): Promise<AuthClient> =>
AuthClient.create({
idleOptions: {
disableIdle: true,
disableDefaultIdleCallback: true,
},
});

/**
* A store to handle authentication and the identity of the user.
*
Expand All @@ -38,7 +26,7 @@ const createAuthClient = (): Promise<AuthClient> =>
*
* - signOut: call auth-client log out and set null in the store. started with a user interaction ("click on a button")
*
* note: clearing the local storage does not happen in the state management but afterwards in its caller function (see <Logout/>)
* note: clearing idb auth keys does not happen in the state management but afterwards in its caller function (see <Logout/>)
*
*/
const initAuthStore = () => {
Expand Down
4 changes: 0 additions & 4 deletions frontend/src/lib/types/auth.ts

This file was deleted.

3 changes: 0 additions & 3 deletions frontend/src/lib/types/post-messages.ts
@@ -1,6 +1,3 @@
import type { LocalStorageAuth } from "./auth";

export interface PostMessageEventData {
msg: "nnsStartIdleTimer" | "nnsStopIdleTimer" | "nnsSignOut";
data?: LocalStorageAuth;
}
26 changes: 13 additions & 13 deletions frontend/src/lib/utils/auth.utils.ts
@@ -1,6 +1,5 @@
import type { Identity } from "@dfinity/agent";
import { LocalStorage } from "@dfinity/auth-client";
import type { LocalStorageAuth } from "../types/auth";
import { AuthClient } from "@dfinity/auth-client";

/**
* The user is signed in when the identity is not undefined and not null.
Expand All @@ -12,14 +11,15 @@ import type { LocalStorageAuth } from "../types/auth";
export const isSignedIn = (identity: Identity | undefined | null): boolean =>
identity !== undefined && identity !== null;

export const localStorageAuth = async (): Promise<LocalStorageAuth> => {
const storage: LocalStorage = new LocalStorage("ic-");

const identityKey: string | null = await storage.get("identity");
const delegationChain: string | null = await storage.get("delegation");

return {
identityKey,
delegationChain,
};
};
/**
* Create an AuthClient to manage authentication and identity.
* - Session duration is 30min (AUTH_SESSION_DURATION).
* - Disable idle manager that sign-out in case of inactivity after default 10min to avoid UX issues if multiple tabs are used as we observe the storage and sync the delegation on any changes
*/
export const createAuthClient = (): Promise<AuthClient> =>
AuthClient.create({
idleOptions: {
disableIdle: true,
disableDefaultIdleCallback: true,
},
});
54 changes: 42 additions & 12 deletions frontend/src/lib/workers/auth.worker.ts
@@ -1,11 +1,15 @@
import { IdbStorage, type AuthClient } from "@dfinity/auth-client";
import { isDelegationValid } from "@dfinity/authentication";
import { DelegationChain } from "@dfinity/identity";
import type { LocalStorageAuth } from "../types/auth";
import { createAuthClient } from "../utils/auth.utils";

let timer: NodeJS.Timeout | undefined = undefined;

export const startIdleTimer = (data?: LocalStorageAuth) =>
(timer = setInterval(() => onIdleSignOut(data), 1000));
/**
* The timer is executed only if user has signed in
*/
export const startIdleTimer = () =>
(timer = setInterval(async () => await onIdleSignOut(), 1000));

export const stopIdleTimer = () => {
if (!timer) {
Expand All @@ -16,21 +20,47 @@ export const stopIdleTimer = () => {
timer = undefined;
};

const onIdleSignOut = (data?: LocalStorageAuth) => {
if (!data) {
const onIdleSignOut = async () => {
const [auth, delegation] = await Promise.all([
checkAuthentication(),
checkDelegationChain(),
]);

// Both identity and delegation are alright, so all good
if (auth && delegation) {
return;
}

const { delegationChain } = data;
logout();
};

if (delegationChain === null) {
return;
}
/**
* If user is not authenticated - i.e. no identity or anonymous and there is no valid delegation chain, then identity is not valid
*
* @returns true if authenticated
*/
const checkAuthentication = async (): Promise<boolean> => {
const authClient: AuthClient = await createAuthClient();
return authClient.isAuthenticated();
};

if (isDelegationValid(DelegationChain.fromJSON(delegationChain))) {
return;
}
/**
* If there is no delegation or if not valid, then delegation is not valid
*
* @returns true if delegation is valid
*/
const checkDelegationChain = async (): Promise<boolean> => {
const idbStorage: IdbStorage = new IdbStorage();
const delegationChain: string | null = await idbStorage.get("delegation");

return (
delegationChain !== null &&
isDelegationValid(DelegationChain.fromJSON(delegationChain))
);
};

// We do the logout on the client side because we reload the window to reload stores afterwards
const logout = () => {
// Clear timer to not emit sign-out multiple times
stopIdleTimer();

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/tests/App.spec.ts
Expand Up @@ -28,6 +28,8 @@ describe("App", () => {
jest
.spyOn(authStore, "subscribe")
.mockImplementation(mutableMockAuthStoreSubscribe);

jest.spyOn(authStore, "sync").mockImplementation(() => Promise.resolve());
});

afterAll(() => {
Expand Down
12 changes: 0 additions & 12 deletions frontend/src/tests/lib/components/common/Guard.spec.ts
Expand Up @@ -18,16 +18,4 @@ describe("Guard", () => {
expect(container.querySelector("svg")).not.toBeNull();
expect(container.querySelector("circle")).not.toBeNull();
});

it("should sync auth on localstorage changes", () => {
const spy = jest
.spyOn(authStore, "sync")
.mockImplementation(() => Promise.resolve());

render(Guard);

window.localStorage.setItem("test", "test");

expect(spy).toHaveBeenCalled();
});
});

0 comments on commit 50f52ee

Please sign in to comment.