packages/fxa-auth-server/lib/oauth/db/mysql/index.js (588 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const buf = require('buf').hex;
const encrypt = require('fxa-shared/auth/encrypt');
const ScopeSet = require('fxa-shared').oauth.scopes;
const unique = require('../../unique');
const AccessToken = require('../accessToken');
// Shared base class
const { MysqlOAuthShared } = require('fxa-shared/db/mysql');
const { Container } = require('typedi');
const { AuthLogger } = require('../../../types');
const { StatsD } = require('hot-shots');
const REQUIRED_SQL_MODES = ['STRICT_ALL_TABLES', 'NO_ENGINE_SUBSTITUTION'];
const QUERY_GET_LOCK = 'SELECT GET_LOCK(?, ?) AS acquired';
const QUERY_RELEASE_LOCK = 'SELECT RELEASE_LOCK(?)';
const QUERY_CLIENT_REGISTER =
'INSERT INTO clients ' +
'(id, name, imageUri, hashedSecret, hashedSecretPrevious, redirectUri,' +
'trusted, allowedScopes, canGrant, publicClient) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);';
const QUERY_CLIENT_DEVELOPER_INSERT =
'INSERT INTO clientDevelopers ' +
'(rowId, developerId, clientId) ' +
'VALUES (?, ?, ?);';
const QUERY_CLIENT_DEVELOPER_LIST_BY_CLIENT_ID =
'SELECT developers.email, developers.createdAt ' +
'FROM clientDevelopers, developers ' +
'WHERE clientDevelopers.developerId = developers.developerId ' +
'AND clientDevelopers.clientId=?;';
const QUERY_DEVELOPER_OWNS_CLIENT =
'SELECT clientDevelopers.rowId ' +
'FROM clientDevelopers, developers ' +
'WHERE developers.developerId = clientDevelopers.developerId ' +
'AND developers.email =? AND clientDevelopers.clientId =?;';
const QUERY_DEVELOPER_INSERT =
'INSERT INTO developers ' + '(developerId, email) ' + 'VALUES (?, ?);';
const QUERY_CLIENT_GET = 'SELECT * FROM clients WHERE id=?';
const QUERY_CLIENT_LIST =
'SELECT id, name, redirectUri, imageUri, ' +
'canGrant, publicClient, trusted ' +
'FROM clients, clientDevelopers, developers ' +
'WHERE clients.id = clientDevelopers.clientId AND ' +
'developers.developerId = clientDevelopers.developerId AND ' +
'developers.email =?;';
const QUERY_CLIENT_UPDATE =
'UPDATE clients SET ' +
'name=COALESCE(?, name), imageUri=COALESCE(?, imageUri), ' +
'hashedSecret=COALESCE(?, hashedSecret), ' +
'hashedSecretPrevious=COALESCE(?, hashedSecretPrevious), ' +
'redirectUri=COALESCE(?, redirectUri), ' +
'trusted=COALESCE(?, trusted), allowedScopes=COALESCE(?, allowedScopes), ' +
'canGrant=COALESCE(?, canGrant) ' +
'WHERE id=?';
// This query deletes everything related to the client, and is thus quite expensive!
// Don't worry, it's not exposed to any production-facing routes.
const QUERY_CLIENT_DELETE =
'DELETE clients, codes, tokens, refreshTokens, clientDevelopers ' +
'FROM clients ' +
'LEFT JOIN codes ON clients.id = codes.clientId ' +
'LEFT JOIN tokens ON clients.id = tokens.clientId ' +
'LEFT JOIN refreshTokens ON clients.id = refreshTokens.clientId ' +
'LEFT JOIN clientDevelopers ON clients.id = clientDevelopers.clientId ' +
'WHERE clients.id=?';
const QUERY_CODE_INSERT =
'INSERT INTO codes (clientId, userId, scope, authAt, amr, aal, offline, code, codeChallengeMethod, codeChallenge, keysJwe, profileChangedAt, sessionTokenId) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
const QUERY_ACCESS_TOKEN_INSERT =
'INSERT INTO tokens (clientId, userId, scope, type, expiresAt, ' +
'token, profileChangedAt) VALUES (?, ?, ?, ?, ?, ?, ?)';
const QUERY_REFRESH_TOKEN_INSERT =
'INSERT INTO refreshTokens (clientId, userId, scope, token, profileChangedAt) VALUES ' +
'(?, ?, ?, ?, ?)';
// Access token storage in redis annotates each token with client metadata,
// so we do the same when reading from MySQL, for consistency.
// Note that the `token` field stores the hash of the token rather than the raw token,
// and hence would more properly be called `tokenId`.
const QUERY_ACCESS_TOKEN_FIND =
'SELECT tokens.token AS tokenId, tokens.clientId, tokens.createdAt, ' +
' tokens.userId, tokens.scope, ' +
' tokens.createdAt, tokens.expiresAt, tokens.profileChangedAt, ' +
' clients.name as clientName, clients.canGrant AS clientCanGrant, ' +
' clients.publicClient ' +
'FROM tokens LEFT OUTER JOIN clients ON clients.id = tokens.clientId ' +
'WHERE tokens.token=?';
// Note that the `token` field stores the hash of the token rather than the raw token,
// and hence would more properly be called `tokenId`.
const QUERY_REFRESH_TOKEN_FIND =
'SELECT token AS tokenId, clientId, userId, scope, createdAt, ' +
'lastUsedAt, profileChangedAt ' +
'FROM refreshTokens where token=?';
const QUERY_REFRESH_TOKEN_LAST_USED_UPDATE =
'UPDATE refreshTokens SET lastUsedAt=? WHERE token=?';
const QUERY_CODE_FIND = 'SELECT * FROM codes WHERE code=?';
const QUERY_CODE_DELETE = 'DELETE FROM codes WHERE code=?';
const QUERY_ACCESS_TOKEN_DELETE = 'DELETE FROM tokens WHERE token=?';
const QUERY_REFRESH_TOKEN_DELETE = 'DELETE FROM refreshTokens WHERE token=?';
const QUERY_ACCESS_TOKEN_DELETE_USER = 'DELETE FROM tokens WHERE userId=?';
// These next two queries can be very expensive if MySQL
// tries to filter by clientId before userId, so we add
// an explicit index hint to help ensure this doesn't happen.
const QUERY_DELETE_ACCESS_TOKEN_FOR_PUBLIC_CLIENTS =
'DELETE tokens ' +
'FROM tokens FORCE INDEX (tokens_user_id)' +
'INNER JOIN clients ON tokens.clientId = clients.id ' +
'WHERE tokens.userId=? AND (clients.publicClient = 1 OR clients.canGrant = 1)';
const QUERY_DELETE_REFRESH_TOKEN_FOR_PUBLIC_CLIENTS =
'DELETE refreshTokens ' +
'FROM refreshTokens FORCE INDEX (tokens_user_id)' +
'INNER JOIN clients ON refreshTokens.clientId = clients.id ' +
'WHERE refreshTokens.userId=? AND (clients.publicClient = 1 OR clients.canGrant = 1)';
const QUERY_REFRESH_TOKEN_DELETE_USER =
'DELETE FROM refreshTokens WHERE userId=?';
const QUERY_CODE_DELETE_USER = 'DELETE FROM codes WHERE userId=?';
const QUERY_DEVELOPER = 'SELECT * FROM developers WHERE email=?';
const QUERY_DEVELOPER_DELETE =
'DELETE developers, clientDevelopers ' +
'FROM developers ' +
'LEFT JOIN clientDevelopers ON developers.developerId = clientDevelopers.developerId ' +
'WHERE developers.email=?';
// When listing access tokens, we deliberately do not exclude tokens that have expired.
// Such tokens will be cleaned up by a background job, except for those belonging to Pocket, which might
// one day come back to life as refresh tokens. (ref https://bugzilla.mozilla.org/show_bug.cgi?id=1547902).
// There's minimal downside to showing tokens in the brief period between when they expire and when
// they get deleted from the db.
const QUERY_LIST_ACCESS_TOKENS_BY_UID =
'SELECT tokens.token AS tokenId, tokens.clientId, tokens.createdAt, ' +
' tokens.userId, tokens.scope, ' +
' tokens.createdAt, tokens.expiresAt, tokens.profileChangedAt, ' +
' clients.name as clientName, clients.canGrant AS clientCanGrant, ' +
' clients.publicClient ' +
'FROM tokens LEFT OUTER JOIN clients ON clients.id = tokens.clientId ' +
'WHERE tokens.userId=?';
const QUERY_LIST_REFRESH_TOKENS_BY_UID =
'SELECT refreshTokens.token AS tokenId, refreshTokens.clientId, refreshTokens.createdAt, refreshTokens.lastUsedAt, ' +
' refreshTokens.scope, clients.name as clientName, clients.canGrant AS clientCanGrant ' +
'FROM refreshTokens LEFT OUTER JOIN clients ON clients.id = refreshTokens.clientId ' +
'WHERE refreshTokens.userId=?';
const QUERY_LIST_REFRESH_TOKENS_BY_CLIENT_ID =
'SELECT refreshTokens.createdAt, refreshTokens.userId FROM refreshTokens WHERE refreshTokens.clientId=?';
const DELETE_ACTIVE_CODES_BY_CLIENT_AND_UID =
'DELETE FROM codes WHERE clientId=? AND userId=?';
const DELETE_ACTIVE_TOKENS_BY_CLIENT_AND_UID =
'DELETE FROM tokens WHERE clientId=? AND userId=?';
const DELETE_ACTIVE_REFRESH_TOKENS_BY_CLIENT_AND_UID =
'DELETE FROM refreshTokens WHERE clientId=? AND userId=?';
const DELETE_REFRESH_TOKEN_WITH_CLIENT_AND_UID =
'DELETE FROM refreshTokens WHERE token=? AND clientId=? AND userId=?';
const PRUNE_AUTHZ_CODES =
'DELETE FROM codes WHERE TIMESTAMPDIFF(SECOND, createdAt, NOW()) > ? LIMIT 10000';
// Scope queries
const QUERY_SCOPE_FIND = 'SELECT * ' + 'FROM scopes ' + 'WHERE scopes.scope=?;';
const QUERY_SCOPES_INSERT =
'INSERT INTO scopes (scope, hasScopedKeys) ' + 'VALUES (?, ?);';
function firstRow(rows) {
return rows[0];
}
function resolveLogger() {
if (Container.has(AuthLogger)) return Container.get(AuthLogger);
}
function resolveMetrics() {
if (Container.has(StatsD)) return Container.get(StatsD);
}
class MysqlStore extends MysqlOAuthShared {
constructor(config) {
super(config, undefined, resolveLogger(), resolveMetrics());
}
async ping() {
const conn = await this._getConnection();
try {
return await new Promise(function (resolve, reject) {
conn.ping(function (err) {
if (err) {
return reject(err);
}
resolve({});
});
});
} finally {
conn.release();
}
}
async _withLock(cb, lockName, timeout = 3) {
const conn = await this._getConnection();
try {
this._queryWithConnection(conn, QUERY_GET_LOCK, [lockName, timeout]);
return await cb(conn);
} finally {
this._queryWithConnection(conn, QUERY_RELEASE_LOCK, [lockName]);
conn.release();
}
}
// createdAt is DEFAULT NOW() in the schema.sql
registerClient(client) {
var id;
if (client.id) {
id = buf(client.id);
} else {
id = unique.id();
}
return this._write(QUERY_CLIENT_REGISTER, [
id,
client.name,
client.imageUri || '',
buf(client.hashedSecret),
client.hashedSecretPrevious ? buf(client.hashedSecretPrevious) : null,
client.redirectUri,
!!client.trusted,
client.allowedScopes ? client.allowedScopes : null,
!!client.canGrant,
!!client.publicClient,
]).then(function () {
client.id = id;
return client;
});
}
registerClientDeveloper(developerId, clientId) {
if (!developerId || !clientId) {
var err = new Error('Owner registration requires user and developer id');
return Promise.reject(err);
}
var rowId = unique.id();
return this._write(QUERY_CLIENT_DEVELOPER_INSERT, [
buf(rowId),
buf(developerId),
buf(clientId),
]);
}
getClientDevelopers(clientId) {
if (!clientId) {
return Promise.reject(new Error('Client id is required'));
}
return this._read(QUERY_CLIENT_DEVELOPER_LIST_BY_CLIENT_ID, [
buf(clientId),
]);
}
activateDeveloper(email) {
if (!email) {
return Promise.reject(new Error('Email is required'));
}
var developerId = unique.developerId();
return this._write(QUERY_DEVELOPER_INSERT, [developerId, email]).then(
function () {
return this.getDeveloper(email);
}.bind(this)
);
}
getDeveloper(email) {
if (!email) {
return Promise.reject(new Error('Email is required'));
}
return this._readOne(QUERY_DEVELOPER, [email]);
}
removeDeveloper(email) {
if (!email) {
return Promise.reject(new Error('Email is required'));
}
return this._write(QUERY_DEVELOPER_DELETE, [email]);
}
developerOwnsClient(developerEmail, clientId) {
return this._readOne(QUERY_DEVELOPER_OWNS_CLIENT, [
developerEmail,
buf(clientId),
]).then(function (result) {
if (result) {
return Promise.resolve(true);
} else {
return Promise.reject(false);
}
});
}
updateClient(client) {
if (!client.id) {
return Promise.reject(new Error('Update client needs an id'));
}
var secret = client.hashedSecret;
if (secret) {
secret = buf(secret);
}
var secretPrevious = client.hashedSecretPrevious;
if (secretPrevious) {
secretPrevious = buf(secretPrevious);
}
return this._write(QUERY_CLIENT_UPDATE, [
// VALUES
client.name,
client.imageUri,
secret,
secretPrevious,
client.redirectUri,
client.trusted,
client.allowedScopes,
client.canGrant,
// WHERE
buf(client.id),
]);
}
getClient(id) {
return this._readOne(QUERY_CLIENT_GET, [buf(id)]);
}
getClients(email) {
return this._read(QUERY_CLIENT_LIST, [email]);
}
removeClient(id) {
return this._write(QUERY_CLIENT_DELETE, [buf(id)]);
}
generateCode(codeObj) {
var code = unique.code();
var hash = encrypt.hash(code);
return this._write(QUERY_CODE_INSERT, [
codeObj.clientId,
codeObj.userId,
codeObj.scope.toString(),
codeObj.authAt,
codeObj.amr ? codeObj.amr.join(',') : null,
codeObj.aal || null,
!!codeObj.offline,
hash,
codeObj.codeChallengeMethod,
codeObj.codeChallenge,
codeObj.keysJwe,
codeObj.profileChangedAt,
codeObj.sessionTokenId,
]).then(function () {
return code;
});
}
getCode(code) {
var hash = encrypt.hash(code);
return this._readOne(QUERY_CODE_FIND, [hash]).then(function (code) {
if (code) {
code.scope = ScopeSet.fromString(code.scope);
if (code.amr !== null) {
code.amr = code.amr.split(',');
}
}
return code;
});
}
removeCode(code) {
var hash = encrypt.hash(code);
return this._write(QUERY_CODE_DELETE, [hash]);
}
_generateAccessToken(accessToken) {
return this._write(QUERY_ACCESS_TOKEN_INSERT, [
accessToken.clientId,
accessToken.userId,
accessToken.scope.toString(),
accessToken.type,
accessToken.expiresAt,
accessToken.tokenId,
accessToken.profileChangedAt,
]);
}
/**
* Get an access token by token id
* @param id Token Id
* @returns {*}
*/
_getAccessToken(id) {
return this._readOne(QUERY_ACCESS_TOKEN_FIND, [buf(id)]).then(function (t) {
if (t) {
t = AccessToken.fromMySQL(t);
}
return t;
});
}
/**
* Remove token by token id
* @param id
* @returns {*}
*/
_removeAccessToken(id) {
return this._write(QUERY_ACCESS_TOKEN_DELETE, [buf(id)]);
}
/**
* Get all access tokens for a given user.
* @param {String} uid User ID as hex
* @returns {Promise}
*/
async _getAccessTokensByUid(uid) {
const accessTokens = await this._read(QUERY_LIST_ACCESS_TOKENS_BY_UID, [
buf(uid),
]);
return accessTokens.map((t) => {
return AccessToken.fromMySQL(t);
});
}
/**
* Get all refresh tokens for a given user.
* @param {String} uid User ID as hex
* @returns {Promise}
*/
async _getRefreshTokensByUid(uid) {
const refreshTokens = await this._read(QUERY_LIST_REFRESH_TOKENS_BY_UID, [
buf(uid),
]);
refreshTokens.forEach((t) => {
t.scope = ScopeSet.fromString(t.scope);
});
return refreshTokens;
}
/**
* Get all refresh tokens for all users for a given clientId.
* @param {String} clientId
* @param {String} uid
* @returns {Promise}
*/
async getRefreshTokensByClientId(clientId) {
return this._read(QUERY_LIST_REFRESH_TOKENS_BY_CLIENT_ID, [buf(clientId)]);
}
/**
* Delete all authorization grants for some clientId and uid.
*
* @param {String} clientId Client ID
* @param {String} uid User Id as Hex
* @returns {Promise}
*/
_deleteClientAuthorization(clientId, uid) {
const deleteCodes = this._write(DELETE_ACTIVE_CODES_BY_CLIENT_AND_UID, [
buf(clientId),
buf(uid),
]);
const deleteTokens = this._write(DELETE_ACTIVE_TOKENS_BY_CLIENT_AND_UID, [
buf(clientId),
buf(uid),
]);
const deleteRefreshTokens = this._write(
DELETE_ACTIVE_REFRESH_TOKENS_BY_CLIENT_AND_UID,
[buf(clientId), buf(uid)]
);
return Promise.all([deleteCodes, deleteTokens, deleteRefreshTokens]);
}
async _pruneAuthorizationCodes(ttl) {
const pruneAuthzCodes = async (conn) => {
const ttlInSeconds = ttl / 1000;
await this._queryWithConnection(conn, PRUNE_AUTHZ_CODES, [ttlInSeconds]);
const prunedCount = await this._queryWithConnection(
conn,
'SELECT ROW_COUNT() AS pruned'
);
return firstRow(prunedCount);
};
return await this._withLock(
pruneAuthzCodes,
'fxa-oauth.auth-codes.prune-lock'
);
}
/**
* Delete a specific refresh token, for some clientId and uid.
* Also deletes *all* access tokens for the clientId and uid combination,
* otherwise the refresh token is deleted but none of the access tokens
* created from that refresh token are, leaving ghost access tokens
* to appear in the users devices & apps list. See:
*
* https://github.com/mozilla/fxa/issues/1249
* https://github.com/mozilla/fxa/issues/3017
*
* If a user has multiple refresh tokens for a given client_id, clients
* will use their active refresh tokens to get new access tokens.
*
* @param {String} tokenId Refresh Token ID as Hex
* @param {String} clientId Client ID as Hex
* @param {String} uid User Id as Hex
* @returns {Promise} `true` if the token was found and deleted, `false` otherwise
*/
async _deleteClientRefreshToken(tokenId, clientId, uid) {
const deleteRefreshTokenRes = await this._write(
DELETE_REFRESH_TOKEN_WITH_CLIENT_AND_UID,
[buf(tokenId), buf(clientId), buf(uid)]
);
// only delete access tokens if deleting the refresh
// tokens has succeeded.
if (deleteRefreshTokenRes.affectedRows) {
await this._write(DELETE_ACTIVE_TOKENS_BY_CLIENT_AND_UID, [
buf(clientId),
buf(uid),
]);
return true;
}
return false;
}
generateRefreshToken(vals) {
const t = {
clientId: vals.clientId,
userId: vals.userId,
scope: vals.scope,
profileChangedAt: vals.profileChangedAt,
};
const token = unique.token();
const tokenId = encrypt.hash(token);
return this._write(QUERY_REFRESH_TOKEN_INSERT, [
t.clientId,
t.userId,
t.scope.toString(),
tokenId,
t.profileChangedAt,
]).then(function () {
t.token = token;
t.tokenId = tokenId;
return t;
});
}
_getRefreshToken(token) {
return this._readOne(QUERY_REFRESH_TOKEN_FIND, [buf(token)]).then(function (
t
) {
if (t) {
t.scope = ScopeSet.fromString(t.scope);
}
return t;
});
}
_touchRefreshToken(token, now) {
return this._write(QUERY_REFRESH_TOKEN_LAST_USED_UPDATE, [
now,
// WHERE
buf(token),
]);
}
_removeRefreshToken(id) {
return this._write(QUERY_REFRESH_TOKEN_DELETE, [buf(id)]);
}
getEncodingInfo() {
var info = {};
// eslint-disable-next-line @typescript-eslint/no-this-alias
var self = this;
var qry = 'SHOW VARIABLES LIKE "%character\\_set\\_%"';
return this._read(qry).then(function (rows) {
rows.forEach(function (row) {
info[row.Variable_name] = row.Value;
});
qry = 'SHOW VARIABLES LIKE "%collation\\_%"';
return self._read(qry).then(function (rows) {
rows.forEach(function (row) {
info[row.Variable_name] = row.Value;
});
return info;
});
});
}
_removeTokensAndCodes(userId) {
// TODO this should be a transaction or stored procedure
var id = buf(userId);
return this._write(QUERY_ACCESS_TOKEN_DELETE_USER, [id])
.then(this._write.bind(this, QUERY_REFRESH_TOKEN_DELETE_USER, [id]))
.then(this._write.bind(this, QUERY_CODE_DELETE_USER, [id]));
}
/**
* Removes user's tokens and refreshTokens for canGrant and publicClient clients
*
* @param {Buffer | string} userId
* @returns {Promise}
*/
_removePublicAndCanGrantTokens(userId) {
const uid = buf(userId);
return this._write(QUERY_DELETE_ACCESS_TOKEN_FOR_PUBLIC_CLIENTS, [
uid,
]).then(() =>
this._write(QUERY_DELETE_REFRESH_TOKEN_FOR_PUBLIC_CLIENTS, [uid])
);
}
async getScope(scope) {
// We currently only have database entries for URL-format scopes,
// so don't bother hitting the db for common scopes like 'profile'.
if (!scope.startsWith('https://')) {
return null;
}
return (await this._readOne(QUERY_SCOPE_FIND, [scope])) || null;
}
registerScope(scope) {
return this._write(QUERY_SCOPES_INSERT, [scope.scope, scope.hasScopedKeys]);
}
_write(sql, params) {
return this._query(sql, params);
}
_read(sql, params) {
return this._query(sql, params);
}
_readOne(sql, params) {
return this._read(sql, params).then(firstRow);
}
async _getConnection() {
var pool = this._pool;
return new Promise(function (resolve, reject) {
pool.getConnection(function (err, conn) {
if (err) {
return reject(err);
}
if (conn._fxa_initialized) {
return resolve(conn);
}
// Enforce sane defaults on every new connection.
// These *should* be set by the database by default, but it's nice
// to have an additional layer of protection here.
const query = (sql) =>
new Promise((resolve, reject) => {
conn.query(sql, (err, result) => {
if (err) {
return reject(err);
}
resolve(result);
});
});
return resolve(
(async () => {
// Always communicate timestamps in UTC.
await query("SET time_zone = '+00:00'");
// Always use full 4-byte UTF-8 for communicating unicode.
await query('SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;');
// Always have certain modes active. The complexity here is to
// preserve any extra modes active by default on the server.
// We also try to preserve the order of the existing mode flags,
// just in case the order has some obscure effect we don't know about.
const rows = await query('SELECT @@sql_mode AS mode');
const modes = rows[0]['mode'].split(',');
let needToSetMode = false;
for (const requiredMode of REQUIRED_SQL_MODES) {
if (modes.indexOf(requiredMode) === -1) {
modes.push(requiredMode);
needToSetMode = true;
}
}
if (needToSetMode) {
const mode = modes.join(',');
await query("SET SESSION sql_mode = '" + mode + "'");
}
// Avoid repeating all that work for existing connections.
conn._fxa_initialized = true;
return conn;
})()
);
});
});
}
async _query(sql, params) {
const conn = await this._getConnection();
try {
return await this._queryWithConnection(conn, sql, params);
} finally {
conn.release();
}
}
async _queryWithConnection(conn, sql, params) {
return await new Promise(function (resolve, reject) {
conn.query(sql, params || [], function (err, results) {
if (err) {
return reject(err);
}
resolve(results);
});
});
}
getProxyableFunctions() {
return Reflect.ownKeys(MysqlStore.prototype).filter(
(x) => x !== 'constructor' && /^[a-zA-Z]/.test(x)
);
}
}
function connect(config) {
return new MysqlStore(config);
}
module.exports = connect;
module.exports.connect = connect;