@perfood/couch-auth
Version:
Easy and secure authentication for CouchDB/Cloudant. Based on SuperLogin, updated and rewritten in Typescript.
292 lines (291 loc) • 9.45 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.USER_REGEXP = exports.EMAIL_REGEXP = void 0;
exports.URLSafeUUID = URLSafeUUID;
exports.getSessionKey = getSessionKey;
exports.generateSlUserKey = generateSlUserKey;
exports.hyphenizeUUID = hyphenizeUUID;
exports.removeHyphens = removeHyphens;
exports.hashToken = hashToken;
exports.putSecurityDoc = putSecurityDoc;
exports.getSecurityDoc = getSecurityDoc;
exports.getCloudantURL = getCloudantURL;
exports.getDBURL = getDBURL;
exports.getFullDBURL = getFullDBURL;
exports.toArray = toArray;
exports.getSessions = getSessions;
exports.getExpiredSessions = getExpiredSessions;
exports.getSessionToken = getSessionToken;
exports.addProvidersToDesignDoc = addProvidersToDesignDoc;
exports.capitalizeFirstLetter = capitalizeFirstLetter;
exports.mergeConfig = mergeConfig;
exports.arrayUnion = arrayUnion;
exports.isUserFacingError = isUserFacingError;
exports.replaceAt = replaceAt;
exports.timeoutPromise = timeoutPromise;
exports.extractCurrentConsents = extractCurrentConsents;
exports.verifyConsentUpdate = verifyConsentUpdate;
exports.verifySessionConfigRoles = verifySessionConfigRoles;
const crypto_1 = __importDefault(require("crypto"));
const urlsafe_base64_1 = __importDefault(require("urlsafe-base64"));
const uuid_1 = require("uuid");
// regexp from https://emailregex.com/
exports.EMAIL_REGEXP = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
exports.USER_REGEXP = /^[a-z0-9_-]{3,16}$/;
function URLSafeUUID() {
return urlsafe_base64_1.default.encode(Buffer.from((0, uuid_1.v4)().replace(/-/g, ''), 'hex'));
}
function getSessionKey() {
let token = URLSafeUUID();
// Make sure our token doesn't start with illegal characters
while (token[0] === '_' || token[0] === '-') {
token = URLSafeUUID();
}
return token;
}
function getUserKey() {
return URLSafeUUID().substring(0, 8).toLowerCase();
}
function generateSlUserKey() {
let newKey = getUserKey();
while (!exports.USER_REGEXP.test(newKey)) {
newKey = getUserKey();
}
return newKey;
}
function hyphenizeUUID(uuid) {
return (uuid.substring(0, 8) +
'-' +
uuid.substring(8, 12) +
'-' +
uuid.substring(12, 16) +
'-' +
uuid.substring(16, 20) +
'-' +
uuid.substring(20));
}
function removeHyphens(uuid) {
return uuid.split('-').join('');
}
function hashToken(token) {
return crypto_1.default.createHash('sha256').update(token).digest('hex');
}
function putSecurityDoc(server, db, doc) {
// @ts-ignore
return server.request({
db: db.config.db,
method: 'PUT',
doc: '_security',
body: doc
});
}
function getSecurityDoc(server, db) {
// @ts-ignore
return server.request({
db: db.config.db,
method: 'GET',
doc: '_security'
});
}
/** returns the Cloudant url - including credentials, if `CLOUDANT_PASS` is provided. */
function getCloudantURL() {
let url = 'https://';
if (process.env.CLOUDANT_PASS) {
url += process.env.CLOUDANT_USER + ':' + process.env.CLOUDANT_PASS + '@';
}
url += process.env.CLOUDANT_USER + '.cloudantnosqldb.appdomain.cloud';
return url;
}
/** @internal @deprecated - only used in tests */
function getDBURL(db) {
let url;
if (db.user) {
url =
db.protocol +
encodeURIComponent(db.user) +
':' +
encodeURIComponent(db.password) +
'@' +
db.host;
}
else {
url = db.protocol + db.host;
}
return url;
}
function getFullDBURL(dbConfig, dbName) {
return exports.getDBURL(dbConfig) + '/' + dbName;
}
function toArray(obj) {
if (!(obj instanceof Array)) {
return [obj];
}
return obj;
}
/**
* extracts the session keys from the SlUserDoc
*/
function getSessions(userDoc) {
return userDoc.session ? Array.from(Object.keys(userDoc.session)) : [];
}
function getExpiredSessions(userDoc, now) {
return userDoc.session
? Array.from(Object.keys(userDoc.session)).filter(s => userDoc.session[s].expires <= now)
: [];
}
/**
* Takes a req object and returns the bearer token, or undefined if it is not found
*/
function getSessionToken(req) {
if (req.headers && req.headers.authorization) {
const parts = req.headers.authorization.split(' ');
if (parts.length == 2) {
const scheme = parts[0];
const credentials = parts[1];
if (/^Bearer$/i.test(scheme)) {
const parse = credentials.split(':');
if (parse.length < 2) {
return;
}
return parse[0];
}
}
}
}
/**
* Generates views for each registered provider in the user design doc
*/
function addProvidersToDesignDoc(config, ddoc) {
const providers = config.providers;
if (!providers) {
return ddoc;
}
Object.keys(providers).forEach(provider => {
ddoc.auth.views[provider] = {
map: `function(doc) {
if(doc.${provider} && doc.${provider}.profile){
emit(doc.${provider}.profile.id, null);
}}`
};
});
return ddoc;
}
/** Capitalizes the first letter of a string */
function capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* adds the nested properties of `source` to `dest`, overwriting present entries
*/
function mergeConfig(dest, source) {
for (const [k, v] of Object.entries(source)) {
if (typeof dest[k] === 'object' && !Array.isArray(dest[k])) {
dest[k] = mergeConfig(dest[k], source[k]);
}
else {
dest[k] = v;
}
}
return dest;
}
/**
* Concatenates two arrays and removes duplicate elements
*
* @param a First array
* @param b Second array
* @return resulting array
*/
function arrayUnion(a, b) {
const result = a.concat(b);
for (let i = 0; i < result.length; ++i) {
for (let j = i + 1; j < result.length; ++j) {
if (result[i] === result[j])
result.splice(j--, 1);
}
}
return result;
}
/**
* return `true` if the passed object has the format
* of errors thrown by SuperLogin itself, i.e. it has
* `status`, `error` and optionally one of
* `validationErrors` or `message`.
*/
function isUserFacingError(errObj) {
if (!errObj || typeof errObj !== 'object') {
return false;
}
const requiredProps = new Set(['status', 'error']);
const legalProps = ['status', 'error', 'validationErrors', 'message'];
for (const [key, value] of Object.entries(errObj)) {
if (!value ||
!legalProps.includes(key) ||
(key === 'status' && typeof value !== 'number') ||
(['error', 'message'].includes(key) && typeof value !== 'string')) {
return false;
}
if (requiredProps.has(key)) {
requiredProps.delete(key);
}
}
return requiredProps.size === 0;
}
function replaceAt(str, idx, repl) {
return str.substring(0, idx) + repl + str.substring(idx + 1, str.length);
}
function timeoutPromise(duration) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, duration);
});
}
function extractCurrentConsents(userDoc) {
const ret = {};
for (const [consentKey, consentLog] of Object.entries(userDoc.consents ?? {})) {
ret[consentKey] = consentLog[consentLog.length - 1];
}
return ret;
}
function verifyConsentUpdate(consentUpdate, config) {
if (typeof consentUpdate !== 'object') {
return 'must not have an invalid format';
}
for (const [consentKey, consentRequest] of Object.entries(consentUpdate)) {
const configEntry = config.local.consents[consentKey];
if (!configEntry ||
typeof consentRequest.accepted !== 'boolean' ||
typeof consentRequest.version !== 'number') {
return 'must not have an invalid format';
}
if (consentRequest.version < configEntry.minVersion ||
consentRequest.version > configEntry.currentVersion) {
return 'must provide a supported version';
}
// it's not possible to revoke a required consents -> delete user instead.
if (configEntry.required && consentRequest.accepted !== true) {
return 'must include all required consents';
}
}
}
function verifySessionConfigRoles(roles, sessionConfig) {
if (!sessionConfig) {
throw { error: 'Bad Request', status: 400 };
}
const userRoles = new Set(roles);
if (!sessionConfig.includedRoles.some(r => userRoles.has(r))) {
throw { error: 'Forbidden', status: 403 };
}
if (sessionConfig.excludedRolePrefixes) {
for (const excludedPrefix of sessionConfig.excludedRolePrefixes) {
for (const userRole of roles) {
if (userRole.startsWith(excludedPrefix)) {
throw { error: 'Forbidden', status: 403 };
}
}
}
}
}