diff --git a/docs/generated/changelog.html b/docs/generated/changelog.html
index 9d2ad0c19..e42febf40 100644
--- a/docs/generated/changelog.html
+++ b/docs/generated/changelog.html
@@ -10,6 +10,13 @@
Agent-JS Changelog
+ Version 0.11.2
+
+ -
+ Adds a default callback to the IdleManager that will refresh the page after clearing the
+ storage
+
+
Version 0.11.1
- Fix for a corner case that could lead to incorrect decoding of record types.
diff --git a/packages/auth-client/README.md b/packages/auth-client/README.md
index 6380ed484..ba92c9652 100644
--- a/packages/auth-client/README.md
+++ b/packages/auth-client/README.md
@@ -56,21 +56,61 @@ const actor = Actor.createActor(idlFactory, {
});
```
-Idle Update
+Idle Management
-As of 0.10.5, the authClient has two notable new features:
+The AuthClient provides two forms of security for session management. The first is built into the Internet Identity delegation - the `maxTimeToLive` option in nanoseconds determines how long the `DelegationIdentity` you get back will be valid for. The second is the Idle Manager, which moniters keyboard, mouse and touchscreen identity. The Idle Manager will automatically log you out if you don't interact with the browser for a period of time.
-1. the maxTimeToLive is now a set to 8 hours by default, down from 24.
-2. you can now set a timeout for when your identity will be considered idle
+If you pass no options to the IdleManager, it will log you out after 10 minutes of inactivity by removing the `DelegationIdentity` from localStorage and then calling `window.location.reload()`.
-These defaults are more conservative, out of the interest of protecting users as more sites are starting to manage ICP and NFT's. You can override these defaults, and opt out of the Idle Manager if you so choose. For more details, see the [forum discussion](https://forum.dfinity.org/t/authclient-update-idle-timeouts).
+If you pass an `onIdle` option, it will call that function when the user is idle, replacing the default window.location.reload() behavior. You can also register callbacks after the idleManager is created with the `idleManager.registerCallback()` method, which will also replace the default callback.
-Additionally, we now support utility methods in Agents to invalidate an identity. It is suggested that you use this method to invalidate an identity once the user goes idle by calling `Actor.getAgent(actor).invalidateIdentity()`. See the below code for an example:
+The full set of options for the IdleManager is:
+
+```js
+ /**
+ * 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;
+```
+
+Additionally, the AuthClient accepts a couple additional flags to `idleOptions` to control the IdleManager:
+
+```js
+ /**
+ * Disables idle functionality for {@link IdleManager}
+ * @default false
+ */
+ disableIdle?: boolean;
+
+ /**
+ * Disables default idle behavior - call logout & reload window
+ * @default false
+ */
+ disableDefaultIdleCallback?: boolean;
+```
+
+### IdleManager Example Usage
```js
const authClient = await AuthClient.create({
idleOptions: {
idleTimeout: 1000 * 60 * 30, // set to 30 minutes
+ disableDefaultIdleCallback: true // disable the default reload behavior
}
});
// ...authClient.login()
diff --git a/packages/auth-client/src/idleManager.test.ts b/packages/auth-client/src/idleManager.test.ts
index fa1d55a9c..3961f3add 100644
--- a/packages/auth-client/src/idleManager.test.ts
+++ b/packages/auth-client/src/idleManager.test.ts
@@ -1,7 +1,14 @@
-import IdleManager from './idleManager';
+import { IdleManager } from './idleManager';
jest.useFakeTimers();
+const { location } = window;
+
+beforeEach(() => {
+ delete (window as any).location;
+ (window as any).location = { reload: jest.fn() };
+});
+
describe('IdleManager tests', () => {
it('should call its callback after time spent inactive', () => {
const cb = jest.fn();
@@ -12,6 +19,26 @@ describe('IdleManager tests', () => {
expect(cb).toHaveBeenCalled();
manager.exit();
});
+ it('should replace the default callback if a callback is passed during creation', () => {
+ const idleFn = jest.fn();
+ IdleManager.create({ onIdle: idleFn });
+
+ expect(window.location.reload).not.toHaveBeenCalled();
+ // simulate user being inactive for 10 minutes
+ jest.advanceTimersByTime(10 * 60 * 1000);
+ expect(window.location.reload).not.toHaveBeenCalled();
+ expect(idleFn).toBeCalled();
+ });
+ it('should replace the default callback if a callback is registered', () => {
+ const manager = IdleManager.create();
+
+ manager.registerCallback(jest.fn());
+
+ expect(window.location.reload).not.toHaveBeenCalled();
+ // simulate user being inactive for 10 minutes
+ jest.advanceTimersByTime(10 * 60 * 1000);
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
it('should delay allow configuration of the timeout', () => {
const cb = jest.fn();
const extraDelay = 100;
diff --git a/packages/auth-client/src/idleManager.ts b/packages/auth-client/src/idleManager.ts
index 08f71a7b4..604aada1e 100644
--- a/packages/auth-client/src/idleManager.ts
+++ b/packages/auth-client/src/idleManager.ts
@@ -26,11 +26,12 @@ const events = ['mousedown', 'mousemove', 'keydown', 'touchstart', 'wheel'];
/**
* Detects if the user has been idle for a duration of `idleTimeout` ms, and calls `onIdle` and registered callbacks.
- * @see {@link IdleManager}
+ * By default, the IdleManager will log a user out after 10 minutes of inactivity.
+ * To override these defaults, you can pass an `onIdle` callback, or configure a custom `idleTimeout` in milliseconds
*/
-class IdleManager {
+export class IdleManager {
callbacks: IdleCB[] = [];
- idleTimeout: IdleManagerOptions['idleTimeout'] = 30 * 60 * 1000;
+ idleTimeout: IdleManagerOptions['idleTimeout'] = 10 * 60 * 1000;
timeoutID?: number = undefined;
/**
@@ -75,6 +76,7 @@ class IdleManager {
*/
protected constructor(options: IdleManagerOptions = {}) {
const { onIdle, idleTimeout = 10 * 60 * 1000 } = options || {};
+
this.callbacks = onIdle ? [onIdle] : [];
this.idleTimeout = idleTimeout;
@@ -121,7 +123,6 @@ class IdleManager {
* 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);
@@ -129,6 +130,7 @@ class IdleManager {
events.forEach(function (name) {
document.removeEventListener(name, _resetTimer, true);
});
+ this.callbacks.forEach(cb => cb());
}
/**
@@ -140,5 +142,3 @@ class IdleManager {
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 668bc0f05..e3d9276c5 100644
--- a/packages/auth-client/src/index.test.ts
+++ b/packages/auth-client/src/index.test.ts
@@ -3,7 +3,7 @@ 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';
+import { AuthClient, AuthClientStorage, ERROR_USER_INTERRUPT } from './index';
/**
* A class for mocking the IDP service.
@@ -31,6 +31,16 @@ class IdpMock {
}
}
+const { location, fetch } = window;
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+afterEach(() => {
+ delete (window as any).location;
+ (window as any).location = location;
+});
+
describe('Auth Client', () => {
it('should initialize with an AnonymousIdentity', async () => {
const test = await AuthClient.create();
@@ -58,7 +68,6 @@ describe('Auth Client', () => {
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();
@@ -97,6 +106,135 @@ describe('Auth Client', () => {
expect((error as AgentError).message).toBe(expectedError);
}
});
+ it('should log out after idle and reload the window by default', 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([]),
+ });
+ },
+ });
+ delete (window as any).location;
+ (window as any).location = { reload: jest.fn(), fetch };
+ const mockFetch: jest.Mock = jest.fn();
+
+ const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c');
+ const actorInterface = () => {
+ return IDL.Service({
+ greet: IDL.Func([IDL.Text], [IDL.Text]),
+ });
+ };
+
+ const storage: AuthClientStorage = {
+ remove: jest.fn(),
+ get: jest.fn(),
+ set: jest.fn(),
+ };
+
+ // setup auth client
+ const test = await AuthClient.create({
+ storage,
+ idleOptions: {
+ idleTimeout: 1000,
+ },
+ });
+
+ // Test login flow
+ await test.login({ identityProvider: 'http://localhost' });
+
+ expect(storage.set).toBeCalled();
+ expect(storage.remove).not.toBeCalled();
+
+ const httpAgent = new HttpAgent({ fetch: mockFetch, host: 'http://127.0.0.1:8000' });
+ const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent });
+
+ // simulate user being inactive for 10 minutes
+ jest.advanceTimersByTime(10 * 60 * 1000);
+
+ // Storage should be cleared by default after logging out
+ expect(storage.remove).toBeCalled();
+
+ expect(window.location.reload).toBeCalled();
+ });
+ it('should not reload the page if the default callback is disabled', 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([]),
+ });
+ },
+ });
+ delete (window as any).location;
+ (window as any).location = { reload: jest.fn(), fetch };
+
+ const storage: AuthClientStorage = {
+ remove: jest.fn(),
+ get: jest.fn(),
+ set: jest.fn(),
+ };
+
+ const test = await AuthClient.create({
+ storage,
+ idleOptions: {
+ idleTimeout: 1000,
+ disableDefaultIdleCallback: true,
+ },
+ });
+
+ // Test login flow
+ await test.login({ identityProvider: 'http://localhost' });
+
+ expect(storage.set).toBeCalled();
+ expect(storage.remove).not.toBeCalled();
+
+ // simulate user being inactive for 10 minutes
+ jest.advanceTimersByTime(10 * 60 * 1000);
+
+ // Storage should not be cleared
+ expect(storage.remove).not.toBeCalled();
+ // Page should not be reloaded
+ expect(window.location.reload).not.toBeCalled();
+ });
+ it('should not reload the page if a callback is provided', async () => {
+ delete (window as any).location;
+ (window as any).location = { reload: jest.fn(), fetch };
+ const idleCb = jest.fn();
+ const test = await AuthClient.create({
+ idleOptions: {
+ idleTimeout: 1000,
+ onIdle: idleCb,
+ },
+ });
+
+ // simulate user being inactive for 10 minutes
+ jest.advanceTimersByTime(10 * 60 * 1000);
+
+ expect(window.location.reload).not.toBeCalled();
+ expect(idleCb).toBeCalled();
+ });
+
/**
* This test reflects a feature that may be added at a future date,
* allowing the authClient to register actors for automatic invalidation
diff --git a/packages/auth-client/src/index.ts b/packages/auth-client/src/index.ts
index b6ca3b4cc..f226e247d 100644
--- a/packages/auth-client/src/index.ts
+++ b/packages/auth-client/src/index.ts
@@ -14,7 +14,7 @@ import {
Ed25519KeyIdentity,
} from '@dfinity/identity';
import { Principal } from '@dfinity/principal';
-import IdleManager, { IdleManagerOptions } from './idleManager';
+import { IdleManager, IdleManagerOptions } from './idleManager';
const KEY_LOCALSTORAGE_KEY = 'identity';
const KEY_LOCALSTORAGE_DELEGATION = 'delegation';
@@ -50,8 +50,16 @@ export interface IdleOptions extends IdleManagerOptions {
* @default false
*/
disableIdle?: boolean;
+
+ /**
+ * Disables default idle behavior - call logout & reload window
+ * @default false
+ */
+ disableDefaultIdleCallback?: boolean;
}
+export * from './idleManager';
+
export interface AuthClientLoginOptions {
/**
* Identity provider
@@ -193,6 +201,7 @@ export class AuthClient {
* @see {@link AuthClientStorage}
* @param {IdleOptions} options.idleOptions Configures an {@link IdleManager}
* @see {@link IdleOptions}
+ * Default behavior is to clear stored identity and reload the page when a user goes idle, unless you set the disableDefaultIdleCallback flag or pass in a custom idle callback.
* @example
* const authClient = await AuthClient.create({
* idleOptions: {
@@ -263,12 +272,11 @@ export class AuthClient {
key = null;
}
}
-
const idleManager = options.idleOptions?.disableIdle
? undefined
: IdleManager.create(options.idleOptions);
- return new this(identity, key, chain, storage, idleManager);
+ return new this(identity, key, chain, storage, idleManager, options);
}
protected constructor(
@@ -277,13 +285,24 @@ export class AuthClient {
private _chain: DelegationChain | null,
private _storage: AuthClientStorage,
public readonly idleManager: IdleManager | undefined,
+ private _createOptions: AuthClientCreateOptions | 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);
+ const idleOptions = _createOptions?.idleOptions;
+ /**
+ * Default behavior is to clear stored identity and reload the page.
+ * By either setting the disableDefaultIdleCallback flag or passing in a custom idle callback, we will ignore this config
+ */
+ if (!idleOptions?.onIdle && !idleOptions?.disableDefaultIdleCallback) {
+ this.idleManager?.registerCallback(() => {
+ logout();
+ location.reload();
+ });
+ }
}
private _handleSuccess(message: InternetIdentityAuthResponseSuccess, onSuccess?: () => void) {