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
diff --git a/packages/auth-client/README.md b/packages/auth-client/README.md
index 2ba7a7b2c..8c28892d0 100644
--- a/packages/auth-client/README.md
+++ b/packages/auth-client/README.md
@@ -52,5 +52,39 @@ 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
+ 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,
+});
+
+authClient.registerActor("ii", actor);
+
+refreshLogin() {
+ // 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.
+
+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/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts
new file mode 100644
index 000000000..3b7ecb986
--- /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 = IdleManager.create({ onIdle: cb, captureScroll: true });
+ expect(cb).not.toHaveBeenCalled();
+ // 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: 10 * 60 * 1000 + extraDelay });
+ expect(cb).not.toHaveBeenCalled();
+ // simulate user being inactive for 10 minutes
+ jest.advanceTimersByTime(10 * 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 = IdleManager.create({ onIdle: cb });
+ expect(cb).not.toHaveBeenCalled();
+ // 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 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 9 minutes
+ jest.advanceTimersByTime(9 * 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 9 minutes
+ jest.advanceTimersByTime(9 * 60 * 1000);
+ expect(cb).toHaveBeenCalled();
+ });
+
+ it('should delay its callback on touch events', () => {
+ const cb = jest.fn();
+ const manager = IdleManager.create({ onIdle: cb });
+ expect(cb).not.toHaveBeenCalled();
+ // 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'));
+
+ // wait 5 minutes
+ jest.advanceTimersByTime(5 * 60 * 1000);
+ expect(cb).not.toHaveBeenCalled();
+ // simulate user being inactive for 9 minutes
+ jest.advanceTimersByTime(9 * 60 * 1000);
+ expect(cb).toHaveBeenCalled();
+ });
+ it('should delay its callback on scroll events', () => {
+ const cb = jest.fn();
+
+ const scrollDebounce = 100;
+
+ const manager = IdleManager.create({ onIdle: cb, captureScroll: true, scrollDebounce });
+ expect(cb).not.toHaveBeenCalled();
+ // simulate user being inactive for 9 minutes
+ jest.advanceTimersByTime(9 * 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 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
new file mode 100644
index 000000000..fc32d9a66
--- /dev/null
+++ b/packages/auth-client/src/idleManager.ts
@@ -0,0 +1,136 @@
+type IdleCB = () => unknown;
+export type IdleManagerOptions = {
+ /**
+ * Callback after the user has gone idle
+ */
+ onIdle?: IdleCB;
+ /**
+ * timeout in ms
+ * @default 30 minutes [600_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 {
+ callbacks: IdleCB[] = [];
+ idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000;
+ timeoutID?: number = undefined;
+
+ /**
+ * 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: {
+ /**
+ * Callback after the user has gone idle
+ */
+ onIdle?: IdleCB;
+ /**
+ * timeout in ms
+ * @default 10 minutes [600_000]
+ */
+ idleTimeout?: number;
+ /**
+ * capture scroll events
+ * @default false
+ */
+ captureScroll?: boolean;
+ /**
+ * scroll debounce time in ms
+ * @default 100
+ */
+ scrollDebounce?: number;
+ } = {},
+ ): IdleManager {
+ return new this(options);
+ }
+
+ protected constructor(options: IdleManagerOptions = {}) {
+ const { onIdle, idleTimeout = 10 * 60 * 1000 } = options || {};
+ this.callbacks = 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();
+ }
+
+ /**
+ * 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.callbacks.forEach(cb => cb());
+ 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;
diff --git a/packages/auth-client/src/index.test.ts b/packages/auth-client/src/index.test.ts
index bad5e93d3..0eacc56d4 100644
--- a/packages/auth-client/src/index.test.ts
+++ b/packages/auth-client/src/index.test.ts
@@ -1,4 +1,8 @@
+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';
import { AuthClient, ERROR_USER_INTERRUPT } from './index';
/**
@@ -49,6 +53,126 @@ 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();
+ });
+ 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 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 });
+
+ test.idleManager?.registerCallback(() => {
+ Actor.agentOf(actor)?.invalidateIdentity?.();
+ });
+
+ // wait for the idle timeout
+ jest.advanceTimersByTime(1000);
+
+ // 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 as AgentError).message).toBe(expectedError);
+ }
+ });
+ /**
+ * 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.
+ 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?.();
+
+ // 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 as AgentError).message).toBe(expectedError);
+ }
+
+ 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();
+ 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.
@@ -224,7 +348,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 ccf2272ef..30ff2efec 100644
--- a/packages/auth-client/src/index.ts
+++ b/packages/auth-client/src/index.ts
@@ -1,4 +1,5 @@
import {
+ Actor,
AnonymousIdentity,
DerEncodedPublicKey,
Identity,
@@ -13,6 +14,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 +37,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 +217,11 @@ export class AuthClient {
}
}
- return new this(identity, key, chain, storage);
+ const idleManager = options.idleOptions?.disableIdle
+ ? undefined
+ : IdleManager.create(options.idleOptions);
+
+ return new this(identity, key, chain, storage, idleManager);
}
protected constructor(
@@ -210,11 +229,15 @@ export class AuthClient {
private _key: SignIdentity | null,
private _chain: DelegationChain | null,
private _storage: AuthClientStorage,
+ 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,
- ) {}
+ ) {
+ const logout = this.logout.bind(this);
+ this.idleManager?.registerCallback(logout);
+ }
private _handleSuccess(message: InternetIdentityAuthResponseSuccess, onSuccess?: () => void) {
const delegations = message.delegations.map(signedDelegation => {
@@ -307,7 +330,9 @@ export class AuthClient {
private _getEventHandler(identityProviderUrl: URL, options?: AuthClientLoginOptions) {
return async (event: MessageEvent) => {
if (event.origin !== identityProviderUrl.origin) {
- console.warn(`WARNING: expected origin '${identityProviderUrl.origin}', got '${event.origin}' (ignoring)`);
+ console.warn(
+ `WARNING: expected origin '${identityProviderUrl.origin}', got '${event.origin}' (ignoring)`,
+ );
return;
}