oniyi-http-plugin-credentials
Advanced tools
Comparing version 0.2.2 to 1.0.0
264
lib/index.js
'use strict'; | ||
// node core modules | ||
// 3rd party modules | ||
const _ = require('lodash'); | ||
const passport = require('passport'); | ||
const logger = require('oniyi-logger')('oniyi:http-client:plugin:attach-credentials'); | ||
const debug = require('debug')('oniyi:http-client:plugin:attach-credentials'); | ||
// internal modules | ||
const { makeRequestParamsExtractor, getUserId } = require('./utils'); | ||
const { applyCredentials } = require('./apply-credentials'); | ||
function areCredentialsExpired(credentials, callback) { | ||
return callback(null, credentials.expiresAt && credentials.expiresAt < Date.now()); | ||
} | ||
const PLUGIN_NAME = 'attach-credentials'; | ||
function refreshCredentials(strategy, currentCredentials, callback) { | ||
const params = { grant_type: 'refresh_token' }; | ||
// eslint-disable-next-line no-underscore-dangle | ||
strategy._oauth2.getOAuthAccessToken(currentCredentials.refreshToken, params, | ||
(err, accessToken, refreshToken, respParams) => { | ||
if (err) { | ||
return callback(err); | ||
} | ||
const defaults = { | ||
removeUserProp: true, | ||
userPropName: 'user', | ||
credentialsMethodName: 'getCredentialsForProvider', | ||
}; | ||
// @TODO: add max expiresIn and default issuedOn | ||
const expiresIn = respParams && respParams.expires_in ? parseInt(respParams.expires_in, 10) : undefined; | ||
const issuedOn = respParams && respParams.issued_on ? parseInt(respParams.issued_on, 10) : undefined; | ||
const expiresAt = issuedOn + expiresIn; | ||
// make credentials object | ||
const credentials = { | ||
accessToken, | ||
refreshToken, | ||
expiresAt, | ||
expiresIn, | ||
issuedOn, | ||
tokenType: respParams.token_type || 'Bearer', | ||
}; | ||
return callback(null, credentials); | ||
}); | ||
} | ||
function makeAuthParams(credentials, callback) { | ||
return callback(null, { | ||
auth: { | ||
bearer: credentials.accessToken, | ||
}, | ||
authType: 'oauth', | ||
}); | ||
} | ||
/** | ||
* attachCredentialsPluginFactory | ||
* @param {Object} pluginOptions options to define general plugin behaviour | ||
* @param {String} pluginOptions.providerName Name of the provider that credentials should be resolved for | ||
* @param {Boolean} [pluginOptions.removeUserProp=true] should plugin remove `user` prop from `reqParams` | ||
* @param {String} [pluginOptions.userPropName="user"] name of the `reqParams` property that holds the `user` object | ||
* @param {String} [pluginOptions.credentialsMethodName="getCredentialsForProvider"] name of the method on `user` object that resolves credentials for `providerName` | ||
* @return {Object} [description] | ||
*/ | ||
module.exports = function attachCredentialsPluginFactory(pluginOptions) { | ||
// compile plugin options; set default values for properties that have not been provided | ||
const options = _.defaults({}, pluginOptions, { | ||
removeUserProp: true, | ||
areCredentialsExpired, | ||
refreshCredentials, | ||
makeAuthParams, | ||
// when the providerName ends with "-link", we'll find provider related credentials in the | ||
// "credentials" relation. Otherwise, provider related credentials will be available in the | ||
// "identities" relation of the user | ||
userRelationProp: /-link$/.test(pluginOptions.providerName) ? 'credentials' : 'identities', | ||
// name of the related document's property actually holding the credentials | ||
credentialsProp: 'credentials', | ||
}); | ||
const options = _.defaults({}, pluginOptions, defaults); | ||
// options verification | ||
if (!_.isString(options.providerName)) { | ||
throw new TypeError(`options.providerName must be of type "String"; | ||
provided: ${options.providerName} [${typeof options.providerName}]`); | ||
const err = new TypeError('providerName must be a "String"'); | ||
debug(err.message, { options }); | ||
throw err; | ||
} | ||
if (!_.isFunction(options.areCredentialsExpired)) { | ||
throw new TypeError(`options.areCredentialsExpired must be of type "Function"; | ||
provided: [${typeof options.areCredentialsExpired}]`); | ||
} | ||
const { | ||
providerName, removeUserProp, userPropName, credentialsMethodName, | ||
} = options; | ||
if (!_.isFunction(options.refreshCredentials)) { | ||
throw new TypeError(`options.refreshCredentials must be of type "Function"; | ||
provided: [${typeof options.refreshCredentials}]`); | ||
} | ||
const extractRequestparams = makeRequestParamsExtractor(removeUserProp, userPropName); | ||
if (!_.isFunction(options.makeAuthParams)) { | ||
throw new TypeError(`options.makeAuthParams must be of type "Function"; | ||
provided: [${typeof options.makeAuthParams}]`); | ||
} | ||
return { | ||
name: 'attach-credentials', | ||
name: PLUGIN_NAME, | ||
load: (req, origParams, callback) => { | ||
// create a copy of provided request parameters | ||
const reqParams = options.removeUserProp ? _.omit(origParams, ['user']) : _.assign({}, origParams || {}); | ||
const user = origParams.user; | ||
// create a copy of provided request parameters (remove user prop if requested in `options`) | ||
const reqParams = extractRequestparams(origParams); | ||
const { [userPropName]: user } = origParams; | ||
if (!user) { | ||
logger.debug('No "user" prop found in request params, skipping plugin operations'); | ||
return callback(null, origParams); | ||
debug('No "%s" prop found in request params, skipping plugin operations', userPropName, origParams); | ||
// [bk] @TODO: should we use `reqParams` here instead? | ||
// That would mean we potentially pass back params without | ||
// the user prop and without credentials | ||
callback(null, origParams); | ||
return; | ||
} | ||
user[options.userRelationProp]({ | ||
where: { | ||
provider: options.providerName, | ||
}, | ||
}, (credentialsErr, results) => { | ||
if (credentialsErr) { | ||
logger.error(`Error while loading identities for user "${user.id}"`, credentialsErr); | ||
return callback(credentialsErr); | ||
} | ||
if (!_.isFunction(user[credentialsMethodName])) { | ||
const msg = `${userPropName}.${credentialsMethodName} must be a function`; | ||
debug(msg, { user }); | ||
callback(new TypeError(msg)); | ||
return; | ||
} | ||
// verify results | ||
// must be array of length === 1 | ||
if (!Array.isArray(results) || results.length !== 1) { | ||
logger.error(`Failed to load identities for user "${user.id}"`); | ||
logger.debug(`results is array? ${Array.isArray(results)}`); | ||
logger.debug(`results length? ${results.length}`); | ||
return callback(new Error(`Failed to load identities for user "${user.id}"`)); | ||
user[credentialsMethodName](providerName, reqParams, (getCredentialsError, credentials) => { | ||
if (getCredentialsError) { | ||
debug( | ||
'Failed to load credentials for %s %s and provider %s', | ||
userPropName, | ||
getUserId(user), | ||
providerName, | ||
getCredentialsError | ||
); | ||
callback(getCredentialsError); | ||
return; | ||
} | ||
const identity = results[0]; | ||
const credentials = identity[options.credentialsProp]; | ||
if (!credentials) { | ||
logger.warn(`No credentials found for user "${user.id}" and provider "${options.providerName}"`); | ||
return callback(null, origParams); | ||
// [bk] @TODO: add switch to plugin options to either abort or ignore this error. | ||
// currently we abort | ||
const err = new Error(`No credentials found for user "${getUserId(user)}" and provider "${providerName}"`); | ||
debug(err.message); | ||
callback(err); | ||
return; | ||
} | ||
if (!credentials.userId) { | ||
Object.assign(credentials, { userId: user.id }); | ||
} | ||
// must handle credentials refresh in pre-flight phase due to possible use of stream API | ||
// with request. If client wants to send data, we can not ensure that data is still available | ||
// when retrying. | ||
options.areCredentialsExpired(credentials, (expiredErr, credentialsExpired) => { | ||
if (expiredErr) { | ||
return callback(expiredErr); | ||
applyCredentials(reqParams, credentials, (applyCredentialsError, reqParamsWithCredentials) => { | ||
if (applyCredentialsError) { | ||
callback(applyCredentialsError); | ||
return; | ||
} | ||
if (credentialsExpired) { | ||
logger.warn(`credentials for user "${user.id}" and provider "${options.providerName}" are expired`); | ||
// load the passport-strategy instance from passport | ||
// eslint-disable-next-line no-underscore-dangle | ||
const strategy = passport._strategy(options.providerName); | ||
if (!strategy) { | ||
throw new Error(`Auth provider with name "${options.providerName}" is not registered`); | ||
} | ||
return options.refreshCredentials(strategy, credentials, (refreshCredentialsErr, newCredentials) => { | ||
if (refreshCredentialsErr) { | ||
return callback(refreshCredentialsErr); | ||
} | ||
// store new credentials in the user's identities / credentials | ||
identity.updateAttribute(options.credentialsProp, newCredentials, | ||
(updateIdentityErr, updatedIdentity) => { | ||
if (updateIdentityErr) { | ||
return callback(updateIdentityErr); | ||
} | ||
logger.debug('updated user identity', updatedIdentity); | ||
// pass newCredentials to makeAuthParams and merge return value with the modified request parameters | ||
// explicitly using "merge" here to allow partial updates of nested object literals | ||
options.makeAuthParams(newCredentials, (authParamsErr, authParams) => { | ||
if (authParamsErr) { | ||
return callback(authParamsErr); | ||
} | ||
_.merge(reqParams, authParams); | ||
// finish plugin execution; hand over the modified request parameters | ||
return callback(null, reqParams); | ||
}); | ||
return null; | ||
}); | ||
return null; | ||
}); | ||
} | ||
// when original credentials were not expired, attach them to the request parameters and finish | ||
// plugin execution | ||
// pass credentials to makeAuthParams and merge return value with the modified request parameters | ||
// explicitly using "merge" here to allow partial updates of nested object literals | ||
options.makeAuthParams(credentials, (authParamsErr, authParams) => { | ||
if (authParamsErr) { | ||
return callback(authParamsErr); | ||
} | ||
_.merge(reqParams, authParams); | ||
// finish plugin execution; hand over the modified request parameters | ||
return callback(null, reqParams); | ||
}); | ||
return null; | ||
callback(null, reqParamsWithCredentials); | ||
}); | ||
return null; | ||
}); | ||
return null; | ||
}, | ||
}; | ||
}; | ||
// remember reference to the original callback function | ||
// const originalCallback = origParams.callback; | ||
// reqParams.callback = (requestErr, response, body) => { | ||
// if (requestErr) { | ||
// return originalCallback(requestErr, response, body); | ||
// } | ||
// // this approach lacks support of data-providing requests | ||
// // stream API | ||
// if (response && response.statusCode === 401) { | ||
// return refreshCredentials(strategy, credentials, (refreshCredentialsErr, newCredentials) => { | ||
// if (refreshCredentialsErr) { | ||
// return originalCallback(requestErr, response, body); | ||
// } | ||
// // store new credentials in the user's identities / credentials | ||
// identity.updateAttribute(options.credentialsProp, newCredentials, | ||
// (updateIdentityErr, updatedIdentity) => { | ||
// if (updateIdentityErr) { | ||
// return originalCallback(requestErr, response, body); | ||
// } | ||
// // update request parameters with new accessToken and original callback function | ||
// reqParams.auth = { | ||
// bearer: newCredentials.accessToken, | ||
// }; | ||
// reqParams.callback = originalCallback; | ||
// // restart the request | ||
// return callback(null, reqParams); | ||
// }); | ||
// }); | ||
// } | ||
// return originalCallback(requestErr, response, body); | ||
// }; |
{ | ||
"name": "oniyi-http-plugin-credentials", | ||
"version": "0.2.2", | ||
"version": "1.0.0", | ||
"description": "A plugin for oniyi-http-client for automatic attachment of user credentials", | ||
"homepage": "", | ||
"author": { | ||
"name": "Benjamin Kroeger", | ||
"email": "benjamin.kroeger@gmail.com", | ||
"url": "" | ||
}, | ||
"homepage": "https://github.com/benkroeger/oniyi-http-plugin-credentials#readme", | ||
"author": "Benjamin Kroeger <benjamin.kroeger@gmail.com>", | ||
"files": [ | ||
"lib" | ||
"lib/" | ||
], | ||
@@ -26,25 +22,33 @@ "main": "lib/index.js", | ||
"dependencies": { | ||
"lodash": "^4.10.0", | ||
"oniyi-logger": "^1.0.0", | ||
"passport": "^0.3.2" | ||
"debug": "^3.1.0", | ||
"lodash": "^4.17.4" | ||
}, | ||
"devDependencies": { | ||
"eslint": "^3.5.0", | ||
"eslint-config-oniyi": "^4.2.0", | ||
"gulp": "^3.9.1", | ||
"gulp-coveralls": "^0.1.4", | ||
"gulp-eslint": "^2.0.0", | ||
"gulp-exclude-gitignore": "^1.0.0", | ||
"gulp-istanbul": "^0.10.4", | ||
"gulp-mocha": "^2.2.0", | ||
"gulp-nsp": "^2.4.0", | ||
"gulp-plumber": "^1.1.0", | ||
"lodash": "^4.15.0" | ||
"ava": " ^0.23.0", | ||
"eslint": "^4.10.0", | ||
"eslint-config-oniyi": "^5.0.2", | ||
"eslint-plugin-ava": "^4.2.2", | ||
"nyc": "^11.2.1", | ||
"prettier-eslint-cli": "^4.4.0", | ||
"sinon": "^4.1.2" | ||
}, | ||
"repository": "benkroeger/oniyi-http-plugin-credentials", | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/benkroeger/oniyi-http-plugin-credentials.git" | ||
}, | ||
"scripts": { | ||
"prepublish": "gulp prepublish", | ||
"test": "gulp" | ||
"format": "prettier-eslint --write \"lib/**/*.js\" \"test/**/*.js\"", | ||
"prelint": "npm run format", | ||
"lint": "eslint --ignore-path .gitignore .", | ||
"test": "ava --verbose", | ||
"test:watch": "npm test -- --watch", | ||
"coverage": "nyc npm test && nyc report --reporter=html" | ||
}, | ||
"license": "MIT" | ||
"license": "MIT", | ||
"bugs": { | ||
"url": "https://github.com/benkroeger/oniyi-http-plugin-credentials/issues" | ||
}, | ||
"directories": { | ||
"test": "test" | ||
} | ||
} |
@@ -1,3 +0,3 @@ | ||
# oniyi-http-plugin-credentials [![NPM version][npm-image]][npm-url] [![Dependency Status][daviddm-image]][daviddm-url] [![Coverage percentage][coveralls-image]][coveralls-url] | ||
> A plugin for oniyi-http-client for automatic attachment of user credentials | ||
# oniyi-http-plugin-credentials [![NPM version][npm-image]][npm-url] [![Dependency Status][daviddm-image]][daviddm-url] | ||
> An async plugin for oniyi-http-client to resolve and attach credentials to request params | ||
@@ -23,3 +23,8 @@ This plugin is designed to work with the [third-party login component](https://docs.strongloop.com/pages/releaseview.action?pageId=3836277) of [loopback](https://docs.strongloop.com/display/public/LB/LoopBack). | ||
const pluginOptions = {}; | ||
const pluginOptions = { | ||
providerName: 'my-auth-provider', // Name of the provider that credentials should be resolved for | ||
removeUserProp: true, // should plugin remove `user` prop from `reqParams` | ||
userPropName: 'user', // name of the `reqParams` property that holds the `user` object | ||
credentialsMethodName: 'getCredentialsForProvider', // name of the method on `user` object that resolves credentials for `providerName` | ||
}; | ||
const plugin = oniyiHttpPluginCredentials(pluginOptions); | ||
@@ -33,14 +38,50 @@ | ||
available options are: | ||
- **providerName**: undefined (string, required) - name of the passport-strategy to be used. *Note:* passport-strategy **must** be registered first | ||
- **removeUserProp**: true (boolean, optional) - indicates if the `user` property should be removed from the request options | ||
- **areCredentialsExpired**: (function, optional) - async function that checks if credentials are expired. Must take two arguments (`credentials`, `callback(err, isExpired)`) | ||
- **refreshCredentials**: (function, optional) - async function that provides refreshed credentials. Must take three arguments (`strategy`, `currentCredentials`, `callback(err, freshCredentials)`) | ||
- **makeAuthParams**: (function, optional) - async function that provides an object literal to be merged with request parameters. Must take two arguments (`credentials`, `callback(err, authParams)`) | ||
- **userRelationProp**: `/-link$/.test(pluginOptions.providerName) ? 'credentials' : 'identities'`, (string, optional) - name of the relation on `req.user` that we should search for user credentials | ||
- **credentialsProp**: 'credentials' (string, optional) - name of the property in the relation's document to be used for credentials | ||
available options are: | ||
- **providerName**: `undefined` (string, required) - is passed to `getCredentialsForProvider` to indicate which backend we need credentials for | ||
- **removeUserProp**: `true` (boolean, optional) - indicates if the `user` property should be removed from the request options | ||
- **userPropName**: `user` (string, optional) - name of the `reqParams` property that holds the `user` object | ||
- **credentialsMethodName**: `getCredentialsForProvider` (string, optional) - name of the method on `user` object that resolves credentials for `providerName` | ||
## How does it work? | ||
All options of type `function` have default values that can with OAuth2 strategies. | ||
`plugin.load()` retrieves an object with parameters (origParams) that will later be used to make an http(s) request. From there, the following flow is applied: | ||
copy `origParams` into `reqParams`. Depending on `options.removeUserProp`, the original prop named `options.userPropName` will be omitted or included. | ||
read prop named `options.userPropName` from `origParams` into `user`. | ||
If `user` can not be found, abort flow and invoke callback with `origParams`. | ||
If `user[options.credentialsMethodName]` is not a function, invoke callback with `Error`. | ||
Invoke `user[options.credentialsMethodName]` with `options.providerName` and `reqParams` as well as a callback function. | ||
Now `user[options.credentialsMethodName]` is supposed to resolve credentials for `user` and the authentication provider. This resolution should happen async and results be passed to our local callback (which takes `err` and `credentials` arguments). | ||
If an error occurs, plugin flow is aborted and `err` passed to callback. | ||
If `credentials` is falsy, plugin flow is also aborted and callback invoked with an according error. | ||
At this point, we let `user[options.credentialsMethodName]` resolve credentials for the auth provider that this plugin instance is configured for – and no errors occurred. | ||
Now the plugin applies `credentials` to `reqParams`. For that, `credentials.type` is mapped against a list of supported credential types. If `credentials.type` is supported, that type specific implementation is invoked with `reqParams` and `credentials.payload`. | ||
Each credentials type expects a different layout of `credentials.payload`. | ||
## Credentials types | ||
### basic | ||
Reads `username`, `password` and optionally `sendImmediately` (default: `true`) and `authType` (default: `basic`) from `payload` and injects them into `reqParams`. | ||
Use this type when you have username and password at hand (plain) | ||
### bearer | ||
Reads `token` and optionally `sendImmediately` (default: `true`) and `authType` (default: `oauth`) from `payload` and injects them into `reqParams`. | ||
Use this type when you have e.g. an OAuth2 / OIDC access token at hand | ||
### cookie | ||
Reads `cookie` and optionally `authType` (default: `cookie`) from `payload` and injects them into `reqParams`. The value of `cookie` is set into `reqParams.headers.cookie`. If `reqParams.headers.cookie` was not empty to begin with, value of `cookie` is appended. | ||
Use this type when you have an authentication cookie (e.g. LtpaToken2 for IBM Websphere ApplicationServer) at hand. | ||
### header | ||
Reads `value` and optionally `name` (default: `authorization`) and `authType` (default: `undefined`) from `payload` and injects them into `reqParams`. The `value` is set into `reqParams.headers[name]`. | ||
Use this type for any other form of credentials that are provided in a http header. E.g. if you only have basic credentials already base64 encoded or you're working with a custom TAI for IBM Websphere ApplicationServer where you simply pass an encrypted username to the remote host. | ||
## License | ||
@@ -57,3 +98,1 @@ | ||
[daviddm-url]: https://david-dm.org/benkroeger/oniyi-http-plugin-credentials | ||
[coveralls-image]: https://coveralls.io/repos/benkroeger/oniyi-http-plugin-credentials/badge.svg | ||
[coveralls-url]: https://coveralls.io/r/benkroeger/oniyi-http-plugin-credentials |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
2
7
6
1
0
96
0
14572
148
1
+ Addeddebug@^3.1.0
+ Addeddebug@3.2.7(transitive)
+ Addedms@2.1.3(transitive)
- Removedoniyi-logger@^1.0.0
- Removedpassport@^0.3.2
- Removedoniyi-logger@1.0.0(transitive)
- Removedpassport@0.3.2(transitive)
- Removedpassport-strategy@1.0.0(transitive)
- Removedpause@0.0.1(transitive)
Updatedlodash@^4.17.4