UNPKG

wkr-util

Version:
312 lines (267 loc) 11.1 kB
import {Router} from 'express' import urlJoin from 'url-join' import fetch from 'node-fetch' import {compose, liftA, get, reduce, filter} from '@cullylarson/f' import {retry} from '@cullylarson/p' import jwt from 'express-jwt' import {expressJwtSecret} from 'jwks-rsa' import isUuid from 'is-uuid' import ResponseError from './errors/ResponseError' import {nowStamp, responseData} from './' import {logLevels} from './log' import validateMustExist from './validators/validateMustExist' import ForbiddenError from './errors/ForbiddenError' import PageNotFoundError from './errors/PageNotFoundError' import CannotContinueError from './errors/CannotContinueError' const fetchAllGroupsRolesDefault = (authApiUrl, appAuth) => { return appAuth.getToken() .then(token => fetch(urlJoin(authApiUrl, '/api/v1/.well-known/all-roles-groups'), { method: 'get', headers: { Authorization: `Bearer ${token}`, }, })) .then(responseData) .then(({response, data}) => { if(!response.ok) { throw new ResponseError(response.status, data, `Failed to get groups and roles. Got status: ${response.status}`) } return data }) } // fetches all groups and roles from the auth API and caches them export const GroupsRolesCache = ({ authApiUrl, appAuth, cacheFor = 1200, // in seconds. 1200 seconds === 20 minutes maxNumRetries = 3, // if we fail, try again; number of times to try again fetchAllGroupsRoles = fetchAllGroupsRolesDefault, // a function that will resolve to what is returned by the auth .well-known/all-roles-groups endpoint }) => { fetchAllGroupsRoles = retry(maxNumRetries, fetchAllGroupsRoles) let lastResult let lastResultFetched return { getAllGroupsRoles: async () => { const now = nowStamp() if(!lastResult || !lastResultFetched || (now - lastResultFetched > cacheFor)) { lastResult = await fetchAllGroupsRoles(authApiUrl, appAuth) lastResultFetched = nowStamp() } return lastResult }, } } // checks that a jwt is provided and is valid. if so, will add the decoded jwt to jwtDecoded and the list of permissions to jwtPermissions, on the request. // // if appAuth and authApiUrl, or a non-default fetchAllGroupsRoles, are provided, will fetch all groups and roles (and their associated permissions) from the auth API and use them when doing permission checks. this allows JWTs to only list the groups, roles, and permissions a user has rather than exahustively list all of them, since we can use the list from the auth API to see if a certain permission is in the roles and groups the user has. export const CheckJwt = ( jwksUri, audience, issuer, jwtClaimsNamespace, // namespace for custom claims (expects to find permissions, roles, groups keys) { authApiUrl, // if this and appAuth are provided, will ignore fetchAllGroupsRoles appAuth, fetchAllGroupsRoles = fetchAllGroupsRolesDefault, // will use this function to fetch all of the groups and roles cacheFor = 1200, // in seconds. 1200 seconds === 20 minutes logRepo = null, } = {}, ) => { const check = jwt({ // Dynamically provide a signing key based on the kid in the header and the signing keys provided by the JWKS endpoint. secret: expressJwtSecret({ cache: true, rateLimit: true, jwksRequestsPerMinute: 5, jwksUri, }), audience, issuer, algorithms: ['RS256'], requestProperty: 'jwtDecoded', // the request object will get the decoded jwt at this key }) const augJwtAccount = (req, res, next) => { req.jwtAccount = get(['jwtDecoded', jwtClaimsNamespace, 'account'], null, req) return next() } const augJwtPermissions = (req, res, next) => { const jwtPermissionsKey = [jwtClaimsNamespace, 'permissions'] const jwtRolesKey = [jwtClaimsNamespace, 'roles'] const jwtGroupsKey = [jwtClaimsNamespace, 'groups'] const permissions = jwtPermissionsKey ? compose( get(jwtPermissionsKey, []), get('jwtDecoded', {}), )(req) : [] const roles = jwtRolesKey ? compose( get(jwtRolesKey, []), get('jwtDecoded', {}), )(req) : [] const groups = jwtGroupsKey ? compose( get(jwtGroupsKey, []), get('jwtDecoded', {}), )(req) : [] req.jwtPermissions = Array.isArray(permissions) ? permissions : [] req.jwtRoles = Array.isArray(roles) ? roles : [] req.jwtGroups = Array.isArray(groups) ? groups : [] return next() } // will add all of the permissions associated with the JWT's roles and groups to req.jwtPermissions const AugPermissionsFromGroupsAndRoles = groupsRolesCache => async (req, res, next) => { if(!groupsRolesCache) return next() let allGroupsRoles try { allGroupsRoles = await groupsRolesCache.getAllGroupsRoles() } catch(err) { logRepo && logRepo.add( 'check-jwt-get-all-groups-roles-fail', logLevels.ERROR, 'Exception while fetching all groups and roles.', { data: { authApiUrl, cacheFor, }, }, req, err, ) return next(new CannotContinueError('permission-fetch-fail', 'Permissions could not be fetched in order to authorize the request.')) } const permissionsSet = new Set(req.jwtPermissions) req.jwtRoles.forEach(roleName => { const rolePermissions = get(['roles', roleName.toLowerCase(), 'permissions'], [], allGroupsRoles) rolePermissions.forEach(permission => permissionsSet.add(permission)) }) req.jwtGroups.forEach(groupName => { const groupPermissions = get(['groups', groupName.toLowerCase(), 'permissions'], [], allGroupsRoles) groupPermissions.forEach(permission => permissionsSet.add(permission)) }) req.jwtPermissions = Array.from(permissionsSet) next() } const groupsRolesCache = (authApiUrl && appAuth) || fetchAllGroupsRoles !== fetchAllGroupsRolesDefault ? GroupsRolesCache({authApiUrl, appAuth, fetchAllGroupsRoles, cacheFor}) : null // compose the middlewarres return Router().use( check, augJwtAccount, augJwtPermissions, AugPermissionsFromGroupsAndRoles(groupsRolesCache), ) } // at least one permission passed must match. // permissionsOrRequest can be an array of permissions, or the request object. if the // request object, will grab permissions from it. export const allowedAny = (permissionsOrRequest, checkPermissions) => { checkPermissions = liftA(checkPermissions).filter(x => !!x) const permissions = compose( filter(x => !!x), liftA, x => Array.isArray(x) ? x : get('jwtPermissions', [], x), // the request object )(permissionsOrRequest) return compose( reduce((acc, x) => { // already matched if(acc) return acc // ends with a :, so can match a category of permissions if(/:$/.test(x)) { const regex = new RegExp(`^${x}`) return permissions.filter(regex.test.bind(regex)).length > 0 } // must matched a permission exactly else { return permissions.includes(x) } }, false), filter(x => !!x), liftA, )(checkPermissions) } // all permissions passed must match. // // permissionsOrRequest can be an array of permissions, or the request object. if the // request object, will grab permissions from it. export const allowedAll = (permissionsOrRequest, checkPermissions) => { checkPermissions = liftA(checkPermissions).filter(x => !!x) const permissions = compose( filter(x => !!x), liftA, x => Array.isArray(x) ? x : get('jwtPermissions', [], x), // the request object )(permissionsOrRequest) return compose( reduce((acc, x) => { // already found one that doesn't match if(!acc) return acc // ends with a :, so can match a category of permissions if(/:$/.test(x)) { const regex = new RegExp(`^${x}`) return permissions.filter(regex.test.bind(regex)).length > 0 } // must matched a permission exactly else { return permissions.includes(x) } }, true), filter(x => !!x), liftA, )(checkPermissions) } // checks that the current account has all of the provided permissions. // relies on jwtPermissions being present on the request (so use CheckJwt first). export const CheckPermission = permissionsToCheck => (req, res, next) => { if(allowedAll(req, permissionsToCheck)) { next() } else { next(new ForbiddenError('not-permitted', {message: 'Not permitted to access this resource.'})) } } // checks that the current account has any of the provided permissions. // relies on jwtPermissions being present on the request (so use CheckJwt first). export const CheckPermissionAny = permissionsToCheck => (req, res, next) => { if(allowedAny(req, permissionsToCheck)) { next() } else { next(new ForbiddenError('not-permitted', {message: 'Not permitted to access this resource.'})) } } export const CheckExists = (pool, tableName, paramNameToColumnName) => (req, res, next) => { validateMustExist(pool, tableName, paramNameToColumnName, undefined, req.params) .then(x => { x.isValid ? next() : next(new PageNotFoundError()) }) .catch(err => { next(err) }) } // makes sure a row in the database exists based on a uuid provided as a request param. if // the id param is not a valid uuid, will not hit the database (passing a non-valid uuid to // mysql's UUID_TO_BIN function will throw an error). // if record not found, will next with a PageNotFoundError. export const CheckExistsUuid = (pool, tableName, idParamName, idColumnName = undefined) => (req, res, next) => { idColumnName = idColumnName || idParamName const idParam = get(['params', idParamName], undefined, req) if(!idParam || !isUuid.v1(idParam)) return next(new PageNotFoundError()) CheckExists(pool, tableName, {[idParamName]: [idColumnName, 'UUID_TO_BIN(?)']})(req, res, next) }