UNPKG

@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
'use strict'; 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 }; } } } } }