Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support idle management in AuthClient #547

Merged
merged 25 commits into from Mar 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2afc69a
feat: idleManager
krpeacock Mar 23, 2022
f91524e
feat: setup idleManager in authClient
krpeacock Mar 24, 2022
409c32d
test: full lifecycle test of idle logout
krpeacock Mar 24, 2022
5bf003d
some basic documentation
krpeacock Mar 24, 2022
f43a3d1
feat: registerActor and registerCallback
krpeacock Mar 25, 2022
6088842
fixing test typing
krpeacock Mar 25, 2022
f941b32
reverting actorMap and using onIdle for creation options
krpeacock Mar 29, 2022
e826b43
friendlier JSDOC formatting
krpeacock Mar 29, 2022
22a1db2
Merge branch 'main' into SDK-307-support-invalidating-and-replacing-i…
krpeacock Mar 29, 2022
730eae6
changelog
krpeacock Mar 29, 2022
d2f0077
Merge branch 'SDK-307-support-invalidating-and-replacing-identities' …
krpeacock Mar 29, 2022
70c5085
typescript checks for tests
krpeacock Mar 30, 2022
2d23b7a
feat: idleManager
krpeacock Mar 23, 2022
e8f4378
feat: setup idleManager in authClient
krpeacock Mar 24, 2022
88aba80
test: full lifecycle test of idle logout
krpeacock Mar 24, 2022
6ad006e
some basic documentation
krpeacock Mar 24, 2022
fad7732
feat: registerActor and registerCallback
krpeacock Mar 25, 2022
5faae7b
fixing test typing
krpeacock Mar 25, 2022
eb10643
reverting actorMap and using onIdle for creation options
krpeacock Mar 29, 2022
3d1d737
friendlier JSDOC formatting
krpeacock Mar 29, 2022
1f113bb
changelog
krpeacock Mar 29, 2022
5a03a97
typescript checks for tests
krpeacock Mar 30, 2022
fb5908e
Merge branch 'main' into SDK-307-support-invalidating-and-replacing-i…
krpeacock Mar 30, 2022
eaebae3
chore: setting default idle timeout to 10 minutes
krpeacock Mar 30, 2022
03c8782
Merge branch 'SDK-307-support-invalidating-and-replacing-identities' …
krpeacock Mar 30, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is auth-client ever meant to be used in a nodejs environment?

};
};

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;