From 2afc69a887cac45baa510033a6c0943aee2d5c7f Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Wed, 23 Mar 2022 16:57:11 -0700 Subject: [PATCH 01/21] feat: idleManager --- packages/auth-client/src/idleManager.test.ts | 97 ++++++++++++++++++++ packages/auth-client/src/idleManager.ts | 93 +++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/auth-client/src/idleManager.test.ts create mode 100644 packages/auth-client/src/idleManager.ts diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts new file mode 100644 index 000000000..248bc4f51 --- /dev/null +++ b/packages/auth-client/src/idleManager.test.ts @@ -0,0 +1,97 @@ +import IdleManager from './idleManager'; + +jest.useFakeTimers(); + +describe('IdleManager tests', () => { + it('should call its callback after time spent inactive', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 30 minutes + jest.advanceTimersByTime(30 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + manager.exit(); + }); + it('should delay allow configuration of the timeout', () => { + const cb = jest.fn(); + const extraDelay = 100; + new IdleManager({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 30 minutes + jest.advanceTimersByTime(30 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + jest.advanceTimersByTime(extraDelay); + expect(cb).toHaveBeenCalled(); + }); + it('should delay its callback on keyboard events', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + document.dispatchEvent(new KeyboardEvent('keypress')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + }); + it('should delay its callback on mouse events', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user moving the mouse + document.dispatchEvent(new MouseEvent('mousemove')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + }); + + it('should delay its callback on touch events', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user touching the screen + document.dispatchEvent(new TouchEvent('touchstart')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + }); + it('should delay its callback on scroll events', () => { + const cb = jest.fn(); + + const scrollDebounce = 100; + + const manager = new IdleManager({ onIdle: cb, captureScroll: true, scrollDebounce }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user scrolling + document.dispatchEvent(new WheelEvent('scroll')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes, plus the debounce + jest.advanceTimersByTime(25 * 60 * 1000 + scrollDebounce); + expect(cb).toHaveBeenCalled(); + }); +}); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts new file mode 100644 index 000000000..7b542b11b --- /dev/null +++ b/packages/auth-client/src/idleManager.ts @@ -0,0 +1,93 @@ +export type IdleManagerOptions = { + /** + * Callback after the user has gone idle + */ + onIdle?: () => void; + /** + * timeout in ms + * @default 30 minutes [1_800_000] + */ + idleTimeout?: number; + /** + * capture scroll events + * @default false + */ + captureScroll?: boolean; + /** + * scroll debounce time in ms + * @default 100 + */ + scrollDebounce?: number; +}; + +const events = ['mousedown', 'mousemove', 'keypress', 'touchstart', 'wheel']; + +/** + * Detects if the user has been idle for a duration of `idleTimeout` ms, and calls `onIdle`. + * + * @param {IdleManagerOptions} options + */ +class IdleManager { + onIdle: IdleManagerOptions['onIdle'] = undefined; + idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; + timeoutID?: number = undefined; + + constructor(options?: IdleManagerOptions) { + const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; + this.onIdle = onIdle; + this.idleTimeout = idleTimeout; + + const resetTimer = this.resetTimer.bind(this); + + window.addEventListener('load', resetTimer, true); + + events.forEach(function (name) { + document.addEventListener(name, resetTimer, true); + }); + + // eslint-disable-next-line @typescript-eslint/ban-types + const debounce = (func: Function, wait: number) => { + let timeout: number | undefined; + return (...args: unknown[]) => { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const context = this; + const later = function () { + timeout = undefined; + func.apply(context, args); + }; + clearTimeout(timeout); + timeout = window.setTimeout(later, wait); + }; + }; + + if (options?.captureScroll) { + // debounce scroll events + const scroll = debounce(resetTimer, options?.scrollDebounce ?? 100); + window.addEventListener('scroll', scroll, true); + } + + resetTimer(); + } + + /** + * Cleans up the idle manager and its listeners + */ + public exit(): void { + this.onIdle?.(); + clearTimeout(this.timeoutID); + window.removeEventListener('load', this.resetTimer, true); + + const resetTimer = this.resetTimer.bind(this); + events.forEach(function (name) { + document.removeEventListener(name, resetTimer, true); + }); + } + + resetTimer(): void { + const exit = this.exit.bind(this); + window.clearTimeout(this.timeoutID); + this.timeoutID = window.setTimeout(exit, this.idleTimeout); + } +} + +export default IdleManager; From f91524ef7504cace3fdf519348efe40d3e4d1b2c Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 24 Mar 2022 15:05:56 -0700 Subject: [PATCH 02/21] feat: setup idleManager in authClient --- packages/auth-client/src/idleManager.ts | 2 +- packages/auth-client/src/index.test.ts | 4 ++++ packages/auth-client/src/index.ts | 26 ++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 7b542b11b..9ca8a313c 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -32,7 +32,7 @@ class IdleManager { idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; timeoutID?: number = undefined; - constructor(options?: IdleManagerOptions) { + constructor(options: IdleManagerOptions = {}) { const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; this.onIdle = onIdle; this.idleTimeout = idleTimeout; diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index bad5e93d3..cf979b524 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -49,6 +49,10 @@ describe('Auth Client', () => { expect(await test.isAuthenticated()).toBe(false); expect(test.getIdentity().getPrincipal().isAnonymous()).toBe(true); }); + it('should initialize an idleManager', async () => { + const test = await AuthClient.create(); + expect(test.idleManager).toBeDefined(); + }); }); // A minimal interface of our interactions with the Window object of the IDP. diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 4c6eadf6a..61b915e1f 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -13,6 +13,7 @@ import { Ed25519KeyIdentity, } from '@dfinity/identity'; import { Principal } from '@dfinity/principal'; +import IdleManager, { IdleManagerOptions } from './idleManager'; const KEY_LOCALSTORAGE_KEY = 'identity'; const KEY_LOCALSTORAGE_DELEGATION = 'delegation'; @@ -35,6 +36,19 @@ export interface AuthClientCreateOptions { * Optional storage with get, set, and remove. Uses LocalStorage by default */ storage?: AuthClientStorage; + /** + * Options to handle idle timeouts + * @default after 30 minutes, invalidates the identity + */ + idleOptions?: IdleOptions; +} + +export interface IdleOptions extends IdleManagerOptions { + /** + * Disables idle functionality + * @default false + */ + disableIdle?: boolean; } export interface AuthClientLoginOptions { @@ -202,7 +216,11 @@ export class AuthClient { } } - return new this(identity, key, chain, storage); + const idleManager = options.idleOptions?.disableIdle + ? undefined + : new IdleManager({ ...options.idleOptions }); + + return new this(identity, key, chain, storage, idleManager); } protected constructor( @@ -210,6 +228,7 @@ export class AuthClient { private _key: SignIdentity | null, private _chain: DelegationChain | null, private _storage: AuthClientStorage, + public idleManager?: IdleManager, // A handle on the IdP window. private _idpWindow?: Window, // The event handler for processing events from the IdP. @@ -367,6 +386,11 @@ export class AuthClient { public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); + // Exit idleManager if it is enabled. + if (this._idleManager) { + this._idleManager.exit(); + } + // Reset this auth client to a non-authenticated state. this._identity = new AnonymousIdentity(); this._key = null; From 409c32df5f06b550c35588c19c0d98ed01f25bb7 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 24 Mar 2022 15:45:49 -0700 Subject: [PATCH 03/21] test: full lifecycle test of idle logout --- packages/auth-client/src/idleManager.test.ts | 12 ++--- packages/auth-client/src/idleManager.ts | 10 +++- packages/auth-client/src/index.test.ts | 57 +++++++++++++++++++- packages/auth-client/src/index.ts | 6 +-- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index 248bc4f51..0bbb87477 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -15,7 +15,7 @@ describe('IdleManager tests', () => { it('should delay allow configuration of the timeout', () => { const cb = jest.fn(); const extraDelay = 100; - new IdleManager({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); + IdleManager.create({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -25,7 +25,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on keyboard events', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -41,7 +41,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on mouse events', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -59,7 +59,7 @@ describe('IdleManager tests', () => { it('should delay its callback on touch events', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -79,7 +79,7 @@ describe('IdleManager tests', () => { const scrollDebounce = 100; - const manager = new IdleManager({ onIdle: cb, captureScroll: true, scrollDebounce }); + const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 9ca8a313c..561628ef4 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -32,7 +32,15 @@ class IdleManager { idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; timeoutID?: number = undefined; - constructor(options: IdleManagerOptions = {}) { + /** + * creates an idle manager + * @param {IdleManagerOptions} options + */ + public static create(options: IdleManagerOptions = {}): IdleManager { + return new this(options); + } + + protected constructor(options: IdleManagerOptions = {}) { const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; this.onIdle = onIdle; this.idleTimeout = idleTimeout; diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index cf979b524..6e009f87c 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -1,4 +1,7 @@ +import { Actor, HttpAgent } from '@dfinity/agent'; +import { IDL } from '@dfinity/candid'; import { Ed25519KeyIdentity } from '@dfinity/identity'; +import { Principal } from '@dfinity/principal'; import { AuthClient, ERROR_USER_INTERRUPT } from './index'; /** @@ -53,6 +56,57 @@ describe('Auth Client', () => { const test = await AuthClient.create(); expect(test.idleManager).toBeDefined(); }); + it('should be able to invalidate an identity after going idle', async () => { + jest.useFakeTimers(); + // setup actor + const identity = Ed25519KeyIdentity.generate(); + const mockFetch: jest.Mock = jest.fn(); + // http agent uses identity + const httpAgent = new HttpAgent({ fetch: mockFetch, identity }); + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); + const actorInterface = () => { + return IDL.Service({ + greet: IDL.Func([IDL.Text], [IDL.Text]), + }); + }; + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + // idle function invalidates actor + const idleFn = jest.fn(() => Actor.agentOf(actor).invalidateIdentity()); + + // setup auth client + const test = await AuthClient.create({ + identity, + idleOptions: { + idleTimeout: 1000, + onIdle: idleFn, + }, + }); + expect(idleFn).not.toHaveBeenCalled(); + // wait for the idle timeout + jest.advanceTimersByTime(1000); + expect(idleFn).toHaveBeenCalled(); + const expectedError = + "This identity has expired due this application's security policy. Please refresh your authentication."; + try { + await actor.greet('hello'); + } catch (error) { + expect(error.message).toBe(expectedError); + } + }); + it('should not set up an idle timer if the disable option is set', async () => { + const idleFn = jest.fn(); + const test = await AuthClient.create({ + idleOptions: { + idleTimeout: 1000, + disableIdle: true, + }, + }); + expect(idleFn).not.toHaveBeenCalled(); + expect(test.idleManager).toBeUndefined(); + // wait for default 30 minute idle timeout + jest.advanceTimersByTime(30 * 60 * 1000); + expect(idleFn).not.toHaveBeenCalled(); + }); }); // A minimal interface of our interactions with the Window object of the IDP. @@ -228,7 +282,8 @@ describe('Auth Client login', () => { it('should call onError if the user closed the IDP window', async () => { setup(); - const client = await AuthClient.create(); + jest.useRealTimers(); + const client = await AuthClient.create({ idleOptions: { disableIdle: true } }); await expect( new Promise((onSuccess, onError) => diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 61b915e1f..59e1af67a 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -218,7 +218,7 @@ export class AuthClient { const idleManager = options.idleOptions?.disableIdle ? undefined - : new IdleManager({ ...options.idleOptions }); + : IdleManager.create(options.idleOptions); return new this(identity, key, chain, storage, idleManager); } @@ -387,8 +387,8 @@ export class AuthClient { _deleteStorage(this._storage); // Exit idleManager if it is enabled. - if (this._idleManager) { - this._idleManager.exit(); + if (this.idleManager) { + this.idleManager.exit(); } // Reset this auth client to a non-authenticated state. From 5bf003d938eb2b55a68cff2330e219dc4674b9cf Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 24 Mar 2022 16:33:01 -0700 Subject: [PATCH 04/21] some basic documentation --- packages/auth-client/README.md | 43 ++++++++++++++++++++++++++ packages/auth-client/src/index.test.ts | 10 ++++-- packages/auth-client/src/index.ts | 23 +++++++++----- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/auth-client/README.md b/packages/auth-client/README.md index 2ba7a7b2c..97051a8b1 100644 --- a/packages/auth-client/README.md +++ b/packages/auth-client/README.md @@ -52,5 +52,48 @@ const actor = Actor.createActor(idlFactory, { }); ``` +## Idle Timeout + +As of 0.10.5, you can now set a timeout for when your identity will be considered idle, and you can use that to log out or prompt your user to refresh their authentication. This is recommended for applications managing tokens or other valuable information. + +```js +const authClient = await AuthClient.create({ + idleOptions: { + idleTimeout: 1000 * 60 * 30, // default is 30 minutes + onIdle: () => { + // invalidate identity in your actor + Actor.agentOf(actor).invalidateIdentity() + // prompt user to refresh their authentication + refreshLogin(); + }, + disableIdle: false, // set to true to disable idle timeout + } +}); +// ...authClient.login() +const identity = await authClient.getIdentity(); +const actor = Actor.createActor(idlFactory, { + agent: new HttpAgent({ + identity, + }), + canisterId, +}); + +refreshLogin() { + // prompt the user before refreshing their authentication + authClient.login({ + onSuccess: async () => { + // authClient now has an identity + const identity = await authClient.getIdentity(); + // set new identity in your actor without reloading the page + Actor.agentOf(actor).replaceIdentity(identity); + }, + }); +} +``` + +In this code, we create an `authClient` with an idle timeout of 30 minutes. When the user is idle, we invalidate their identity and prompt them to login again. + +After the user logs in, we can set the new identity in the actor without reloading the page. + Note: depends on [@dfinity/agent](https://www.npmjs.com/package/@dfinity/agent), [@dfinity/authentication](https://www.npmjs.com/package/@dfinity/authentication), and [@dfinity/identity](https://www.npmjs.com/package/@dfinity/identity). diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 6e009f87c..e7d6d7757 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -62,16 +62,18 @@ describe('Auth Client', () => { const identity = Ed25519KeyIdentity.generate(); const mockFetch: jest.Mock = jest.fn(); // http agent uses identity - const httpAgent = new HttpAgent({ fetch: mockFetch, identity }); + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); const actorInterface = () => { return IDL.Service({ greet: IDL.Func([IDL.Text], [IDL.Text]), }); }; - const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + // idle function invalidates actor - const idleFn = jest.fn(() => Actor.agentOf(actor).invalidateIdentity()); + const idleFn = jest.fn(() => { + Actor.agentOf(actor).invalidateIdentity(); + }); // setup auth client const test = await AuthClient.create({ @@ -81,6 +83,8 @@ describe('Auth Client', () => { onIdle: idleFn, }, }); + const httpAgent = new HttpAgent({ fetch: mockFetch, identity: await test.getIdentity() }); + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); expect(idleFn).not.toHaveBeenCalled(); // wait for the idle timeout jest.advanceTimersByTime(1000); diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 59e1af67a..f6f234cad 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -220,20 +220,32 @@ export class AuthClient { ? undefined : IdleManager.create(options.idleOptions); - return new this(identity, key, chain, storage, idleManager); + return new this(identity, key, chain, storage, options.idleOptions); } + public readonly idleManager: IdleManager | undefined; + protected constructor( private _identity: Identity, private _key: SignIdentity | null, private _chain: DelegationChain | null, private _storage: AuthClientStorage, - public idleManager?: IdleManager, + private _idleOptions?: IdleOptions, // A handle on the IdP window. private _idpWindow?: Window, // The event handler for processing events from the IdP. private _eventHandler?: (event: MessageEvent) => void, - ) {} + ) { + this.idleManager = _idleOptions?.disableIdle + ? undefined + : IdleManager.create({ + ..._idleOptions, + onIdle: () => { + this.logout(); + _idleOptions?.onIdle?.(); + }, + }); + } private _handleSuccess(message: InternetIdentityAuthResponseSuccess, onSuccess?: () => void) { const delegations = message.delegations.map(signedDelegation => { @@ -386,11 +398,6 @@ export class AuthClient { public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); - // Exit idleManager if it is enabled. - if (this.idleManager) { - this.idleManager.exit(); - } - // Reset this auth client to a non-authenticated state. this._identity = new AnonymousIdentity(); this._key = null; From f43a3d120cdd64416a6a71ee465c42dcfe5bcabd Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Fri, 25 Mar 2022 15:23:44 -0700 Subject: [PATCH 05/21] feat: registerActor and registerCallback Allows the authClient to register actors to automatically invalidate them going idle, or revalidate them after logging in --- packages/auth-client/README.md | 21 ++---- packages/auth-client/src/idleManager.test.ts | 12 +-- packages/auth-client/src/idleManager.ts | 19 +++-- packages/auth-client/src/index.test.ts | 77 +++++++++++++++++--- packages/auth-client/src/index.ts | 31 +++++--- 5 files changed, 113 insertions(+), 47 deletions(-) diff --git a/packages/auth-client/README.md b/packages/auth-client/README.md index 97051a8b1..8c28892d0 100644 --- a/packages/auth-client/README.md +++ b/packages/auth-client/README.md @@ -60,12 +60,6 @@ As of 0.10.5, you can now set a timeout for when your identity will be considere const authClient = await AuthClient.create({ idleOptions: { idleTimeout: 1000 * 60 * 30, // default is 30 minutes - onIdle: () => { - // invalidate identity in your actor - Actor.agentOf(actor).invalidateIdentity() - // prompt user to refresh their authentication - refreshLogin(); - }, disableIdle: false, // set to true to disable idle timeout } }); @@ -78,17 +72,14 @@ const actor = Actor.createActor(idlFactory, { canisterId, }); +authClient.registerActor("ii", actor); + refreshLogin() { - // prompt the user before refreshing their authentication - authClient.login({ - onSuccess: async () => { - // authClient now has an identity - const identity = await authClient.getIdentity(); - // set new identity in your actor without reloading the page - Actor.agentOf(actor).replaceIdentity(identity); - }, - }); + // prompt the user then refresh their authentication + authClient.login(); } + +authClient.idleManager?.registerCallback?.(refreshLogin) ``` In this code, we create an `authClient` with an idle timeout of 30 minutes. When the user is idle, we invalidate their identity and prompt them to login again. diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index 0bbb87477..f7d208eba 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -15,7 +15,7 @@ describe('IdleManager tests', () => { it('should delay allow configuration of the timeout', () => { const cb = jest.fn(); const extraDelay = 100; - IdleManager.create({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); + IdleManager.create({ callbacks: [cb], idleTimeout: 30 * 60 * 1000 + extraDelay }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -25,7 +25,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on keyboard events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -41,7 +41,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on mouse events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -59,7 +59,7 @@ describe('IdleManager tests', () => { it('should delay its callback on touch events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -79,7 +79,7 @@ describe('IdleManager tests', () => { const scrollDebounce = 100; - const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); + const manager = IdleManager.create({ callbacks: [cb], captureScroll: true, scrollDebounce }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 561628ef4..9c8001fee 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -1,8 +1,9 @@ +type IdleCB = () => unknown; export type IdleManagerOptions = { /** * Callback after the user has gone idle */ - onIdle?: () => void; + callbacks?: IdleCB[]; /** * timeout in ms * @default 30 minutes [1_800_000] @@ -28,7 +29,7 @@ const events = ['mousedown', 'mousemove', 'keypress', 'touchstart', 'wheel']; * @param {IdleManagerOptions} options */ class IdleManager { - onIdle: IdleManagerOptions['onIdle'] = undefined; + callbacks: IdleCB[] = []; idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; timeoutID?: number = undefined; @@ -41,8 +42,8 @@ class IdleManager { } protected constructor(options: IdleManagerOptions = {}) { - const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; - this.onIdle = onIdle; + const { callbacks = [], idleTimeout = 30 * 60 * 1000 } = options || {}; + this.callbacks = [...callbacks]; this.idleTimeout = idleTimeout; const resetTimer = this.resetTimer.bind(this); @@ -77,11 +78,19 @@ class IdleManager { resetTimer(); } + /** + * adds a callback to the list of callbacks + * @param {IdleCB} callback + */ + public registerCallback(callback: IdleCB): void { + this.callbacks.push(callback); + } + /** * Cleans up the idle manager and its listeners */ public exit(): void { - this.onIdle?.(); + this.callbacks.forEach(cb => cb()); clearTimeout(this.timeoutID); window.removeEventListener('load', this.resetTimer, true); diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index e7d6d7757..7b9f9a6cf 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -70,25 +70,77 @@ describe('Auth Client', () => { }); }; - // idle function invalidates actor - const idleFn = jest.fn(() => { - Actor.agentOf(actor).invalidateIdentity(); - }); - // setup auth client const test = await AuthClient.create({ identity, idleOptions: { idleTimeout: 1000, - onIdle: idleFn, }, }); - const httpAgent = new HttpAgent({ fetch: mockFetch, identity: await test.getIdentity() }); + const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - expect(idleFn).not.toHaveBeenCalled(); + + test.registerActor('default', actor); + // wait for the idle timeout jest.advanceTimersByTime(1000); - expect(idleFn).toHaveBeenCalled(); + + // check that the registered actor has been invalidated + const expectedError = + "This identity has expired due this application's security policy. Please refresh your authentication."; + try { + await actor.greet('hello'); + } catch (error) { + expect(error.message).toBe(expectedError); + } + }); + it('should allow a registeredActor to get refreshed', async () => { + setup({ + onAuthRequest: () => { + // Send a valid request. + idpMock.send({ + kind: 'authorize-client-success', + delegations: [ + { + delegation: { + pubkey: Uint8Array.from([]), + expiration: BigInt(0), + }, + signature: Uint8Array.from([]), + }, + ], + userPublicKey: Uint8Array.from([]), + }); + }, + }); + + // setup actor + const identity = Ed25519KeyIdentity.generate(); + const mockFetch: jest.Mock = jest.fn(); + // http agent uses identity + + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); + const actorInterface = () => { + return IDL.Service({ + greet: IDL.Func([IDL.Text], [IDL.Text]), + }); + }; + + // setup auth client + const test = await AuthClient.create({ + identity, + idleOptions: { + idleTimeout: 1000, + }, + }); + const httpAgent = new HttpAgent({ fetch: mockFetch }); + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + + Actor.agentOf(actor).invalidateIdentity(); + + test.registerActor('default', actor); + + // check that the registered actor has been invalidated const expectedError = "This identity has expired due this application's security policy. Please refresh your authentication."; try { @@ -96,6 +148,13 @@ describe('Auth Client', () => { } catch (error) { expect(error.message).toBe(expectedError); } + + await test.login(); + + idpMock.ready(); + + // check that the registered actor has been invalidated + expect((Actor.agentOf(actor) as any)._identity).toBeTruthy(); }); it('should not set up an idle timer if the disable option is set', async () => { const idleFn = jest.fn(); diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index f6f234cad..2ce770cbf 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -1,4 +1,5 @@ import { + Actor, AnonymousIdentity, DerEncodedPublicKey, Identity, @@ -100,6 +101,8 @@ interface InternetIdentityAuthResponseSuccess { userPublicKey: Uint8Array; } +type ActorMap = Map; + async function _deleteStorage(storage: AuthClientStorage) { await storage.remove(KEY_LOCALSTORAGE_KEY); await storage.remove(KEY_LOCALSTORAGE_DELEGATION); @@ -220,31 +223,24 @@ export class AuthClient { ? undefined : IdleManager.create(options.idleOptions); - return new this(identity, key, chain, storage, options.idleOptions); + return new this(identity, key, chain, storage, idleManager); } - public readonly idleManager: IdleManager | undefined; + private readonly actorMap: ActorMap = new Map(); protected constructor( private _identity: Identity, private _key: SignIdentity | null, private _chain: DelegationChain | null, private _storage: AuthClientStorage, - private _idleOptions?: IdleOptions, + public readonly idleManager: IdleManager | undefined, // A handle on the IdP window. private _idpWindow?: Window, // The event handler for processing events from the IdP. private _eventHandler?: (event: MessageEvent) => void, ) { - this.idleManager = _idleOptions?.disableIdle - ? undefined - : IdleManager.create({ - ..._idleOptions, - onIdle: () => { - this.logout(); - _idleOptions?.onIdle?.(); - }, - }); + const logout = this.logout.bind(this); + this.idleManager?.registerCallback(logout); } private _handleSuccess(message: InternetIdentityAuthResponseSuccess, onSuccess?: () => void) { @@ -272,6 +268,10 @@ export class AuthClient { this._chain = delegationChain; this._identity = DelegationIdentity.fromDelegation(key, this._chain); + // Update registered actors with new identity + + this.actorMap.forEach(actor => Actor.agentOf(actor)?.replaceIdentity?.(this._identity)); + this._idpWindow?.close(); onSuccess?.(); this._removeEventListener(); @@ -395,6 +395,13 @@ export class AuthClient { this._eventHandler = undefined; } + public registerActor(key: string, actor: Actor): void { + this.actorMap.set(key, actor); + this.idleManager?.registerCallback(() => { + Actor.agentOf(actor)?.invalidateIdentity?.(); + }); + } + public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); From 6088842ad71c1ebf491dcc0c1b0d00e0417a4372 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Fri, 25 Mar 2022 15:43:45 -0700 Subject: [PATCH 06/21] fixing test typing --- packages/auth-client/src/index.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 7b9f9a6cf..66aaac0a7 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -1,4 +1,5 @@ import { Actor, HttpAgent } from '@dfinity/agent'; +import { AgentError } from '@dfinity/agent/lib/cjs/errors'; import { IDL } from '@dfinity/candid'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { Principal } from '@dfinity/principal'; @@ -91,7 +92,7 @@ describe('Auth Client', () => { try { await actor.greet('hello'); } catch (error) { - expect(error.message).toBe(expectedError); + expect((error as AgentError).message).toBe(expectedError); } }); it('should allow a registeredActor to get refreshed', async () => { @@ -136,7 +137,7 @@ describe('Auth Client', () => { const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - Actor.agentOf(actor).invalidateIdentity(); + Actor.agentOf(actor)?.invalidateIdentity?.(); test.registerActor('default', actor); @@ -146,7 +147,7 @@ describe('Auth Client', () => { try { await actor.greet('hello'); } catch (error) { - expect(error.message).toBe(expectedError); + expect((error as AgentError).message).toBe(expectedError); } await test.login(); From f941b32009e01d88270dad2b4bbab27cb4570673 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 29 Mar 2022 14:13:42 -0700 Subject: [PATCH 07/21] reverting actorMap and using onIdle for creation options --- packages/auth-client/src/idleManager.test.ts | 12 ++++++------ packages/auth-client/src/idleManager.ts | 6 +++--- packages/auth-client/src/index.test.ts | 16 +++++++++------- packages/auth-client/src/index.ts | 15 --------------- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index f7d208eba..0bbb87477 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -15,7 +15,7 @@ describe('IdleManager tests', () => { it('should delay allow configuration of the timeout', () => { const cb = jest.fn(); const extraDelay = 100; - IdleManager.create({ callbacks: [cb], idleTimeout: 30 * 60 * 1000 + extraDelay }); + IdleManager.create({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -25,7 +25,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on keyboard events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -41,7 +41,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on mouse events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -59,7 +59,7 @@ describe('IdleManager tests', () => { it('should delay its callback on touch events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -79,7 +79,7 @@ describe('IdleManager tests', () => { const scrollDebounce = 100; - const manager = IdleManager.create({ callbacks: [cb], captureScroll: true, scrollDebounce }); + const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 9c8001fee..592c044cb 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -3,7 +3,7 @@ export type IdleManagerOptions = { /** * Callback after the user has gone idle */ - callbacks?: IdleCB[]; + onIdle?: IdleCB; /** * timeout in ms * @default 30 minutes [1_800_000] @@ -42,8 +42,8 @@ class IdleManager { } protected constructor(options: IdleManagerOptions = {}) { - const { callbacks = [], idleTimeout = 30 * 60 * 1000 } = options || {}; - this.callbacks = [...callbacks]; + const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; + this.callbacks = onIdle ? [onIdle] : []; this.idleTimeout = idleTimeout; const resetTimer = this.resetTimer.bind(this); diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 66aaac0a7..8f70b7ec1 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -81,7 +81,9 @@ describe('Auth Client', () => { const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - test.registerActor('default', actor); + test.idleManager.registerCallback(() => { + Actor.agentOf(actor).invalidateIdentity(); + }); // wait for the idle timeout jest.advanceTimersByTime(1000); @@ -95,7 +97,12 @@ describe('Auth Client', () => { expect((error as AgentError).message).toBe(expectedError); } }); - it('should allow a registeredActor to get refreshed', async () => { + /** + * This test reflects a feature that may be added at a future date, + * allowing the authClient to register actors for automatic invalidation + * and revalidation of identities + */ + it.skip('should allow a registeredActor to get refreshed', async () => { setup({ onAuthRequest: () => { // Send a valid request. @@ -139,8 +146,6 @@ describe('Auth Client', () => { Actor.agentOf(actor)?.invalidateIdentity?.(); - test.registerActor('default', actor); - // check that the registered actor has been invalidated const expectedError = "This identity has expired due this application's security policy. Please refresh your authentication."; @@ -150,10 +155,7 @@ describe('Auth Client', () => { expect((error as AgentError).message).toBe(expectedError); } - await test.login(); - idpMock.ready(); - // check that the registered actor has been invalidated expect((Actor.agentOf(actor) as any)._identity).toBeTruthy(); }); diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 2ce770cbf..226185552 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -101,8 +101,6 @@ interface InternetIdentityAuthResponseSuccess { userPublicKey: Uint8Array; } -type ActorMap = Map; - async function _deleteStorage(storage: AuthClientStorage) { await storage.remove(KEY_LOCALSTORAGE_KEY); await storage.remove(KEY_LOCALSTORAGE_DELEGATION); @@ -226,8 +224,6 @@ export class AuthClient { return new this(identity, key, chain, storage, idleManager); } - private readonly actorMap: ActorMap = new Map(); - protected constructor( private _identity: Identity, private _key: SignIdentity | null, @@ -268,10 +264,6 @@ export class AuthClient { this._chain = delegationChain; this._identity = DelegationIdentity.fromDelegation(key, this._chain); - // Update registered actors with new identity - - this.actorMap.forEach(actor => Actor.agentOf(actor)?.replaceIdentity?.(this._identity)); - this._idpWindow?.close(); onSuccess?.(); this._removeEventListener(); @@ -395,13 +387,6 @@ export class AuthClient { this._eventHandler = undefined; } - public registerActor(key: string, actor: Actor): void { - this.actorMap.set(key, actor); - this.idleManager?.registerCallback(() => { - Actor.agentOf(actor)?.invalidateIdentity?.(); - }); - } - public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); From e826b43ca53e8783553843da472c5774574f9b25 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 29 Mar 2022 14:26:24 -0700 Subject: [PATCH 08/21] friendlier JSDOC formatting --- packages/auth-client/src/idleManager.test.ts | 2 +- packages/auth-client/src/idleManager.ts | 32 ++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index 0bbb87477..5d5ad611b 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb, captureScroll: true }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 592c044cb..d67184957 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -34,10 +34,36 @@ class IdleManager { timeoutID?: number = undefined; /** - * creates an idle manager - * @param {IdleManagerOptions} options + * Creates an idle manager + * @param {IdleManagerOptions} options Optional configuration + * @param options.onIdle Callback once user has been idle. Use to prompt for fresh login, and use Actor.agentOf(your_actor).invalidateIdentity() to protect the user + * @param options.idleTimeout timeout in ms + * @param options.captureScroll capture scroll events + * @param options.scrollDebounce scroll debounce time in ms */ - public static create(options: IdleManagerOptions = {}): IdleManager { + public static create( + options: { + /** + * Callback after the user has gone idle + */ + onIdle?: IdleCB; + /** + * timeout in ms + * @default 30 minutes [1_800_000] + */ + idleTimeout?: number; + /** + * capture scroll events + * @default false + */ + captureScroll?: boolean; + /** + * scroll debounce time in ms + * @default 100 + */ + scrollDebounce?: number; + } = {}, + ): IdleManager { return new this(options); } From 730eae6375df2cf14cdf6271735aae095d045ebe Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 29 Mar 2022 14:30:42 -0700 Subject: [PATCH 09/21] changelog --- docs/generated/changelog.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/generated/changelog.html b/docs/generated/changelog.html index e5f15c9cb..6b3ead8fd 100644 --- a/docs/generated/changelog.html +++ b/docs/generated/changelog.html @@ -16,6 +16,12 @@

Version 0.10.5

makeNonce now returns unique values. Previously only the first byte of the nonce was populated. +
  • + Introduces IdleManager to the auth-client. You can now use it to detect when the user has + been idle for a configurable amount of time, and then to register callbacks for the sake + of logging the user out and prompting re-authentication. See the @dfinity/auth-client + Readme for more details +
  • Version 0.10.3

      From 70c50853dab6ad6d3b8bce84d060907bd2eb43d2 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Wed, 30 Mar 2022 09:52:15 -0700 Subject: [PATCH 10/21] typescript checks for tests --- packages/auth-client/src/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 8f70b7ec1..0eacc56d4 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -81,8 +81,8 @@ describe('Auth Client', () => { const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - test.idleManager.registerCallback(() => { - Actor.agentOf(actor).invalidateIdentity(); + test.idleManager?.registerCallback(() => { + Actor.agentOf(actor)?.invalidateIdentity?.(); }); // wait for the idle timeout From 2d23b7a3619fbeae60a485d8ece3cb76c87b78fb Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Wed, 23 Mar 2022 16:57:11 -0700 Subject: [PATCH 11/21] feat: idleManager --- packages/auth-client/src/idleManager.test.ts | 97 ++++++++++++++++++++ packages/auth-client/src/idleManager.ts | 93 +++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/auth-client/src/idleManager.test.ts create mode 100644 packages/auth-client/src/idleManager.ts diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts new file mode 100644 index 000000000..248bc4f51 --- /dev/null +++ b/packages/auth-client/src/idleManager.test.ts @@ -0,0 +1,97 @@ +import IdleManager from './idleManager'; + +jest.useFakeTimers(); + +describe('IdleManager tests', () => { + it('should call its callback after time spent inactive', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 30 minutes + jest.advanceTimersByTime(30 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + manager.exit(); + }); + it('should delay allow configuration of the timeout', () => { + const cb = jest.fn(); + const extraDelay = 100; + new IdleManager({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 30 minutes + jest.advanceTimersByTime(30 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + jest.advanceTimersByTime(extraDelay); + expect(cb).toHaveBeenCalled(); + }); + it('should delay its callback on keyboard events', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + document.dispatchEvent(new KeyboardEvent('keypress')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + }); + it('should delay its callback on mouse events', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user moving the mouse + document.dispatchEvent(new MouseEvent('mousemove')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + }); + + it('should delay its callback on touch events', () => { + const cb = jest.fn(); + const manager = new IdleManager({ onIdle: cb }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user touching the screen + document.dispatchEvent(new TouchEvent('touchstart')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).toHaveBeenCalled(); + }); + it('should delay its callback on scroll events', () => { + const cb = jest.fn(); + + const scrollDebounce = 100; + + const manager = new IdleManager({ onIdle: cb, captureScroll: true, scrollDebounce }); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes + jest.advanceTimersByTime(25 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user scrolling + document.dispatchEvent(new WheelEvent('scroll')); + + // wait 5 minutes + jest.advanceTimersByTime(5 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + // simulate user being inactive for 25 minutes, plus the debounce + jest.advanceTimersByTime(25 * 60 * 1000 + scrollDebounce); + expect(cb).toHaveBeenCalled(); + }); +}); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts new file mode 100644 index 000000000..7b542b11b --- /dev/null +++ b/packages/auth-client/src/idleManager.ts @@ -0,0 +1,93 @@ +export type IdleManagerOptions = { + /** + * Callback after the user has gone idle + */ + onIdle?: () => void; + /** + * timeout in ms + * @default 30 minutes [1_800_000] + */ + idleTimeout?: number; + /** + * capture scroll events + * @default false + */ + captureScroll?: boolean; + /** + * scroll debounce time in ms + * @default 100 + */ + scrollDebounce?: number; +}; + +const events = ['mousedown', 'mousemove', 'keypress', 'touchstart', 'wheel']; + +/** + * Detects if the user has been idle for a duration of `idleTimeout` ms, and calls `onIdle`. + * + * @param {IdleManagerOptions} options + */ +class IdleManager { + onIdle: IdleManagerOptions['onIdle'] = undefined; + idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; + timeoutID?: number = undefined; + + constructor(options?: IdleManagerOptions) { + const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; + this.onIdle = onIdle; + this.idleTimeout = idleTimeout; + + const resetTimer = this.resetTimer.bind(this); + + window.addEventListener('load', resetTimer, true); + + events.forEach(function (name) { + document.addEventListener(name, resetTimer, true); + }); + + // eslint-disable-next-line @typescript-eslint/ban-types + const debounce = (func: Function, wait: number) => { + let timeout: number | undefined; + return (...args: unknown[]) => { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const context = this; + const later = function () { + timeout = undefined; + func.apply(context, args); + }; + clearTimeout(timeout); + timeout = window.setTimeout(later, wait); + }; + }; + + if (options?.captureScroll) { + // debounce scroll events + const scroll = debounce(resetTimer, options?.scrollDebounce ?? 100); + window.addEventListener('scroll', scroll, true); + } + + resetTimer(); + } + + /** + * Cleans up the idle manager and its listeners + */ + public exit(): void { + this.onIdle?.(); + clearTimeout(this.timeoutID); + window.removeEventListener('load', this.resetTimer, true); + + const resetTimer = this.resetTimer.bind(this); + events.forEach(function (name) { + document.removeEventListener(name, resetTimer, true); + }); + } + + resetTimer(): void { + const exit = this.exit.bind(this); + window.clearTimeout(this.timeoutID); + this.timeoutID = window.setTimeout(exit, this.idleTimeout); + } +} + +export default IdleManager; From e8f437841e61b62c463985a36f7fdce5851d6df7 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 24 Mar 2022 15:05:56 -0700 Subject: [PATCH 12/21] feat: setup idleManager in authClient --- packages/auth-client/src/idleManager.ts | 2 +- packages/auth-client/src/index.test.ts | 4 ++++ packages/auth-client/src/index.ts | 26 ++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 7b542b11b..9ca8a313c 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -32,7 +32,7 @@ class IdleManager { idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; timeoutID?: number = undefined; - constructor(options?: IdleManagerOptions) { + constructor(options: IdleManagerOptions = {}) { const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; this.onIdle = onIdle; this.idleTimeout = idleTimeout; diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index bad5e93d3..cf979b524 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -49,6 +49,10 @@ describe('Auth Client', () => { expect(await test.isAuthenticated()).toBe(false); expect(test.getIdentity().getPrincipal().isAnonymous()).toBe(true); }); + it('should initialize an idleManager', async () => { + const test = await AuthClient.create(); + expect(test.idleManager).toBeDefined(); + }); }); // A minimal interface of our interactions with the Window object of the IDP. diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 4c6eadf6a..61b915e1f 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -13,6 +13,7 @@ import { Ed25519KeyIdentity, } from '@dfinity/identity'; import { Principal } from '@dfinity/principal'; +import IdleManager, { IdleManagerOptions } from './idleManager'; const KEY_LOCALSTORAGE_KEY = 'identity'; const KEY_LOCALSTORAGE_DELEGATION = 'delegation'; @@ -35,6 +36,19 @@ export interface AuthClientCreateOptions { * Optional storage with get, set, and remove. Uses LocalStorage by default */ storage?: AuthClientStorage; + /** + * Options to handle idle timeouts + * @default after 30 minutes, invalidates the identity + */ + idleOptions?: IdleOptions; +} + +export interface IdleOptions extends IdleManagerOptions { + /** + * Disables idle functionality + * @default false + */ + disableIdle?: boolean; } export interface AuthClientLoginOptions { @@ -202,7 +216,11 @@ export class AuthClient { } } - return new this(identity, key, chain, storage); + const idleManager = options.idleOptions?.disableIdle + ? undefined + : new IdleManager({ ...options.idleOptions }); + + return new this(identity, key, chain, storage, idleManager); } protected constructor( @@ -210,6 +228,7 @@ export class AuthClient { private _key: SignIdentity | null, private _chain: DelegationChain | null, private _storage: AuthClientStorage, + public idleManager?: IdleManager, // A handle on the IdP window. private _idpWindow?: Window, // The event handler for processing events from the IdP. @@ -367,6 +386,11 @@ export class AuthClient { public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); + // Exit idleManager if it is enabled. + if (this._idleManager) { + this._idleManager.exit(); + } + // Reset this auth client to a non-authenticated state. this._identity = new AnonymousIdentity(); this._key = null; From 88aba802f0d2765927be861cc6451cb9fffa3713 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 24 Mar 2022 15:45:49 -0700 Subject: [PATCH 13/21] test: full lifecycle test of idle logout --- packages/auth-client/src/idleManager.test.ts | 12 ++--- packages/auth-client/src/idleManager.ts | 10 +++- packages/auth-client/src/index.test.ts | 57 +++++++++++++++++++- packages/auth-client/src/index.ts | 6 +-- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index 248bc4f51..0bbb87477 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -15,7 +15,7 @@ describe('IdleManager tests', () => { it('should delay allow configuration of the timeout', () => { const cb = jest.fn(); const extraDelay = 100; - new IdleManager({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); + IdleManager.create({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -25,7 +25,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on keyboard events', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -41,7 +41,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on mouse events', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -59,7 +59,7 @@ describe('IdleManager tests', () => { it('should delay its callback on touch events', () => { const cb = jest.fn(); - const manager = new IdleManager({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -79,7 +79,7 @@ describe('IdleManager tests', () => { const scrollDebounce = 100; - const manager = new IdleManager({ onIdle: cb, captureScroll: true, scrollDebounce }); + const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 9ca8a313c..561628ef4 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -32,7 +32,15 @@ class IdleManager { idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; timeoutID?: number = undefined; - constructor(options: IdleManagerOptions = {}) { + /** + * creates an idle manager + * @param {IdleManagerOptions} options + */ + public static create(options: IdleManagerOptions = {}): IdleManager { + return new this(options); + } + + protected constructor(options: IdleManagerOptions = {}) { const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; this.onIdle = onIdle; this.idleTimeout = idleTimeout; diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index cf979b524..6e009f87c 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -1,4 +1,7 @@ +import { Actor, HttpAgent } from '@dfinity/agent'; +import { IDL } from '@dfinity/candid'; import { Ed25519KeyIdentity } from '@dfinity/identity'; +import { Principal } from '@dfinity/principal'; import { AuthClient, ERROR_USER_INTERRUPT } from './index'; /** @@ -53,6 +56,57 @@ describe('Auth Client', () => { const test = await AuthClient.create(); expect(test.idleManager).toBeDefined(); }); + it('should be able to invalidate an identity after going idle', async () => { + jest.useFakeTimers(); + // setup actor + const identity = Ed25519KeyIdentity.generate(); + const mockFetch: jest.Mock = jest.fn(); + // http agent uses identity + const httpAgent = new HttpAgent({ fetch: mockFetch, identity }); + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); + const actorInterface = () => { + return IDL.Service({ + greet: IDL.Func([IDL.Text], [IDL.Text]), + }); + }; + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + // idle function invalidates actor + const idleFn = jest.fn(() => Actor.agentOf(actor).invalidateIdentity()); + + // setup auth client + const test = await AuthClient.create({ + identity, + idleOptions: { + idleTimeout: 1000, + onIdle: idleFn, + }, + }); + expect(idleFn).not.toHaveBeenCalled(); + // wait for the idle timeout + jest.advanceTimersByTime(1000); + expect(idleFn).toHaveBeenCalled(); + const expectedError = + "This identity has expired due this application's security policy. Please refresh your authentication."; + try { + await actor.greet('hello'); + } catch (error) { + expect(error.message).toBe(expectedError); + } + }); + it('should not set up an idle timer if the disable option is set', async () => { + const idleFn = jest.fn(); + const test = await AuthClient.create({ + idleOptions: { + idleTimeout: 1000, + disableIdle: true, + }, + }); + expect(idleFn).not.toHaveBeenCalled(); + expect(test.idleManager).toBeUndefined(); + // wait for default 30 minute idle timeout + jest.advanceTimersByTime(30 * 60 * 1000); + expect(idleFn).not.toHaveBeenCalled(); + }); }); // A minimal interface of our interactions with the Window object of the IDP. @@ -228,7 +282,8 @@ describe('Auth Client login', () => { it('should call onError if the user closed the IDP window', async () => { setup(); - const client = await AuthClient.create(); + jest.useRealTimers(); + const client = await AuthClient.create({ idleOptions: { disableIdle: true } }); await expect( new Promise((onSuccess, onError) => diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 61b915e1f..59e1af67a 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -218,7 +218,7 @@ export class AuthClient { const idleManager = options.idleOptions?.disableIdle ? undefined - : new IdleManager({ ...options.idleOptions }); + : IdleManager.create(options.idleOptions); return new this(identity, key, chain, storage, idleManager); } @@ -387,8 +387,8 @@ export class AuthClient { _deleteStorage(this._storage); // Exit idleManager if it is enabled. - if (this._idleManager) { - this._idleManager.exit(); + if (this.idleManager) { + this.idleManager.exit(); } // Reset this auth client to a non-authenticated state. From 6ad006eaf8d0fc9e995e1e9dbb5f99d616de2d37 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 24 Mar 2022 16:33:01 -0700 Subject: [PATCH 14/21] some basic documentation --- packages/auth-client/README.md | 43 ++++++++++++++++++++++++++ packages/auth-client/src/index.test.ts | 10 ++++-- packages/auth-client/src/index.ts | 23 +++++++++----- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/auth-client/README.md b/packages/auth-client/README.md index 2ba7a7b2c..97051a8b1 100644 --- a/packages/auth-client/README.md +++ b/packages/auth-client/README.md @@ -52,5 +52,48 @@ const actor = Actor.createActor(idlFactory, { }); ``` +## Idle Timeout + +As of 0.10.5, you can now set a timeout for when your identity will be considered idle, and you can use that to log out or prompt your user to refresh their authentication. This is recommended for applications managing tokens or other valuable information. + +```js +const authClient = await AuthClient.create({ + idleOptions: { + idleTimeout: 1000 * 60 * 30, // default is 30 minutes + onIdle: () => { + // invalidate identity in your actor + Actor.agentOf(actor).invalidateIdentity() + // prompt user to refresh their authentication + refreshLogin(); + }, + disableIdle: false, // set to true to disable idle timeout + } +}); +// ...authClient.login() +const identity = await authClient.getIdentity(); +const actor = Actor.createActor(idlFactory, { + agent: new HttpAgent({ + identity, + }), + canisterId, +}); + +refreshLogin() { + // prompt the user before refreshing their authentication + authClient.login({ + onSuccess: async () => { + // authClient now has an identity + const identity = await authClient.getIdentity(); + // set new identity in your actor without reloading the page + Actor.agentOf(actor).replaceIdentity(identity); + }, + }); +} +``` + +In this code, we create an `authClient` with an idle timeout of 30 minutes. When the user is idle, we invalidate their identity and prompt them to login again. + +After the user logs in, we can set the new identity in the actor without reloading the page. + Note: depends on [@dfinity/agent](https://www.npmjs.com/package/@dfinity/agent), [@dfinity/authentication](https://www.npmjs.com/package/@dfinity/authentication), and [@dfinity/identity](https://www.npmjs.com/package/@dfinity/identity). diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 6e009f87c..e7d6d7757 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -62,16 +62,18 @@ describe('Auth Client', () => { const identity = Ed25519KeyIdentity.generate(); const mockFetch: jest.Mock = jest.fn(); // http agent uses identity - const httpAgent = new HttpAgent({ fetch: mockFetch, identity }); + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); const actorInterface = () => { return IDL.Service({ greet: IDL.Func([IDL.Text], [IDL.Text]), }); }; - const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + // idle function invalidates actor - const idleFn = jest.fn(() => Actor.agentOf(actor).invalidateIdentity()); + const idleFn = jest.fn(() => { + Actor.agentOf(actor).invalidateIdentity(); + }); // setup auth client const test = await AuthClient.create({ @@ -81,6 +83,8 @@ describe('Auth Client', () => { onIdle: idleFn, }, }); + const httpAgent = new HttpAgent({ fetch: mockFetch, identity: await test.getIdentity() }); + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); expect(idleFn).not.toHaveBeenCalled(); // wait for the idle timeout jest.advanceTimersByTime(1000); diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 59e1af67a..f6f234cad 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -220,20 +220,32 @@ export class AuthClient { ? undefined : IdleManager.create(options.idleOptions); - return new this(identity, key, chain, storage, idleManager); + return new this(identity, key, chain, storage, options.idleOptions); } + public readonly idleManager: IdleManager | undefined; + protected constructor( private _identity: Identity, private _key: SignIdentity | null, private _chain: DelegationChain | null, private _storage: AuthClientStorage, - public idleManager?: IdleManager, + private _idleOptions?: IdleOptions, // A handle on the IdP window. private _idpWindow?: Window, // The event handler for processing events from the IdP. private _eventHandler?: (event: MessageEvent) => void, - ) {} + ) { + this.idleManager = _idleOptions?.disableIdle + ? undefined + : IdleManager.create({ + ..._idleOptions, + onIdle: () => { + this.logout(); + _idleOptions?.onIdle?.(); + }, + }); + } private _handleSuccess(message: InternetIdentityAuthResponseSuccess, onSuccess?: () => void) { const delegations = message.delegations.map(signedDelegation => { @@ -386,11 +398,6 @@ export class AuthClient { public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); - // Exit idleManager if it is enabled. - if (this.idleManager) { - this.idleManager.exit(); - } - // Reset this auth client to a non-authenticated state. this._identity = new AnonymousIdentity(); this._key = null; From fad77325a5df2183fd0a0059bcb8b54544a2424d Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Fri, 25 Mar 2022 15:23:44 -0700 Subject: [PATCH 15/21] feat: registerActor and registerCallback Allows the authClient to register actors to automatically invalidate them going idle, or revalidate them after logging in --- packages/auth-client/README.md | 21 ++---- packages/auth-client/src/idleManager.test.ts | 12 +-- packages/auth-client/src/idleManager.ts | 19 +++-- packages/auth-client/src/index.test.ts | 77 +++++++++++++++++--- packages/auth-client/src/index.ts | 31 +++++--- 5 files changed, 113 insertions(+), 47 deletions(-) diff --git a/packages/auth-client/README.md b/packages/auth-client/README.md index 97051a8b1..8c28892d0 100644 --- a/packages/auth-client/README.md +++ b/packages/auth-client/README.md @@ -60,12 +60,6 @@ As of 0.10.5, you can now set a timeout for when your identity will be considere const authClient = await AuthClient.create({ idleOptions: { idleTimeout: 1000 * 60 * 30, // default is 30 minutes - onIdle: () => { - // invalidate identity in your actor - Actor.agentOf(actor).invalidateIdentity() - // prompt user to refresh their authentication - refreshLogin(); - }, disableIdle: false, // set to true to disable idle timeout } }); @@ -78,17 +72,14 @@ const actor = Actor.createActor(idlFactory, { canisterId, }); +authClient.registerActor("ii", actor); + refreshLogin() { - // prompt the user before refreshing their authentication - authClient.login({ - onSuccess: async () => { - // authClient now has an identity - const identity = await authClient.getIdentity(); - // set new identity in your actor without reloading the page - Actor.agentOf(actor).replaceIdentity(identity); - }, - }); + // prompt the user then refresh their authentication + authClient.login(); } + +authClient.idleManager?.registerCallback?.(refreshLogin) ``` In this code, we create an `authClient` with an idle timeout of 30 minutes. When the user is idle, we invalidate their identity and prompt them to login again. diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index 0bbb87477..f7d208eba 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -15,7 +15,7 @@ describe('IdleManager tests', () => { it('should delay allow configuration of the timeout', () => { const cb = jest.fn(); const extraDelay = 100; - IdleManager.create({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); + IdleManager.create({ callbacks: [cb], idleTimeout: 30 * 60 * 1000 + extraDelay }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -25,7 +25,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on keyboard events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -41,7 +41,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on mouse events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -59,7 +59,7 @@ describe('IdleManager tests', () => { it('should delay its callback on touch events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ callbacks: [cb] }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -79,7 +79,7 @@ describe('IdleManager tests', () => { const scrollDebounce = 100; - const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); + const manager = IdleManager.create({ callbacks: [cb], captureScroll: true, scrollDebounce }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 561628ef4..9c8001fee 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -1,8 +1,9 @@ +type IdleCB = () => unknown; export type IdleManagerOptions = { /** * Callback after the user has gone idle */ - onIdle?: () => void; + callbacks?: IdleCB[]; /** * timeout in ms * @default 30 minutes [1_800_000] @@ -28,7 +29,7 @@ const events = ['mousedown', 'mousemove', 'keypress', 'touchstart', 'wheel']; * @param {IdleManagerOptions} options */ class IdleManager { - onIdle: IdleManagerOptions['onIdle'] = undefined; + callbacks: IdleCB[] = []; idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000; timeoutID?: number = undefined; @@ -41,8 +42,8 @@ class IdleManager { } protected constructor(options: IdleManagerOptions = {}) { - const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; - this.onIdle = onIdle; + const { callbacks = [], idleTimeout = 30 * 60 * 1000 } = options || {}; + this.callbacks = [...callbacks]; this.idleTimeout = idleTimeout; const resetTimer = this.resetTimer.bind(this); @@ -77,11 +78,19 @@ class IdleManager { resetTimer(); } + /** + * adds a callback to the list of callbacks + * @param {IdleCB} callback + */ + public registerCallback(callback: IdleCB): void { + this.callbacks.push(callback); + } + /** * Cleans up the idle manager and its listeners */ public exit(): void { - this.onIdle?.(); + this.callbacks.forEach(cb => cb()); clearTimeout(this.timeoutID); window.removeEventListener('load', this.resetTimer, true); diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index e7d6d7757..7b9f9a6cf 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -70,25 +70,77 @@ describe('Auth Client', () => { }); }; - // idle function invalidates actor - const idleFn = jest.fn(() => { - Actor.agentOf(actor).invalidateIdentity(); - }); - // setup auth client const test = await AuthClient.create({ identity, idleOptions: { idleTimeout: 1000, - onIdle: idleFn, }, }); - const httpAgent = new HttpAgent({ fetch: mockFetch, identity: await test.getIdentity() }); + const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - expect(idleFn).not.toHaveBeenCalled(); + + test.registerActor('default', actor); + // wait for the idle timeout jest.advanceTimersByTime(1000); - expect(idleFn).toHaveBeenCalled(); + + // check that the registered actor has been invalidated + const expectedError = + "This identity has expired due this application's security policy. Please refresh your authentication."; + try { + await actor.greet('hello'); + } catch (error) { + expect(error.message).toBe(expectedError); + } + }); + it('should allow a registeredActor to get refreshed', async () => { + setup({ + onAuthRequest: () => { + // Send a valid request. + idpMock.send({ + kind: 'authorize-client-success', + delegations: [ + { + delegation: { + pubkey: Uint8Array.from([]), + expiration: BigInt(0), + }, + signature: Uint8Array.from([]), + }, + ], + userPublicKey: Uint8Array.from([]), + }); + }, + }); + + // setup actor + const identity = Ed25519KeyIdentity.generate(); + const mockFetch: jest.Mock = jest.fn(); + // http agent uses identity + + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); + const actorInterface = () => { + return IDL.Service({ + greet: IDL.Func([IDL.Text], [IDL.Text]), + }); + }; + + // setup auth client + const test = await AuthClient.create({ + identity, + idleOptions: { + idleTimeout: 1000, + }, + }); + const httpAgent = new HttpAgent({ fetch: mockFetch }); + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + + Actor.agentOf(actor).invalidateIdentity(); + + test.registerActor('default', actor); + + // check that the registered actor has been invalidated const expectedError = "This identity has expired due this application's security policy. Please refresh your authentication."; try { @@ -96,6 +148,13 @@ describe('Auth Client', () => { } catch (error) { expect(error.message).toBe(expectedError); } + + await test.login(); + + idpMock.ready(); + + // check that the registered actor has been invalidated + expect((Actor.agentOf(actor) as any)._identity).toBeTruthy(); }); it('should not set up an idle timer if the disable option is set', async () => { const idleFn = jest.fn(); diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index f6f234cad..2ce770cbf 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -1,4 +1,5 @@ import { + Actor, AnonymousIdentity, DerEncodedPublicKey, Identity, @@ -100,6 +101,8 @@ interface InternetIdentityAuthResponseSuccess { userPublicKey: Uint8Array; } +type ActorMap = Map; + async function _deleteStorage(storage: AuthClientStorage) { await storage.remove(KEY_LOCALSTORAGE_KEY); await storage.remove(KEY_LOCALSTORAGE_DELEGATION); @@ -220,31 +223,24 @@ export class AuthClient { ? undefined : IdleManager.create(options.idleOptions); - return new this(identity, key, chain, storage, options.idleOptions); + return new this(identity, key, chain, storage, idleManager); } - public readonly idleManager: IdleManager | undefined; + private readonly actorMap: ActorMap = new Map(); protected constructor( private _identity: Identity, private _key: SignIdentity | null, private _chain: DelegationChain | null, private _storage: AuthClientStorage, - private _idleOptions?: IdleOptions, + public readonly idleManager: IdleManager | undefined, // A handle on the IdP window. private _idpWindow?: Window, // The event handler for processing events from the IdP. private _eventHandler?: (event: MessageEvent) => void, ) { - this.idleManager = _idleOptions?.disableIdle - ? undefined - : IdleManager.create({ - ..._idleOptions, - onIdle: () => { - this.logout(); - _idleOptions?.onIdle?.(); - }, - }); + const logout = this.logout.bind(this); + this.idleManager?.registerCallback(logout); } private _handleSuccess(message: InternetIdentityAuthResponseSuccess, onSuccess?: () => void) { @@ -272,6 +268,10 @@ export class AuthClient { this._chain = delegationChain; this._identity = DelegationIdentity.fromDelegation(key, this._chain); + // Update registered actors with new identity + + this.actorMap.forEach(actor => Actor.agentOf(actor)?.replaceIdentity?.(this._identity)); + this._idpWindow?.close(); onSuccess?.(); this._removeEventListener(); @@ -395,6 +395,13 @@ export class AuthClient { this._eventHandler = undefined; } + public registerActor(key: string, actor: Actor): void { + this.actorMap.set(key, actor); + this.idleManager?.registerCallback(() => { + Actor.agentOf(actor)?.invalidateIdentity?.(); + }); + } + public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); From 5faae7b3478711fc16c9079b73f96194c78d1ed2 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Fri, 25 Mar 2022 15:43:45 -0700 Subject: [PATCH 16/21] fixing test typing --- packages/auth-client/src/index.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 7b9f9a6cf..66aaac0a7 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -1,4 +1,5 @@ import { Actor, HttpAgent } from '@dfinity/agent'; +import { AgentError } from '@dfinity/agent/lib/cjs/errors'; import { IDL } from '@dfinity/candid'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { Principal } from '@dfinity/principal'; @@ -91,7 +92,7 @@ describe('Auth Client', () => { try { await actor.greet('hello'); } catch (error) { - expect(error.message).toBe(expectedError); + expect((error as AgentError).message).toBe(expectedError); } }); it('should allow a registeredActor to get refreshed', async () => { @@ -136,7 +137,7 @@ describe('Auth Client', () => { const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - Actor.agentOf(actor).invalidateIdentity(); + Actor.agentOf(actor)?.invalidateIdentity?.(); test.registerActor('default', actor); @@ -146,7 +147,7 @@ describe('Auth Client', () => { try { await actor.greet('hello'); } catch (error) { - expect(error.message).toBe(expectedError); + expect((error as AgentError).message).toBe(expectedError); } await test.login(); From eb10643b875932a884cf02ebaa4a7ab307ef3eac Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 29 Mar 2022 14:13:42 -0700 Subject: [PATCH 17/21] reverting actorMap and using onIdle for creation options --- packages/auth-client/src/idleManager.test.ts | 12 ++++++------ packages/auth-client/src/idleManager.ts | 6 +++--- packages/auth-client/src/index.test.ts | 16 +++++++++------- packages/auth-client/src/index.ts | 15 --------------- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index f7d208eba..0bbb87477 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -15,7 +15,7 @@ describe('IdleManager tests', () => { it('should delay allow configuration of the timeout', () => { const cb = jest.fn(); const extraDelay = 100; - IdleManager.create({ callbacks: [cb], idleTimeout: 30 * 60 * 1000 + extraDelay }); + IdleManager.create({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); @@ -25,7 +25,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on keyboard events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -41,7 +41,7 @@ describe('IdleManager tests', () => { }); it('should delay its callback on mouse events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -59,7 +59,7 @@ describe('IdleManager tests', () => { it('should delay its callback on touch events', () => { const cb = jest.fn(); - const manager = IdleManager.create({ callbacks: [cb] }); + const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); @@ -79,7 +79,7 @@ describe('IdleManager tests', () => { const scrollDebounce = 100; - const manager = IdleManager.create({ callbacks: [cb], captureScroll: true, scrollDebounce }); + const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 25 minutes jest.advanceTimersByTime(25 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 9c8001fee..592c044cb 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -3,7 +3,7 @@ export type IdleManagerOptions = { /** * Callback after the user has gone idle */ - callbacks?: IdleCB[]; + onIdle?: IdleCB; /** * timeout in ms * @default 30 minutes [1_800_000] @@ -42,8 +42,8 @@ class IdleManager { } protected constructor(options: IdleManagerOptions = {}) { - const { callbacks = [], idleTimeout = 30 * 60 * 1000 } = options || {}; - this.callbacks = [...callbacks]; + const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; + this.callbacks = onIdle ? [onIdle] : []; this.idleTimeout = idleTimeout; const resetTimer = this.resetTimer.bind(this); diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 66aaac0a7..8f70b7ec1 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -81,7 +81,9 @@ describe('Auth Client', () => { const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - test.registerActor('default', actor); + test.idleManager.registerCallback(() => { + Actor.agentOf(actor).invalidateIdentity(); + }); // wait for the idle timeout jest.advanceTimersByTime(1000); @@ -95,7 +97,12 @@ describe('Auth Client', () => { expect((error as AgentError).message).toBe(expectedError); } }); - it('should allow a registeredActor to get refreshed', async () => { + /** + * This test reflects a feature that may be added at a future date, + * allowing the authClient to register actors for automatic invalidation + * and revalidation of identities + */ + it.skip('should allow a registeredActor to get refreshed', async () => { setup({ onAuthRequest: () => { // Send a valid request. @@ -139,8 +146,6 @@ describe('Auth Client', () => { Actor.agentOf(actor)?.invalidateIdentity?.(); - test.registerActor('default', actor); - // check that the registered actor has been invalidated const expectedError = "This identity has expired due this application's security policy. Please refresh your authentication."; @@ -150,10 +155,7 @@ describe('Auth Client', () => { expect((error as AgentError).message).toBe(expectedError); } - await test.login(); - idpMock.ready(); - // check that the registered actor has been invalidated expect((Actor.agentOf(actor) as any)._identity).toBeTruthy(); }); diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts index 2ce770cbf..226185552 100644 --- a/packages/auth-client/src/index.ts +++ b/packages/auth-client/src/index.ts @@ -101,8 +101,6 @@ interface InternetIdentityAuthResponseSuccess { userPublicKey: Uint8Array; } -type ActorMap = Map; - async function _deleteStorage(storage: AuthClientStorage) { await storage.remove(KEY_LOCALSTORAGE_KEY); await storage.remove(KEY_LOCALSTORAGE_DELEGATION); @@ -226,8 +224,6 @@ export class AuthClient { return new this(identity, key, chain, storage, idleManager); } - private readonly actorMap: ActorMap = new Map(); - protected constructor( private _identity: Identity, private _key: SignIdentity | null, @@ -268,10 +264,6 @@ export class AuthClient { this._chain = delegationChain; this._identity = DelegationIdentity.fromDelegation(key, this._chain); - // Update registered actors with new identity - - this.actorMap.forEach(actor => Actor.agentOf(actor)?.replaceIdentity?.(this._identity)); - this._idpWindow?.close(); onSuccess?.(); this._removeEventListener(); @@ -395,13 +387,6 @@ export class AuthClient { this._eventHandler = undefined; } - public registerActor(key: string, actor: Actor): void { - this.actorMap.set(key, actor); - this.idleManager?.registerCallback(() => { - Actor.agentOf(actor)?.invalidateIdentity?.(); - }); - } - public async logout(options: { returnTo?: string } = {}): Promise { _deleteStorage(this._storage); From 3d1d7373e45f8cd03cf1315b3b88b371c434d9e1 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 29 Mar 2022 14:26:24 -0700 Subject: [PATCH 18/21] friendlier JSDOC formatting --- packages/auth-client/src/idleManager.test.ts | 2 +- packages/auth-client/src/idleManager.ts | 32 ++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index 0bbb87477..5d5ad611b 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -5,7 +5,7 @@ jest.useFakeTimers(); describe('IdleManager tests', () => { it('should call its callback after time spent inactive', () => { const cb = jest.fn(); - const manager = IdleManager.create({ onIdle: cb }); + const manager = IdleManager.create({ onIdle: cb, captureScroll: true }); expect(cb).not.toHaveBeenCalled(); // simulate user being inactive for 30 minutes jest.advanceTimersByTime(30 * 60 * 1000); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index 592c044cb..d67184957 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -34,10 +34,36 @@ class IdleManager { timeoutID?: number = undefined; /** - * creates an idle manager - * @param {IdleManagerOptions} options + * Creates an idle manager + * @param {IdleManagerOptions} options Optional configuration + * @param options.onIdle Callback once user has been idle. Use to prompt for fresh login, and use Actor.agentOf(your_actor).invalidateIdentity() to protect the user + * @param options.idleTimeout timeout in ms + * @param options.captureScroll capture scroll events + * @param options.scrollDebounce scroll debounce time in ms */ - public static create(options: IdleManagerOptions = {}): IdleManager { + public static create( + options: { + /** + * Callback after the user has gone idle + */ + onIdle?: IdleCB; + /** + * timeout in ms + * @default 30 minutes [1_800_000] + */ + idleTimeout?: number; + /** + * capture scroll events + * @default false + */ + captureScroll?: boolean; + /** + * scroll debounce time in ms + * @default 100 + */ + scrollDebounce?: number; + } = {}, + ): IdleManager { return new this(options); } From 1f113bbea28959c2dafade0c5a58051b46b30b36 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 29 Mar 2022 14:30:42 -0700 Subject: [PATCH 19/21] changelog --- docs/generated/changelog.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/generated/changelog.html b/docs/generated/changelog.html index e5f15c9cb..6b3ead8fd 100644 --- a/docs/generated/changelog.html +++ b/docs/generated/changelog.html @@ -16,6 +16,12 @@

      Version 0.10.5

      makeNonce now returns unique values. Previously only the first byte of the nonce was populated. +
    • + Introduces IdleManager to the auth-client. You can now use it to detect when the user has + been idle for a configurable amount of time, and then to register callbacks for the sake + of logging the user out and prompting re-authentication. See the @dfinity/auth-client + Readme for more details +

    Version 0.10.3

      From 5a03a97e05269b0c545ad1bea3cd737502fa19a0 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Wed, 30 Mar 2022 09:52:15 -0700 Subject: [PATCH 20/21] typescript checks for tests --- packages/auth-client/src/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts index 8f70b7ec1..0eacc56d4 100644 --- a/packages/auth-client/src/index.test.ts +++ b/packages/auth-client/src/index.test.ts @@ -81,8 +81,8 @@ describe('Auth Client', () => { const httpAgent = new HttpAgent({ fetch: mockFetch }); const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - test.idleManager.registerCallback(() => { - Actor.agentOf(actor).invalidateIdentity(); + test.idleManager?.registerCallback(() => { + Actor.agentOf(actor)?.invalidateIdentity?.(); }); // wait for the idle timeout From eaebae35f2b116a04bb8bf18050e559311752ee3 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Wed, 30 Mar 2022 14:22:56 -0700 Subject: [PATCH 21/21] chore: setting default idle timeout to 10 minutes --- packages/auth-client/src/idleManager.test.ts | 42 ++++++++++---------- packages/auth-client/src/idleManager.ts | 6 +-- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts index 5d5ad611b..3b7ecb986 100644 --- a/packages/auth-client/src/idleManager.test.ts +++ b/packages/auth-client/src/idleManager.test.ts @@ -7,18 +7,18 @@ describe('IdleManager tests', () => { const cb = jest.fn(); const manager = IdleManager.create({ onIdle: cb, captureScroll: true }); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 30 minutes - jest.advanceTimersByTime(30 * 60 * 1000); + // simulate user being inactive for 10 minutes + jest.advanceTimersByTime(10 * 60 * 1000); expect(cb).toHaveBeenCalled(); manager.exit(); }); it('should delay allow configuration of the timeout', () => { const cb = jest.fn(); const extraDelay = 100; - IdleManager.create({ onIdle: cb, idleTimeout: 30 * 60 * 1000 + extraDelay }); + IdleManager.create({ onIdle: cb, idleTimeout: 10 * 60 * 1000 + extraDelay }); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 30 minutes - jest.advanceTimersByTime(30 * 60 * 1000); + // simulate user being inactive for 10 minutes + jest.advanceTimersByTime(10 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); jest.advanceTimersByTime(extraDelay); expect(cb).toHaveBeenCalled(); @@ -27,24 +27,24 @@ describe('IdleManager tests', () => { const cb = jest.fn(); const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes - jest.advanceTimersByTime(25 * 60 * 1000); + // simulate user being inactive for 9 minutes + jest.advanceTimersByTime(9 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); document.dispatchEvent(new KeyboardEvent('keypress')); // wait 5 minutes jest.advanceTimersByTime(5 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes - jest.advanceTimersByTime(25 * 60 * 1000); + // simulate user being inactive for 9 minutes + jest.advanceTimersByTime(9 * 60 * 1000); expect(cb).toHaveBeenCalled(); }); it('should delay its callback on mouse events', () => { const cb = jest.fn(); const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes - jest.advanceTimersByTime(25 * 60 * 1000); + // simulate user being inactive for 9 minutes + jest.advanceTimersByTime(9 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); // simulate user moving the mouse document.dispatchEvent(new MouseEvent('mousemove')); @@ -52,8 +52,8 @@ describe('IdleManager tests', () => { // wait 5 minutes jest.advanceTimersByTime(5 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes - jest.advanceTimersByTime(25 * 60 * 1000); + // simulate user being inactive for 9 minutes + jest.advanceTimersByTime(9 * 60 * 1000); expect(cb).toHaveBeenCalled(); }); @@ -61,8 +61,8 @@ describe('IdleManager tests', () => { const cb = jest.fn(); const manager = IdleManager.create({ onIdle: cb }); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes - jest.advanceTimersByTime(25 * 60 * 1000); + // simulate user being inactive for 9 minutes + jest.advanceTimersByTime(9 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); // simulate user touching the screen document.dispatchEvent(new TouchEvent('touchstart')); @@ -70,8 +70,8 @@ describe('IdleManager tests', () => { // wait 5 minutes jest.advanceTimersByTime(5 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes - jest.advanceTimersByTime(25 * 60 * 1000); + // simulate user being inactive for 9 minutes + jest.advanceTimersByTime(9 * 60 * 1000); expect(cb).toHaveBeenCalled(); }); it('should delay its callback on scroll events', () => { @@ -81,8 +81,8 @@ describe('IdleManager tests', () => { const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce }); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes - jest.advanceTimersByTime(25 * 60 * 1000); + // simulate user being inactive for 9 minutes + jest.advanceTimersByTime(9 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); // simulate user scrolling document.dispatchEvent(new WheelEvent('scroll')); @@ -90,8 +90,8 @@ describe('IdleManager tests', () => { // wait 5 minutes jest.advanceTimersByTime(5 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); - // simulate user being inactive for 25 minutes, plus the debounce - jest.advanceTimersByTime(25 * 60 * 1000 + scrollDebounce); + // simulate user being inactive for 9 minutes, plus the debounce + jest.advanceTimersByTime(9 * 60 * 1000 + scrollDebounce); expect(cb).toHaveBeenCalled(); }); }); diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts index d67184957..fc32d9a66 100644 --- a/packages/auth-client/src/idleManager.ts +++ b/packages/auth-client/src/idleManager.ts @@ -6,7 +6,7 @@ export type IdleManagerOptions = { onIdle?: IdleCB; /** * timeout in ms - * @default 30 minutes [1_800_000] + * @default 30 minutes [600_000] */ idleTimeout?: number; /** @@ -49,7 +49,7 @@ class IdleManager { onIdle?: IdleCB; /** * timeout in ms - * @default 30 minutes [1_800_000] + * @default 10 minutes [600_000] */ idleTimeout?: number; /** @@ -68,7 +68,7 @@ class IdleManager { } protected constructor(options: IdleManagerOptions = {}) { - const { onIdle, idleTimeout = 30 * 60 * 1000 } = options || {}; + const { onIdle, idleTimeout = 10 * 60 * 1000 } = options || {}; this.callbacks = onIdle ? [onIdle] : []; this.idleTimeout = idleTimeout;