Skip to content

Commit

Permalink
[SDK-2306] Add login and signup hooks (#1976)
Browse files Browse the repository at this point in the history
* Start to flesh out public hooks system

* Ensure hook callback is run if no hook was handled

* Add error handling support

* Rename submitting hook, abstract out validPublicHooks

* Add tests for hook success and error cases, plus jest debug launch files

* Add tests for core runHook function with public hooks

* Add hook support for signing up

* Update snapshots

* Fix existing signup test by stubbing hookRunner

* Add tests for signingUp hook

* Pass a context to hook for future use

* Add hooks content to the readme

* Remove console.log statements

* Fill out more acceptance tests for signingUp hook

* Update yarn lock file

* Fix snapshots

* Add guidance around HTML sanitization for hook errors
  • Loading branch information
Steve Hobbs authored Apr 6, 2021
1 parent 44429f5 commit 0b0ee6d
Show file tree
Hide file tree
Showing 13 changed files with 477 additions and 41 deletions.
18 changes: 18 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Jest (current file)",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["${fileBasenameNoExtension}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true
}
]
}
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,60 @@ var options = {

- **passwordlessMethod {String}**: When using `Auth0LockPasswordless` with an email connection, you can use this option to pick between sending a [code](https://auth0.com/docs/connections/passwordless/spa-email-code) or a [magic link](https://auth0.com/docs/connections/passwordless/spa-email-link) to authenticate the user. Available values for email connections are `code` and `link`. Defaults to `code`. SMS passwordless connections will always use `code`.

#### Hooks

Lock supports hooks that can be used to integrate into various procedures within Lock.

| Name | Description |
|----|-----|
| `loggingIn` | Called when the user presses the login button; after validating the login form, but before calling the login endpoint |
| `signingUp` | Called when the user presses the button on the sign-up page; after validating the signup form, but before calling the sign up endpoint |

**API**
Both hooks accept two arguments:

| Name | Description |
|----|----|
| `context` | this argument is currently always `null` but serves as a future-proofing mechanism to support providing additional data without us requiring breaking changes to the library |
| `cb` | a callback function to call when the hook is finished. Execution of the user journey is blocked until this function is called by the hook |

**API**

Specify your hooks using a new `hooks` configuration item when setting up the library:

```js
new Auth0Lock('client ID', 'domain', {
hooks: {
loggingIn: function(context, cb) {
console.log('Hello from the login hook!');
cb();
},
signingUp: function(context, cb) {
console.log('Hello from the sign-up hook!');
cb();
}
});
```
**Error handling**
The developer can throw an error to block the login or sign-up process. The developer can either specify a specific object and show the error on the page, or throw a generic error which causes Lock to show a fallback error:
```js
new Auth0Lock('client ID', 'domain', {
hooks: {
loggingIn: function(context, cb) {
// Throw an object with code: `hook_error` to display this on the Login screen
throw { code: 'hook_error', description: 'There was an error in the login hook!' };

// Throw something generic to show a fallback error message
throw "Some error happened";
},
});
```
**Note:** The error's `description` field is not sanitized by the SDK and so any content that reflects user input or could otherwise display dangerous HTML should be sanitized by your hook.
#### Other options
- **configurationBaseUrl {String}**: Overrides application settings base URL. By default it uses Auth0's CDN URL when the `domain` has the format `*.auth0.com`. Otherwise, it uses the provided `domain`.
Expand Down
60 changes: 58 additions & 2 deletions src/__tests__/connection/database/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ jest.mock('core/web_api', () => ({
}));

describe('database/actions.js', () => {
beforeEach(() => {
jest.resetAllMocks();
});

it('signUp splits root attributes correctly', () => {
const id = 1;
const hookRunner = jest.fn((str, m, context, fn) => fn());

require('connection/database/index').databaseConnectionName = () => 'test-connection';
require('connection/database/index').shouldAutoLogin = () => true;

const m = Immutable.fromJS({
field: {
email: {
Expand Down Expand Up @@ -53,17 +60,24 @@ describe('database/actions.js', () => {
{ name: 'picture', storage: 'root' },
{ name: 'other_prop' }
]
},
core: {
hookRunner
}
});
swap(setEntity, 'lock', id, m);
signUp(id);
const { validateAndSubmit: { mock: validateAndSubmitMock } } = coreActionsMock();
const {
validateAndSubmit: { mock: validateAndSubmitMock }
} = coreActionsMock();
expect(validateAndSubmitMock.calls.length).toBe(1);
expect(validateAndSubmitMock.calls[0][0]).toBe(id);
expect(validateAndSubmitMock.calls[0][1]).toContain('email');
expect(validateAndSubmitMock.calls[0][1]).toContain('password');
validateAndSubmitMock.calls[0][2](m);
const { signUp: { mock: signUpMock } } = webApiMock();
const {
signUp: { mock: signUpMock }
} = webApiMock();
expect(signUpMock.calls.length).toBe(1);
expect(signUpMock.calls[0][0]).toBe(id);
expect(signUpMock.calls[0][1]).toMatchObject({
Expand All @@ -81,4 +95,46 @@ describe('database/actions.js', () => {
}
});
});

it('runs the signingUp hook on signUp', () => {
const id = 1;

require('connection/database/index').databaseConnectionName = () => 'test-connection';
require('connection/database/index').shouldAutoLogin = () => true;

const hookRunner = jest.fn((str, m, context, fn) => fn());

const m = Immutable.fromJS({
field: {
email: {
value: 'test@email.com'
},
password: {
value: 'testpass'
}
},
core: {
hookRunner
}
});

swap(setEntity, 'lock', id, m);

signUp(id);

const {
validateAndSubmit: { mock: validateAndSubmitMock }
} = coreActionsMock();

validateAndSubmitMock.calls[0][2](m);

const {
signUp: { mock: signUpMock }
} = webApiMock();

expect(hookRunner).toHaveBeenCalledTimes(1);
expect(hookRunner).toHaveBeenCalledWith('signingUp', m, null, expect.any(Function));
expect(signUpMock.calls.length).toBe(1);
expect(signUpMock.calls[0][0]).toBe(id);
});
});
1 change: 1 addition & 0 deletions src/__tests__/core/__snapshots__/index.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Object {
"handleEventFn": "handleEventFn",
"hashCleanup": true,
"hookRunner": "hookRunner",
"hooks": Object {},
"languageBaseUrl": "https://cdn.auth0.com",
"prefill": Object {},
"tenantBaseUrl": "https://domain/info-v1.js",
Expand Down
67 changes: 60 additions & 7 deletions src/__tests__/core/actions.test.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
import { checkSession } from '../../core/actions';
import { checkSession, logIn } from '../../core/actions';
import { expectMockToMatch } from 'testUtils';
import * as l from 'core/index';
import { read } from 'store/index';
import webApi from '../../core/web_api';
import { fromJS } from 'immutable';

jest.mock('../../core/web_api', () => ({
checkSession: jest.fn()
__esModule: true,
default: {
logIn: jest.fn(),
checkSession: jest.fn()
}
}));

jest.mock('store/index', () => ({
read: jest.fn(() => 'model'),
getEntity: 'getEntity',
swap: jest.fn(),
updateEntity: 'updateEntity'
updateEntity: 'updateEntity',
read: jest.fn()
}));

jest.mock('core/index', () => ({
id: () => 'id',
setSubmitting: jest.fn()
}));
jest.mock('core/index');

describe('core.actions', () => {
beforeEach(() => {
jest.resetAllMocks();

l.submitting.mockReturnValue(true);
l.id.mockReturnValue('id');
l.auth.params.mockReturnValue(fromJS({}));
});

describe('checkSession', () => {
it('should set submitting on start', () => {
checkSession('id', 'params', 'cb');
Expand All @@ -31,4 +42,46 @@ describe('core.actions', () => {
expectMockToMatch(require('core/index').setSubmitting, 1);
});
});

describe('logIn', () => {
it('run the loggingIn hook', done => {
const m = {};
read.mockReturnValue(m);

webApi.logIn.mockImplementation((id, params, authParams, cb) => {
cb(null, {});
done();
});

l.runHook.mockImplementation((m, hook, context, fn) => {
expect(hook).toEqual('loggingIn');
fn();
});

logIn();
});

it('should display an error if one was thrown from the hook', done => {
const m = {};
read.mockReturnValue(m);

const store = require('store/index');

store.swap.mockImplementation((entity, n, id, fn, value, error) => {
if (error) {
expect(error).toEqual('This is a hook error');
done();
}
});

l.loginErrorMessage.mockImplementation((m, error) => error.description);

l.runHook.mockImplementation((m, hook, fn) => {
expect(hook).toEqual('loggingIn');
throw { code: 'hook_error', description: 'This is a hook error' };
});

logIn();
});
});
});
47 changes: 34 additions & 13 deletions src/connection/database/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,35 @@ export function signUp(id) {
});
}

webApi.signUp(id, params, (error, result, popupHandler, ...args) => {
if (error) {
if (!!popupHandler) {
popupHandler._current_popup.kill();
}
const wasInvalidCaptcha = error && error.code === 'invalid_captcha';
swapCaptcha(id, wasInvalidCaptcha, () => {
setTimeout(() => signUpError(id, error), 250);
});
} else {
signUpSuccess(id, result, popupHandler, ...args);
const errorHandler = (error, popupHandler) => {
if (!!popupHandler) {
popupHandler._current_popup.kill();
}
});

const wasInvalidCaptcha = error && error.code === 'invalid_captcha';

swapCaptcha(id, wasInvalidCaptcha, () => {
setTimeout(() => signUpError(id, error), 250);
});
};

try {
// For now, always pass 'null' for the context as we don't need it yet.
// If we need it later, it'll save a breaking change in hooks already in use.
const context = null;

l.runHook(m, 'signingUp', context, () => {
webApi.signUp(id, params, (error, result, popupHandler, ...args) => {
if (error) {
errorHandler(error, popupHandler);
} else {
signUpSuccess(id, result, popupHandler, ...args);
}
});
});
} catch (e) {
errorHandler(e);
}
});
}

Expand Down Expand Up @@ -180,14 +196,19 @@ export function signUpError(id, error) {
PasswordStrengthError: 'password_strength_error'
};

l.emitEvent(m, 'signup error', error);

const errorKey =
(error.code === 'invalid_password' && invalidPasswordKeys[error.name]) || error.code;

let errorMessage =
i18n.html(m, ['error', 'signUp', errorKey]) ||
i18n.html(m, ['error', 'signUp', 'lock.fallback']);

l.emitEvent(m, 'signup error', error);
if (error.code === 'hook_error') {
swap(updateEntity, 'lock', id, l.setSubmitting, false, error.description || errorMessage);
return;
}

if (errorKey === 'invalid_captcha') {
errorMessage = i18n.html(m, ['error', 'login', errorKey]);
Expand Down
20 changes: 19 additions & 1 deletion src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default class Base extends EventEmitter {
go(this.id);

let m = setupLock(this.id, clientID, domain, options, hookRunner, emitEventFn, handleEventFn);

this.on('newListener', type => {
if (this.validEvents.indexOf(type) === -1) {
l.emitUnrecoverableErrorEvent(m, `Invalid event "${type}".`);
Expand Down Expand Up @@ -139,7 +140,6 @@ export default class Base extends EventEmitter {
};
render(l.ui.containerID(m), props);

// TODO: hack so we can start testing the beta
if (!this.oldScreenName || this.oldScreenName != screen.name) {
if (screen.name === 'main.login') {
l.emitEvent(m, 'signin ready');
Expand Down Expand Up @@ -203,6 +203,24 @@ export default class Base extends EventEmitter {
}

runHook(str, m, ...args) {
const publicHooks = l.hooks(m).toJS();

if (l.validPublicHooks.indexOf(str) !== -1) {
// If the SDK has been configured with a hook handler, run it.
if (typeof publicHooks[str] === 'function') {
publicHooks[str](...args);
return m;
}

// Ensure the hook callback function is executed in the absence of a hook handler,
// so that execution may continue.
if (typeof args[1] === 'function') {
args[1]();
}

return m;
}

if (typeof this.engine[str] != 'function') return m;
return this.engine[str](m, ...args);
}
Expand Down
Loading

0 comments on commit 0b0ee6d

Please sign in to comment.