feat: Add TOTP authentication adapter (#8457)

This commit is contained in:
Daniel
2023-06-24 01:57:57 +10:00
committed by GitHub
parent 3ec3e40dc8
commit cc079a40f6
10 changed files with 580 additions and 19 deletions

35
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"mime": "3.0.0", "mime": "3.0.0",
"mongodb": "4.10.0", "mongodb": "4.10.0",
"mustache": "4.2.0", "mustache": "4.2.0",
"otpauth": "9.0.2",
"parse": "4.1.0", "parse": "4.1.0",
"path-to-regexp": "6.2.1", "path-to-regexp": "6.2.1",
"pg-monitor": "2.0.0", "pg-monitor": "2.0.0",
@@ -46,7 +47,7 @@
"pluralize": "8.0.0", "pluralize": "8.0.0",
"rate-limit-redis": "3.0.2", "rate-limit-redis": "3.0.2",
"redis": "4.6.6", "redis": "4.6.6",
"semver": "^7.5.2", "semver": "7.5.2",
"subscriptions-transport-ws": "0.11.0", "subscriptions-transport-ws": "0.11.0",
"tv4": "1.3.0", "tv4": "1.3.0",
"uuid": "9.0.0", "uuid": "9.0.0",
@@ -10220,6 +10221,14 @@
"extsprintf": "^1.2.0" "extsprintf": "^1.2.0"
} }
}, },
"node_modules/jssha": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz",
"integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==",
"engines": {
"node": "*"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
@@ -15940,6 +15949,17 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/otpauth": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.2.tgz",
"integrity": "sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==",
"dependencies": {
"jssha": "~3.3.0"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/p-cancelable": { "node_modules/p-cancelable": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
@@ -28397,6 +28417,11 @@
} }
} }
}, },
"jssha": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.0.tgz",
"integrity": "sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w=="
},
"jwa": { "jwa": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
@@ -32712,6 +32737,14 @@
} }
} }
}, },
"otpauth": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.0.2.tgz",
"integrity": "sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==",
"requires": {
"jssha": "~3.3.0"
}
},
"p-cancelable": { "p-cancelable": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",

View File

@@ -48,6 +48,7 @@
"mime": "3.0.0", "mime": "3.0.0",
"mongodb": "4.10.0", "mongodb": "4.10.0",
"mustache": "4.2.0", "mustache": "4.2.0",
"otpauth": "9.0.2",
"parse": "4.1.0", "parse": "4.1.0",
"path-to-regexp": "6.2.1", "path-to-regexp": "6.2.1",
"pg-monitor": "2.0.0", "pg-monitor": "2.0.0",

View File

@@ -2406,3 +2406,298 @@ describe('facebook limited auth adapter', () => {
} }
}); });
}); });
describe('OTP TOTP auth adatper', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
beforeEach(async () => {
await reconfigureServer({
auth: {
mfa: {
enabled: true,
options: ['TOTP'],
algorithm: 'SHA1',
digits: 6,
period: 30,
},
},
});
});
it('can enroll', async () => {
const user = await Parse.User.signUp('username', 'password');
const OTPAuth = require('otpauth');
const secret = new OTPAuth.Secret();
const totp = new OTPAuth.TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
const token = totp.generate();
await user.save(
{ authData: { mfa: { secret: secret.base32, token } } },
{ sessionToken: user.getSessionToken() }
);
const response = user.get('authDataResponse');
expect(response.mfa).toBeDefined();
expect(response.mfa.recovery).toBeDefined();
expect(response.mfa.recovery.length).toEqual(2);
await user.fetch();
expect(user.get('authData').mfa).toEqual({ enabled: true });
});
it('can login with valid token', async () => {
const user = await Parse.User.signUp('username', 'password');
const OTPAuth = require('otpauth');
const secret = new OTPAuth.Secret();
const totp = new OTPAuth.TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
const token = totp.generate();
await user.save(
{ authData: { mfa: { secret: secret.base32, token } } },
{ sessionToken: user.getSessionToken() }
);
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/login',
body: JSON.stringify({
username: 'username',
password: 'password',
authData: {
mfa: totp.generate(),
},
}),
}).then(res => res.data);
expect(response.objectId).toEqual(user.id);
expect(response.sessionToken).toBeDefined();
expect(response.authData).toEqual({ mfa: { enabled: true } });
expect(Object.keys(response).sort()).toEqual(
[
'objectId',
'username',
'createdAt',
'updatedAt',
'authData',
'ACL',
'sessionToken',
'authDataResponse',
].sort()
);
});
it('can change OTP with valid token', async () => {
const user = await Parse.User.signUp('username', 'password');
const OTPAuth = require('otpauth');
const secret = new OTPAuth.Secret();
const totp = new OTPAuth.TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
const token = totp.generate();
await user.save(
{ authData: { mfa: { secret: secret.base32, token } } },
{ sessionToken: user.getSessionToken() }
);
const new_secret = new OTPAuth.Secret();
const new_totp = new OTPAuth.TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new_secret,
});
const new_token = new_totp.generate();
await user.save(
{
authData: { mfa: { secret: new_secret.base32, token: new_token, old: totp.generate() } },
},
{ sessionToken: user.getSessionToken() }
);
await user.fetch({ useMasterKey: true });
expect(user.get('authData').mfa.secret).toEqual(new_secret.base32);
});
it('future logins require TOTP token', async () => {
const user = await Parse.User.signUp('username', 'password');
const OTPAuth = require('otpauth');
const secret = new OTPAuth.Secret();
const totp = new OTPAuth.TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
const token = totp.generate();
await user.save(
{ authData: { mfa: { secret: secret.base32, token } } },
{ sessionToken: user.getSessionToken() }
);
await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith(
new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa')
);
});
it('future logins reject incorrect TOTP token', async () => {
const user = await Parse.User.signUp('username', 'password');
const OTPAuth = require('otpauth');
const secret = new OTPAuth.Secret();
const totp = new OTPAuth.TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
});
const token = totp.generate();
await user.save(
{ authData: { mfa: { secret: secret.base32, token } } },
{ sessionToken: user.getSessionToken() }
);
await expectAsync(
request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/login',
body: JSON.stringify({
username: 'username',
password: 'password',
authData: {
mfa: 'abcd',
},
}),
}).catch(e => {
throw e.data;
})
).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' });
});
});
describe('OTP SMS auth adatper', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
let code;
let mobile;
const mfa = {
enabled: true,
options: ['SMS'],
sendSMS(smsCode, number) {
expect(smsCode).toBeDefined();
expect(number).toBeDefined();
expect(smsCode.length).toEqual(6);
code = smsCode;
mobile = number;
},
digits: 6,
period: 30,
};
beforeEach(async () => {
code = '';
mobile = '';
await reconfigureServer({
auth: {
mfa,
},
});
});
it('can enroll', async () => {
const user = await Parse.User.signUp('username', 'password');
const sessionToken = user.getSessionToken();
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
await user.save({ authData: { mfa: { mobile: '+11111111111' } } }, { sessionToken });
await user.fetch({ sessionToken });
expect(user.get('authData')).toEqual({ mfa: { enabled: false } });
expect(spy).toHaveBeenCalledWith(code, '+11111111111');
await user.fetch({ useMasterKey: true });
const authData = user.get('authData').mfa?.pending;
expect(authData).toBeDefined();
expect(authData['+11111111111']).toBeDefined();
expect(Object.keys(authData['+11111111111'])).toEqual(['token', 'expiry']);
await user.save({ authData: { mfa: { mobile, token: code } } }, { sessionToken });
await user.fetch({ sessionToken });
expect(user.get('authData')).toEqual({ mfa: { enabled: true } });
});
it('future logins require SMS code', async () => {
const user = await Parse.User.signUp('username', 'password');
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
await user.save(
{ authData: { mfa: { mobile: '+11111111111' } } },
{ sessionToken: user.getSessionToken() }
);
await user.save(
{ authData: { mfa: { mobile, token: code } } },
{ sessionToken: user.getSessionToken() }
);
spy.calls.reset();
await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith(
new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa')
);
const res = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/login',
body: JSON.stringify({
username: 'username',
password: 'password',
authData: {
mfa: true,
},
}),
}).catch(e => e.data);
expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' });
expect(spy).toHaveBeenCalledWith(code, '+11111111111');
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/login',
body: JSON.stringify({
username: 'username',
password: 'password',
authData: {
mfa: code,
},
}),
}).then(res => res.data);
expect(response.objectId).toEqual(user.id);
expect(response.sessionToken).toBeDefined();
expect(response.authData).toEqual({ mfa: { enabled: true } });
expect(Object.keys(response).sort()).toEqual(
[
'objectId',
'username',
'createdAt',
'updatedAt',
'authData',
'ACL',
'sessionToken',
'authDataResponse',
].sort()
);
});
it('partially enrolled users can still login', async () => {
const user = await Parse.User.signUp('username', 'password');
await user.save({ authData: { mfa: { mobile: '+11111111111' } } });
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
await Parse.User.logIn('username', 'password');
expect(spy).not.toHaveBeenCalled();
});
});

View File

@@ -21,7 +21,9 @@ export class AuthAdapter {
* Usage policy * Usage policy
* @type {AuthPolicy} * @type {AuthPolicy}
*/ */
this.policy = 'default'; if (!this.policy) {
this.policy = 'default';
}
} }
/** /**
* @param appIds The specified app IDs in the configuration * @param appIds The specified app IDs in the configuration

View File

@@ -9,6 +9,7 @@ const facebook = require('./facebook');
const instagram = require('./instagram'); const instagram = require('./instagram');
const linkedin = require('./linkedin'); const linkedin = require('./linkedin');
const meetup = require('./meetup'); const meetup = require('./meetup');
import mfa from './mfa';
const google = require('./google'); const google = require('./google');
const github = require('./github'); const github = require('./github');
const twitter = require('./twitter'); const twitter = require('./twitter');
@@ -44,6 +45,7 @@ const providers = {
instagram, instagram,
linkedin, linkedin,
meetup, meetup,
mfa,
google, google,
github, github,
twitter, twitter,
@@ -75,7 +77,11 @@ function authDataValidator(provider, adapter, appIds, options) {
if (appIds && typeof adapter.validateAppId === 'function') { if (appIds && typeof adapter.validateAppId === 'function') {
await Promise.resolve(adapter.validateAppId(appIds, authData, options, requestObject)); await Promise.resolve(adapter.validateAppId(appIds, authData, options, requestObject));
} }
if (adapter.policy && !authAdapterPolicies[adapter.policy]) { if (
adapter.policy &&
!authAdapterPolicies[adapter.policy] &&
typeof adapter.policy !== 'function'
) {
throw new Parse.Error( throw new Parse.Error(
Parse.Error.OTHER_CAUSE, Parse.Error.OTHER_CAUSE,
'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")'
@@ -225,17 +231,20 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) {
if (!authAdapter) { if (!authAdapter) {
return; return;
} }
const { const { adapter, providerOptions } = authAdapter;
adapter: { afterFind }, const afterFind = adapter.afterFind;
providerOptions,
} = authAdapter;
if (afterFind && typeof afterFind === 'function') { if (afterFind && typeof afterFind === 'function') {
const requestObject = { const requestObject = {
ip: req.config.ip, ip: req.config.ip,
user: req.auth.user, user: req.auth.user,
master: req.auth.isMaster, master: req.auth.isMaster,
}; };
const result = afterFind(requestObject, authData[provider], providerOptions); const result = afterFind.call(
adapter,
requestObject,
authData[provider],
providerOptions
);
if (result) { if (result) {
authData[provider] = result; authData[provider] = result;
} }

212
src/Adapters/Auth/mfa.js Normal file
View File

@@ -0,0 +1,212 @@
import { TOTP, Secret } from 'otpauth';
import { randomString } from '../../cryptoUtils';
import AuthAdapter from './AuthAdapter';
class MFAAdapter extends AuthAdapter {
validateOptions(opts) {
const validOptions = opts.options;
if (!Array.isArray(validOptions)) {
throw 'mfa.options must be an array';
}
this.sms = validOptions.includes('SMS');
this.totp = validOptions.includes('TOTP');
if (!this.sms && !this.totp) {
throw 'mfa.options must include SMS or TOTP';
}
const digits = opts.digits || 6;
const period = opts.period || 30;
if (typeof digits !== 'number') {
throw 'mfa.digits must be a number';
}
if (typeof period !== 'number') {
throw 'mfa.period must be a number';
}
if (digits < 4 || digits > 10) {
throw 'mfa.digits must be between 4 and 10';
}
if (period < 10) {
throw 'mfa.period must be greater than 10';
}
const sendSMS = opts.sendSMS;
if (this.sms && typeof sendSMS !== 'function') {
throw 'mfa.sendSMS callback must be defined when using SMS OTPs';
}
this.smsCallback = sendSMS;
this.digits = digits;
this.period = period;
this.algorithm = opts.algorithm || 'SHA1';
}
validateSetUp(mfaData) {
if (mfaData.mobile && this.sms) {
return this.setupMobileOTP(mfaData.mobile);
}
if (this.totp) {
return this.setupTOTP(mfaData);
}
throw 'Invalid MFA data';
}
async validateLogin(token, _, req) {
const saveResponse = {
doNotSave: true,
};
const auth = req.original.get('authData') || {};
const { secret, recovery, mobile, token: saved, expiry } = auth.mfa || {};
if (this.sms && mobile) {
if (typeof token === 'boolean') {
const { token: sendToken, expiry } = await this.sendSMS(mobile);
auth.mfa.token = sendToken;
auth.mfa.expiry = expiry;
req.object.set('authData', auth);
await req.object.save(null, { useMasterKey: true });
throw 'Please enter the token';
}
if (!saved || token !== saved) {
throw 'Invalid MFA token 1';
}
if (new Date() > expiry) {
throw 'Invalid MFA token 2';
}
delete auth.mfa.token;
delete auth.mfa.expiry;
return {
save: auth.mfa,
};
}
if (this.totp) {
if (typeof token !== 'string') {
throw 'Invalid MFA token';
}
if (!secret) {
return saveResponse;
}
if (recovery[0] === token || recovery[1] === token) {
return saveResponse;
}
const totp = new TOTP({
algorithm: this.algorithm,
digits: this.digits,
period: this.period,
secret: Secret.fromBase32(secret),
});
const valid = totp.validate({
token,
});
if (valid === null) {
throw 'Invalid MFA token';
}
}
return saveResponse;
}
validateUpdate(authData, _, req) {
if (req.master) {
return;
}
if (authData.mobile && this.sms) {
if (!authData.token) {
throw 'MFA is already set up on this account';
}
return this.confirmSMSOTP(authData, req.original.get('authData')?.mfa || {});
}
if (this.totp) {
this.validateLogin(authData.old, null, req);
return this.validateSetUp(authData);
}
throw 'Invalid MFA data';
}
afterFind(req, authData) {
if (req.master) {
return;
}
if (this.totp && authData.secret) {
return {
enabled: true,
};
}
if (this.sms && authData.mobile) {
return {
enabled: true,
};
}
return {
enabled: false,
};
}
policy(req, auth) {
if (this.sms && auth?.pending && Object.keys(auth).length === 1) {
return 'default';
}
return 'additional';
}
async setupMobileOTP(mobile) {
const { token, expiry } = await this.sendSMS(mobile);
return {
save: {
pending: {
[mobile]: {
token,
expiry,
},
},
},
};
}
async sendSMS(mobile) {
if (!/^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\./0-9]*$/g.test(mobile)) {
throw 'Invalid mobile number.';
}
let token = '';
while (token.length < this.digits) {
token += randomString(10).replace(/\D/g, '');
}
token = token.substring(0, this.digits);
await Promise.resolve(this.smsCallback(token, mobile));
const expiry = new Date(new Date().getTime() + this.period * 1000);
return { token, expiry };
}
async confirmSMSOTP(inputData, authData) {
const { mobile, token } = inputData;
if (!authData.pending?.[mobile]) {
throw 'This number is not pending';
}
const pendingData = authData.pending[mobile];
if (token !== pendingData.token) {
throw 'Invalid MFA token';
}
if (new Date() > pendingData.expiry) {
throw 'Invalid MFA token';
}
delete authData.pending[mobile];
authData.mobile = mobile;
return {
save: authData,
};
}
setupTOTP(mfaData) {
const { secret, token } = mfaData;
if (!secret || !token || secret.length < 20) {
throw 'Invalid MFA data';
}
const totp = new TOTP({
algorithm: this.algorithm,
digits: this.digits,
period: this.period,
secret: Secret.fromBase32(secret),
});
const valid = totp.validate({
token,
});
if (valid === null) {
throw 'Invalid MFA token';
}
const recovery = [randomString(30), randomString(30)];
return {
response: { recovery },
save: { secret, recovery },
};
}
}
export default new MFAAdapter();

View File

@@ -407,6 +407,7 @@ const hasMutatedAuthData = (authData, userAuthData) => {
}; };
const checkIfUserHasProvidedConfiguredProvidersForLogin = ( const checkIfUserHasProvidedConfiguredProvidersForLogin = (
req = {},
authData = {}, authData = {},
userAuthData = {}, userAuthData = {},
config config
@@ -430,7 +431,16 @@ const checkIfUserHasProvidedConfiguredProvidersForLogin = (
const additionProvidersNotFound = []; const additionProvidersNotFound = [];
const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => { const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => {
if (provider && provider.adapter && provider.adapter.policy === 'additional') { let policy = provider.adapter.policy;
if (typeof policy === 'function') {
const requestObject = {
ip: req.config.ip,
user: req.auth.user,
master: req.auth.isMaster,
};
policy = policy.call(provider.adapter, requestObject, userAuthData[provider.name]);
}
if (policy === 'additional') {
if (authData[provider.name]) { if (authData[provider.name]) {
return true; return true;
} else { } else {
@@ -467,14 +477,8 @@ const handleAuthDataValidation = async (authData, req, foundUser) => {
await user.fetch({ useMasterKey: true }); await user.fetch({ useMasterKey: true });
} }
const { originalObject, updatedObject } = req.buildParseObjects(); const { updatedObject } = req.buildParseObjects();
const requestObject = getRequestObject( const requestObject = getRequestObject(undefined, req.auth, updatedObject, user, req.config);
undefined,
req.auth,
updatedObject,
originalObject || user,
req.config
);
// Perform validation as step-by-step pipeline for better error consistency // Perform validation as step-by-step pipeline for better error consistency
// and also to avoid to trigger a provider (like OTP SMS) if another one fails // and also to avoid to trigger a provider (like OTP SMS) if another one fails
const acc = { authData: {}, authDataResponse: {} }; const acc = { authData: {}, authDataResponse: {} };

View File

@@ -556,6 +556,7 @@ RestWrite.prototype.handleAuthData = async function (authData) {
// we need to be sure that the user has provided // we need to be sure that the user has provided
// required authData // required authData
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(
{ config: this.config, auth: this.auth },
authData, authData,
userResult.authData, userResult.authData,
this.config this.config

View File

@@ -189,7 +189,12 @@ export class UsersRouter extends ClassesRouter {
const user = await this._authenticateUserFromRequest(req); const user = await this._authenticateUserFromRequest(req);
const authData = req.body && req.body.authData; const authData = req.body && req.body.authData;
// Check if user has provided their required auth providers // Check if user has provided their required auth providers
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config); Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(
req,
authData,
user.authData,
req.config
);
let authDataResponse; let authDataResponse;
let validatedAuthData; let validatedAuthData;

View File

@@ -7,7 +7,6 @@ import LRUCacheAdapter from './Adapters/Cache/LRUCache.js';
import * as TestUtils from './TestUtils'; import * as TestUtils from './TestUtils';
import * as SchemaMigrations from './SchemaMigrations/Migrations'; import * as SchemaMigrations from './SchemaMigrations/Migrations';
import AuthAdapter from './Adapters/Auth/AuthAdapter'; import AuthAdapter from './Adapters/Auth/AuthAdapter';
import { useExternal } from './deprecated'; import { useExternal } from './deprecated';
import { getLogger } from './logger'; import { getLogger } from './logger';
import { PushWorker } from './Push/PushWorker'; import { PushWorker } from './Push/PushWorker';