Skip to content

Commit

Permalink
feat: support idle management in AuthClient (#547)
Browse files Browse the repository at this point in the history
* feat: idleManager

* feat: setup idleManager in authClient

* test: full lifecycle test of idle logout

* some basic documentation

* feat: registerActor and registerCallback
Allows the authClient to register actors to automatically invalidate them going idle, or revalidate them after logging in

* fixing test typing

* reverting actorMap and using onIdle for creation options

* friendlier JSDOC formatting

* changelog

* typescript checks for tests

* feat: idleManager

* feat: setup idleManager in authClient

* test: full lifecycle test of idle logout

* some basic documentation

* feat: registerActor and registerCallback
Allows the authClient to register actors to automatically invalidate them going idle, or revalidate them after logging in

* fixing test typing

* reverting actorMap and using onIdle for creation options

* friendlier JSDOC formatting

* changelog

* typescript checks for tests

* chore: setting default idle timeout to 10 minutes
  • Loading branch information
krpeacock committed Mar 30, 2022
1 parent bc098bf commit 178cb01
Show file tree
Hide file tree
Showing 6 changed files with 427 additions and 4 deletions.
6 changes: 6 additions & 0 deletions docs/generated/changelog.html
Expand Up @@ -16,6 +16,12 @@ <h2>Version 0.10.5</h2>
makeNonce now returns unique values. Previously only the first byte of the nonce was
populated.
</li>
<li>
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
</li>
</ul>
<h2>Version 0.10.3</h2>
<ul>
Expand Down
34 changes: 34 additions & 0 deletions packages/auth-client/README.md
Expand Up @@ -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).
97 changes: 97 additions & 0 deletions 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();
});
});
136 changes: 136 additions & 0 deletions 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;

0 comments on commit 178cb01

Please sign in to comment.