diff --git a/packages/password/__tests__/accounts-password.ts b/packages/password/__tests__/accounts-password.ts index 588bdec35..9edf57f4c 100644 --- a/packages/password/__tests__/accounts-password.ts +++ b/packages/password/__tests__/accounts-password.ts @@ -204,9 +204,9 @@ describe('AccountsPassword', () => { it('throws when token is expired', async () => { const findUserByResetPasswordToken = jest.fn(() => Promise.resolve(invalidUser)); - const isTokenExpired = jest.fn(() => true); + const isEmailTokenExpired = jest.fn(() => true); password.setStore({ findUserByResetPasswordToken } as any); - password.server = { isTokenExpired } as any; + password.server = { tokenManager: { isEmailTokenExpired } } as any; try { await password.resetPassword(token, newPassword); throw new Error(); @@ -217,9 +217,9 @@ describe('AccountsPassword', () => { it('throws when token have invalid email', async () => { const findUserByResetPasswordToken = jest.fn(() => Promise.resolve(invalidUser)); - const isTokenExpired = jest.fn(() => false); + const isEmailTokenExpired = jest.fn(() => false); password.setStore({ findUserByResetPasswordToken } as any); - password.server = { isTokenExpired } as any; + password.server = { tokenManager: { isEmailTokenExpired } } as any; try { await password.resetPassword(token, newPassword); throw new Error(); @@ -230,7 +230,7 @@ describe('AccountsPassword', () => { it('reset password and invalidate all sessions', async () => { const findUserByResetPasswordToken = jest.fn(() => Promise.resolve(validUser)); - const isTokenExpired = jest.fn(() => false); + const isEmailTokenExpired = jest.fn(() => false); const setResetPassword = jest.fn(() => Promise.resolve()); const invalidateAllSessions = jest.fn(() => Promise.resolve()); password.setStore({ @@ -238,7 +238,7 @@ describe('AccountsPassword', () => { setResetPassword, invalidateAllSessions, } as any); - password.server = { isTokenExpired } as any; + password.server = { tokenManager: { isEmailTokenExpired } } as any; await password.resetPassword(token, newPassword); expect(setResetPassword.mock.calls.length).toBe(1); expect(invalidateAllSessions.mock.calls[0]).toMatchSnapshot(); @@ -307,6 +307,9 @@ describe('AccountsPassword', () => { prepareMail, options: { sendMail }, sanitizeUser, + tokenManager: { + generateRandomToken: () => 'randomToken' + } } as any; set(password.server, 'options.emailTemplates', {}); await password.sendVerificationEmail(verifiedEmail); @@ -326,6 +329,9 @@ describe('AccountsPassword', () => { prepareMail, options: { sendMail }, sanitizeUser, + tokenManager: { + generateRandomToken: () => 'randomToken' + } } as any; set(password.server, 'options.emailTemplates', {}); await password.sendVerificationEmail(email); @@ -372,6 +378,9 @@ describe('AccountsPassword', () => { options: { sendMail }, sanitizeUser, getFirstUserEmail, + tokenManager: { + generateRandomToken: () => 'randomToken' + } } as any; set(password.server, 'options.emailTemplates', {}); await password.sendResetPasswordEmail(email); @@ -409,6 +418,9 @@ describe('AccountsPassword', () => { options: { sendMail }, sanitizeUser, getFirstUserEmail, + tokenManager: { + generateRandomToken: () => 'randomToken' + } } as any; set(password.server, 'options.emailTemplates', {}); await password.sendEnrollmentEmail(email); diff --git a/packages/password/src/accounts-password.ts b/packages/password/src/accounts-password.ts index 40349811b..c90b34d28 100644 --- a/packages/password/src/accounts-password.ts +++ b/packages/password/src/accounts-password.ts @@ -2,7 +2,7 @@ import { trim, isEmpty, isFunction, isString, isPlainObject, get, find, includes import { CreateUser, User, Login, EmailRecord, TokenRecord, DatabaseInterface, AuthenticationService } from '@accounts/types'; import { HashAlgorithm } from '@accounts/common'; import { TwoFactor, AccountsTwoFactorOptions } from '@accounts/two-factor'; -import { AccountsServer, generateRandomToken, getFirstUserEmail } from '@accounts/server'; +import { AccountsServer, getFirstUserEmail } from '@accounts/server'; import { hashPassword, bcryptPassword, verifyPassword } from './utils/encryption'; import { PasswordCreateUserType } from './types/password-create-user-type'; @@ -165,7 +165,7 @@ export default class AccountsPassword implements AuthenticationService { const resetTokens = get(user, ['services', 'password', 'reset']); const resetTokenRecord = find(resetTokens, t => t.token === token); - if (this.server.isTokenExpired(token, resetTokenRecord)) { + if (this.server.tokenManager.isEmailTokenExpired(token, resetTokenRecord)) { throw new Error('Reset password link expired'); } @@ -212,7 +212,7 @@ export default class AccountsPassword implements AuthenticationService { if (!address || !includes(emails.map(email => email.address), address)) { throw new Error('No such email address for user'); } - const token = generateRandomToken(); + const token = this.server.tokenManager.generateRandomToken(); await this.db.addEmailVerificationToken(user.id, address, token); const resetPasswordMail = this.server.prepareMail( @@ -243,7 +243,7 @@ export default class AccountsPassword implements AuthenticationService { throw new Error('User not found'); } address = getFirstUserEmail(user, address); - const token = generateRandomToken(); + const token = this.server.tokenManager.generateRandomToken(); await this.db.addResetPasswordToken(user.id, address, token); const resetPasswordMail = this.server.prepareMail( @@ -271,7 +271,7 @@ export default class AccountsPassword implements AuthenticationService { throw new Error('User not found'); } address = getFirstUserEmail(user, address); - const token = generateRandomToken(); + const token = this.server.tokenManager.generateRandomToken(); await this.db.addResetPasswordToken(user.id, address, token, 'enroll'); const enrollmentMail = this.server.prepareMail( diff --git a/packages/server/__tests__/__snapshots__/account-server.ts.snap b/packages/server/__tests__/__snapshots__/account-server.ts.snap index 4fbde10aa..ca8c2a1e3 100644 --- a/packages/server/__tests__/__snapshots__/account-server.ts.snap +++ b/packages/server/__tests__/__snapshots__/account-server.ts.snap @@ -2,6 +2,8 @@ exports[`AccountsServer config throws on invalid db 1`] = `"A database driver is required"`; +exports[`AccountsServer config throws on invalid tokenManager 1`] = `"A tokenManager is required"`; + exports[`AccountsServer loginWithService throws on invalid service 1`] = `"No service with the name facebook was registered."`; exports[`AccountsServer loginWithService throws when user not found 1`] = `"Service facebook was not able to authenticate user"`; diff --git a/packages/server/__tests__/account-server.ts b/packages/server/__tests__/account-server.ts index 6178c504f..72aa0890d 100644 --- a/packages/server/__tests__/account-server.ts +++ b/packages/server/__tests__/account-server.ts @@ -1,8 +1,11 @@ -import * as jwtDecode from 'jwt-decode'; import { AccountsServer } from '../src/accounts-server'; import { JwtData } from '../src/types/jwt-data'; -import { bcryptPassword, hashPassword, verifyPassword } from '../src/utils/encryption'; import { ServerHooks } from '../src/utils/server-hooks'; +import TokenManager from '@accounts/token-manager'; + +const tokenManager = new TokenManager({ + secret: 'secret', +}); describe('AccountsServer', () => { const db = { @@ -23,6 +26,17 @@ describe('AccountsServer', () => { }); }); + describe('config', () => { + it('throws on invalid tokenManager', async () => { + try { + const account = new AccountsServer({ db: {} } as any, {}); + throw new Error(); + } catch (err) { + expect(err.message).toMatchSnapshot(); + } + }); + }); + describe('getServices', () => { it('should return instance services', async () => { const services: any = { @@ -30,7 +44,7 @@ describe('AccountsServer', () => { setStore: () => null, }, }; - const account = new AccountsServer({ db: {} } as any, services); + const account = new AccountsServer({ db: {}, tokenManager } as any, services); expect(account.getServices()).toEqual(services); }); }); @@ -38,7 +52,7 @@ describe('AccountsServer', () => { describe('loginWithService', () => { it('throws on invalid service', async () => { try { - const accountServer = new AccountsServer({ db: {} } as any, {}); + const accountServer = new AccountsServer({ db: {}, tokenManager } as any, {}); await accountServer.loginWithService('facebook', {}, {}); throw new Error(); } catch (err) { @@ -49,7 +63,7 @@ describe('AccountsServer', () => { it('throws when user not found', async () => { const authenticate = jest.fn(() => Promise.resolve()); try { - const accountServer = new AccountsServer({ db: {} } as any, { + const accountServer = new AccountsServer({ db: {}, tokenManager } as any, { facebook: { authenticate, setStore: jest.fn() }, }); await accountServer.loginWithService('facebook', {}, {}); @@ -66,6 +80,7 @@ describe('AccountsServer', () => { { db: { createSession } as any, tokenSecret: 'secret', + tokenManager, }, { facebook: { authenticate, setStore: jest.fn() }, @@ -78,7 +93,6 @@ describe('AccountsServer', () => { describe('loginWithUser', () => { it('creates a session when given a proper user object', async () => { - const hash = bcryptPassword('1234567'); const user = { id: '123', username: 'username', @@ -93,13 +107,16 @@ describe('AccountsServer', () => { createSession: () => Promise.resolve('sessionId'), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); const res = await accountsServer.loginWithUser(user, {}); const { accessToken, refreshToken } = res.tokens; - const decodedAccessToken: { data: JwtData } = jwtDecode(accessToken); + const decodedAccessToken: { data: JwtData } = accountsServer.tokenManager.decodeToken( + accessToken + ); expect(decodedAccessToken.data.token).toBeTruthy(); expect(accessToken).toBeTruthy(); expect(refreshToken).toBeTruthy(); @@ -120,6 +137,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -153,6 +171,7 @@ describe('AccountsServer', () => { invalidateSession, } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -172,6 +191,7 @@ describe('AccountsServer', () => { createSession: () => '123', } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -191,6 +211,7 @@ describe('AccountsServer', () => { }, } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -225,6 +246,7 @@ describe('AccountsServer', () => { invalidateSession, } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -249,6 +271,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -283,6 +306,7 @@ describe('AccountsServer', () => { resumeSessionValidator: () => Promise.resolve(user), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -312,6 +336,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(user), } as any, tokenSecret: 'secret', + tokenManager, resumeSessionValidator: () => Promise.resolve(user), }, {} @@ -342,6 +367,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(user), } as any, tokenSecret: 'secret', + tokenManager, resumeSessionValidator: () => Promise.resolve(user), }, {} @@ -370,6 +396,7 @@ describe('AccountsServer', () => { }), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -404,6 +431,7 @@ describe('AccountsServer', () => { updateSession: () => Promise.resolve(), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -426,6 +454,7 @@ describe('AccountsServer', () => { { db: db as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -452,6 +481,7 @@ describe('AccountsServer', () => { createSession: () => Promise.resolve('001'), } as any, tokenSecret: 'secret', + tokenManager, impersonationAuthorize: async (userObject, impersonateToUser) => { return userObject.id === user.id && impersonateToUser === impersonatedUser; }, @@ -499,6 +529,7 @@ describe('AccountsServer', () => { updateSession, } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -520,6 +551,7 @@ describe('AccountsServer', () => { { db: {} as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -535,6 +567,7 @@ describe('AccountsServer', () => { { db: {} as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -553,6 +586,7 @@ describe('AccountsServer', () => { findSessionByToken: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -575,6 +609,7 @@ describe('AccountsServer', () => { }), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -600,6 +635,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -619,6 +655,7 @@ describe('AccountsServer', () => { { db: {} as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -634,6 +671,7 @@ describe('AccountsServer', () => { { db: {} as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -651,6 +689,7 @@ describe('AccountsServer', () => { findSessionByToken: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -672,6 +711,7 @@ describe('AccountsServer', () => { }), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -692,6 +732,7 @@ describe('AccountsServer', () => { { db: { findUserById } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -715,6 +756,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -746,6 +788,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(user), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -771,6 +814,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(user), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -789,6 +833,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -817,6 +862,7 @@ describe('AccountsServer', () => { setProfile, } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -850,6 +896,7 @@ describe('AccountsServer', () => { setProfile, } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -872,6 +919,7 @@ describe('AccountsServer', () => { { db: db as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -888,6 +936,7 @@ describe('AccountsServer', () => { { db: {} as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -905,6 +954,7 @@ describe('AccountsServer', () => { { db: {} as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -931,6 +981,7 @@ describe('AccountsServer', () => { findUserById: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -960,6 +1011,7 @@ describe('AccountsServer', () => { findUserByUsername: () => Promise.resolve(null), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -987,6 +1039,7 @@ describe('AccountsServer', () => { findUserByUsername: () => Promise.resolve(someUser), } as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -1010,6 +1063,8 @@ describe('AccountsServer', () => { findUserByUsername: () => Promise.resolve(someUser), } as any, tokenSecret: 'secret', + tokenManager, + impersonationAuthorize: async (userObject, impersonateToUser) => { return userObject.id === user.id && impersonateToUser === impersonatedUser; }, @@ -1042,6 +1097,8 @@ describe('AccountsServer', () => { createSession, } as any, tokenSecret: 'secret', + tokenManager, + impersonationAuthorize: async (userObject, impersonateToUser) => { return userObject.id === user.id && impersonateToUser === impersonatedUser; }, @@ -1081,6 +1138,7 @@ describe('AccountsServer', () => { { db: db as any, tokenSecret: 'secret', + tokenManager, }, {} ); @@ -1093,6 +1151,8 @@ describe('AccountsServer', () => { { db: db as any, tokenSecret: 'secret', + tokenManager, + userObjectSanitizer: (user, omit) => omit(user, ['username']), }, {} diff --git a/packages/server/__tests__/utils/encryption.ts b/packages/server/__tests__/utils/encryption.ts deleted file mode 100644 index b365b7513..000000000 --- a/packages/server/__tests__/utils/encryption.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - bcryptPassword, - hashPassword, - verifyPassword, -} from '../../src/utils/encryption'; - -describe('bcryptPassword', () => { - it('hashes password using bcrypt', async () => { - const hash = await bcryptPassword('password'); - expect(hash).toBeTruthy(); - }); -}); - -describe('hashPassword', () => { - it('hashes password', async () => { - const hash = await hashPassword('password', 'sha256'); - expect(hash).toBe('5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8'); - }); -}); - -describe('verifyPassword', () => { - it('true if password matches', async () => { - const password = 'password'; - const hash = await bcryptPassword(password); - expect(await verifyPassword(password, hash)).toBe(true); - }); - it('false if password does not match', async () => { - const password = 'password'; - const wrongPassword = 'wrongPassword'; - const hash = await bcryptPassword(password); - expect(await verifyPassword(wrongPassword, hash)).toBe(false); - }); -}); diff --git a/packages/server/package.json b/packages/server/package.json index 3170dadf4..da6fada93 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -48,16 +48,12 @@ "dependencies": { "@accounts/common": "^0.1.0-beta.10", "@accounts/types": "^0.1.0-beta.10", + "@accounts/token-manager": "^0.1.0-beta.10", "babel-polyfill": "^6.23.0", - "bcryptjs": "^2.4.0", "emittery": "^0.3.0", - "jsonwebtoken": "^8.0.0", - "jwt-decode": "^2.1.0", "lodash": "^4.16.4" }, "devDependencies": { - "@types/jsonwebtoken": "7.2.6", - "@types/jwt-decode": "2.2.1", "rimraf": "2.6.2" } } diff --git a/packages/server/src/accounts-server.ts b/packages/server/src/accounts-server.ts index be86b7247..fd555639e 100644 --- a/packages/server/src/accounts-server.ts +++ b/packages/server/src/accounts-server.ts @@ -1,9 +1,9 @@ import * as pick from 'lodash/pick'; import * as omit from 'lodash/omit'; import * as isString from 'lodash/isString'; -import * as jwt from 'jsonwebtoken'; import * as Emittery from 'emittery'; import { AccountsError } from '@accounts/common'; +import TokenManager from '@accounts/token-manager'; import { User, LoginResult, @@ -17,8 +17,6 @@ import { TokenRecord, } from '@accounts/types'; -import { generateAccessToken, generateRefreshToken, generateRandomToken } from './utils/tokens'; - import { emailTemplates, sendMail } from './utils/email'; import { ServerHooks } from './utils/server-hooks'; @@ -27,15 +25,6 @@ import { JwtData } from './types/jwt-data'; import { EmailTemplateType } from './types/email-template-type'; const defaultOptions = { - tokenSecret: 'secret', - tokenConfigs: { - accessToken: { - expiresIn: '90m', - }, - refreshToken: { - expiresIn: '7d', - }, - }, emailTemplates, userObjectSanitizer: (user: User) => user, sendMail, @@ -44,6 +33,7 @@ const defaultOptions = { export class AccountsServer { public options: AccountsServerOptions; + public tokenManager: TokenManager; private services: { [key: string]: AuthenticationService }; private db: DatabaseInterface; private hooks: Emittery; @@ -53,10 +43,14 @@ export class AccountsServer { if (!this.options.db) { throw new AccountsError('A database driver is required'); } + if (!this.options.tokenManager) { + throw new AccountsError('A tokenManager is required'); + } // TODO if this.options.tokenSecret === 'secret' warm user to change it this.services = services; this.db = this.options.db; + this.tokenManager = this.options.tokenManager; // Set the db to all services // tslint:disable-next-line @@ -120,7 +114,7 @@ export class AccountsServer { const { ip, userAgent } = infos; try { - const token = generateRandomToken(); + const token = this.tokenManager.generateRandomToken(); const sessionId = await this.db.createSession(user.id, token, { ip, userAgent, @@ -168,7 +162,7 @@ export class AccountsServer { } try { - jwt.verify(accessToken, this.options.tokenSecret); + this.tokenManager.decodeToken(accessToken); } catch (err) { throw new AccountsError('Access token is not valid'); } @@ -208,7 +202,7 @@ export class AccountsServer { return { authorized: false }; } - const token = generateRandomToken(); + const token = this.tokenManager.generateRandomToken(); const newSessionId = await this.db.createSession( impersonatedUser.id, token, @@ -259,10 +253,11 @@ export class AccountsServer { let sessionToken: string; try { - jwt.verify(refreshToken, this.options.tokenSecret); - const decodedAccessToken = jwt.verify(accessToken, this.options.tokenSecret, { - ignoreExpiration: true, - }) as { data: JwtData }; + this.tokenManager.decodeToken(refreshToken); + const decodedAccessToken: { data: JwtData } = this.tokenManager.decodeToken( + accessToken, + true + ); sessionToken = decodedAccessToken.data.token; } catch (err) { throw new AccountsError('Tokens are not valid'); @@ -309,20 +304,9 @@ export class AccountsServer { * @returns {Promise} - Return a new accessToken and refreshToken. */ public createTokens(token: string, isImpersonated: boolean = false): Tokens { - const { tokenSecret, tokenConfigs } = this.options; - const jwtData: JwtData = { - token, - isImpersonated, - }; - const accessToken = generateAccessToken({ - data: jwtData, - secret: tokenSecret, - config: tokenConfigs.accessToken || {}, - }); - const refreshToken = generateRefreshToken({ - secret: tokenSecret, - config: tokenConfigs.refreshToken || {}, - }); + const jwtData: JwtData = { token, isImpersonated }; + const accessToken = this.tokenManager.generateAccessToken(jwtData); + const refreshToken = this.tokenManager.generateRefreshToken(); return { accessToken, refreshToken }; } @@ -409,9 +393,7 @@ export class AccountsServer { let sessionToken: string; try { - const decodedAccessToken = jwt.verify(accessToken, this.options.tokenSecret) as { - data: JwtData; - }; + const decodedAccessToken: { data: JwtData } = this.tokenManager.decodeToken(accessToken); sessionToken = decodedAccessToken.data.token; } catch (err) { throw new AccountsError('Tokens are not valid'); @@ -463,10 +445,6 @@ export class AccountsServer { return this.db.setProfile(userId, { ...user.profile, ...profile }); } - public isTokenExpired(token: string, tokenRecord?: TokenRecord): boolean { - return !tokenRecord || Number(tokenRecord.when) + this.options.emailTokensExpiry < Date.now(); - } - public prepareMail( to: string, token: string, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 06c379719..48344b77a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,14 +1,10 @@ import { AccountsServer } from './accounts-server'; -import * as encryption from './utils/encryption'; -import { generateRandomToken } from './utils/tokens'; import { getFirstUserEmail } from './utils/get-first-user-email'; import { ServerHooks } from './utils/server-hooks'; export default AccountsServer; export { AccountsServer, - encryption, ServerHooks, - generateRandomToken, getFirstUserEmail }; diff --git a/packages/server/src/types/accounts-server-options.ts b/packages/server/src/types/accounts-server-options.ts index dd1b5678b..cabab3a0f 100644 --- a/packages/server/src/types/accounts-server-options.ts +++ b/packages/server/src/types/accounts-server-options.ts @@ -1,3 +1,4 @@ +import TokenManager from '@accounts/token-manager'; import { User, DatabaseInterface } from '@accounts/types'; import { EmailTemplateType } from './email-template-type'; import { EmailTemplatesType } from './email-templates-type'; @@ -8,22 +9,10 @@ import { SendMailType } from './send-mail-type'; export interface AccountsServerOptions { db: DatabaseInterface; - tokenSecret: string; - tokenConfigs?: { - accessToken?: { - expiresIn?: string; - }; - refreshToken?: { - expiresIn?: string; - }; - }; - emailTokensExpiry?: number; + tokenManager: TokenManager; emailTemplates?: EmailTemplatesType; userObjectSanitizer?: UserObjectSanitizerFunction; - impersonationAuthorize?: ( - user: User, - impersonateToUser: User - ) => Promise; + impersonationAuthorize?: (user: User, impersonateToUser: User) => Promise; resumeSessionValidator?: ResumeSessionValidator; siteUrl?: string; prepareMail?: PrepareMailFunction; diff --git a/packages/server/src/utils/encryption.ts b/packages/server/src/utils/encryption.ts deleted file mode 100644 index 6353eb94e..000000000 --- a/packages/server/src/utils/encryption.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as bcrypt from 'bcryptjs'; -import { createHash } from 'crypto'; -import * as isString from 'lodash/isString'; - -const bcryptPassword = async password => { - const salt = await bcrypt.genSalt(10); - const hash = await bcrypt.hash(password, salt); - return hash; -}; - -const hashPassword = (password, algorithm) => { - if (isString(password)) { - const hash = createHash(algorithm); - hash.update(password); - return hash.digest('hex'); - } - - return password.digest; -}; - -const verifyPassword = async (password, hash) => bcrypt.compare(password, hash); - -export { bcryptPassword, hashPassword, verifyPassword }; diff --git a/packages/server/src/utils/tokens.ts b/packages/server/src/utils/tokens.ts deleted file mode 100644 index 3754536c7..000000000 --- a/packages/server/src/utils/tokens.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as jwt from 'jsonwebtoken'; -import { randomBytes } from 'crypto'; - -/** - * Generate a random token string - */ -export const generateRandomToken = (length: number = 43): string => - randomBytes(length).toString('hex'); - -export const generateAccessToken = ({ - secret, - data, - config, -}: { - secret: string; - data?: any; - config: object; -}) => - jwt.sign( - { - data, - }, - secret, - config - ); - -export const generateRefreshToken = ({ - secret, - data, - config, -}: { - secret: string; - data?: any; - config: object; -}) => - jwt.sign( - { - data, - }, - secret, - config - ); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 7a3de3c2a..f34cff118 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -3,13 +3,7 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./lib", - "typeRoots": [ - "node_modules/@types" - ] + "typeRoots": ["node_modules/@types"] }, - "exclude": [ - "node_modules", - "__tests__", - "lib" - ] + "exclude": ["node_modules", "__tests__", "lib"] } diff --git a/packages/token-manager/__tests__/index.ts b/packages/token-manager/__tests__/index.ts new file mode 100644 index 000000000..4f146aaad --- /dev/null +++ b/packages/token-manager/__tests__/index.ts @@ -0,0 +1,7 @@ +import TokenManager from '../src'; + +describe('TokenManager entry', () => { + it('should have default export TokenManager', () => { + expect(typeof TokenManager).toBe('function'); + }); +}); diff --git a/packages/token-manager/__tests__/token-manager.ts b/packages/token-manager/__tests__/token-manager.ts new file mode 100644 index 000000000..9b5beb6ae --- /dev/null +++ b/packages/token-manager/__tests__/token-manager.ts @@ -0,0 +1,79 @@ +import TokenManager from '../src'; + +const TM = new TokenManager({ + secret: 'test', + emailTokenExpiration: 60_000, +}); + +describe('TokenManager', () => { + describe('validateConfiguration', () => { + it('should throw if no configuration provided', () => { + expect(() => new TokenManager()).toThrow(); + }); + + it('should throw if configuration does not provide secret property', () => { + expect(() => new TokenManager({})).toThrow(); + }); + }); + + describe('generateRandomToken', () => { + it('should return a 86 char (43 bytes to hex) long random string when no parameters provided', () => { + expect(typeof TM.generateRandomToken()).toBe('string'); + expect(TM.generateRandomToken().length).toBe(86); + }); + + it('should return random string with the first parameter as length', () => { + expect(typeof TM.generateRandomToken(10)).toBe('string'); + expect(TM.generateRandomToken(10).length).toBe(20); + }); + }); + + describe('generateAccessToken', () => { + it('should return a string when first parameter provided', () => { + expect(typeof TM.generateAccessToken({ sessionId: 'test' })).toBe('string'); + }); + }); + + describe('generateRefreshToken', () => { + it('should return a string', () => { + expect(typeof TM.generateRefreshToken()).toBe('string'); + expect(typeof TM.generateRefreshToken({ sessionId: 'test' })).toBe('string'); + }); + }); + + describe('isEmailTokenExpired', () => { + it('should return true if the token provided is expired', () => { + const token = ''; + const tokenRecord = { when: 0 }; + expect(TM.isEmailTokenExpired(token, tokenRecord)).toBe(true); + }); + + it('should return false if the token provided is not expired', () => { + const token = ''; + const tokenRecord = { when: Date.now() + 100_000 }; + expect(TM.isEmailTokenExpired(token, tokenRecord)).toBe(false); + }); + }); + + describe('decodeToken', () => { + const TMdecode = new TokenManager({ + secret: 'test', + access: { + expiresIn: '0s', + }, + }); + + it('should not ignore expiration by default', () => { + const tokenData = { user: 'test' }; + const token = TMdecode.generateAccessToken(tokenData); + expect(() => { + TMdecode.decodeToken(token); + }).toThrow(); + }); + it('should return the decoded token anyway when ignoreExpiration is true', () => { + const tokenData = { user: 'test' }; + const token = TMdecode.generateAccessToken(tokenData); + expect(TMdecode.decodeToken(token, true).data.user).toBe('test'); + }); + }); +}); diff --git a/packages/token-manager/package.json b/packages/token-manager/package.json new file mode 100644 index 000000000..95f7c0b30 --- /dev/null +++ b/packages/token-manager/package.json @@ -0,0 +1,38 @@ +{ + "name": "@accounts/token-manager", + "version": "0.1.0-beta.10", + "license": "MIT", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "scripts": { + "clean": "rimraf lib", + "start": "tsc --watch", + "precompile": "npm run clean", + "compile": "tsc", + "prepublishOnly": "npm run compile", + "test": "npm run testonly", + "test-ci": "npm lint && npm coverage", + "testonly": "jest", + "test:watch": "jest --watch", + "coverage": "npm run testonly -- --coverage" + }, + "jest": { + "transform": { + ".(ts|tsx)": "/../../node_modules/ts-jest/preprocessor.js" + }, + "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx)$", + "moduleFileExtensions": ["ts", "js"] + }, + "dependencies": { + "bcryptjs": "^2.4.0", + "jsonwebtoken": "^8.0.0", + "jwt-decode": "^2.1.0", + "@accounts/types": "^0.1.0-beta.10", + "@accounts/error": "^0.1.0-beta.10" + }, + "devDependencies": { + "@types/jsonwebtoken": "7.2.5", + "@types/jwt-decode": "2.2.1", + "rimraf": "2.6.2" + } +} diff --git a/packages/token-manager/src/index.ts b/packages/token-manager/src/index.ts new file mode 100644 index 000000000..aa5953763 --- /dev/null +++ b/packages/token-manager/src/index.ts @@ -0,0 +1 @@ +export { default } from './token-manager'; \ No newline at end of file diff --git a/packages/token-manager/src/token-manager.ts b/packages/token-manager/src/token-manager.ts new file mode 100644 index 000000000..712512dc2 --- /dev/null +++ b/packages/token-manager/src/token-manager.ts @@ -0,0 +1,68 @@ +import { TokenRecord } from '@accounts/types'; +import AccountsError from '@accounts/error'; + +import { randomBytes } from 'crypto'; +import * as jwt from 'jsonwebtoken'; + +import { Configuration } from './types/configuration'; +import { TokenGenerationConfiguration } from './types/token-generation-configuration'; + +const defaultTokenConfig: TokenGenerationConfiguration = { + algorithm: 'HS256', +}; + +const defaultAccessTokenConfig: TokenGenerationConfiguration = { + expiresIn: '90m', +}; + +const defaultRefreshTokenConfig: TokenGenerationConfiguration = { + expiresIn: '7d', +}; + +export default class TokenManager { + + private secret: string; + + private emailTokenExpiration: number; + + private accessTokenConfig: TokenGenerationConfiguration; + + private refreshTokenConfig: TokenGenerationConfiguration; + + constructor(config: Configuration) { + this.validateConfiguration(config); + this.secret = config.secret; + this.emailTokenExpiration = config.emailTokenExpiration || 1000 * 60; + this.accessTokenConfig = { ...defaultTokenConfig, ...defaultAccessTokenConfig, ...config.access }; + this.refreshTokenConfig = { ...defaultTokenConfig, ...defaultRefreshTokenConfig, ...config.refresh }; + } + + public generateRandomToken(length?: number | undefined): string { + return randomBytes(length || 43).toString('hex'); + } + + public generateAccessToken(data): string { + return jwt.sign({ data }, this.secret, this.accessTokenConfig); + } + + public generateRefreshToken(data = {}): string { + return jwt.sign({ data }, this.secret, this.refreshTokenConfig); + } + + public isEmailTokenExpired(token: string, tokenRecord?: TokenRecord): boolean { + return !tokenRecord || Number(tokenRecord.when) + this.emailTokenExpiration < Date.now(); + } + + public decodeToken(token: string, ignoreExpiration: boolean = false): any { + return jwt.verify(token, this.secret, { ignoreExpiration }); + } + + private validateConfiguration(config: Configuration): void { + if (!config) { + throw new AccountsError('TokenManager', 'configuration', 'A configuration object is needed'); + } + if (typeof config.secret !== 'string') { + throw new AccountsError('TokenManager', 'configuration', 'A string secret property is needed'); + } + } +} diff --git a/packages/token-manager/src/types/configuration.ts b/packages/token-manager/src/types/configuration.ts new file mode 100644 index 000000000..d0ee344e4 --- /dev/null +++ b/packages/token-manager/src/types/configuration.ts @@ -0,0 +1,13 @@ +import { TokenGenerationConfiguration } from './token-generation-configuration'; + +export interface Configuration { + + secret: string; + + emailTokenExpiration?: number; + + access?: TokenGenerationConfiguration; + + refresh?: TokenGenerationConfiguration; + +} diff --git a/packages/token-manager/src/types/token-generation-configuration.ts b/packages/token-manager/src/types/token-generation-configuration.ts new file mode 100644 index 000000000..3edabcba1 --- /dev/null +++ b/packages/token-manager/src/types/token-generation-configuration.ts @@ -0,0 +1,25 @@ +export interface TokenGenerationConfiguration { + + algorithm?: string; + + expiresIn?: string; + + // TODO : explore jwt configuration + /* + + notBefore?: string; + + audience?: string | string[] | RegExp | RegExp[]; + + To complete + + jwtid: + + subject:null, + + noTimestamp:null, + + header:null, + + keyid:null,*/ +} diff --git a/packages/token-manager/tsconfig.json b/packages/token-manager/tsconfig.json new file mode 100644 index 000000000..f34cff118 --- /dev/null +++ b/packages/token-manager/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "typeRoots": ["node_modules/@types"] + }, + "exclude": ["node_modules", "__tests__", "lib"] +} diff --git a/packages/token-manager/yarn.lock b/packages/token-manager/yarn.lock new file mode 100644 index 000000000..515f6b049 --- /dev/null +++ b/packages/token-manager/yarn.lock @@ -0,0 +1,179 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/jsonwebtoken@7.2.5": + version "7.2.5" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.5.tgz#413159d570ec45fc3e7da7ea3f7bca6fb9300e72" + dependencies: + "@types/node" "*" + +"@types/jwt-decode@2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2" + +"@types/node@*": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.1.tgz#e2d374ef15b315b48e7efc308fa1a7cd51faa06c" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + +base64url@2.0.0, base64url@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" + +bcryptjs@^2.4.0: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +ecdsa-sig-formatter@1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1" + dependencies: + base64url "^2.0.0" + safe-buffer "^5.0.1" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +glob@^7.0.5: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +jsonwebtoken@^8.0.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.2.0.tgz#690ec3a9e7e95e2884347ce3e9eb9d389aa598b3" + dependencies: + jws "^3.1.4" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + xtend "^4.0.1" + +jwa@^1.1.4: + version "1.1.5" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5" + dependencies: + base64url "2.0.0" + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.9" + safe-buffer "^5.0.1" + +jws@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2" + dependencies: + base64url "^2.0.0" + jwa "^1.1.4" + safe-buffer "^5.0.1" + +jwt-decode@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + dependencies: + brace-expansion "^1.1.7" + +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +rimraf@2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" + dependencies: + glob "^7.0.5" + +safe-buffer@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + +xtend@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"