Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#75 Add back configurable access token creation plugin support #76

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
[![Downloads/week](https://img.shields.io/npm/dw/@adobe/aio-lib-ims.svg)](https://npmjs.org/package/@adobe/aio-lib-ims)
[![Build Status](https://travis-ci.com/adobe/aio-lib-ims.svg?branch=master)](https://travis-ci.com/adobe/aio-lib-ims)
[![License](https://img.shields.io/npm/l/@adobe/aio-lib-ims.svg)](https://github.com/adobe/aio-lib-ims/blob/master/package.json)
[![Codecov Coverage](https://img.shields.io/codecov/c/github/adobe/aio-lib-ims/master.svg?style=flat-square)](https://codecov.io/gh/adobe/aio-lib-ims/)
[![Codecov Coverage](https://img.shields.io/codecov/c/github/adobe/aio-lib-ims/master.svg?style=flat-square)](https://codecov.io/gh/adobe/aio-lib-ims/)


# Adobe I/O IMS Library
Expand Down Expand Up @@ -126,7 +126,7 @@ In general, you do not need to deal with this property.

## Set Current Context (Advanced)

The default context can be set locally with `await context.setCurrent('contextname')`.
The default context can be set locally with `await context.setCurrent('contextname')`.
This will write the following configuration to the `ims` key in the `.aio` file of the current working directory:

```js
Expand Down Expand Up @@ -166,7 +166,7 @@ JWT (service to service integration) configuration requires the following proper

## Setting the Private Key

For a JWT configuration, your private key is generated in Adobe I/O Console, and is downloaded to your computer when you generate it.
For a JWT configuration, your private key is generated in Adobe I/O Console, and is downloaded to your computer when you generate it.

Adobe I/O Console does not keep the private key (only your corresponding public key) so you will have to set the private key that was downloaded manually in your IMS context configuration.

Expand Down Expand Up @@ -197,6 +197,31 @@ OAuth2 configuration requires the following properties:
| redirect_uri | The _Default redirect URI_ from the integration overview screen in the I/O Console. Alternatively, any URI matching one of the _Redirect URI patterns_ may be used. |
| scope | Scopes to assign to the tokens. This is a string of space separated scope names which depends on the services this integration is subscribed to. Adobe I/O Console does not currently expose the list of scopes defined for OAuth2 integrations, a good list of scopes by service can be found in [OAuth 2.0 Scopes](https://www.adobe.io/authentication/auth-methods.html#!AdobeDocs/adobeio-auth/master/OAuth/Scopes.md). At the very least you may want to enter `openid`. |


## Token Creation Plugins

Additional token creation plugins can be registered with the Adobe I/O IMS library configuration.
Such token creation plugins must comply with the following contract:

* Implemented as a JavaScript module which can be `require()`-ed by the IMS library
* Registered with the module path used by the `require()` function
* Implementing 3 functions as follows:
* `Promise<any> canSupport(config)` -- Receives the configuration properties of the selected context and returns a promise as to whether the token creation plugin can be used with this configuration. The promise must resolve to `true` if supported or be rejected with an `Error` explaining why the configuration is not supported by the plugin.
* `boolean supports(config)` -- Receives the configuration properties of the selected context and returns `true` if supported or `false` if not supported. This is a syncronous function.
* `Promise<any> imsLogin(ims, config)` -- Receives an instance of the IMS token manager class and the configuration properties of the selected context to create an access token from. If the token creation plugin does not support the configuration or if an error occurs creating the token, the function must return a `Promise` rejecting with an `Error` explaining the problem. Otherwise the promise should resolve to an `object` containing the access token and optionally a refresh token.

The `Context` class offers two methods to manage token creation plugins:

* `Context.getPlugins()` returning a `Promise<string[]>` listing the additional token creation plugins.
* `Context.setPlugins(string[])` taking the list of additional token creation plugins to configure.

**Note 1**: the `setPlugins` method completely replaces the current list of plugins.
Any selective addition of removal must be done in a three step process: Get the plugins, update the list, set the plugins.

**Note 2**: The token creation plugins supporting [JWT](https://github.com/adobe/aio-lib-ims-jwt/blob/master/src/ims-jwt.js), [OAuth2](https://github.com/adobe/aio-lib-ims-oauth/blob/master/src/ims-oauth.js), and [CLI](https://github.com/adobe/aio-lib-ims-oauth/blob/master/src/ims-cli.js) login, are always present.
As such they are note returned by the `getPlugins` method and should not be provided in the `setPlugins` method.


# Contributing
Contributions are welcomed! Read the [Contributing Guide](CONTRIBUTING.md) for more information.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"stdout-stderr": "^0.1.9"
},
"engines": {
"node": ">=10.0.0"
"node": ">=12.0.0"
},
"files": [
"/src"
Expand Down
8 changes: 6 additions & 2 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const CLI = 'cli'
/** Property holding the current context name */
const CURRENT = 'current'

/** Property holding the list of additional token creation plugins */
const PLUGINS = 'plugins'

/** @private */
function guessContextType () {
if (process.env.__OW_ACTION_NAME) {
Expand All @@ -52,7 +55,7 @@ function getContext () {
if (guessContextType() === TYPE_ACTION) {
context = new ActionContext({ IMS, CONTEXTS, CONFIG, CURRENT })
} else {
context = new CliContext({ IMS, CONTEXTS, CONFIG, CURRENT, CLI })
context = new CliContext({ IMS, CONTEXTS, CONFIG, CURRENT, CLI, PLUGINS })
}
}
return context
Expand All @@ -72,5 +75,6 @@ module.exports = {
CURRENT,
CLI,
CONTEXTS,
CONFIG
CONFIG,
PLUGINS
}
30 changes: 30 additions & 0 deletions src/ctx/ConfigCliContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ class ConfigCliContext extends Context {
this.setContextValue(`${this.keyNames.CLI}`, { ...existingData, ...contextData }, local)
}

/**
* Override super class implementation to return the plugins configured
* in the `plugins` configuration property.
*
* @override
*/
async getPlugins () {
aioLogger.debug('getPlugins()')
return this.getConfigValue(this.keyNames.PLUGINS)
}

/**
* Override super class implementation to persist the provided plugins
* in the `plugins` configuration property.
*
* This implementation silently ignores a `plugins` parameter which is
* neither and array nor `null`.
*
* @override
*/
async setPlugins (plugins, local = false) {
aioLogger.debug('setPlugins(%o, %s)', plugins, !!local)

if (plugins instanceof Array || plugins === null) {
this.setConfigValue(this.keyNames.PLUGINS, plugins, !!local)
} else {
aioLogger.debug(' > Ignoring unexpected plugins parameter \'%o\'', plugins)
}
}

/**
* @protected
* @override
Expand Down
35 changes: 35 additions & 0 deletions src/ctx/Context.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,41 @@ class Context {
return this.contextKeys()
}

/**
* Gets the list of configured token creation plugins. This base
* implementation returns an empty array. Extensions supporting
* token creation plugins must override to return the list of
* plugins which can be require-d.
*
* @returns {Promise<string[]>} empty list of token creation plugins
*/
async getPlugins () {
aioLogger.debug('getPlugins() (none)')
return []
}

/**
* Sets the list of configured token creation plugins. This base
* implementation throws an Error as plugins are not supported.
* Extensions supporting token creation plugins must override to
* check and persist the list of plugins which can be require-d.
*
* If the plugins parameter is an empty array or null, the current
* plugins configuration is removed. Otherwise the current
* configuration is replaced by the new list of plugins.
*
* Note, that implementations are only required to validate that
* the plugins parameter is a possibly empty array of strings or
* null. Actually provided string values need not be validated.
*
* @param {string[]} plugins An array of plugins to configure
* @param {boolean} [local=false] set to true to save to local config, false for global config
*/
async setPlugins (plugins, local = false) {
aioLogger.debug('setPlugins(%o, %b)', plugins, !!local)
throwNotImplemented()
}

/* To be implemented */

/**
Expand Down
62 changes: 56 additions & 6 deletions src/token-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ governing permissions and limitations under the License.
const { Ims, ACCESS_TOKEN, REFRESH_TOKEN } = require('./ims')
const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-ims:token-helper', { provider: 'debug' })
const { getContext } = require('./context')
const imsJwtPlugin = require('@adobe/aio-lib-ims-jwt')
const imsJwtPlugin = '@adobe/aio-lib-ims-jwt'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you had the chance to test the ims lib in a runtime action? Otherwise we will test it, the reason we do a static require is to let webpack optimizer know it has to include the '@adobe/aio-lib-ims-jwt' module when bundling/compiling the action code

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, only tested the CLI, actually.

That is a good point regarding web pack. Can web pack be instructed to consider this dependency also in another way ?

If not we could revert this to be a static import and adapt the loadPlugin function accordingly.

Copy link
Collaborator

@fe-lix- fe-lix- Feb 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to understand better how web pack is working, but a quick and dirty solution to this issue would be :

diff --git a/src/token-helper.js b/src/token-helper.js
index c2c37ae..9b774a3 100644
--- a/src/token-helper.js
+++ b/src/token-helper.js
@@ -13,7 +13,7 @@ governing permissions and limitations under the License.
 const { Ims, ACCESS_TOKEN, REFRESH_TOKEN } = require('./ims')
 const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-ims:token-helper', { provider: 'debug' })
 const { getContext } = require('./context')
-const imsJwtPlugin = '@adobe/aio-lib-ims-jwt'
+const imsJwtPlugin = require('@adobe/aio-lib-ims-jwt')
 const { codes: errors } = require('./errors')
 
 /**
@@ -80,6 +80,10 @@ async function getMergedPlugins (context) {
 function loadPlugin (name, location) {
   aioLogger.debug('loadPlugin(%s, %s)', name, location)
 
+  if (name === 'jwt') {
+    return imsJwtPlugin
+  }
+
   try {
     return require(location)
   } catch (error) {


/**
* This is the default list of NPM packages used as plugins to create tokens
Expand All @@ -31,8 +31,8 @@ by aio-lib-runtime */
const ACTION_BUILD = (typeof WEBPACK_ACTION_BUILD === 'undefined') ? false : WEBPACK_ACTION_BUILD
if (!ACTION_BUILD) {
// use OAuth and CLI imports only when WEBPACK_ACTION_BUILD global is not set
const imsCliPlugin = require('@adobe/aio-lib-ims-oauth/src/ims-cli')
const imsOAuthPlugin = require('@adobe/aio-lib-ims-oauth')
const imsCliPlugin = '@adobe/aio-lib-ims-oauth/src/ims-cli'
const imsOAuthPlugin = '@adobe/aio-lib-ims-oauth'

DEFAULT_CREATE_TOKEN_PLUGINS = {
cli: imsCliPlugin,
Expand All @@ -41,8 +41,57 @@ if (!ACTION_BUILD) {
}
}

const IMS_TOKEN_MANAGER = {
/**
* Returns a consolidated list of login plugins to try for acquiring the token.
*
* @param {object} context The configuration context providing additional plugins
* @returns {Promise<string[]>} The list of login plugins to try
*/
async function getMergedPlugins (context) {
aioLogger.debug('getMergedPlugins(%o)', context)

return context.getPlugins()
.then((plugins) => {
if (plugins instanceof Array && plugins.length > 0) {
aioLogger.debug(' > adding configured plugins: %o', plugins)
const configPluginMap = Object.fromEntries(plugins.map(element => [element, element]))
return Object.assign(configPluginMap, DEFAULT_CREATE_TOKEN_PLUGINS)
} else if (plugins !== undefined) {
aioLogger.debug('Ignored configured plugins: Expected string[], got: \'%o\'', plugins)
}

aioLogger.debug(' > using default plugins only')
return DEFAULT_CREATE_TOKEN_PLUGINS
}
)
}

/**
* Loads the requested plugin and returns it or a dummy pluggin in case
* of a load failure. The dummy plugin returns "false" for the supports()
* function and will reject the canSupport() function regardless of supplied
* parameters.
*
* @param {string} name The name of the plugin to try to load
* @param {string} location The location from where to load the plugin
* @returns {object} The loaded plugin or a dummy in case of failure to load
*/
function loadPlugin (name, location) {
aioLogger.debug('loadPlugin(%s, %s)', name, location)

try {
return require(location)
} catch (error) {
aioLogger.debug('Ignoring plugin %s due to load failure from %s', name, location)
aioLogger.debug('Error: %o', error)
return {
supports: () => false,
canSupport: async () => Promise.reject(new Error(`Plugin not loaded: ${JSON.stringify(error)}`))
}
}
}

const IMS_TOKEN_MANAGER = {
async getToken (contextName) {
aioLogger.debug('getToken(%s, %s)', contextName)

Expand Down Expand Up @@ -107,13 +156,14 @@ const IMS_TOKEN_MANAGER = {
async _generateToken (ims, config, reason) {
aioLogger.debug('_generateToken(reason=%s)', reason)

const imsLoginPlugins = DEFAULT_CREATE_TOKEN_PLUGINS
const imsLoginPlugins = await getMergedPlugins(this._context)
aioLogger.debug(' > Got imsLoginPlugins: %o', imsLoginPlugins)
let pluginErrors = ['Cannot generate token because no plugin supports configuration:'] // eslint-disable-line prefer-const

for (const name of Object.keys(imsLoginPlugins)) {
aioLogger.debug(' > Trying: %s', name)
try {
const { canSupport, supports, imsLogin } = imsLoginPlugins[name]
const { canSupport, supports, imsLogin } = await loadPlugin(name, imsLoginPlugins[name])
aioLogger.debug(' > supports(%o): %s', config, supports(config))
if (typeof supports === 'function' && supports(config) && typeof imsLogin === 'function') {
const result = imsLogin(ims, config)
Expand Down
57 changes: 56 additions & 1 deletion test/ctx/ConfigCliContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const keyNames = {
CONFIG: 'b',
CONTEXTS: 'c',
CURRENT: 'd',
CLI: 'd'
CLI: 'd',
PLUGINS: 'e'
}

let context
Expand Down Expand Up @@ -127,3 +128,57 @@ describe('contextKeys', () => {
expect(aioConfig.get).toHaveBeenCalledWith(`${keyNames.IMS}.${keyNames.CONTEXTS}`)
})
})

describe('getPlugins', () => {
test('(<no args>), plugins=undefined', async () => {
context.getConfigValue = jest.fn().mockResolvedValue(undefined)
const ret = await context.getPlugins()
expect(ret).toEqual(undefined)
expect(context.getConfigValue).toHaveBeenCalledWith(keyNames.PLUGINS)
})
test('(<no args>), plugins=[]', async () => {
context.getConfigValue = jest.fn().mockResolvedValue([])
const ret = await context.getPlugins()
expect(ret).toEqual([])
expect(context.getConfigValue).toHaveBeenCalledWith(keyNames.PLUGINS)
})
test('(<no args>), plugins=45', async () => {
context.getConfigValue = jest.fn().mockResolvedValue(45)
const ret = await context.getPlugins()
expect(ret).toEqual(45)
expect(context.getConfigValue).toHaveBeenCalledWith(keyNames.PLUGINS)
})
test('(<no args>), plugins=[\'@adobe/internal_plugin\']', async () => {
context.getConfigValue = jest.fn().mockResolvedValue(['@adobe/internal_plugin'])
const ret = await context.getPlugins()
expect(ret).toEqual(['@adobe/internal_plugin'])
expect(context.getConfigValue).toHaveBeenCalledWith(keyNames.PLUGINS)
})
})

describe('setPlugins', () => {
test('(undefined, false)', async () => {
await expect(context.setPlugins()).resolves.toEqual(undefined)
expect(aioConfig.set).toHaveBeenCalledTimes(0)
})
test('([], false)', async () => {
await expect(context.setPlugins([])).resolves.toEqual(undefined)
expect(aioConfig.set).toHaveBeenCalledWith(`${keyNames.IMS}.${keyNames.CONFIG}.${keyNames.PLUGINS}`, [], false)
})
test('([\'@adobe/internal_plugin\'], false)', async () => {
await expect(context.setPlugins(['@adobe/internal_plugin'])).resolves.toEqual(undefined)
expect(aioConfig.set).toHaveBeenCalledWith(`${keyNames.IMS}.${keyNames.CONFIG}.${keyNames.PLUGINS}`, ['@adobe/internal_plugin'], false)
})
test('(undefined, true)', async () => {
await expect(context.setPlugins(undefined, true)).resolves.toEqual(undefined)
expect(aioConfig.set).toHaveBeenCalledTimes(0)
})
test('([], true)', async () => {
await expect(context.setPlugins([], true)).resolves.toEqual(undefined)
expect(aioConfig.set).toHaveBeenCalledWith(`${keyNames.IMS}.${keyNames.CONFIG}.${keyNames.PLUGINS}`, [], true)
})
test('([\'@adobe/internal_plugin\'], true)', async () => {
await expect(context.setPlugins(['@adobe/internal_plugin'], true)).resolves.toEqual(undefined)
expect(aioConfig.set).toHaveBeenCalledWith(`${keyNames.IMS}.${keyNames.CONFIG}.${keyNames.PLUGINS}`, ['@adobe/internal_plugin'], true)
})
})
10 changes: 10 additions & 0 deletions test/ctx/Context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ describe('not implemented methods', () => {
test('Context.contextKeys', async () => {
await expect(context.contextKeys('key', 'value')).rejects.toThrow('abstract method is not implemented')
})
test('Context.setPlugins', async () => {
await expect(context.setPlugins(['plugin'])).rejects.toThrow('abstract method is not implemented')
})
})

describe('getCurrent', () => {
Expand Down Expand Up @@ -141,3 +144,10 @@ describe('keys', () => {
expect(context.contextKeys).toHaveBeenCalledWith()
})
})

describe('getPlugins', () => {
test('(<no args>)', async () => {
const ret = await context.getPlugins()
expect(ret).toEqual([])
})
})
Loading