Skip to content

Commit

Permalink
feat: idlemanager reload by default (#570)
Browse files Browse the repository at this point in the history
* feature: adds a default callback to the idleManager
By default, refreshes the page unless an alternative CB is provided during initialization or is registered after the fact

* documentation: updating IdleManager readme and changelog
* storage tests
  • Loading branch information
krpeacock committed May 18, 2022
1 parent 974c06a commit 5177ffd
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 19 deletions.
7 changes: 7 additions & 0 deletions docs/generated/changelog.html
Expand Up @@ -10,6 +10,13 @@
<h1>Agent-JS Changelog</h1>

<section>
<h2>Version 0.11.2</h2>
<ul>
<li>
Adds a default callback to the IdleManager that will refresh the page after clearing the
storage
</li>
</ul>
<h2>Version 0.11.1</h2>
<ul>
<li>Fix for a corner case that could lead to incorrect decoding of record types.</li>
Expand Down
52 changes: 46 additions & 6 deletions packages/auth-client/README.md
Expand Up @@ -56,21 +56,61 @@ const actor = Actor.createActor(idlFactory, {
});
```

<h2 id="0.10.5-idle-update">Idle Update</h2>
<h2 id="0.10.5-idle-update">Idle Management</h2>

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()
Expand Down
29 changes: 28 additions & 1 deletion 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();
Expand All @@ -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;
Expand Down
12 changes: 6 additions & 6 deletions packages/auth-client/src/idleManager.ts
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -75,6 +76,7 @@ class IdleManager {
*/
protected constructor(options: IdleManagerOptions = {}) {
const { onIdle, idleTimeout = 10 * 60 * 1000 } = options || {};

this.callbacks = onIdle ? [onIdle] : [];
this.idleTimeout = idleTimeout;

Expand Down Expand Up @@ -121,14 +123,14 @@ 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);

const _resetTimer = this._resetTimer.bind(this);
events.forEach(function (name) {
document.removeEventListener(name, _resetTimer, true);
});
this.callbacks.forEach(cb => cb());
}

/**
Expand All @@ -140,5 +142,3 @@ class IdleManager {
this.timeoutID = window.setTimeout(exit, this.idleTimeout);
}
}

export default IdleManager;
142 changes: 140 additions & 2 deletions packages/auth-client/src/index.test.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 5177ffd

Please sign in to comment.