@infomaker/service-authorization-lib
Version:
IMID Service Authorization Library
370 lines (330 loc) • 12.5 kB
JavaScript
/* eslint-env node */
'use-strict'
const jwt = require('jsonwebtoken')
const errors = require('./errors')
const log = require('@infomaker/json-log')(__filename)
const validators = require('./validators')
const authorizationModes = require('./authorization-modes')
/** @module Authorize */
/**
* The result object returnd if the authorization was successful
* @typedef authorizationSuccessResult
* @param {Object} result
* @param {Object} result.credentials
* @param {Object} result.credentials.serviceToken - Decoded service token used to authorize the request
* @param {Object} result.artifacts
* @param {string} result.artifacts.reason - Why the request was authorized
* @param {Object[]} result.artifacts.authenticationParameters - The built authorization parameters used to authorize the request
* @param {Object[]} result.artifacts.matchingAccessRules - The built access rules that matched the provided token
* @param {Object} result.artifacts.matchingServiceAdmin - If the token matched a service admin rule
*/
/**
* The error object returned if the authorization failed.
*
* Returns either Unauthoried, AccessDenied or ConfigError
* @typedef authorizationErrorResult
* @param {Object} result
* @param {(Errors~Unauthorized|Errors~AccessDenied|Errors~ConfigError)} result.err - The error thrown during authorization
*/
/**
*
* Main authorization function
*
* Consists of the following steps:
* 1. If token is missing and endpoint is open, authorize request.
* 2. Validate and decode service token.
* 3. If servieToken exists and endpoint is open, authorize request.
* 4. Try to authorize using serviceAdmin.
* 5. Build auth params and check if service token exists.
* 6. Authorize organization.
* 7. Authorize using optional accessRules.
*
* @async
* @function authorize
* @param {Object} params
* @param {AccessRule} params.authParams - Object with auth parameters from the request
* @param {string} params.unverifiedServiceToken - Unverified service token in JWT format
* @param {string} params.serviceTokenSignSecret - The secret the unverified token should be validated with
* @param {Object} params.request - The request object to be made availbable in authParams
*
* @returns {(authorizationSuccessResult|authorizationErrorResult)} Either returns an authorizeSuccessResult object or an authorizationErrorResult object
*/
const authorize = async ({authParams, unverifiedServiceToken, serviceTokenSignSecret, imidToken, request}) => {
_removeAppendedImidHeadersFromRequest(request)
try {
const validEndpointAuthParams = await _validateAuthParams(authParams)
/**
* Step 1: If token is missing and endpoint is open, authorize request
*/
if (!unverifiedServiceToken && validEndpointAuthParams === authorizationModes.openEndpoint) {
return _authorizeRequest({
reason: 'Open endpoint and no serviceToken',
requestAuthParams: validEndpointAuthParams
})
}
/**
* Step 2: Validate and decode service token
* @private
*/
const serviceToken = await _verifyAndDecodeServiceToken(unverifiedServiceToken, serviceTokenSignSecret)
/**
* Step 3: If servieToken exists and endpoint is open, authorize request
*/
if (validEndpointAuthParams === authorizationModes.openEndpoint) {
return _authorizeRequest({
reason: 'Authorized: Valid serviceToken for open endpoint',
serviceToken,
imidToken,
requestAuthParams: validEndpointAuthParams
})
}
if (serviceToken && validEndpointAuthParams === authorizationModes.authenticatedEndpoint) {
return _authorizeRequest({
reason: 'Authorized: Valid serviceToken for authenticated endpoint',
serviceToken,
imidToken,
requestAuthParams: validEndpointAuthParams
})
}
/**
* Step 4: Try to authorize using serviceAdmin
* @private
*/
const matchingServiceAdmin = await _authorizeUsingServiceAdmin(serviceToken)
if (matchingServiceAdmin !== null) {
return _authorizeRequest({
reason: 'Authorized: Service admin rule matched',
matchingServiceAdmin,
serviceToken,
imidToken,
requestAuthParams: validEndpointAuthParams,
})
}
/**
* Step 5: Build auth params and check if service token exists
* @private
*/
const requestAuthParams = await _buildAuthParamsForThisRequest(validEndpointAuthParams, request)
if (!serviceToken) {
throw new errors.Unauthorized({
reason: 'Invalid or missing service token for locked down endpoint',
serviceToken,
requestAuthParams,
loginRedirectOrg: requestAuthParams.org
})
}
/**
* Step 6: Authorize organization
*/
if (Object.prototype.hasOwnProperty.call(requestAuthParams, 'org') && requestAuthParams.org !== false) {
if (requestAuthParams.org === serviceToken.org) {
if (!Object.prototype.hasOwnProperty.call(requestAuthParams,'accessRules')) {
return _authorizeRequest({
reason: 'Authorized: Org matched org and no accessRules exists (locked down endpoint)',
serviceToken,
imidToken,
requestAuthParams
}) // means that anyone in org is allowed
}
} else {
throw new errors.Unauthorized({
reason: 'Token org does not match required org for locked down endpoint',
serviceToken,
requestAuthParams,
loginRedirectOrg: requestAuthParams.org
})
}
}
/**
* Step 7: Authorize using optional accessRules
*/
if (Object.prototype.hasOwnProperty.call(requestAuthParams,'accessRules')) {
const matchingAccessRules = await _authorizeUsingAccessRules(serviceToken, requestAuthParams)
if (matchingAccessRules.length) {
const reason = requestAuthParams.org ? 'Authorized: Matching org and access rule (for locked down endpoint)' : 'Authorized: Matching access rule and false org (for locked down endpoint)'
return _authorizeRequest({
reason,
matchingAccessRules,
serviceToken,
imidToken,
requestAuthParams
})
}
}
throw new errors.AccessDenied({
reason: 'Neither serviceAdmin nor org/accessRules matched for locked down endpoint',
serviceToken,
requestAuthParams,
})
} catch (err) {
// We don't want to throw execptions, instead return a object holding the error
return {
err: err
}
}
}
const _removeAppendedImidHeadersFromRequest = (req) => {
const rawRequest = (req.raw && req.raw.req) ? req.raw.req : req
/* istanbul ignore if */
if (!Object.prototype.hasOwnProperty.call(rawRequest,'headers')) {
log.warn('Request contains no headers, skipping cleaning', rawRequest)
return
}
const appendedHeaders = [
'authorization',
'x-imid-req-id',
'x-imid-token'
]
appendedHeaders.forEach(appendedHeader => {
delete rawRequest.headers[appendedHeader]
})
}
const _authorizeRequest = ({reason, serviceToken, imidToken, requestAuthParams, matchingAccessRules=[], matchingServiceAdmin=null}) => {
return {
credentials: {
serviceToken,
imidToken
},
artifacts: {
reason,
authenticationParameters: requestAuthParams,
matchingAccessRules,
matchingServiceAdmin
}
}
}
const _authorizeUsingServiceAdmin = async (serviceToken) => {
if (serviceToken !== null && serviceToken.service_admin === true) {
return { serviceAdmin: true }
}
return null
}
const _authorizeUsingAccessRules = async (serviceToken, requestAuthParams) => {
const accessRules = await validators.accessRules.validateAsync(requestAuthParams.accessRules)
const matchingAccessRules = accessRules.filter(accessRule => _accessRuleMatcher(accessRule, serviceToken))
return matchingAccessRules
}
const _accessRuleMatcher = (rule, serviceToken) => {
let permissionsToCheck = serviceToken.permissions.org.slice()
const unitPermissions = serviceToken.permissions.units[rule.unit]
if (Object.prototype.hasOwnProperty.call(rule,'unit')) {
if (Array.isArray(unitPermissions)) {
permissionsToCheck = permissionsToCheck.concat(unitPermissions)
} else {
return false // Token does not belong to unit
}
}
if (Object.prototype.hasOwnProperty.call(rule,'permission') && !permissionsToCheck.includes(rule.permission)) {
return false // Token does not contain correct permission
}
if (Object.prototype.hasOwnProperty.call(rule,'sub') && serviceToken.sub !== rule.sub) {
return false // Token does not contain correct sub
}
return true
}
const _verifyAndDecodeServiceToken = (serviceToken, serviceTokenSignSecret) => {
return new Promise(resolve => {
jwt.verify(serviceToken, serviceTokenSignSecret, {}, (err, decodedToken) => {
err ? resolve(null) : resolve(decodedToken)
})
})
}
const _validateAuthParams = async (authParams) => {
try {
return await validators.authParams.validateAsync(authParams)
} catch (err) {
throw new errors.ConfigError({
msg: 'Authentication parameters are misconfigured',
originalError: err,
stack: err.stack
})
}
}
const _buildAuthParamsForThisRequest = async (endpointAuthParams, request) => {
log.debug('SAL: Building auth params from request', {
endpointAuthParams
})
if (endpointAuthParams === authorizationModes.serviceAdminEndpoint) {
return authorizationModes.serviceAdminEndpoint // Special handling for service admin enpoints
}
if (endpointAuthParams === authorizationModes.authenticatedEndpoint) {
return authorizationModes.authenticatedEndpoint // Special handling for authenticated enpoints
}
let requestAuthParams = {}
await _buildOnPreAuth(endpointAuthParams.onPreAuth, request)
requestAuthParams.org = await _buildOrg(endpointAuthParams.org, request)
if (endpointAuthParams.accessRules) {
requestAuthParams.accessRules = await _buildAccessRules(endpointAuthParams.accessRules, request)
}
return requestAuthParams
}
const _buildOnPreAuth = async (onPreAuth, request) => {
try {
typeof onPreAuth === 'function' ? await onPreAuth(request) : onPreAuth
} catch (err) {
// Propagate all IM-errors
if (err.isImError) {
throw err
}
// Wrap all others as config error
throw new errors.ConfigError({
msg: 'Error thrown while building onPreAuth',
originalError: err,
onPreAuth,
stack: err.stack
})
}
}
const _buildOrg = async (org, request) => {
try {
const builtOrg = typeof org === 'function' ? await org(request) : org
if (builtOrg === undefined) {
throw new Error('Org function returned undefined')
}
return await validators.requiredStringOrFalse.validateAsync(builtOrg) // ensure resulting org is string or false
} catch (err) {
// Propagate all IM-errors
if (err.isImError) {
throw err
}
// Wrap all others as config error
throw new errors.ConfigError({
msg: 'Error thrown while building org',
originalError: err,
org,
stack: err.stack
})
}
}
const _buildAccessRules = async (accessRules, request) => {
try {
const builtAccessRules = accessRules.map(async authorizationRule => {
const builtAccessRule = {}
const builtAccessRulePromise = Object.keys(authorizationRule).map(async key => {
const valueOrFunToGenValue = authorizationRule[key]
builtAccessRule[key] = typeof valueOrFunToGenValue === 'function' ? await valueOrFunToGenValue(request) : valueOrFunToGenValue
if (builtAccessRule[key] === undefined) {
throw new Error(`Access rule function for key ${key} returned undefined`)
}
return builtAccessRule[key]
})
await Promise.all(builtAccessRulePromise)
await validators.builtAccessRule.error(() => `Built access rule "${builtAccessRule}" failed validation`).validateAsync(builtAccessRule)
return builtAccessRule
})
return await Promise.all(builtAccessRules)
} catch (err) {
// Propagate all IM-errors
if (err.isImError) {
throw err
}
// Wrap all others as config error
throw new errors.ConfigError({
msg: 'Error thrown while building access rules',
originalError: err,
accessRules,
stack: err.stack
})
}
}
module.exports = authorize