@accounts/server
Version:
Fullstack authentication and accounts-management
685 lines (610 loc) • 21.2 kB
text/typescript
import merge from 'lodash.merge';
import * as jwt from 'jsonwebtoken';
import Emittery from 'emittery';
import {
User,
LoginResult,
Tokens,
Session,
ImpersonationUserIdentity,
ImpersonationResult,
HookListener,
DatabaseInterface,
AuthenticationService,
ConnectionInformations,
} from '@accounts/types';
import { generateAccessToken, generateRefreshToken, generateRandomToken } from './utils/tokens';
import { emailTemplates, sendMail } from './utils/email';
import { ServerHooks } from './utils/server-hooks';
import { AccountsServerOptions } from './types/accounts-server-options';
import { JwtData } from './types/jwt-data';
import { EmailTemplateType } from './types/email-template-type';
import { JwtPayload } from './types/jwt-payload';
import { AccountsJsError } from './utils/accounts-error';
import {
AuthenticateWithServiceErrors,
LoginWithServiceErrors,
ImpersonateErrors,
FindSessionByAccessTokenErrors,
RefreshTokensErrors,
LogoutErrors,
ResumeSessionErrors,
} from './errors';
import { isString } from './utils/validation';
const defaultOptions = {
ambiguousErrorMessages: true,
tokenSecret: 'secret' as
| string
| {
publicKey: jwt.Secret;
privateKey: jwt.Secret;
},
tokenConfigs: {
accessToken: {
expiresIn: '90m',
},
refreshToken: {
expiresIn: '7d',
},
},
emailTemplates,
sendMail,
siteUrl: 'http://localhost:3000',
userObjectSanitizer: (user: User) => user,
createNewSessionTokenOnRefresh: false,
useInternalUserObjectSanitizer: true,
useStatelessSession: false,
};
export class AccountsServer<CustomUser extends User = User> {
public options: AccountsServerOptions<CustomUser> & typeof defaultOptions;
private services: { [key: string]: AuthenticationService<CustomUser> };
private db: DatabaseInterface<CustomUser>;
private hooks: Emittery;
constructor(
options: AccountsServerOptions<CustomUser>,
services: { [key: string]: AuthenticationService<CustomUser> }
) {
this.options = merge({ ...defaultOptions }, options);
if (!this.options.db) {
throw new Error('A database driver is required');
}
if (this.options.tokenSecret === defaultOptions.tokenSecret) {
console.log(`
You are using the default secret "${this.options.tokenSecret}" which is not secure.
Please change it with a strong random token.`);
}
if (this.options.ambiguousErrorMessages && this.options.enableAutologin) {
throw new Error(
`Can't enable autologin when ambiguous error messages are enabled (https://www.accountsjs.com/docs/api/server/globals#ambiguouserrormessages).
Please set ambiguousErrorMessages to false to be able to use autologin.`
);
}
this.services = services || {};
this.db = this.options.db;
// Set the db to all services
for (const service in this.services) {
this.services[service].setStore(this.db);
this.services[service].server = this;
}
// Initialize hooks
this.hooks = new Emittery();
}
public getServices(): { [key: string]: AuthenticationService } {
return this.services;
}
public getOptions(): AccountsServerOptions<CustomUser> {
return this.options;
}
public getHooks(): Emittery {
return this.hooks;
}
/**
* Subscribe to an accounts-js event.
* ```javascript
* accountsServer.on(ServerHooks.ValidateLogin, ({ user }) => {
* // This hook is called every time a user try to login
* // You can use it to only allow users with verified email to login
* });
* ```
*/
public on(eventName: string, callback: HookListener): () => void {
this.hooks.on(eventName, callback);
return () => this.hooks.off(eventName, callback);
}
/**
* @description Try to authenticate the user for a given service
* @throws {@link AuthenticateWithServiceErrors}
*/
public async authenticateWithService(
serviceName: string,
params: any,
infos: ConnectionInformations
): Promise<boolean> {
const hooksInfo: any = {
// The service name, such as “password” or “twitter”.
service: serviceName,
// The connection informations <ConnectionInformations>
connection: infos,
// Params received
params,
};
try {
if (!this.services[serviceName]) {
throw new AccountsJsError(
`No service with the name ${serviceName} was registered.`,
AuthenticateWithServiceErrors.ServiceNotFound
);
}
const user: CustomUser | null = await this.services[serviceName].authenticate(params);
hooksInfo.user = user;
if (!user) {
throw new AccountsJsError(
`Service ${serviceName} was not able to authenticate user`,
AuthenticateWithServiceErrors.AuthenticationFailed
);
}
if (user.deactivated) {
throw new AccountsJsError(
'Your account has been deactivated',
AuthenticateWithServiceErrors.UserDeactivated
);
}
await this.hooks.emit(ServerHooks.AuthenticateSuccess, hooksInfo);
return true;
} catch (err) {
await this.hooks.emit(ServerHooks.AuthenticateError, { ...hooksInfo, error: err });
throw err;
}
}
/**
* @throws {@link LoginWithServiceErrors}
*/
public async loginWithService(
serviceName: string,
params: any,
infos: ConnectionInformations
): Promise<LoginResult> {
const hooksInfo: any = {
// The service name, such as “password” or “twitter”.
service: serviceName,
// The connection informations <ConnectionInformations>
connection: infos,
// Params received
params,
};
try {
if (!this.services[serviceName]) {
throw new AccountsJsError(
`No service with the name ${serviceName} was registered.`,
LoginWithServiceErrors.ServiceNotFound
);
}
const user: CustomUser | null = await this.services[serviceName].authenticate(params);
hooksInfo.user = user;
if (!user) {
throw new AccountsJsError(
`Service ${serviceName} was not able to authenticate user`,
LoginWithServiceErrors.AuthenticationFailed
);
}
if (user.deactivated) {
throw new AccountsJsError(
'Your account has been deactivated',
LoginWithServiceErrors.UserDeactivated
);
}
// Let the user validate the login attempt
await this.hooks.emitSerial(ServerHooks.ValidateLogin, hooksInfo);
const loginResult = await this.loginWithUser(user, infos);
await this.hooks.emit(ServerHooks.LoginSuccess, hooksInfo);
return loginResult;
} catch (err) {
await this.hooks.emit(ServerHooks.LoginError, { ...hooksInfo, error: err });
throw err;
}
}
/**
* @description Server use only.
* This method creates a session without authenticating any user identity.
* Any authentication should happen before calling this function.
* @param {User} user - The user object.
* @param {ConnectionInformations} infos - User's connection informations.
* @returns {Promise<LoginResult>} - Session tokens and user object.
*/
public async loginWithUser(
user: CustomUser,
infos: ConnectionInformations
): Promise<LoginResult> {
const token = await this.createSessionToken(user);
const sessionId = await this.db.createSession(user.id, token, infos);
const { accessToken, refreshToken } = await this.createTokens({
token,
user,
});
return {
sessionId,
tokens: {
refreshToken,
accessToken,
},
user,
};
}
/**
* @description Impersonate to another user.
* For security reasons, even if `useStatelessSession` is set to true the token will be checked against the database.
* @param {string} accessToken - User access token.
* @param {object} impersonated - impersonated user.
* @param {ConnectionInformations} infos - User connection informations.
* @returns {Promise<Object>} - ImpersonationResult
* @throws {@link LoginWithServiceErrors}
*/
public async impersonate(
accessToken: string,
impersonated: ImpersonationUserIdentity,
infos: ConnectionInformations
): Promise<ImpersonationResult> {
try {
const session = await this.findSessionByAccessToken(accessToken);
if (!session.valid) {
throw new AccountsJsError(
'Session is not valid for user',
ImpersonateErrors.InvalidSession
);
}
const user = await this.db.findUserById(session.userId);
if (!user) {
throw new AccountsJsError('User not found', ImpersonateErrors.UserNotFound);
}
let impersonatedUser;
if (impersonated.userId) {
impersonatedUser = await this.db.findUserById(impersonated.userId);
} else if (impersonated.username) {
impersonatedUser = await this.db.findUserByUsername(impersonated.username);
} else if (impersonated.email) {
impersonatedUser = await this.db.findUserByEmail(impersonated.email);
}
if (!impersonatedUser) {
if (this.options.ambiguousErrorMessages) {
return { authorized: false };
}
throw new AccountsJsError(
`Impersonated user not found`,
ImpersonateErrors.ImpersonatedUserNotFound
);
}
if (!this.options.impersonationAuthorize) {
return { authorized: false };
}
const isAuthorized = await this.options.impersonationAuthorize(user, impersonatedUser);
if (!isAuthorized) {
return { authorized: false };
}
const token = await this.createSessionToken(impersonatedUser);
const newSessionId = await this.db.createSession(impersonatedUser.id, token, infos, {
impersonatorUserId: user.id,
});
const impersonationTokens = await this.createTokens({
token,
isImpersonated: true,
user,
});
const impersonationResult = {
authorized: true,
tokens: impersonationTokens,
user: this.sanitizeUser(impersonatedUser),
};
await this.hooks.emit(ServerHooks.ImpersonationSuccess, {
user,
impersonationResult,
sessionId: newSessionId,
});
return impersonationResult;
} catch (e) {
await this.hooks.emit(ServerHooks.ImpersonationError, e);
throw e;
}
}
/**
* @description Refresh a user token.
* @param {string} accessToken - User access token.
* @param {string} refreshToken - User refresh token.
* @param {ConnectionInformations} infos - User connection informations.
* @returns {Promise<Object>} - LoginResult.
* @throws {@link RefreshTokensErrors}
*/
public async refreshTokens(
accessToken: string,
refreshToken: string,
infos: ConnectionInformations
): Promise<LoginResult> {
try {
if (!isString(accessToken) || !isString(refreshToken)) {
throw new AccountsJsError(
'An accessToken and refreshToken are required',
RefreshTokensErrors.InvalidTokens
);
}
let sessionToken: string;
try {
jwt.verify(refreshToken, this.getSecretOrPublicKey());
const decodedAccessToken = jwt.verify(accessToken, this.getSecretOrPublicKey(), {
ignoreExpiration: true,
}) as { data: JwtData };
sessionToken = decodedAccessToken.data.token;
} catch (err) {
throw new AccountsJsError(
'Tokens are not valid',
RefreshTokensErrors.TokenVerificationFailed
);
}
const session: Session | null = await this.db.findSessionByToken(sessionToken);
if (!session) {
throw new AccountsJsError('Session not found', RefreshTokensErrors.SessionNotFound);
}
if (session.valid) {
const user = await this.db.findUserById(session.userId);
if (!user) {
throw new AccountsJsError('User not found', RefreshTokensErrors.UserNotFound);
}
let newToken;
if (this.options.createNewSessionTokenOnRefresh) {
newToken = await this.createSessionToken(user);
}
const tokens = await this.createTokens({ token: newToken || sessionToken, user });
await this.db.updateSession(session.id, infos, newToken);
const result = {
sessionId: session.id,
tokens,
user,
infos,
};
await this.hooks.emit(ServerHooks.RefreshTokensSuccess, result);
return result;
} else {
throw new AccountsJsError('Session is no longer valid', RefreshTokensErrors.InvalidSession);
}
} catch (err) {
await this.hooks.emit(ServerHooks.RefreshTokensError, err);
throw err;
}
}
/**
* @description Refresh a user token.
* @param {string} token - User session token.
* @param {boolean} isImpersonated - Should be true if impersonating another user.
* @param {User} user - The user object.
* @returns {Promise<Tokens>} - Return a new accessToken and refreshToken.
*/
public async createTokens({
token,
isImpersonated = false,
user,
}: {
token: string;
isImpersonated?: boolean;
user: CustomUser;
}): Promise<Tokens> {
const { tokenConfigs } = this.options;
const jwtData: JwtData = {
token,
isImpersonated,
userId: user.id,
};
const accessToken = generateAccessToken({
payload: await this.createJwtPayload(jwtData, user),
secret: this.getSecretOrPrivateKey(),
config: tokenConfigs.accessToken,
});
const refreshToken = generateRefreshToken({
secret: this.getSecretOrPrivateKey(),
config: tokenConfigs.refreshToken,
});
return { accessToken, refreshToken };
}
/**
* @description Logout a user and invalidate his session.
* @param {string} accessToken - User access token.
* @returns {Promise<void>} - Return a promise.
* @throws {@link LogoutErrors}
*/
public async logout(accessToken: string): Promise<void> {
try {
const session: Session = await this.findSessionByAccessToken(accessToken);
if (session.valid) {
await this.db.invalidateSession(session.id);
await this.hooks.emit(ServerHooks.LogoutSuccess, {
session,
accessToken,
});
} else {
throw new AccountsJsError('Session is no longer valid', LogoutErrors.InvalidSession);
}
} catch (error) {
await this.hooks.emit(ServerHooks.LogoutError, error);
throw error;
}
}
/**
* @description Resume the current session associated to the access token. Will throw if the token
* or the session is invalid.
* If `useStatelessSession` is false the session validity will be checked against the database.
* @param accessToken - User JWT access token.
* @returns Return the user associated to the session.
* @throws {@link ResumeSessionErrors}
*/
public async resumeSession(accessToken: string): Promise<CustomUser> {
try {
if (!isString(accessToken)) {
throw new AccountsJsError('An accessToken is required', ResumeSessionErrors.InvalidToken);
}
let sessionToken: string;
let userId: string;
try {
const decodedAccessToken = jwt.verify(accessToken, this.getSecretOrPublicKey()) as {
data: JwtData;
};
sessionToken = decodedAccessToken.data.token;
userId = decodedAccessToken.data.userId;
} catch (err) {
throw new AccountsJsError(
'Tokens are not valid',
ResumeSessionErrors.TokenVerificationFailed
);
}
// If the session is stateful we check the validity of the token against the db
let session: Session | null = null;
if (!this.options.useStatelessSession) {
session = await this.db.findSessionByToken(sessionToken);
if (!session) {
throw new AccountsJsError('Session not found', ResumeSessionErrors.SessionNotFound);
}
if (!session.valid) {
throw new AccountsJsError('Invalid Session', ResumeSessionErrors.InvalidSession);
}
}
const user = await this.db.findUserById(userId);
if (!user) {
throw new AccountsJsError('User not found', ResumeSessionErrors.UserNotFound);
}
await this.options.resumeSessionValidator?.(user, session!);
await this.hooks.emit(ServerHooks.ResumeSessionSuccess, { user, accessToken, session });
return this.sanitizeUser(user);
} catch (error) {
await this.hooks.emit(ServerHooks.ResumeSessionError, error);
throw error;
}
}
/**
* @description Find a session by his token.
* @param {string} accessToken
* @returns {Promise<Session>} - Return a session.
* @throws {@link FindSessionByAccessTokenErrors}
*/
public async findSessionByAccessToken(accessToken: string): Promise<Session> {
if (!isString(accessToken)) {
throw new AccountsJsError(
'An accessToken is required',
FindSessionByAccessTokenErrors.InvalidToken
);
}
let sessionToken: string;
try {
const decodedAccessToken = jwt.verify(accessToken, this.getSecretOrPublicKey()) as {
data: JwtData;
};
sessionToken = decodedAccessToken.data.token;
} catch (err) {
throw new AccountsJsError(
'Tokens are not valid',
FindSessionByAccessTokenErrors.TokenVerificationFailed
);
}
const session: Session | null = await this.db.findSessionByToken(sessionToken);
if (!session) {
throw new AccountsJsError(
'Session not found',
FindSessionByAccessTokenErrors.SessionNotFound
);
}
return session;
}
/**
* @description Find a user by his id.
* @param {string} userId - User id.
* @returns {Promise<Object>} - Return a user or null if not found.
*/
public findUserById(userId: string): Promise<CustomUser | null> {
return this.db.findUserById(userId);
}
/**
* @description Deactivate a user, the user will not be able to login until his account is reactivated.
* @param {string} userId - User id.
* @returns {Promise<void>} - Return a Promise.
*/
public async deactivateUser(userId: string): Promise<void> {
return this.db.setUserDeactivated(userId, true);
}
/**
* @description Activate a user.
* @param {string} userId - User id.
* @returns {Promise<void>} - Return a Promise.
*/
public async activateUser(userId: string): Promise<void> {
return this.db.setUserDeactivated(userId, false);
}
public prepareMail(
to: string,
token: string,
user: CustomUser,
pathFragment: string,
emailTemplate: EmailTemplateType,
from: string
): any {
if (this.options.prepareMail) {
return this.options.prepareMail(to, token, user, pathFragment, emailTemplate, from);
}
return this.defaultPrepareEmail(to, token, user, pathFragment, emailTemplate, from);
}
public sanitizeUser(user: CustomUser): CustomUser {
const { userObjectSanitizer } = this.options;
const baseUser = this.options.useInternalUserObjectSanitizer
? this.internalUserSanitizer(user)
: user;
return userObjectSanitizer(baseUser) as CustomUser;
}
private internalUserSanitizer(user: CustomUser): CustomUser {
// Remove services from the user object
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
services,
...sanitizedUser
} = user;
return sanitizedUser as any;
}
private defaultPrepareEmail(
to: string,
token: string,
user: CustomUser,
pathFragment: string,
emailTemplate: EmailTemplateType,
from: string
): object {
const tokenizedUrl = this.defaultCreateTokenizedUrl(pathFragment, token);
return {
from: emailTemplate.from || from,
to,
subject: emailTemplate.subject(user),
text: emailTemplate.text(user, tokenizedUrl),
html: emailTemplate.html && emailTemplate.html(user, tokenizedUrl),
};
}
private defaultCreateTokenizedUrl(pathFragment: string, token: string): string {
const siteUrl = this.options.siteUrl;
return `${siteUrl}/${pathFragment}/${token}`;
}
private async createSessionToken(user: CustomUser): Promise<string> {
return this.options.tokenCreator
? this.options.tokenCreator.createToken(user)
: generateRandomToken();
}
private async createJwtPayload(data: JwtData, user: CustomUser): Promise<JwtPayload> {
return this.options.createJwtPayload
? {
...(await this.options.createJwtPayload(data, user)),
data,
}
: { data };
}
private getSecretOrPublicKey(): jwt.Secret {
return typeof this.options.tokenSecret === 'string'
? this.options.tokenSecret
: this.options.tokenSecret.publicKey;
}
private getSecretOrPrivateKey(): jwt.Secret {
return typeof this.options.tokenSecret === 'string'
? this.options.tokenSecret
: this.options.tokenSecret.privateKey;
}
}
export default AccountsServer;