Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jtoar/57ffa67a7ae0c8e7255fd5b6400f0fb0 to your computer and use it in GitHub Desktop.
Save jtoar/57ffa67a7ae0c8e7255fd5b6400f0fb0 to your computer and use it in GitHub Desktop.

ℹ️ Note

These instructions require yarn v3

These instructions will walk you through using the yarn patch command to manually apply a fix to the @redwoodjs/api package's DbAuthHandler.js file.

1. Run yarn patch on @redwoodjs/api

Navigate to your project and run yarn patch @redwoodjs/api. Yarn will log something like the following:

➤ YN0000: Package @redwoodjs/api@npm:x.x.x got extracted with success!
➤ YN0000: You can now edit the following folder: ...
➤ YN0000: Once you are done run yarn patch-commit -s ... and Yarn will store a patchfile based on your changes.
➤ YN0000: Done in 0s 46ms

We'll refer to the directory yarn logged (whatever follows "You can now edit the following folder") as PKG_DIR. Navigate there now.

2. Replace the contents of dist/functions/dbAuth/DbAuthHandler.js

In PKG_DIR, find the DbAuthHandler.js file (the full path is dist/functions/dbAuth/DbAuthHandler.js). Replace its contents with one of the attached files, depending on the version of Redwood you're on (v3 or v2), and save.

What are these files?

These files are the transpiled versions ("dist") of the @redwoodjs/api package's DbAuthHandler.ts file, v3.3.1 and v2.2.5 respectively. Besides changes made between v2 and v3, one of the main reasons that v2 looks different from v3 is visual changes in the way babel generates code. I.e., updates to @babel/generator.

3. Save the patch

Navigate back to your project. We're now going to save the patch. You can save the patch using:

yarn patch-commit -s PKG_DIR

Where PKG_DIR is the directory yarn logged in the first step. After you've done so, run yarn install. Lastly, make sure to commit all the changes. Your project should now be patched.

"use strict";
var _Object$defineProperty = require("@babel/runtime-corejs3/core-js/object/define-property");
var _interopRequireWildcard = require("@babel/runtime-corejs3/helpers/interopRequireWildcard").default;
var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault").default;
_Object$defineProperty(exports, "__esModule", {
value: true
});
exports.DbAuthHandler = void 0;
var _includes = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/includes"));
var _trim = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/trim"));
var _assign = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/object/assign"));
var _stringify = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/json/stringify"));
var _filter = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/filter"));
var _map = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/map"));
var _keys = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/object/keys"));
var _cryptoJs = _interopRequireDefault(require("crypto-js"));
var _md = _interopRequireDefault(require("md5"));
var _uuid = require("uuid");
var _cors = require("../../cors");
var _transforms = require("../../transforms");
var DbAuthError = _interopRequireWildcard(require("./errors"));
var _shared = require("./shared");
class DbAuthHandler {
// class constant: list of auth methods that are supported
static get METHODS() {
return ['forgotPassword', 'getToken', 'login', 'logout', 'resetPassword', 'signup', 'validateResetToken'];
} // class constant: maps the auth functions to their required HTTP verb for access
static get VERBS() {
return {
forgotPassword: 'POST',
getToken: 'GET',
login: 'POST',
logout: 'POST',
resetPassword: 'POST',
signup: 'POST',
validateResetToken: 'POST'
};
} // default to epoch when we want to expire
static get PAST_EXPIRES_DATE() {
return new Date('1970-01-01T00:00:00.000+00:00').toUTCString();
} // generate a new token (standard UUID)
static get CSRF_TOKEN() {
return (0, _uuid.v4)();
} // returns the Set-Cookie header to mark the cookie as expired ("deletes" the session)
get _deleteSessionHeader() {
return {
'Set-Cookie': ['session=', ...this._cookieAttributes({
expires: 'now'
})].join(';')
};
}
constructor(event, context, options) {
this.event = void 0;
this.context = void 0;
this.options = void 0;
this.cookie = void 0;
this.params = void 0;
this.db = void 0;
this.dbAccessor = void 0;
this.headerCsrfToken = void 0;
this.hasInvalidSession = void 0;
this.session = void 0;
this.sessionCsrfToken = void 0;
this.corsContext = void 0;
this.futureExpiresDate = void 0;
this.event = event;
this.context = context;
this.options = options;
this.cookie = (0, _shared.extractCookie)(this.event);
this._validateOptions();
this.params = this._parseBody();
this.db = this.options.db;
this.dbAccessor = this.db[this.options.authModelAccessor];
this.headerCsrfToken = this.event.headers['csrf-token'];
this.hasInvalidSession = false;
const futureDate = new Date();
futureDate.setSeconds(futureDate.getSeconds() + this.options.login.expires);
this.futureExpiresDate = futureDate.toUTCString(); // Note that we handle these headers differently in functions/graphql.ts
// because it's handled by graphql-yoga, so we map the cors config to yoga config
// See packages/graphql-server/src/__tests__/mapRwCorsToYoga.test.ts
if (options.cors) {
this.corsContext = (0, _cors.createCorsContext)(options.cors);
}
try {
const [session, csrfToken] = (0, _shared.decryptSession)((0, _shared.getSession)(this.cookie));
this.session = session;
this.sessionCsrfToken = csrfToken;
} catch (e) {
// if session can't be decrypted, keep track so we can log them out when
// the auth method is called
if (e instanceof DbAuthError.SessionDecryptionError) {
this.hasInvalidSession = true;
} else {
throw e;
}
}
} // Actual function that triggers everything else to happen: `login`, `signup`,
// etc. is called from here, after some checks to make sure the request is good
async invoke() {
const request = (0, _transforms.normalizeRequest)(this.event);
let corsHeaders = {};
if (this.corsContext) {
corsHeaders = this.corsContext.getRequestHeaders(request); // Return CORS headers for OPTIONS requests
if (this.corsContext.shouldHandleCors(request)) {
return this._buildResponseWithCorsHeaders({
body: '',
statusCode: 200
}, corsHeaders);
}
} // if there was a problem decryption the session, just return the logout
// response immediately
if (this.hasInvalidSession) {
return this._buildResponseWithCorsHeaders(this._ok(...this._logoutResponse()), corsHeaders);
}
try {
var _context;
const method = this._getAuthMethod(); // get the auth method the incoming request is trying to call
if (!(0, _includes.default)(_context = DbAuthHandler.METHODS).call(_context, method)) {
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders);
} // make sure it's using the correct verb, GET vs POST
if (this.event.httpMethod !== DbAuthHandler.VERBS[method]) {
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders);
} // call whatever auth method was requested and return the body and headers
const [body, headers, options = {
statusCode: 200
}] = await this[method]();
return this._buildResponseWithCorsHeaders(this._ok(body, headers, options), corsHeaders);
} catch (e) {
if (e instanceof DbAuthError.WrongVerbError) {
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders);
} else {
return this._buildResponseWithCorsHeaders(this._badRequest(e.message || e), corsHeaders);
}
}
}
async forgotPassword() {
const {
username
} = this.params; // was the username sent in at all?
if (!username || (0, _trim.default)(username).call(username) === '') {
var _this$options$forgotP, _this$options$forgotP2;
throw new DbAuthError.UsernameRequiredError(((_this$options$forgotP = this.options.forgotPassword) === null || _this$options$forgotP === void 0 ? void 0 : (_this$options$forgotP2 = _this$options$forgotP.errors) === null || _this$options$forgotP2 === void 0 ? void 0 : _this$options$forgotP2.usernameRequired) || `Username is required`);
}
let user;
try {
user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.username]: username
}
});
} catch (e) {
console.log(e);
throw new DbAuthError.GenericError();
}
if (user) {
const tokenExpires = new Date();
tokenExpires.setSeconds(tokenExpires.getSeconds() + this.options.forgotPassword.expires); // generate a token
let token = (0, _md.default)((0, _uuid.v4)());
const buffer = new Buffer(token);
token = buffer.toString('base64').replace('=', '').substring(0, 16);
try {
// set token and expires time
user = await this.dbAccessor.update({
where: {
[this.options.authFields.id]: user[this.options.authFields.id]
},
data: {
[this.options.authFields.resetToken]: token,
[this.options.authFields.resetTokenExpiresAt]: tokenExpires
}
});
} catch (e) {
console.log(e);
throw new DbAuthError.GenericError();
} // call user-defined handler in their functions/auth.js
const response = await this.options.forgotPassword.handler(this._sanitizeUser(user)); // remove resetToken and resetTokenExpiresAt if in the body of the
// forgotPassword handler response
let responseObj = response;
if (typeof response === 'object') {
responseObj = (0, _assign.default)(response, {
[this.options.authFields.resetToken]: undefined,
[this.options.authFields.resetTokenExpiresAt]: undefined
});
}
return [response ? (0, _stringify.default)(responseObj) : '', { ...this._deleteSessionHeader
}];
} else {
var _this$options$forgotP3, _this$options$forgotP4;
throw new DbAuthError.UsernameNotFoundError(((_this$options$forgotP3 = this.options.forgotPassword) === null || _this$options$forgotP3 === void 0 ? void 0 : (_this$options$forgotP4 = _this$options$forgotP3.errors) === null || _this$options$forgotP4 === void 0 ? void 0 : _this$options$forgotP4.usernameNotFound) || `Username '${username} not found`);
}
}
async getToken() {
try {
const user = await this._getCurrentUser(); // need to return *something* for our existing Authorization header stuff
// to work, so return the user's ID in case we can use it for something
// in the future
return [user.id];
} catch (e) {
if (e instanceof DbAuthError.NotLoggedInError) {
return this._logoutResponse();
} else {
return this._logoutResponse({
error: e.message
});
}
}
}
async login() {
const {
username,
password
} = this.params;
const dbUser = await this._verifyUser(username, password);
const handlerUser = await this.options.login.handler(dbUser);
if (handlerUser == null || handlerUser[this.options.authFields.id] == null) {
throw new DbAuthError.NoUserIdError();
}
return this._loginResponse(handlerUser);
}
logout() {
return this._logoutResponse();
}
async resetPassword() {
var _context2, _context3;
const {
password,
resetToken
} = this.params; // is the resetToken present?
if (resetToken == null || (0, _trim.default)(_context2 = String(resetToken)).call(_context2) === '') {
var _this$options$resetPa, _this$options$resetPa2;
throw new DbAuthError.ResetTokenRequiredError((_this$options$resetPa = this.options.resetPassword) === null || _this$options$resetPa === void 0 ? void 0 : (_this$options$resetPa2 = _this$options$resetPa.errors) === null || _this$options$resetPa2 === void 0 ? void 0 : _this$options$resetPa2.resetTokenRequired);
} // is password present?
if (password == null || (0, _trim.default)(_context3 = String(password)).call(_context3) === '') {
throw new DbAuthError.PasswordRequiredError();
}
let user = await this._findUserByToken(resetToken);
const [hashedPassword] = this._hashPassword(password, user.salt);
if (!this.options.resetPassword.allowReusedPassword && user.hashedPassword === hashedPassword) {
var _this$options$resetPa3, _this$options$resetPa4;
throw new DbAuthError.ReusedPasswordError((_this$options$resetPa3 = this.options.resetPassword) === null || _this$options$resetPa3 === void 0 ? void 0 : (_this$options$resetPa4 = _this$options$resetPa3.errors) === null || _this$options$resetPa4 === void 0 ? void 0 : _this$options$resetPa4.reusedPassword);
}
try {
// if we got here then we can update the password in the database
user = await this.dbAccessor.update({
where: {
[this.options.authFields.id]: user[this.options.authFields.id]
},
data: {
[this.options.authFields.hashedPassword]: hashedPassword
}
});
} catch (e) {
console.log(e);
throw new DbAuthError.GenericError();
}
await this._clearResetToken(user); // call the user-defined handler so they can decide what to do with this user
const response = await this.options.resetPassword.handler(this._sanitizeUser(user)); // returning the user from the handler means to log them in automatically
if (response) {
return this._loginResponse(user);
} else {
return this._logoutResponse({});
}
}
async signup() {
const userOrMessage = await this._createUser(); // at this point `user` is either an actual user, in which case log the
// user in automatically, or it's a string, which is a message to show
// the user (something like "please verify your email")
if (typeof userOrMessage === 'object') {
const user = userOrMessage;
return this._loginResponse(user, 201);
} else {
const message = userOrMessage;
return [(0, _stringify.default)({
message
}), {}, {
statusCode: 201
}];
}
}
async validateResetToken() {
var _context4;
// is token present at all?
if (this.params.resetToken == null || (0, _trim.default)(_context4 = String(this.params.resetToken)).call(_context4) === '') {
var _this$options$resetPa5, _this$options$resetPa6;
throw new DbAuthError.ResetTokenRequiredError((_this$options$resetPa5 = this.options.resetPassword) === null || _this$options$resetPa5 === void 0 ? void 0 : (_this$options$resetPa6 = _this$options$resetPa5.errors) === null || _this$options$resetPa6 === void 0 ? void 0 : _this$options$resetPa6.resetTokenRequired);
}
const user = await this._findUserByToken(this.params.resetToken);
return [(0, _stringify.default)(this._sanitizeUser(user)), { ...this._deleteSessionHeader
}];
} // validates that we have all the ENV and options we need to login/signup
_validateOptions() {
var _this$options, _this$options$login, _this$options2, _this$options2$login, _this$options3, _this$options3$signup, _this$options4, _this$options4$forgot, _this$options5, _this$options5$resetP;
// must have a SESSION_SECRET so we can encrypt/decrypt the cookie
if (!process.env.SESSION_SECRET) {
throw new DbAuthError.NoSessionSecretError();
} // must have an expiration time set for the session cookie
if (!((_this$options = this.options) !== null && _this$options !== void 0 && (_this$options$login = _this$options.login) !== null && _this$options$login !== void 0 && _this$options$login.expires)) {
throw new DbAuthError.NoSessionExpirationError();
} // must have a login handler to actually log a user in
if (!((_this$options2 = this.options) !== null && _this$options2 !== void 0 && (_this$options2$login = _this$options2.login) !== null && _this$options2$login !== void 0 && _this$options2$login.handler)) {
throw new DbAuthError.NoLoginHandlerError();
} // must have a signup handler to define how to create a new user
if (!((_this$options3 = this.options) !== null && _this$options3 !== void 0 && (_this$options3$signup = _this$options3.signup) !== null && _this$options3$signup !== void 0 && _this$options3$signup.handler)) {
throw new DbAuthError.NoSignupHandlerError();
} // must have a forgot password handler to define how to notify user of reset token
if (!((_this$options4 = this.options) !== null && _this$options4 !== void 0 && (_this$options4$forgot = _this$options4.forgotPassword) !== null && _this$options4$forgot !== void 0 && _this$options4$forgot.handler)) {
throw new DbAuthError.NoForgotPasswordHandlerError();
} // must have a reset password handler to define what to do with user once password changed
if (!((_this$options5 = this.options) !== null && _this$options5 !== void 0 && (_this$options5$resetP = _this$options5.resetPassword) !== null && _this$options5$resetP !== void 0 && _this$options5$resetP.handler)) {
throw new DbAuthError.NoResetPasswordHandlerError();
}
} // removes sensative fields from user before sending over the wire
_sanitizeUser(user) {
const sanitized = JSON.parse((0, _stringify.default)(user));
delete sanitized[this.options.authFields.hashedPassword];
delete sanitized[this.options.authFields.salt];
return sanitized;
} // parses the event body into JSON, whether it's base64 encoded or not
_parseBody() {
if (this.event.body) {
if (this.event.isBase64Encoded) {
return JSON.parse(Buffer.from(this.event.body || '', 'base64').toString('utf-8'));
} else {
return JSON.parse(this.event.body);
}
} else {
return {};
}
} // returns all the cookie attributes in an array with the proper expiration date
//
// pass the argument `expires` set to "now" to get the attributes needed to expire
// the session, or "future" (or left out completely) to set to `futureExpiresDate`
_cookieAttributes({
expires = 'future'
}) {
var _context5, _context6;
const cookieOptions = this.options.cookie || {};
const meta = (0, _filter.default)(_context5 = (0, _map.default)(_context6 = (0, _keys.default)(cookieOptions)).call(_context6, key => {
const optionValue = cookieOptions[key]; // Convert the options to valid cookie string
if (optionValue === true) {
return key;
} else if (optionValue === false) {
return null;
} else {
return `${key}=${optionValue}`;
}
})).call(_context5, v => v);
const expiresAt = expires === 'now' ? DbAuthHandler.PAST_EXPIRES_DATE : this.futureExpiresDate;
meta.push(`Expires=${expiresAt}`);
return meta;
}
_encrypt(data) {
return _cryptoJs.default.AES.encrypt(data, process.env.SESSION_SECRET);
} // returns the Set-Cookie header to be returned in the request (effectively creates the session)
_createSessionHeader(data, csrfToken) {
const session = (0, _stringify.default)(data) + ';' + csrfToken;
const encrypted = this._encrypt(session);
const cookie = [`session=${encrypted.toString()}`, ...this._cookieAttributes({
expires: 'future'
})].join(';');
return {
'Set-Cookie': cookie
};
} // checks the CSRF token in the header against the CSRF token in the session and
// throw an error if they are not the same (not used yet)
_validateCsrf() {
if (this.sessionCsrfToken !== this.headerCsrfToken) {
throw new DbAuthError.CsrfTokenMismatchError();
}
return true;
}
async _findUserByToken(token) {
const tokenExpires = new Date();
tokenExpires.setSeconds(tokenExpires.getSeconds() - this.options.forgotPassword.expires);
const user = await this.dbAccessor.findFirst({
where: {
[this.options.authFields.resetToken]: token
}
}); // user not found with the given token
if (!user) {
var _this$options$resetPa7, _this$options$resetPa8;
throw new DbAuthError.ResetTokenInvalidError((_this$options$resetPa7 = this.options.resetPassword) === null || _this$options$resetPa7 === void 0 ? void 0 : (_this$options$resetPa8 = _this$options$resetPa7.errors) === null || _this$options$resetPa8 === void 0 ? void 0 : _this$options$resetPa8.resetTokenInvalid);
} // token has expired
if (user[this.options.authFields.resetTokenExpiresAt] < tokenExpires) {
var _this$options$resetPa9, _this$options$resetPa10;
await this._clearResetToken(user);
throw new DbAuthError.ResetTokenExpiredError((_this$options$resetPa9 = this.options.resetPassword) === null || _this$options$resetPa9 === void 0 ? void 0 : (_this$options$resetPa10 = _this$options$resetPa9.errors) === null || _this$options$resetPa10 === void 0 ? void 0 : _this$options$resetPa10.resetTokenExpired);
}
return user;
}
async _clearResetToken(user) {
try {
await this.dbAccessor.update({
where: {
[this.options.authFields.id]: user[this.options.authFields.id]
},
data: {
[this.options.authFields.resetToken]: null,
[this.options.authFields.resetTokenExpiresAt]: null
}
});
} catch (e) {
console.log(e);
throw new DbAuthError.GenericError();
}
} // verifies that a username and password are correct, and returns the user if so
async _verifyUser(username, password) {
var _context7, _context8;
// do we have all the query params we need to check the user?
if (!username || (0, _trim.default)(_context7 = username.toString()).call(_context7) === '' || !password || (0, _trim.default)(_context8 = password.toString()).call(_context8) === '') {
var _this$options$login2, _this$options$login2$;
throw new DbAuthError.UsernameAndPasswordRequiredError((_this$options$login2 = this.options.login) === null || _this$options$login2 === void 0 ? void 0 : (_this$options$login2$ = _this$options$login2.errors) === null || _this$options$login2$ === void 0 ? void 0 : _this$options$login2$.usernameOrPasswordMissing);
}
let user;
try {
// does user exist?
user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.username]: username
}
});
} catch (e) {
console.log(e);
throw new DbAuthError.GenericError();
}
if (!user) {
var _this$options$login3, _this$options$login3$;
throw new DbAuthError.UserNotFoundError(username, (_this$options$login3 = this.options.login) === null || _this$options$login3 === void 0 ? void 0 : (_this$options$login3$ = _this$options$login3.errors) === null || _this$options$login3$ === void 0 ? void 0 : _this$options$login3$.usernameNotFound);
} // is password correct?
const [hashedPassword, _salt] = this._hashPassword(password, user[this.options.authFields.salt]);
if (hashedPassword === user[this.options.authFields.hashedPassword]) {
return user;
} else {
var _this$options$login4, _this$options$login4$;
throw new DbAuthError.IncorrectPasswordError(username, (_this$options$login4 = this.options.login) === null || _this$options$login4 === void 0 ? void 0 : (_this$options$login4$ = _this$options$login4.errors) === null || _this$options$login4$ === void 0 ? void 0 : _this$options$login4$.incorrectPassword);
}
} // gets the user from the database and returns only its ID
async _getCurrentUser() {
var _this$session;
if (!((_this$session = this.session) !== null && _this$session !== void 0 && _this$session.id)) {
throw new DbAuthError.NotLoggedInError();
}
let user;
try {
var _this$session2;
user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.id]: (_this$session2 = this.session) === null || _this$session2 === void 0 ? void 0 : _this$session2.id
},
select: {
[this.options.authFields.id]: true
}
});
} catch (e) {
console.log(e);
throw new DbAuthError.GenericError();
}
if (!user) {
throw new DbAuthError.UserNotFoundError();
}
return user;
} // creates and returns a user, first checking that the username/password
// values pass validation
async _createUser() {
const {
username,
password,
...userAttributes
} = this.params;
if (this._validateField('username', username) && this._validateField('password', password)) {
const user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.username]: username
}
});
if (user) {
var _this$options$signup, _this$options$signup$;
throw new DbAuthError.DuplicateUsernameError(username, (_this$options$signup = this.options.signup) === null || _this$options$signup === void 0 ? void 0 : (_this$options$signup$ = _this$options$signup.errors) === null || _this$options$signup$ === void 0 ? void 0 : _this$options$signup$.usernameTaken);
} // if we get here everything is good, call the app's signup handler and let
// them worry about scrubbing data and saving to the DB
const [hashedPassword, salt] = this._hashPassword(password);
const newUser = await this.options.signup.handler({
username,
hashedPassword,
salt,
userAttributes
});
return newUser;
}
} // hashes a password using either the given `salt` argument, or creates a new
// salt and hashes using that. Either way, returns an array with [hash, salt]
_hashPassword(text, salt) {
const useSalt = salt || _cryptoJs.default.lib.WordArray.random(128 / 8).toString();
return [_cryptoJs.default.PBKDF2(text, useSalt, {
keySize: 256 / 32
}).toString(), useSalt];
} // figure out which auth method we're trying to call
_getAuthMethod() {
var _this$event$queryStri, _context9;
// try getting it from the query string, /.redwood/functions/auth?method=[methodName]
let methodName = (_this$event$queryStri = this.event.queryStringParameters) === null || _this$event$queryStri === void 0 ? void 0 : _this$event$queryStri.method;
if (!(0, _includes.default)(_context9 = DbAuthHandler.METHODS).call(_context9, methodName) && this.params) {
// try getting it from the body in JSON: { method: [methodName] }
try {
methodName = this.params.method;
} catch (e) {// there's no body, or it's not JSON, `handler` will return a 404
}
}
return methodName;
} // checks that a single field meets validation requirements and
// currently checks for presense only
_validateField(name, value) {
// check for presense
if (!value || (0, _trim.default)(value).call(value) === '') {
var _this$options$signup2, _this$options$signup3;
throw new DbAuthError.FieldRequiredError(name, (_this$options$signup2 = this.options.signup) === null || _this$options$signup2 === void 0 ? void 0 : (_this$options$signup3 = _this$options$signup2.errors) === null || _this$options$signup3 === void 0 ? void 0 : _this$options$signup3.fieldMissing);
} else {
return true;
}
}
_loginResponse(user, statusCode = 200) {
const sessionData = {
id: user[this.options.authFields.id]
}; // TODO: this needs to go into graphql somewhere so that each request makes
// a new CSRF token and sets it in both the encrypted session and the
// csrf-token header
const csrfToken = DbAuthHandler.CSRF_TOKEN;
return [sessionData, {
'csrf-token': csrfToken,
...this._createSessionHeader(sessionData, csrfToken)
}, {
statusCode
}];
}
_logoutResponse(response) {
return [response ? (0, _stringify.default)(response) : '', { ...this._deleteSessionHeader
}];
}
_ok(body, headers = {}, options = {
statusCode: 200
}) {
return {
statusCode: options.statusCode,
body: typeof body === 'string' ? body : (0, _stringify.default)(body),
headers: {
'Content-Type': 'application/json',
...headers
}
};
}
_notFound() {
return {
statusCode: 404
};
}
_badRequest(message) {
return {
statusCode: 400,
body: (0, _stringify.default)({
error: message
}),
headers: {
'Content-Type': 'application/json'
}
};
}
_buildResponseWithCorsHeaders(response, corsHeaders) {
return { ...response,
headers: { ...(response.headers || {}),
...corsHeaders
}
};
}
}
exports.DbAuthHandler = DbAuthHandler;
"use strict";
var _Object$defineProperty = require("@babel/runtime-corejs3/core-js/object/define-property");
var _interopRequireWildcard = require("@babel/runtime-corejs3/helpers/interopRequireWildcard").default;
var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault").default;
_Object$defineProperty(exports, "__esModule", {
value: true
});
exports.DbAuthHandler = void 0;
require("core-js/modules/es.array.push.js");
var _includes = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/includes"));
var _trim = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/trim"));
var _assign = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/object/assign"));
var _stringify = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/json/stringify"));
var _flat = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/flat"));
var _map = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/map"));
var _filter = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/instance/filter"));
var _keys = _interopRequireDefault(require("@babel/runtime-corejs3/core-js/object/keys"));
var _base64url = _interopRequireDefault(require("base64url"));
var _cryptoJs = _interopRequireDefault(require("crypto-js"));
var _md = _interopRequireDefault(require("md5"));
var _uuid = require("uuid");
var _cors = require("../../cors");
var _transforms = require("../../transforms");
var DbAuthError = _interopRequireWildcard(require("./errors"));
var _shared = require("./shared");
class DbAuthHandler {
// class constant: list of auth methods that are supported
static get METHODS() {
return ['forgotPassword', 'getToken', 'login', 'logout', 'resetPassword', 'signup', 'validateResetToken', 'webAuthnRegOptions', 'webAuthnRegister', 'webAuthnAuthOptions', 'webAuthnAuthenticate'];
}
// class constant: maps the auth functions to their required HTTP verb for access
static get VERBS() {
return {
forgotPassword: 'POST',
getToken: 'GET',
login: 'POST',
logout: 'POST',
resetPassword: 'POST',
signup: 'POST',
validateResetToken: 'POST',
webAuthnRegOptions: 'GET',
webAuthnRegister: 'POST',
webAuthnAuthOptions: 'GET',
webAuthnAuthenticate: 'POST'
};
}
// default to epoch when we want to expire
static get PAST_EXPIRES_DATE() {
return new Date('1970-01-01T00:00:00.000+00:00').toUTCString();
}
// generate a new token (standard UUID)
static get CSRF_TOKEN() {
return (0, _uuid.v4)();
}
static get AVAILABLE_WEBAUTHN_TRANSPORTS() {
return ['usb', 'ble', 'nfc', 'internal'];
}
// returns the set-cookie header to mark the cookie as expired ("deletes" the session)
/**
* The header keys are case insensitive, but Fastify prefers these to be lowercase.
* Therefore, we want to ensure that the headers are always lowercase and unique
* for compliance with HTTP/2.
*
* @see: https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2
*/
get _deleteSessionHeader() {
return {
'set-cookie': ['session=', ...this._cookieAttributes({
expires: 'now'
})].join(';')
};
}
constructor(event, context, options) {
var _this$options, _this$options$webAuth;
this.event = void 0;
this.context = void 0;
this.options = void 0;
this.cookie = void 0;
this.params = void 0;
this.db = void 0;
this.dbAccessor = void 0;
this.dbCredentialAccessor = void 0;
this.headerCsrfToken = void 0;
this.hasInvalidSession = void 0;
this.session = void 0;
this.sessionCsrfToken = void 0;
this.corsContext = void 0;
this.sessionExpiresDate = void 0;
this.webAuthnExpiresDate = void 0;
this.event = event;
this.context = context;
this.options = options;
this.cookie = (0, _shared.extractCookie)(this.event);
this._validateOptions();
this.params = this._parseBody();
this.db = this.options.db;
this.dbAccessor = this.db[this.options.authModelAccessor];
this.dbCredentialAccessor = this.options.credentialModelAccessor ? this.db[this.options.credentialModelAccessor] : null;
this.headerCsrfToken = this.event.headers['csrf-token'];
this.hasInvalidSession = false;
const sessionExpiresAt = new Date();
sessionExpiresAt.setSeconds(sessionExpiresAt.getSeconds() + this.options.login.expires);
this.sessionExpiresDate = sessionExpiresAt.toUTCString();
const webAuthnExpiresAt = new Date();
webAuthnExpiresAt.setSeconds(webAuthnExpiresAt.getSeconds() + (((_this$options = this.options) === null || _this$options === void 0 ? void 0 : (_this$options$webAuth = _this$options.webAuthn) === null || _this$options$webAuth === void 0 ? void 0 : _this$options$webAuth.expires) || 0));
this.webAuthnExpiresDate = webAuthnExpiresAt.toUTCString();
// Note that we handle these headers differently in functions/graphql.ts
// because it's handled by graphql-yoga, so we map the cors config to yoga config
// See packages/graphql-server/src/__tests__/mapRwCorsToYoga.test.ts
if (options.cors) {
this.corsContext = (0, _cors.createCorsContext)(options.cors);
}
try {
const [session, csrfToken] = (0, _shared.decryptSession)((0, _shared.getSession)(this.cookie));
this.session = session;
this.sessionCsrfToken = csrfToken;
} catch (e) {
// if session can't be decrypted, keep track so we can log them out when
// the auth method is called
if (e instanceof DbAuthError.SessionDecryptionError) {
this.hasInvalidSession = true;
} else {
throw e;
}
}
}
// Actual function that triggers everything else to happen: `login`, `signup`,
// etc. is called from here, after some checks to make sure the request is good
async invoke() {
const request = (0, _transforms.normalizeRequest)(this.event);
let corsHeaders = {};
if (this.corsContext) {
corsHeaders = this.corsContext.getRequestHeaders(request);
// Return CORS headers for OPTIONS requests
if (this.corsContext.shouldHandleCors(request)) {
return this._buildResponseWithCorsHeaders({
body: '',
statusCode: 200
}, corsHeaders);
}
}
// if there was a problem decryption the session, just return the logout
// response immediately
if (this.hasInvalidSession) {
return this._buildResponseWithCorsHeaders(this._ok(...this._logoutResponse()), corsHeaders);
}
try {
var _context;
const method = this._getAuthMethod();
// get the auth method the incoming request is trying to call
if (!(0, _includes.default)(_context = DbAuthHandler.METHODS).call(_context, method)) {
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders);
}
// make sure it's using the correct verb, GET vs POST
if (this.event.httpMethod !== DbAuthHandler.VERBS[method]) {
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders);
}
// call whatever auth method was requested and return the body and headers
const [body, headers, options = {
statusCode: 200
}] = await this[method]();
return this._buildResponseWithCorsHeaders(this._ok(body, headers, options), corsHeaders);
} catch (e) {
if (e instanceof DbAuthError.WrongVerbError) {
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders);
} else {
return this._buildResponseWithCorsHeaders(this._badRequest(e.message || e), corsHeaders);
}
}
}
async forgotPassword() {
const {
enabled = true
} = this.options.forgotPassword;
if (!enabled) {
var _this$options$forgotP, _this$options$forgotP2;
throw new DbAuthError.FlowNotEnabledError(((_this$options$forgotP = this.options.forgotPassword) === null || _this$options$forgotP === void 0 ? void 0 : (_this$options$forgotP2 = _this$options$forgotP.errors) === null || _this$options$forgotP2 === void 0 ? void 0 : _this$options$forgotP2.flowNotEnabled) || `Forgot password flow is not enabled`);
}
const {
username
} = this.params;
// was the username sent in at all?
if (!username || (0, _trim.default)(username).call(username) === '') {
var _this$options$forgotP3, _this$options$forgotP4;
throw new DbAuthError.UsernameRequiredError(((_this$options$forgotP3 = this.options.forgotPassword) === null || _this$options$forgotP3 === void 0 ? void 0 : (_this$options$forgotP4 = _this$options$forgotP3.errors) === null || _this$options$forgotP4 === void 0 ? void 0 : _this$options$forgotP4.usernameRequired) || `Username is required`);
}
let user;
try {
user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.username]: username
}
});
} catch (e) {
throw new DbAuthError.GenericError();
}
if (user) {
const tokenExpires = new Date();
tokenExpires.setSeconds(tokenExpires.getSeconds() + this.options.forgotPassword.expires);
// generate a token
let token = (0, _md.default)((0, _uuid.v4)());
const buffer = Buffer.from(token);
token = buffer.toString('base64').replace('=', '').substring(0, 16);
try {
// set token and expires time
user = await this.dbAccessor.update({
where: {
[this.options.authFields.id]: user[this.options.authFields.id]
},
data: {
[this.options.authFields.resetToken]: token,
[this.options.authFields.resetTokenExpiresAt]: tokenExpires
}
});
} catch (e) {
throw new DbAuthError.GenericError();
}
// call user-defined handler in their functions/auth.js
const response = await this.options.forgotPassword.handler(this._sanitizeUser(user));
// remove resetToken and resetTokenExpiresAt if in the body of the
// forgotPassword handler response
let responseObj = response;
if (typeof response === 'object') {
responseObj = (0, _assign.default)(response, {
[this.options.authFields.resetToken]: undefined,
[this.options.authFields.resetTokenExpiresAt]: undefined
});
}
return [response ? (0, _stringify.default)(responseObj) : '', {
...this._deleteSessionHeader
}];
} else {
var _this$options$forgotP5, _this$options$forgotP6;
throw new DbAuthError.UsernameNotFoundError(((_this$options$forgotP5 = this.options.forgotPassword) === null || _this$options$forgotP5 === void 0 ? void 0 : (_this$options$forgotP6 = _this$options$forgotP5.errors) === null || _this$options$forgotP6 === void 0 ? void 0 : _this$options$forgotP6.usernameNotFound) || `Username '${username} not found`);
}
}
async getToken() {
try {
const user = await this._getCurrentUser();
// need to return *something* for our existing Authorization header stuff
// to work, so return the user's ID in case we can use it for something
// in the future
return [user[this.options.authFields.id]];
} catch (e) {
if (e instanceof DbAuthError.NotLoggedInError) {
return this._logoutResponse();
} else {
return this._logoutResponse({
error: e.message
});
}
}
}
async login() {
const {
enabled = true
} = this.options.login;
if (!enabled) {
var _this$options$login, _this$options$login$e;
throw new DbAuthError.FlowNotEnabledError(((_this$options$login = this.options.login) === null || _this$options$login === void 0 ? void 0 : (_this$options$login$e = _this$options$login.errors) === null || _this$options$login$e === void 0 ? void 0 : _this$options$login$e.flowNotEnabled) || `Login flow is not enabled`);
}
const {
username,
password
} = this.params;
const dbUser = await this._verifyUser(username, password);
const handlerUser = await this.options.login.handler(dbUser);
if (handlerUser == null || handlerUser[this.options.authFields.id] == null) {
throw new DbAuthError.NoUserIdError();
}
return this._loginResponse(handlerUser);
}
logout() {
return this._logoutResponse();
}
async resetPassword() {
var _context2, _context3;
const {
enabled = true
} = this.options.resetPassword;
if (!enabled) {
var _this$options$resetPa, _this$options$resetPa2;
throw new DbAuthError.FlowNotEnabledError(((_this$options$resetPa = this.options.resetPassword) === null || _this$options$resetPa === void 0 ? void 0 : (_this$options$resetPa2 = _this$options$resetPa.errors) === null || _this$options$resetPa2 === void 0 ? void 0 : _this$options$resetPa2.flowNotEnabled) || `Reset password flow is not enabled`);
}
const {
password,
resetToken
} = this.params;
// is the resetToken present?
if (resetToken == null || (0, _trim.default)(_context2 = String(resetToken)).call(_context2) === '') {
var _this$options$resetPa3, _this$options$resetPa4;
throw new DbAuthError.ResetTokenRequiredError((_this$options$resetPa3 = this.options.resetPassword) === null || _this$options$resetPa3 === void 0 ? void 0 : (_this$options$resetPa4 = _this$options$resetPa3.errors) === null || _this$options$resetPa4 === void 0 ? void 0 : _this$options$resetPa4.resetTokenRequired);
}
// is password present?
if (password == null || (0, _trim.default)(_context3 = String(password)).call(_context3) === '') {
throw new DbAuthError.PasswordRequiredError();
}
let user = await this._findUserByToken(resetToken);
const [hashedPassword] = (0, _shared.hashPassword)(password, user.salt);
if (!this.options.resetPassword.allowReusedPassword && user.hashedPassword === hashedPassword) {
var _this$options$resetPa5, _this$options$resetPa6;
throw new DbAuthError.ReusedPasswordError((_this$options$resetPa5 = this.options.resetPassword) === null || _this$options$resetPa5 === void 0 ? void 0 : (_this$options$resetPa6 = _this$options$resetPa5.errors) === null || _this$options$resetPa6 === void 0 ? void 0 : _this$options$resetPa6.reusedPassword);
}
try {
// if we got here then we can update the password in the database
user = await this.dbAccessor.update({
where: {
[this.options.authFields.id]: user[this.options.authFields.id]
},
data: {
[this.options.authFields.hashedPassword]: hashedPassword
}
});
} catch (e) {
throw new DbAuthError.GenericError();
}
await this._clearResetToken(user);
// call the user-defined handler so they can decide what to do with this user
const response = await this.options.resetPassword.handler(this._sanitizeUser(user));
// returning the user from the handler means to log them in automatically
if (response) {
return this._loginResponse(user);
} else {
return this._logoutResponse({});
}
}
async signup() {
var _passwordValidation, _ref;
const {
enabled = true
} = this.options.signup;
if (!enabled) {
var _this$options$signup, _this$options$signup$;
throw new DbAuthError.FlowNotEnabledError(((_this$options$signup = this.options.signup) === null || _this$options$signup === void 0 ? void 0 : (_this$options$signup$ = _this$options$signup.errors) === null || _this$options$signup$ === void 0 ? void 0 : _this$options$signup$.flowNotEnabled) || `Signup flow is not enabled`);
}
// check if password is valid
const {
password
} = this.params;
(_passwordValidation = (_ref = this.options.signup).passwordValidation) === null || _passwordValidation === void 0 ? void 0 : _passwordValidation.call(_ref, password);
const userOrMessage = await this._createUser();
// at this point `user` is either an actual user, in which case log the
// user in automatically, or it's a string, which is a message to show
// the user (something like "please verify your email")
if (typeof userOrMessage === 'object') {
const user = userOrMessage;
return this._loginResponse(user, 201);
} else {
const message = userOrMessage;
return [(0, _stringify.default)({
message
}), {}, {
statusCode: 201
}];
}
}
async validateResetToken() {
var _context4;
// is token present at all?
if (this.params.resetToken == null || (0, _trim.default)(_context4 = String(this.params.resetToken)).call(_context4) === '') {
var _this$options$resetPa7, _this$options$resetPa8;
throw new DbAuthError.ResetTokenRequiredError((_this$options$resetPa7 = this.options.resetPassword) === null || _this$options$resetPa7 === void 0 ? void 0 : (_this$options$resetPa8 = _this$options$resetPa7.errors) === null || _this$options$resetPa8 === void 0 ? void 0 : _this$options$resetPa8.resetTokenRequired);
}
const user = await this._findUserByToken(this.params.resetToken);
return [(0, _stringify.default)(this._sanitizeUser(user)), {
...this._deleteSessionHeader
}];
}
// browser submits WebAuthn credentials
async webAuthnAuthenticate() {
var _context5;
const {
verifyAuthenticationResponse
} = require('@simplewebauthn/server');
const webAuthnOptions = this.options.webAuthn;
if (!webAuthnOptions || !webAuthnOptions.enabled) {
throw new DbAuthError.WebAuthnError('WebAuthn is not enabled');
}
const credential = await this.dbCredentialAccessor.findFirst({
where: {
id: this.params.rawId
}
});
if (!credential) {
throw new DbAuthError.WebAuthnError('Credentials not found');
}
const user = await this.dbAccessor.findFirst({
where: {
[this.options.authFields.id]: credential[webAuthnOptions.credentialFields.userId]
}
});
let verification;
try {
const opts = {
credential: this.params,
expectedChallenge: user[this.options.authFields.challenge],
expectedOrigin: webAuthnOptions.origin,
expectedRPID: webAuthnOptions.domain,
authenticator: {
credentialID: _base64url.default.toBuffer(credential[webAuthnOptions.credentialFields.id]),
credentialPublicKey: credential[webAuthnOptions.credentialFields.publicKey],
counter: credential[webAuthnOptions.credentialFields.counter],
transports: credential[webAuthnOptions.credentialFields.transports] ? JSON.parse(credential[webAuthnOptions.credentialFields.transports]) : DbAuthHandler.AVAILABLE_WEBAUTHN_TRANSPORTS
},
requireUserVerification: true
};
verification = await verifyAuthenticationResponse(opts);
} catch (e) {
throw new DbAuthError.WebAuthnError(e.message);
} finally {
// whether it worked or errored, clear the challenge in the user record
// and user can get a new one next time they try to authenticate
await this._saveChallenge(user[this.options.authFields.id], null);
}
const {
verified,
authenticationInfo
} = verification;
if (verified) {
// update counter in credentials
await this.dbCredentialAccessor.update({
where: {
[webAuthnOptions.credentialFields.id]: credential[webAuthnOptions.credentialFields.id]
},
data: {
[webAuthnOptions.credentialFields.counter]: authenticationInfo.newCounter
}
});
}
// get the regular `login` cookies
const [, loginHeaders] = this._loginResponse(user);
const cookies = (0, _flat.default)(_context5 = [this._webAuthnCookie(this.params.rawId, this.webAuthnExpiresDate), loginHeaders['set-cookie']]).call(_context5);
return [verified, {
'set-cookie': cookies
}];
}
// get options for a WebAuthn authentication
async webAuthnAuthOptions() {
const {
generateAuthenticationOptions
} = require('@simplewebauthn/server');
if (this.options.webAuthn === undefined || !this.options.webAuthn.enabled) {
throw new DbAuthError.WebAuthnError('WebAuthn is not enabled');
}
const webAuthnOptions = this.options.webAuthn;
const credentialId = (0, _shared.webAuthnSession)(this.event);
let user;
if (credentialId) {
user = await this.dbCredentialAccessor.findFirst({
where: {
[webAuthnOptions.credentialFields.id]: credentialId
}
}).user();
} else {
// webauthn session not present, fallback to getting user from regular
// session cookie
user = await this._getCurrentUser();
}
// webauthn cookie has been tampered with or UserCredential has been deleted
// from the DB, remove their cookie so it doesn't happen again
if (!user) {
return [{
error: 'Log in with username and password to enable WebAuthn'
}, {
'set-cookie': this._webAuthnCookie('', 'now')
}, {
statusCode: 400
}];
}
const credentials = await this.dbCredentialAccessor.findMany({
where: {
[webAuthnOptions.credentialFields.userId]: user[this.options.authFields.id]
}
});
const someOptions = {
timeout: webAuthnOptions.timeout || 60000,
allowCredentials: (0, _map.default)(credentials).call(credentials, cred => ({
id: _base64url.default.toBuffer(cred[webAuthnOptions.credentialFields.id]),
type: 'public-key',
transports: cred[webAuthnOptions.credentialFields.transports] ? JSON.parse(cred[webAuthnOptions.credentialFields.transports]) : DbAuthHandler.AVAILABLE_WEBAUTHN_TRANSPORTS
})),
userVerification: 'required',
rpID: webAuthnOptions.domain
};
const authOptions = generateAuthenticationOptions(someOptions);
await this._saveChallenge(user[this.options.authFields.id], authOptions.challenge);
return [authOptions];
}
// get options for WebAuthn registration
async webAuthnRegOptions() {
var _this$options2, _this$options2$webAut;
const {
generateRegistrationOptions
} = require('@simplewebauthn/server');
if (!((_this$options2 = this.options) !== null && _this$options2 !== void 0 && (_this$options2$webAut = _this$options2.webAuthn) !== null && _this$options2$webAut !== void 0 && _this$options2$webAut.enabled)) {
throw new DbAuthError.WebAuthnError('WebAuthn is not enabled');
}
const webAuthnOptions = this.options.webAuthn;
const user = await this._getCurrentUser();
const options = {
rpName: webAuthnOptions.name,
rpID: webAuthnOptions.domain,
userID: user[this.options.authFields.id],
userName: user[this.options.authFields.username],
timeout: (webAuthnOptions === null || webAuthnOptions === void 0 ? void 0 : webAuthnOptions.timeout) || 60000,
excludeCredentials: [],
authenticatorSelection: {
userVerification: 'required'
},
// Support the two most common algorithms: ES256, and RS256
supportedAlgorithmIDs: [-7, -257]
};
// if a type is specified other than `any` assign it (the default behavior
// of this prop if `undefined` means to allow any authenticator)
if (webAuthnOptions.type && webAuthnOptions.type !== 'any') {
options.authenticatorSelection = (0, _assign.default)(options.authenticatorSelection || {}, {
authenticatorAttachment: webAuthnOptions.type
});
}
const regOptions = generateRegistrationOptions(options);
await this._saveChallenge(user[this.options.authFields.id], regOptions.challenge);
return [regOptions];
}
// browser submits WebAuthn credentials for the first time on a new device
async webAuthnRegister() {
const {
verifyRegistrationResponse
} = require('@simplewebauthn/server');
if (this.options.webAuthn === undefined || !this.options.webAuthn.enabled) {
throw new DbAuthError.WebAuthnError('WebAuthn is not enabled');
}
const user = await this._getCurrentUser();
let verification;
try {
const options = {
credential: this.params,
expectedChallenge: user[this.options.authFields.challenge],
expectedOrigin: this.options.webAuthn.origin,
expectedRPID: this.options.webAuthn.domain,
requireUserVerification: true
};
verification = await verifyRegistrationResponse(options);
} catch (e) {
throw new DbAuthError.WebAuthnError(e.message);
}
const {
verified,
registrationInfo
} = verification;
let plainCredentialId;
if (verified && registrationInfo) {
const {
credentialPublicKey,
credentialID,
counter
} = registrationInfo;
plainCredentialId = _base64url.default.encode(credentialID);
const existingDevice = await this.dbCredentialAccessor.findFirst({
where: {
id: plainCredentialId,
userId: user[this.options.authFields.id]
}
});
if (!existingDevice) {
await this.dbCredentialAccessor.create({
data: {
[this.options.webAuthn.credentialFields.id]: plainCredentialId,
[this.options.webAuthn.credentialFields.userId]: user[this.options.authFields.id],
[this.options.webAuthn.credentialFields.publicKey]: credentialPublicKey,
[this.options.webAuthn.credentialFields.transports]: this.params.transports ? (0, _stringify.default)(this.params.transports) : null,
[this.options.webAuthn.credentialFields.counter]: counter
}
});
}
} else {
throw new DbAuthError.WebAuthnError('Registration failed');
}
// clear challenge
await this._saveChallenge(user[this.options.authFields.id], null);
return [verified, {
'set-cookie': this._webAuthnCookie(plainCredentialId, this.webAuthnExpiresDate)
}];
}
// validates that we have all the ENV and options we need to login/signup
_validateOptions() {
var _this$options3, _this$options3$login, _this$options4, _this$options4$login, _this$options5, _this$options5$login, _this$options6, _this$options6$login, _this$options7, _this$options7$signup, _this$options8, _this$options8$signup, _this$options9, _this$options9$forgot, _this$options10, _this$options10$forgo, _this$options11, _this$options11$reset, _this$options12, _this$options12$reset, _this$options13, _this$options14, _this$options15, _this$options16, _this$options17, _this$options17$webAu, _this$options18, _this$options18$webAu, _this$options19, _this$options19$webAu, _this$options20, _this$options20$webAu, _this$options21, _this$options21$webAu;
// must have a SESSION_SECRET so we can encrypt/decrypt the cookie
if (!process.env.SESSION_SECRET) {
throw new DbAuthError.NoSessionSecretError();
}
// must have an expiration time set for the session cookie
if (((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : (_this$options3$login = _this$options3.login) === null || _this$options3$login === void 0 ? void 0 : _this$options3$login.enabled) !== false && !((_this$options4 = this.options) !== null && _this$options4 !== void 0 && (_this$options4$login = _this$options4.login) !== null && _this$options4$login !== void 0 && _this$options4$login.expires)) {
throw new DbAuthError.NoSessionExpirationError();
}
// must have a login handler to actually log a user in
if (((_this$options5 = this.options) === null || _this$options5 === void 0 ? void 0 : (_this$options5$login = _this$options5.login) === null || _this$options5$login === void 0 ? void 0 : _this$options5$login.enabled) !== false && !((_this$options6 = this.options) !== null && _this$options6 !== void 0 && (_this$options6$login = _this$options6.login) !== null && _this$options6$login !== void 0 && _this$options6$login.handler)) {
throw new DbAuthError.NoLoginHandlerError();
}
// must have a signup handler to define how to create a new user
if (((_this$options7 = this.options) === null || _this$options7 === void 0 ? void 0 : (_this$options7$signup = _this$options7.signup) === null || _this$options7$signup === void 0 ? void 0 : _this$options7$signup.enabled) !== false && !((_this$options8 = this.options) !== null && _this$options8 !== void 0 && (_this$options8$signup = _this$options8.signup) !== null && _this$options8$signup !== void 0 && _this$options8$signup.handler)) {
throw new DbAuthError.NoSignupHandlerError();
}
// must have a forgot password handler to define how to notify user of reset token
if (((_this$options9 = this.options) === null || _this$options9 === void 0 ? void 0 : (_this$options9$forgot = _this$options9.forgotPassword) === null || _this$options9$forgot === void 0 ? void 0 : _this$options9$forgot.enabled) !== false && !((_this$options10 = this.options) !== null && _this$options10 !== void 0 && (_this$options10$forgo = _this$options10.forgotPassword) !== null && _this$options10$forgo !== void 0 && _this$options10$forgo.handler)) {
throw new DbAuthError.NoForgotPasswordHandlerError();
}
// must have a reset password handler to define what to do with user once password changed
if (((_this$options11 = this.options) === null || _this$options11 === void 0 ? void 0 : (_this$options11$reset = _this$options11.resetPassword) === null || _this$options11$reset === void 0 ? void 0 : _this$options11$reset.enabled) !== false && !((_this$options12 = this.options) !== null && _this$options12 !== void 0 && (_this$options12$reset = _this$options12.resetPassword) !== null && _this$options12$reset !== void 0 && _this$options12$reset.handler)) {
throw new DbAuthError.NoResetPasswordHandlerError();
}
// must have webAuthn config if credentialModelAccessor present and vice versa
if ((_this$options13 = this.options) !== null && _this$options13 !== void 0 && _this$options13.credentialModelAccessor && !((_this$options14 = this.options) !== null && _this$options14 !== void 0 && _this$options14.webAuthn) || (_this$options15 = this.options) !== null && _this$options15 !== void 0 && _this$options15.webAuthn && !((_this$options16 = this.options) !== null && _this$options16 !== void 0 && _this$options16.credentialModelAccessor)) {
throw new DbAuthError.NoWebAuthnConfigError();
}
if ((_this$options17 = this.options) !== null && _this$options17 !== void 0 && (_this$options17$webAu = _this$options17.webAuthn) !== null && _this$options17$webAu !== void 0 && _this$options17$webAu.enabled && (!((_this$options18 = this.options) !== null && _this$options18 !== void 0 && (_this$options18$webAu = _this$options18.webAuthn) !== null && _this$options18$webAu !== void 0 && _this$options18$webAu.name) || !((_this$options19 = this.options) !== null && _this$options19 !== void 0 && (_this$options19$webAu = _this$options19.webAuthn) !== null && _this$options19$webAu !== void 0 && _this$options19$webAu.domain) || !((_this$options20 = this.options) !== null && _this$options20 !== void 0 && (_this$options20$webAu = _this$options20.webAuthn) !== null && _this$options20$webAu !== void 0 && _this$options20$webAu.origin) || !((_this$options21 = this.options) !== null && _this$options21 !== void 0 && (_this$options21$webAu = _this$options21.webAuthn) !== null && _this$options21$webAu !== void 0 && _this$options21$webAu.credentialFields))) {
throw new DbAuthError.MissingWebAuthnConfigError();
}
}
// Save challenge string for WebAuthn
async _saveChallenge(userId, value) {
await this.dbAccessor.update({
where: {
[this.options.authFields.id]: userId
},
data: {
[this.options.authFields.challenge]: value
}
});
}
// returns the string for the webAuthn set-cookie header
_webAuthnCookie(id, expires) {
return [`webAuthn=${id}`, ...this._cookieAttributes({
expires,
options: {
HttpOnly: false
}
})].join(';');
}
// removes sensitive fields from user before sending over the wire
_sanitizeUser(user) {
const sanitized = JSON.parse((0, _stringify.default)(user));
delete sanitized[this.options.authFields.hashedPassword];
delete sanitized[this.options.authFields.salt];
return sanitized;
}
// parses the event body into JSON, whether it's base64 encoded or not
_parseBody() {
if (this.event.body) {
if (this.event.isBase64Encoded) {
return JSON.parse(Buffer.from(this.event.body || '', 'base64').toString('utf-8'));
} else {
return JSON.parse(this.event.body);
}
} else {
return {};
}
}
// returns all the cookie attributes in an array with the proper expiration date
//
// pass the argument `expires` set to "now" to get the attributes needed to expire
// the session, or "future" (or left out completely) to set to `futureExpiresDate`
_cookieAttributes({
expires = 'now',
options = {}
}) {
var _context6, _context7;
const cookieOptions = {
...this.options.cookie,
...options
} || {
...options
};
const meta = (0, _filter.default)(_context6 = (0, _map.default)(_context7 = (0, _keys.default)(cookieOptions)).call(_context7, key => {
const optionValue = cookieOptions[key];
// Convert the options to valid cookie string
if (optionValue === true) {
return key;
} else if (optionValue === false) {
return null;
} else {
return `${key}=${optionValue}`;
}
})).call(_context6, v => v);
const expiresAt = expires === 'now' ? DbAuthHandler.PAST_EXPIRES_DATE : expires;
meta.push(`Expires=${expiresAt}`);
return meta;
}
// encrypts a string with the SESSION_SECRET
_encrypt(data) {
return _cryptoJs.default.AES.encrypt(data, process.env.SESSION_SECRET);
}
// returns the set-cookie header to be returned in the request (effectively
// creates the session)
_createSessionHeader(data, csrfToken) {
const session = (0, _stringify.default)(data) + ';' + csrfToken;
const encrypted = this._encrypt(session);
const cookie = [`session=${encrypted.toString()}`, ...this._cookieAttributes({
expires: this.sessionExpiresDate
})].join(';');
return {
'set-cookie': cookie
};
}
// checks the CSRF token in the header against the CSRF token in the session
// and throw an error if they are not the same (not used yet)
_validateCsrf() {
if (this.sessionCsrfToken !== this.headerCsrfToken) {
throw new DbAuthError.CsrfTokenMismatchError();
}
return true;
}
async _findUserByToken(token) {
const tokenExpires = new Date();
tokenExpires.setSeconds(tokenExpires.getSeconds() - this.options.forgotPassword.expires);
const user = await this.dbAccessor.findFirst({
where: {
[this.options.authFields.resetToken]: token
}
});
// user not found with the given token
if (!user) {
var _this$options$resetPa9, _this$options$resetPa10;
throw new DbAuthError.ResetTokenInvalidError((_this$options$resetPa9 = this.options.resetPassword) === null || _this$options$resetPa9 === void 0 ? void 0 : (_this$options$resetPa10 = _this$options$resetPa9.errors) === null || _this$options$resetPa10 === void 0 ? void 0 : _this$options$resetPa10.resetTokenInvalid);
}
// token has expired
if (user[this.options.authFields.resetTokenExpiresAt] < tokenExpires) {
var _this$options$resetPa11, _this$options$resetPa12;
await this._clearResetToken(user);
throw new DbAuthError.ResetTokenExpiredError((_this$options$resetPa11 = this.options.resetPassword) === null || _this$options$resetPa11 === void 0 ? void 0 : (_this$options$resetPa12 = _this$options$resetPa11.errors) === null || _this$options$resetPa12 === void 0 ? void 0 : _this$options$resetPa12.resetTokenExpired);
}
return user;
}
// removes the resetToken from the database
async _clearResetToken(user) {
try {
await this.dbAccessor.update({
where: {
[this.options.authFields.id]: user[this.options.authFields.id]
},
data: {
[this.options.authFields.resetToken]: null,
[this.options.authFields.resetTokenExpiresAt]: null
}
});
} catch (e) {
throw new DbAuthError.GenericError();
}
}
// verifies that a username and password are correct, and returns the user if so
async _verifyUser(username, password) {
var _context8, _context9;
// do we have all the query params we need to check the user?
if (!username || (0, _trim.default)(_context8 = username.toString()).call(_context8) === '' || !password || (0, _trim.default)(_context9 = password.toString()).call(_context9) === '') {
var _this$options$login2, _this$options$login2$;
throw new DbAuthError.UsernameAndPasswordRequiredError((_this$options$login2 = this.options.login) === null || _this$options$login2 === void 0 ? void 0 : (_this$options$login2$ = _this$options$login2.errors) === null || _this$options$login2$ === void 0 ? void 0 : _this$options$login2$.usernameOrPasswordMissing);
}
let user;
try {
// does user exist?
user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.username]: username
}
});
} catch (e) {
throw new DbAuthError.GenericError();
}
if (!user) {
var _this$options$login3, _this$options$login3$;
throw new DbAuthError.UserNotFoundError(username, (_this$options$login3 = this.options.login) === null || _this$options$login3 === void 0 ? void 0 : (_this$options$login3$ = _this$options$login3.errors) === null || _this$options$login3$ === void 0 ? void 0 : _this$options$login3$.usernameNotFound);
}
// is password correct?
const [hashedPassword, _salt] = (0, _shared.hashPassword)(password, user[this.options.authFields.salt]);
if (hashedPassword === user[this.options.authFields.hashedPassword]) {
return user;
} else {
var _this$options$login4, _this$options$login4$;
throw new DbAuthError.IncorrectPasswordError(username, (_this$options$login4 = this.options.login) === null || _this$options$login4 === void 0 ? void 0 : (_this$options$login4$ = _this$options$login4.errors) === null || _this$options$login4$ === void 0 ? void 0 : _this$options$login4$.incorrectPassword);
}
}
// gets the user from the database and returns only its ID
async _getCurrentUser() {
var _this$session, _this$options$webAuth2;
if (!((_this$session = this.session) !== null && _this$session !== void 0 && _this$session.id)) {
throw new DbAuthError.NotLoggedInError();
}
const select = {
[this.options.authFields.id]: true,
[this.options.authFields.username]: true
};
if ((_this$options$webAuth2 = this.options.webAuthn) !== null && _this$options$webAuth2 !== void 0 && _this$options$webAuth2.enabled && this.options.authFields.challenge) {
select[this.options.authFields.challenge] = true;
}
let user;
try {
var _this$session2;
user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.id]: (_this$session2 = this.session) === null || _this$session2 === void 0 ? void 0 : _this$session2.id
},
select
});
} catch (e) {
throw new DbAuthError.GenericError(e.message);
}
if (!user) {
throw new DbAuthError.UserNotFoundError();
}
return user;
}
// creates and returns a user, first checking that the username/password
// values pass validation
async _createUser() {
const {
username,
password,
...userAttributes
} = this.params;
if (this._validateField('username', username) && this._validateField('password', password)) {
const user = await this.dbAccessor.findUnique({
where: {
[this.options.authFields.username]: username
}
});
if (user) {
var _this$options$signup2, _this$options$signup3;
throw new DbAuthError.DuplicateUsernameError(username, (_this$options$signup2 = this.options.signup) === null || _this$options$signup2 === void 0 ? void 0 : (_this$options$signup3 = _this$options$signup2.errors) === null || _this$options$signup3 === void 0 ? void 0 : _this$options$signup3.usernameTaken);
}
// if we get here everything is good, call the app's signup handler and let
// them worry about scrubbing data and saving to the DB
const [hashedPassword, salt] = (0, _shared.hashPassword)(password);
const newUser = await this.options.signup.handler({
username,
hashedPassword,
salt,
userAttributes
});
return newUser;
}
}
// figure out which auth method we're trying to call
_getAuthMethod() {
var _this$event$queryStri, _context10;
// try getting it from the query string, /.redwood/functions/auth?method=[methodName]
let methodName = (_this$event$queryStri = this.event.queryStringParameters) === null || _this$event$queryStri === void 0 ? void 0 : _this$event$queryStri.method;
if (!(0, _includes.default)(_context10 = DbAuthHandler.METHODS).call(_context10, methodName) && this.params) {
// try getting it from the body in JSON: { method: [methodName] }
try {
methodName = this.params.method;
} catch (e) {
// there's no body, or it's not JSON, `handler` will return a 404
}
}
return methodName;
}
// checks that a single field meets validation requirements and
// currently checks for presense only
_validateField(name, value) {
// check for presense
if (!value || (0, _trim.default)(value).call(value) === '') {
var _this$options$signup4, _this$options$signup5;
throw new DbAuthError.FieldRequiredError(name, (_this$options$signup4 = this.options.signup) === null || _this$options$signup4 === void 0 ? void 0 : (_this$options$signup5 = _this$options$signup4.errors) === null || _this$options$signup5 === void 0 ? void 0 : _this$options$signup5.fieldMissing);
} else {
return true;
}
}
_loginResponse(user, statusCode = 200) {
const sessionData = {
id: user[this.options.authFields.id]
};
// TODO: this needs to go into graphql somewhere so that each request makes
// a new CSRF token and sets it in both the encrypted session and the
// csrf-token header
const csrfToken = DbAuthHandler.CSRF_TOKEN;
return [sessionData, {
'csrf-token': csrfToken,
...this._createSessionHeader(sessionData, csrfToken)
}, {
statusCode
}];
}
_logoutResponse(response) {
return [response ? (0, _stringify.default)(response) : '', {
...this._deleteSessionHeader
}];
}
_ok(body, headers = {}, options = {
statusCode: 200
}) {
return {
statusCode: options.statusCode,
body: typeof body === 'string' ? body : (0, _stringify.default)(body),
headers: {
'Content-Type': 'application/json',
...headers
}
};
}
_notFound() {
return {
statusCode: 404
};
}
_badRequest(message) {
return {
statusCode: 400,
body: (0, _stringify.default)({
error: message
}),
headers: {
'Content-Type': 'application/json'
}
};
}
_buildResponseWithCorsHeaders(response, corsHeaders) {
return {
...response,
headers: {
...(response.headers || {}),
...corsHeaders
}
};
}
}
exports.DbAuthHandler = DbAuthHandler;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment