diff --git a/src/serviceContext.js b/src/serviceContext.js new file mode 100644 index 0000000..580d71b --- /dev/null +++ b/src/serviceContext.js @@ -0,0 +1,202 @@ +const crypto = require('crypto'); +const KeyJar = require('../nodeOIDCMsg/src/oicMsg/keystore/KeyJar').KeyJar; + +const ATTRMAP = { + 'userinfo': { + 'sign': 'userinfo_signed_response_alg', + 'alg': 'userinfo_encrypted_response_alg', + 'enc': 'userinfo_encrypted_response_enc' + }, + 'id_token': { + 'sign': 'id_token_signed_response_alg', + 'alg': 'id_token_encrypted_response_alg', + 'enc': 'id_token_encrypted_response_enc' + }, + 'request': { + 'sign': 'request_object_signing_alg', + 'alg': 'request_object_encryption_alg', + 'enc': 'request_object_encryption_enc' + } +}; + +const DEFAULT_SIGN_ALG = { + 'userinfo': 'RS256', + 'request': 'RS384', + 'id_token': 'ES384', +}; + +/** + * This class keeps information that a client needs to be able to talk + * to a server. Some of this information comes from configuration and some + * from dynamic provider info discovery or client registration. + * But information is also picked up during the conversation with a server. + */ +class ServiceContext { + /** + * @param {KeyJar} keyjar OIDCMsg KeyJar instance that contains the RP signing and encyrpting keys + * @param {Object} config Client configuration + * @param {Object} params Other attributes that might be needed + */ + constructor(keyjar, config, params) { + this.keyjar = keyjar || new KeyJar(); + this.providerInfo = {}; + this.registrationResponse = {}; + this.kid = {'sig': {}, 'enc': {}}; + this.config = config || {}; + let defaultVal = ''; + + if (params) { + for (var i = 0; i < Object.keys(params).length; i++){ + let key = Object.keys(params)[i]; + let val = params[key]; + this[key] = val; + } + } + + this.client_id = this.config['client_id'] || defaultVal; + this.issuer = this.config['issuer'] || defaultVal; + this.client_secret = this.config['client_secret'] || defaultVal; + this.setClientSecret(this.client_secret); + this.base_url = this.config['base_url'] || defaultVal; + this.request_dir = this.config['requests_dir'] || defaultVal; + + defaultVal = {} + this.allow = this.config['allow'] || defaultVal; + this.client_prefs = this.config['client_preferences'] || defaultVal; + this.behavior = this.config['behaviour'] || defaultVal; + this.provider_info = this.config['provider_info'] || defaultVal; + + try { + this.redirectUris = this.config['redirect_uris']; + } catch (err) { + this.redirectUris = [null]; + } + + this.callback = this.config['callback'] || {}; + + if (config && Object.keys(config).indexOf('keydefs') !== -1) { + this.keyjar = this.keyjar.buildKeyJar(config['keydefs'], this.keyjar)[1]; + } + + return this; + } + + getClientSecret() { + return this.client_secret; + } + + setClientSecret(val) { + if (!val) { + this.client_secret = ''; + } else { + this.client_secret = val; + // client uses it for signing + // Server might also use it for signing which means the + // client uses it for verifying server signatures + if (this.keyjar == null) { + this.keyjar = new KeyJar(); + } + this.keyjar.addSymmetric('', val.toString()); + } + } + + /** + * Need to generate a redirect_uri path that is unique for a OP/RP combo + * This is to counter the mix-up attack. + * @param {string} path Leading path + * @return A list of one unique URL + */ + generateRequestUris(path) { + let m = crypto.createHmac('sha256', ''); + try { + m.update(this.providerInfo['issuer']); + } catch (error) { + m.update(this.issuer); + } + m.update(this.base_url); + if (!path.startsWith('/')){ + return [this.base_url + '/' + path+ '/' + m.digest('hex')]; + }else{ + return [this.base_url + path + '/' + m.digest('hex')]; + } + } + + /** + * A 1<->1 map is maintained between a URL pointing to a file and + * the name of the file in the file system. + * + * As an example if the base_url is 'https://example.com' and a jwks_uri + * is 'https://example.com/jwks_uri.json' then the filename of the + * corresponding file on the local filesystem would be 'jwks_uri.json. + * Relative to the directory from which the RP instance is run. + * + * @param {string} webName + */ + filenameFromWebName(webName) { + if (webName.startsWith(this.base_url) == false){ + console.log('ValueError'); + } + let name = webName.substring(this.base_url.length, webName.length); + if (name.startsWith('/')) { + return name.substring(1, name.length); + } else { + let splitName = name.split('/'); + return splitName[splitName.length - 1]; + } + } + + /** + * Reformat the crypto algorithm information gathered from a + * client registration response into something more palatable. + * + * @param {string} typ: 'id_token', 'userinfo' or 'request_object' + */ + signEncAlgs(typ) { + let serviceContext = this; + let resp = {}; + for (let i = 0; i < Object.keys(ATTRMAP[typ]).length; i++) { + let key = Object.keys(ATTRMAP[typ])[i]; + let val = ATTRMAP[typ][key]; + if (serviceContext.registrationResponse && serviceContext.registrationResponse[val]){ + resp[key] = serviceContext.registrationResponse[val]; + }else if (key === 'sign') { + try { + resp[key] = DEFAULT_SIGN_ALG[typ]; + } catch (err) { + return; + } + } + } + return resp; + } + + /** + * Verifies that the algorithm to be used are supported by the other side. + * This will look at provider information either statically configured or + * obtained through dynamic provider info discovery. + * + * @param {string} alg The algorithm specification + * @param {string} usage In which context the 'alg' will be used. + * The following contexts are supported: + * - userinfo + * - id_token + * - request_object + * - token_endpoint_auth + * @param {string} typ Type of alg + * - signing_alg + * - encryption_alg + * - encryption_enc + */ + verifyAlgSupport(alg, usage, typ) { + let serviceContext = this; + let supported = serviceContext.providerInfo[usage + '_' + typ + '_values_supported']; + if (supported.indexOf(alg) !== -1) { + return true; + } else { + return false; + } + } +} + + +module.exports.ServiceContext = ServiceContext; diff --git a/test/serviceContext-test.js b/test/serviceContext-test.js new file mode 100644 index 0000000..4b72501 --- /dev/null +++ b/test/serviceContext-test.js @@ -0,0 +1,252 @@ +const assert = require('chai').assert; +const ServiceContext = require('../src/serviceContext.js').ServiceContext; +const urlParse = require('url-parse'); + +describe('', function() { + let config = { + 'client_id': 'client_id', + 'issuer': 'issuer', + 'client_secret': 'client_secret', + 'base_url': 'https://example.com', + 'requests_dir': 'requests' + }; + + let ci = new ServiceContext(null, config); + + it('create serviceContext instance', function() { + assert.isNotNull(ci); + }); + + ci.registrationResponse = { + 'application_type': 'web', + 'redirect_uris': [ + 'https://client.example.org/callback', + 'https://client.example.org/callback2' + ], + 'token_endpoint_auth_method': 'client_secret_basic', + 'jwks_uri': 'https://client.example.org/my_public_keys.jwks', + 'userinfo_encrypted_response_alg': 'RSA1_5', + 'userinfo_encrypted_response_enc': 'A128CBC-HS256', + }; + + let res = ci.signEncAlgs('userinfo'); + it('registration userInfo signEncAlgs', function() { + assert.deepEqual( + res, {'sign': 'RS256', 'alg': 'RSA1_5', 'enc': 'A128CBC-HS256'}); + }); + + ci.registrationResponse = { + 'application_type': 'web', + 'redirect_uris': [ + 'https://client.example.org/callback', + 'https://client.example.org/callback2' + ], + 'token_endpoint_auth_method': 'client_secret_basic', + 'jwks_uri': 'https://client.example.org/my_public_keys.jwks', + 'userinfo_encrypted_response_alg': 'RSA1_5', + 'userinfo_encrypted_response_enc': 'A128CBC-HS256', + 'request_object_signing_alg': 'RS384' + }; + + res = ci.signEncAlgs('userinfo'); + it('registration request object signEncAlgs typ userinfo', function() { + assert.deepEqual( + res, {'sign': 'RS256', 'alg': 'RSA1_5', 'enc': 'A128CBC-HS256'}); + }); + + let res2 = ci.signEncAlgs('request'); + it('registration request object signEncAlgs typ request', function() { + assert.deepEqual(res2, {'sign': 'RS384'}); + }); + + ci.registrationResponse = { + 'application_type': 'web', + 'redirect_uris': [ + 'https://client.example.org/callback', + 'https://client.example.org/callback2' + ], + 'token_endpoint_auth_method': 'client_secret_basic', + 'jwks_uri': 'https://client.example.org/my_public_keys.jwks', + 'userinfo_encrypted_response_alg': 'RSA1_5', + 'userinfo_encrypted_response_enc': 'A128CBC-HS256', + 'request_object_signing_alg': 'RS384', + 'id_token_encrypted_response_alg': 'ECDH-ES', + 'id_token_encrypted_response_enc': 'A128GCM', + 'id_token_signed_response_alg': 'ES384', + }; + + let res3 = ci.signEncAlgs('userinfo'); + it('registration request object signEncAlgs typ userinfo', function() { + assert.deepEqual( + res3, {'sign': 'RS256', 'alg': 'RSA1_5', 'enc': 'A128CBC-HS256'}); + }); + + let res4 = ci.signEncAlgs('request'); + it('registration request object signEncAlgs typ request', function() { + assert.deepEqual(res4, {'sign': 'RS384'}); + }); + + let res5 = ci.signEncAlgs('id_token'); + it('registration request object signEncAlgs typ id_token', function() { + assert.deepEqual( + res5, {'sign': 'ES384', 'alg': 'ECDH-ES', 'enc': 'A128GCM'}); + }); + + ci.providerInfo = { + 'version': '3.0', + 'issuer': 'https://server.example.com', + 'authorization_endpoint': 'https://server.example.com/connect/authorize', + 'token_endpoint': 'https://server.example.com/connect/token', + 'token_endpoint_auth_methods_supported': + ['client_secret_basic', 'private_key_jwt'], + 'token_endpoint_auth_signing_alg_values_supported': ['RS256', 'ES256'], + 'userinfo_endpoint': 'https://server.example.com/connect/userinfo', + 'check_session_iframe': 'https://server.example.com/connect/check_session', + 'end_session_endpoint': 'https://server.example.com/connect/end_session', + 'jwks_uri': 'https://server.example.com/jwks.json', + 'registration_endpoint': 'https://server.example.com/connect/register', + 'scopes_supported': + ['openid', 'profile', 'email', 'address', 'phone', 'offline_access'], + 'response_types_supported': + ['code', 'code id_token', 'id_token', 'token id_token'], + 'acr_values_supported': + ['urn:mace:incommon:iap:silver', 'urn:mace:incommon:iap:bronze'], + 'subject_types_supported': ['public', 'pairwise'], + 'userinfo_signing_alg_values_supported': ['RS256', 'ES256', 'HS256'], + 'userinfo_encryption_alg_values_supported': ['RSA1_5', 'A128KW'], + 'userinfo_encryption_enc_values_supported': ['A128CBC+HS256', 'A128GCM'], + 'id_token_signing_alg_values_supported': ['RS256', 'ES256', 'HS256'], + 'id_token_encryption_alg_values_supported': ['RSA1_5', 'A128KW'], + 'id_token_encryption_enc_values_supported': ['A128CBC+HS256', 'A128GCM'], + 'request_object_signing_alg_values_supported': ['none', 'RS256', 'ES256'], + 'display_values_supported': ['page', 'popup'], + 'claim_types_supported': ['normal', 'distributed'], + 'claims_supported': [ + 'sub', 'iss', 'auth_time', 'acr', 'name', 'given_name', 'family_name', + 'nickname', 'profile', 'picture', 'website', 'email', 'email_verified', + 'locale', 'zoneinfo', 'http://example.info/claims/groups' + ], + 'claims_parameter_supported': true, + 'service_documentation': + 'http://server.example.com/connect/service_documentation.html', + 'ui_locales_supported': ['en-US', 'en-GB', 'en-CA', 'fr-FR', 'fr-CA'] + }; + + let res6 = ci.verifyAlgSupport('RS256', 'id_token', 'signing_alg'); + it('verify_alg_support', function() { + assert.isTrue(res6); + }); + + let res7 = ci.verifyAlgSupport('RS512', 'id_token', 'signing_alg'); + it('verify_alg_support', function() { + assert.isFalse(res7); + }); + + let res8 = ci.verifyAlgSupport('RSA1_5', 'userinfo', 'encryption_alg'); + it('verify_alg_support', function() { + assert.isTrue(res8); + }); + + let res9 = ci.verifyAlgSupport('ES256', 'token_endpoint_auth', 'signing_alg'); + it('verify_alg_support', function() { + assert.isTrue(res9); + }); + + ci.providerInfo['issuer'] = 'https://example.com/'; + let url_list = ci.generateRequestUris('/leading'); + let sp = urlParse(url_list[0]); + let p = sp.pathname.split('/'); + + it('verify_requests_uri', function() { + assert.deepEqual(p[0], ''); + assert.deepEqual(p[1], 'leading'); + assert.deepEqual(p.length, 3); + }); + + ci.providerInfo['issuer'] = 'https://op.example.org/'; + url_list = ci.generateRequestUris('/leading'); + sp = urlParse(url_list[0]); + let np = sp.pathname.split('/'); + + it('verify_requests_uri test2', function() { + assert.deepEqual(np[0], ''); + assert.deepEqual(np[1], 'leading'); + assert.deepEqual(np.length, 3); + assert.notDeepEqual(np[2], p[2]); + }); +}); + +describe('client info tests', function() { + let config; + let ci; + beforeEach(function() { + config = { + 'client_id': 'client_id', + 'issuer': 'issuer', + 'client_secret': 'client_secret', + 'base_url': 'https://example.com', + 'requests_dir': 'requests' + }; + + ci = new ServiceContext(null, config); + }); + + it('client info init', function() { + assert.isNotNull(ci); + if (Object.keys(config).indexOf('client_id') > -1) { + assert.deepEqual(ci.config['client_id'], config['client_id']); + } else if (Object.keys(config).indexOf('issuer') > -1) { + assert.deepEqual(ci.config['issuer'], config['issuer']); + } else if (Object.keys(config).indexOf('client_secret') > -1) { + assert.deepEqual(ci.config['client_secret'], config['client_secret']); + } else if (Object.keys(config).indexOf('base_url') > -1) { + assert.deepEqual(ci.config['base_url'], config['base_url']); + } else if (Object.keys(config).indexOf('request_dir') > -1) { + assert.deepEqual(ci.config['request_dir'], config['request_dir']); + } + }); +}); + +describe('set and get client secret', function() { + let ci; + beforeEach(function() { + ci = new ServiceContext(); + ci.clientSecret = 'supersecret'; + }); + + it('client info init', function() { + assert.deepEqual(ci.clientSecret, 'supersecret'); + }); +}); + +describe('set and get client id', function() { + let ci; + beforeEach(function() { + ci = new ServiceContext(); + ci.clientId = 'myself'; + }); + + it('client info init clientId', function() { + assert.deepEqual(ci.clientId, 'myself'); + }); +}); + +describe('client filename', function() { + let config; + let ci; + let fname; + beforeEach(function() { + config = { + 'client_id': 'client_id', + 'issuer': 'issuer', + 'client_secret': 'client_secret', + 'base_url': 'https://example.com', + 'requests_dir': 'requests' + }; + ci = new ServiceContext(null, config); + fname = ci.filenameFromWebName('https://example.com/rq12345'); + }); + it('client filename', function() { + assert.deepEqual(fname, 'rq12345'); + }); +}); \ No newline at end of file