Skip to content
This repository was archived by the owner on Dec 9, 2024. It is now read-only.

Commit 447d3fb

Browse files
committed
Add IBM Cloud Functions IAM support using CLI configuration file.
1 parent 9e6930f commit 447d3fb

File tree

6 files changed

+204
-1
lines changed

6 files changed

+204
-1
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"fs-extra": "^1.0.0",
3636
"get-stdin": "^5.0.1",
3737
"jszip": "^3.1.3",
38+
"jws": "^3.2.2",
3839
"lodash": "^4.17.11",
3940
"moment": "^2.16.0",
4041
"openwhisk": "^3.19.0"

provider/cliTokenManager.js

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use strict';
2+
3+
"use strict";
4+
5+
const jws = require('jws');
6+
const { exec } = require('child_process');
7+
const { readFileSync } = require('fs');
8+
const path = require('path');
9+
10+
// Configuration file location for IBM Cloud CLI.
11+
// This will contain the current IAM tokens for the user.
12+
const DEFAULT_CONFIG_LOCATION = `.bluemix/config.json`
13+
14+
// This class handles retrieving authentication tokens for IAM namespaces on IBM Cloud Functions.
15+
// Tokens are parsed from the configuration file used by the IBM Cloud CLI.
16+
// If tokens have expired, the CLI command `ibmcloud iam oauth-tokens` is executed.
17+
// This will automatically refresh the tokens in the configuration.
18+
module.exports = class CliTokenManager {
19+
constructor(_exec = exec, _readFile = readFileSync) {
20+
this.exec = _exec
21+
this.readFile = _readFile
22+
this.refresh_command = 'ibmcloud iam oauth-tokens'
23+
}
24+
25+
getAuthHeader () {
26+
const to_header = token => `Bearer ${token}`
27+
const token = this.readTokenFromConfig()
28+
if (this.isTokenExpired(token)) {
29+
return this.refreshToken().then(to_header)
30+
}
31+
32+
return Promise.resolve(to_header(token))
33+
}
34+
35+
refreshToken () {
36+
return new Promise((resolve, reject) => {
37+
this.exec(this.refresh_command, error => {
38+
if (error) {
39+
const err_message = `IAM token from IBM Cloud CLI configuration file (.bluemix/config.json) has expired. `
40+
+ `Refresh failed using CLI command (ibmcloud iam oauth-tokens). Check error message for details: ${error}`
41+
return reject(new Error(err_message))
42+
}
43+
resolve(this.readTokenFromConfig())
44+
});
45+
})
46+
}
47+
48+
// IAM Tokens stored under the IAMToken field in configuration.
49+
readTokenFromConfig (configPath = CliTokenManager.configFilePath()) {
50+
const contents = this.readFile(configPath, 'utf-8')
51+
const config = JSON.parse(contents)
52+
const [prefix, token] = config.IAMToken.split(' ')
53+
return token
54+
}
55+
56+
isTokenExpired (token) {
57+
const decoded = jws.decode(token, { json: true })
58+
const expiry_time = decoded.payload.exp
59+
const now = Math.floor(Date.now() / 1000)
60+
61+
return expiry_time <= now
62+
}
63+
64+
// Support both platforms for configuration files.
65+
static configFilePath (config_file = DEFAULT_CONFIG_LOCATION) {
66+
const home_dir = process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'];
67+
const config_path = path.format({ dir: home_dir, base: config_file });
68+
return config_path
69+
}
70+
}

provider/openwhiskProvider.js

+14-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const BbPromise = require('bluebird');
44
const openwhisk = require('openwhisk')
55
const IamTokenManager = require('@ibm-functions/iam-token-manager')
6+
const CliTokenManager = require('./cliTokenManager')
67
const Credentials = require('./credentials');
78

89
const constants = {
@@ -23,14 +24,20 @@ class OpenwhiskProvider {
2324
this.sdk = openwhisk
2425
}
2526

27+
// Returns OpenWhisk SDK client configured with authentication credentials.
28+
// Auto-detects use of IAM namespaces when using IBM Cloud Functions and adds
29+
// external auth handler to client.
2630
client() {
2731
if (this._client) return BbPromise.resolve(this._client)
2832

2933
const ignore_certs = this.serverless.service.provider.ignore_certs || false
3034
return this.props().then(props => {
3135
if (props.hasOwnProperty('iam_namespace_api_key')) {
3236
const auth_handler = new IamTokenManager({ iamApikey: props.iam_namespace_api_key });
33-
this._client = openwhisk({ apihost: props.apihost, auth_handler, namespace: props.namespace, ignore_certs });
37+
this._client = openwhisk({ apihost: props.apihost, auth_handler, namespace: props.namespace });
38+
} else if (this.isIBMCloudIAMProps(props)) {
39+
const auth_handler = new CliTokenManager()
40+
this._client = openwhisk({ apihost: props.apihost, auth_handler, namespace: props.namespace });
3441
} else {
3542
this.hasValidCreds(props)
3643
this._client = openwhisk({ apihost: props.apihost, api_key: props.auth, namespace: props.namespace, ignore_certs, apigw_token: props.apigw_access_token });
@@ -58,6 +65,12 @@ class OpenwhiskProvider {
5865
});
5966
return creds;
6067
}
68+
69+
// Auto-detect whether ~/.wskprops uses IBM Cloud IAM namespace (and therefore requires IAM auth handler).
70+
// Namespace will be IAM NS ID rather than default namespace. Api host will end with ibm.com hostname.
71+
isIBMCloudIAMProps (props) {
72+
return props.namespace !== '_' && props.apihost.endsWith('cloud.ibm.com')
73+
}
6174
}
6275

6376
module.exports = OpenwhiskProvider;

provider/tests/cliTokenManager.js

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use strict';
2+
3+
const expect = require('chai').expect;
4+
const sinon = require('sinon');
5+
const fs = require('fs-extra');
6+
const chaiAsPromised = require('chai-as-promised');
7+
const CliTokenManager = require('../cliTokenManager.js');
8+
9+
require('chai').use(chaiAsPromised);
10+
11+
describe('CliTokenManager', () => {
12+
describe('#getAuthHeader()', () => {
13+
it('should return bearer token from configuration', () => {
14+
const cliTokenManager = new CliTokenManager()
15+
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM'
16+
cliTokenManager.readTokenFromConfig = () => token
17+
cliTokenManager.isTokenExpired = () => false
18+
const header = `Bearer ${token}`
19+
return cliTokenManager.getAuthHeader().then(result => {
20+
expect(result).to.equal(header);
21+
})
22+
});
23+
24+
it('should return refreshed bearer token when token is expired', () => {
25+
const cliTokenManager = new CliTokenManager()
26+
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM'
27+
cliTokenManager.readTokenFromConfig = () => null
28+
cliTokenManager.isTokenExpired = () => true
29+
cliTokenManager.refreshToken = () => Promise.resolve(token)
30+
const header = `Bearer ${token}`
31+
return cliTokenManager.getAuthHeader().then(result => {
32+
expect(result).to.equal(header);
33+
})
34+
});
35+
})
36+
37+
describe('#readTokenFromConfig()', () => {
38+
it('should return bearer token from default configuration file', () => {
39+
const readFile = (path, format) => {
40+
expect(path).to.equal(config_path)
41+
expect(format).to.equal('utf-8')
42+
return JSON.stringify({ IAMToken: `Bearer ${config_token}`})
43+
}
44+
45+
const cliTokenManager = new CliTokenManager(null, readFile)
46+
const config_token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM'
47+
const config_path = `~/.bluemix/config.json`
48+
const token = cliTokenManager.readTokenFromConfig(config_path)
49+
expect(token).to.equal(config_token)
50+
});
51+
});
52+
53+
describe('#isTokenExpired()', () => {
54+
it('should return true for expired JWT tokens', () => {
55+
const cliTokenManager = new CliTokenManager()
56+
// created from http://jwtbuilder.jamiekurtz.com/
57+
// JWT expired in 2000.
58+
const expired_token = 'eyJraWQiOiIyMDE5MDIwNCIsImFsZyI6IlJTMjU2In0.eyJpYW1faWQiOiJJQk1pZC0yNzAwMDJQUzIxIiwiaWQiOiJJQk1pZC0yNzAwMDJQUzIxIiwicmVhbG1pZCI6IklCTWlkIiwiaWRlbnRpZmllciI6IjI3MDAwMlBTMjEiLCJnaXZlbl9uYW1lIjoiSmFtZXMiLCJmYW1pbHlfbmFtZSI6IlRob21hcyIsIm5hbWUiOiJKYW1lcyBUaG9tYXMiLCJlbWFpbCI6ImphbWVzLnRob21hc0B1ay5pYm0uY29tIiwic3ViIjoiamFtZXMudGhvbWFzQHVrLmlibS5jb20iLCJhY2NvdW50Ijp7InZhbGlkIjp0cnVlLCJic3MiOiI4ZDYzZmIxY2M1ZTk5ZTg2ZGQ3MjI5ZGRkZmExNjY0OSJ9LCJpYXQiOjE1NjM0NDAyMzEsImV4cCI6MTU2MzQ0MzgzMSwiaXNzIjoiaHR0cHM6Ly9pYW0uY2xvdWQuaWJtLmNvbS9pZGVudGl0eSIsImdyYW50X3R5cGUiOiJwYXNzd29yZCIsInNjb3BlIjoiaWJtIG9wZW5pZCIsImNsaWVudF9pZCI6ImJ4IiwiYWNyIjoxLCJhbXIiOlsicHdkIl19.DhgBTV_dxtSirpSoe-H_xXfxBKYIrxFqiu4eVluTq78Sqp9FCCQoMSuJBD0ysHsD-0sIp5yHq03-0DnAdldnD2YkFRwrDXY-9uG5cJGB1vH3l6X6BaWprGG-AcswqeTklnjCrRqIiUr5EU9odZAfwbDPYdoE21gudS2kMZoVgezJsUtYz2tJH-I-1JfbBPuTLLuhWVr4ZPP2GzOvI7xpWBVwMYmUviLrxD_-Gq2vJyly1rNBYA4VZKf1G46yT790EqRz9N3o18bmKUxDCP6ur2oVHwGNQy15fn8LsiylHf4s9p9yPuLtgExN6FcdMfPU8hUT1UWfaWssjpetk3crjA'
59+
const expired = cliTokenManager.isTokenExpired(expired_token)
60+
expect(expired).to.equal(true)
61+
});
62+
63+
it('should return false for non-expired JWT tokens', () => {
64+
const cliTokenManager = new CliTokenManager()
65+
// created from http://jwtbuilder.jamiekurtz.com/ - example JWT expires in 2100.
66+
// I won't be around when this unit test starts failing...
67+
const expired_token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE1NjM0NTM2OTYsImV4cCI6NDExOTUxMTI5NiwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.WNqaMqKIqkKXT731uGV8jnJmNj74qYUSiZeLLYl6ME0'
68+
const expired = cliTokenManager.isTokenExpired(expired_token)
69+
expect(expired).to.equal(false)
70+
});
71+
});
72+
73+
describe('#configFilePath()', () => {
74+
it('should return default config location', () => {
75+
const default_path = `${process.env['HOME']}/.bluemix/config.json`
76+
expect(CliTokenManager.configFilePath()).to.equal(default_path)
77+
});
78+
});
79+
80+
describe('#refreshToken()', () => {
81+
it('should return current token once command has executed', () => {
82+
const cliTokenManager = new CliTokenManager()
83+
const token = 'eyj0exaioijkv1qilcjhbgcioijiuzi1nij9.eyj1c2vyswqioijimdhmodzhzi0znwrhltq4zjitogzhyi1jzwyzota0njywymqifq.-xn_h82phvtcma9vdohrczxh-x5mb11y1537t3rgzcm'
84+
cliTokenManager.readTokenFromConfig = () => token
85+
cliTokenManager.exec = (cmd, cb) => {
86+
expect(cmd).to.equal(cliTokenManager.refresh_command)
87+
setTimeout(() => cb(), 0)
88+
}
89+
90+
return cliTokenManager.refreshToken().then(_token => {
91+
expect(_token).to.equal(token)
92+
})
93+
});
94+
95+
it('should throw error when refresh token command fails', () => {
96+
const cliTokenManager = new CliTokenManager()
97+
cliTokenManager.exec = (_, cb) => {
98+
setTimeout(() => cb(new Error("cmd failed")), 0)
99+
}
100+
101+
return expect(cliTokenManager.refreshToken()).to.eventually.be.rejectedWith(/^IAM token from IBM Cloud CLI/);
102+
});
103+
});
104+
});

provider/tests/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
require('./openwhiskProvider');
44
require('./credentials');
5+
require('./cliTokenManager');

provider/tests/openwhiskProvider.js

+14
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require('chai').use(chaiAsPromised);
1010

1111
const OpenwhiskProvider = require('../openwhiskProvider');
1212
const Credentials = require('../credentials');
13+
const CliTokenManager = require('../cliTokenManager.js');
1314

1415
describe('OpenwhiskProvider', () => {
1516
let openwhiskProvider;
@@ -105,6 +106,19 @@ describe('OpenwhiskProvider', () => {
105106
expect(client.actions.client.options.authHandler.iamApikey).to.be.deep.equal(API_KEY)
106107
})
107108
})
109+
110+
it('should support client auth using IBM Cloud CLI configuration file', () => {
111+
openwhiskProvider._client = null
112+
const API_KEY = 'some-key-value';
113+
const creds = {apihost: 'region.functions.cloud.ibm.com', namespace: 'a34dd39e-e3de-4160-bbab-59ac345678ed'}
114+
sandbox.stub(openwhiskProvider, "props").returns(BbPromise.resolve(creds))
115+
116+
return openwhiskProvider.client().then(client => {
117+
expect(client.actions.client.options.namespace).to.be.deep.equal(creds.namespace)
118+
expect(client.actions.client.options.api).to.be.deep.equal(`https://${creds.apihost}/api/v1/`)
119+
expect(client.actions.client.options.authHandler instanceof CliTokenManager).to.be.equal(true)
120+
})
121+
})
108122
})
109123

110124
describe('#props()', () => {

0 commit comments

Comments
 (0)