diff --git a/config/yoti.js b/config/yoti.js index 0869efe35..a4630bdf6 100644 --- a/config/yoti.js +++ b/config/yoti.js @@ -6,6 +6,7 @@ const yoti = { connectApi: process.env.YOTI_CONNECT_API || process.env.YOTI_API_URL || `${constants.API_BASE_URL}/api/v1`, idvApi: process.env.YOTI_IDV_API || process.env.YOTI_IDV_API_URL || `${constants.API_BASE_URL}/idverify/v1`, digitalIdentityApi: process.env.YOTI_DIGITAL_IDENTITY_API_URL || `${constants.API_BASE_URL}/share`, + authApi: process.env.YOTI_AUTH_API_URL || `${constants.AUTH_API_BASE_URL}/v1/oauth/token`, }; module.exports = yoti; diff --git a/index.js b/index.js index 1c9613ea3..05a3704ed 100644 --- a/index.js +++ b/index.js @@ -64,6 +64,11 @@ const { const YotiCommon = require('./src/yoti_common'); const { YotiRequest } = require('./src/request/request'); const IDVError = require('./src/idv_service/idv.error'); +const { + AuthTokenStrategy, + AuthTokenGenerator, + CreateAuthenticationTokenResponse, +} = require('./src/auth'); module.exports = { internals: { @@ -124,4 +129,7 @@ module.exports = { AdvancedIdentityProfileBuilder, AdvancedIdentityProfileSchemeBuilder, AdvancedIdentityProfileRequirementsBuilder, + AuthTokenStrategy, + AuthTokenGenerator, + CreateAuthenticationTokenResponse, }; diff --git a/package-lock.json b/package-lock.json index 431c9c258..734510084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "form-data": "4.0.4", + "jsonwebtoken": "9.0.3", "node-forge": "1.3.2", "protobufjs": "7.5.2", "superagent": "10.3.0", @@ -2074,6 +2075,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2547,6 +2554,15 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.283", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", @@ -5108,6 +5124,49 @@ "node": ">=10" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5168,12 +5227,54 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -6102,7 +6203,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -6158,7 +6258,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8610,6 +8709,11 @@ "node-int64": "^0.4.0" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8925,6 +9029,14 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "electron-to-chromium": { "version": "1.5.283", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", @@ -10686,6 +10798,42 @@ "through2": "^4.0.2" } }, + "jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "requires": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + } + }, + "jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "requires": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "requires": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -10729,12 +10877,47 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11352,8 +11535,7 @@ "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" }, "safe-push-apply": { "version": "1.0.0", @@ -11379,8 +11561,7 @@ "semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==" }, "set-function-length": { "version": "1.2.2", diff --git a/package.json b/package.json index 5a664525e..32b521f01 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "form-data": "4.0.4", + "jsonwebtoken": "9.0.3", "node-forge": "1.3.2", "protobufjs": "7.5.2", "superagent": "10.3.0", diff --git a/src/auth/auth.token.generator.js b/src/auth/auth.token.generator.js new file mode 100644 index 000000000..3c8033a09 --- /dev/null +++ b/src/auth/auth.token.generator.js @@ -0,0 +1,82 @@ +'use strict'; + +const jwt = require('jsonwebtoken'); +const { v4: uuid } = require('uuid'); +const superagent = require('superagent'); +const Validation = require('../yoti_common/validation'); +const config = require('../../config'); +const CreateAuthenticationTokenResponse = require('./create.authentication.token.response'); + +/** + * Generates authentication tokens via OAuth2 client_credentials grant. + * + * @class AuthTokenGenerator + */ +class AuthTokenGenerator { + /** + * @param {string} sdkId + * @param {string|Buffer} pem + * @param {{authUrl?: string}} options + */ + constructor(sdkId, pem, { authUrl } = {}) { + Validation.isString(sdkId, 'sdkId'); + Validation.notNullOrEmpty(pem, 'pem'); + + /** @private */ + this.sdkId = sdkId; + /** @private */ + this.pem = pem; + /** @private */ + this.authUrl = authUrl || config.yoti.authApi; + } + + /** + * Generate an authentication token with the given scopes. + * + * @param {string[]} scopes + * + * @returns {Promise} + */ + async generate(scopes) { + if (!Array.isArray(scopes) || scopes.length === 0) { + throw new Error('At least one scope must be provided'); + } + + const assertion = this.createAssertion(); + + const response = await superagent + .post(this.authUrl) + .type('form') + .send({ + grant_type: 'client_credentials', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: assertion, + scope: scopes.join(' '), + }); + + return new CreateAuthenticationTokenResponse(response.body); + } + + /** + * @private + * @returns {string} + */ + createAssertion() { + const now = Math.floor(Date.now() / 1000); + + return jwt.sign( + { + iss: `sdk:${this.sdkId}`, + sub: `sdk:${this.sdkId}`, + aud: this.authUrl, + iat: now, + exp: now + 300, + jti: uuid(), + }, + this.pem.toString(), + { algorithm: 'PS384' } + ); + } +} + +module.exports = AuthTokenGenerator; diff --git a/src/auth/auth.token.strategy.js b/src/auth/auth.token.strategy.js new file mode 100644 index 000000000..0d09622d2 --- /dev/null +++ b/src/auth/auth.token.strategy.js @@ -0,0 +1,30 @@ +'use strict'; + +const Validation = require('../yoti_common/validation'); + +/** + * Bearer token authentication strategy. + * + * @class AuthTokenStrategy + */ +class AuthTokenStrategy { + /** + * @param {string} token + */ + constructor(token) { + Validation.isString(token, 'token'); + /** @private */ + this.token = token; + } + + createAuthHeaders() { + return { Authorization: `Bearer ${this.token}` }; + } + + // eslint-disable-next-line class-methods-use-this + createQueryParams() { + return {}; + } +} + +module.exports = AuthTokenStrategy; diff --git a/src/auth/create.authentication.token.response.js b/src/auth/create.authentication.token.response.js new file mode 100644 index 000000000..332428fa5 --- /dev/null +++ b/src/auth/create.authentication.token.response.js @@ -0,0 +1,56 @@ +'use strict'; + +/** + * Response from the authentication token endpoint. + * + * @class CreateAuthenticationTokenResponse + */ +class CreateAuthenticationTokenResponse { + /** + * @param {Object} response + * @param {string} response.access_token + * @param {string} response.token_type + * @param {number} response.expires_in + * @param {string} response.scope + */ + constructor(response) { + /** @private */ + this.accessToken = response.access_token; + /** @private */ + this.tokenType = response.token_type; + /** @private */ + this.expiresIn = response.expires_in; + /** @private */ + this.scope = response.scope; + } + + /** + * @returns {string} + */ + getAccessToken() { + return this.accessToken; + } + + /** + * @returns {string} + */ + getTokenType() { + return this.tokenType; + } + + /** + * @returns {number} + */ + getExpiresIn() { + return this.expiresIn; + } + + /** + * @returns {string} + */ + getScope() { + return this.scope; + } +} + +module.exports = CreateAuthenticationTokenResponse; diff --git a/src/auth/index.js b/src/auth/index.js new file mode 100644 index 000000000..9b4842134 --- /dev/null +++ b/src/auth/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const AuthTokenStrategy = require('./auth.token.strategy'); +const SignedRequestStrategy = require('./signed.request.strategy'); +const AuthTokenGenerator = require('./auth.token.generator'); +const CreateAuthenticationTokenResponse = require('./create.authentication.token.response'); + +module.exports = { + AuthTokenStrategy, + SignedRequestStrategy, + AuthTokenGenerator, + CreateAuthenticationTokenResponse, +}; diff --git a/src/auth/signed.request.strategy.js b/src/auth/signed.request.strategy.js new file mode 100644 index 000000000..ceb2ce0bb --- /dev/null +++ b/src/auth/signed.request.strategy.js @@ -0,0 +1,49 @@ +'use strict'; + +const { v4: uuid } = require('uuid'); +const yotiCommon = require('../yoti_common'); +const Validation = require('../yoti_common/validation'); + +/** + * Signed request authentication strategy. + * + * @class SignedRequestStrategy + */ +class SignedRequestStrategy { + /** + * @param {string} pem + */ + constructor(pem) { + Validation.notNullOrEmpty(pem, 'pem'); + /** @private */ + this.pem = pem; + } + + /** + * @param {string} method + * @param {string} endpointPath + * @param {string} payloadBase64 + * + * @returns {Object.} + */ + createAuthHeaders(method, endpointPath, payloadBase64) { + const messageSignature = yotiCommon.getRSASignatureForMessage( + `${method}&${endpointPath}${payloadBase64}`, + this.pem + ); + return { 'X-Yoti-Auth-Digest': messageSignature }; + } + + /** + * @returns {Object.} + */ + // eslint-disable-next-line class-methods-use-this + createQueryParams() { + return { + nonce: uuid(), + timestamp: Date.now(), + }; + } +} + +module.exports = SignedRequestStrategy; diff --git a/src/client/idv.client.js b/src/client/idv.client.js index a8cbc145c..febdb7994 100644 --- a/src/client/idv.client.js +++ b/src/client/idv.client.js @@ -2,6 +2,7 @@ const config = require('../../config'); const { IDVService } = require('../idv_service'); +const AuthTokenStrategy = require('../auth/auth.token.strategy'); /** * Client used for communication with the Yoti IDV service @@ -12,16 +13,28 @@ const { IDVService } = require('../idv_service'); */ class IDVClient { /** - * @param {string} sdkId - * @param {string|Buffer} pem - * @param {{apiUrl?: string}} options + * @param {?string} sdkId + * @param {?(string|Buffer)} pem + * @param {{apiUrl?: string, authToken?: string}} options */ - constructor(sdkId, pem, { apiUrl } = {}) { - const options = { - apiUrl: apiUrl || config.yoti.idvApi, - }; - /** @private */ - this.idvService = new IDVService(sdkId, pem, options); + constructor(sdkId, pem, { apiUrl, authToken } = {}) { + if (authToken) { + if (sdkId || pem) { + throw new Error( + 'Must not supply sdkId or PEM when using an authentication token' + ); + } + /** @private */ + this.idvService = new IDVService(null, null, { + apiUrl: apiUrl || config.yoti.idvApi, + authStrategy: new AuthTokenStrategy(authToken), + }); + } else { + /** @private */ + this.idvService = new IDVService(sdkId, pem, { + apiUrl: apiUrl || config.yoti.idvApi, + }); + } } /** diff --git a/src/idv_service/idv.service.js b/src/idv_service/idv.service.js index 1ead1c5b9..c24780d60 100644 --- a/src/idv_service/idv.service.js +++ b/src/idv_service/idv.service.js @@ -41,22 +41,42 @@ const mediaContentPath = (sessionId, mediaId) => `${sessionPath(sessionId)}/medi */ class IDVService { /** - * @param {string} sdkId - * @param {string|Buffer} pem - * @param {{apiUrl?: string}} options + * @param {?string} sdkId + * @param {?(string|Buffer)} pem + * @param {{apiUrl?: string, authStrategy?: Object}} options */ - constructor(sdkId, pem, { apiUrl = DEFAULT_API_URL } = {}) { - Validation.isString(sdkId, 'sdkId'); - Validation.notNullOrEmpty(pem, 'pem'); - - /** @protected */ - this.sdkId = sdkId; - /** @protected */ - this.pem = pem; - /** @protected */ + constructor(sdkId, pem, { apiUrl = DEFAULT_API_URL, authStrategy } = {}) { + if (authStrategy) { + /** @private */ + this.authStrategy = authStrategy; + } else { + Validation.isString(sdkId, 'sdkId'); + Validation.notNullOrEmpty(pem, 'pem'); + /** @private */ + this.sdkId = sdkId; + /** @private */ + this.pem = pem; + } + /** @private */ this.apiUrl = apiUrl; } + /** + * Applies authentication to a request builder. + * + * @param {RequestBuilder} builder + * @returns {RequestBuilder} + * @private + */ + applyAuthToBuilder(builder) { + if (this.authStrategy) { + return builder.withAuthStrategy(this.authStrategy); + } + return builder + .withPemString(this.pem.toString()) + .withQueryParam('sdkId', this.sdkId); + } + /** * Uses the supplied session specification to create a session * @@ -67,15 +87,14 @@ class IDVService { createSession(sessionSpecification) { Validation.instanceOf(sessionSpecification, SessionSpecification, 'sessionSpecification'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint('/sessions') - .withQueryParam('sdkId', this.sdkId) .withPost() .withPayload(new Payload(sessionSpecification)) - .withHeader('Content-Type', 'application/json') - .build(); + .withHeader('Content-Type', 'application/json'); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -100,13 +119,12 @@ class IDVService { getSession(sessionId) { Validation.isString(sessionId, 'sessionId'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(sessionPath(sessionId)) - .withQueryParam('sdkId', this.sdkId) - .withGet() - .build(); + .withGet(); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -131,13 +149,12 @@ class IDVService { deleteSession(sessionId) { Validation.isString(sessionId, 'sessionId'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(sessionPath(sessionId)) - .withQueryParam('sdkId', this.sdkId) - .withMethod('DELETE') - .build(); + .withMethod('DELETE'); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -158,13 +175,12 @@ class IDVService { Validation.isString(sessionId, 'sessionId'); Validation.isString(mediaId, 'mediaId'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(mediaContentPath(sessionId, mediaId)) - .withQueryParam('sdkId', this.sdkId) - .withGet() - .build(); + .withGet(); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute(true) @@ -201,13 +217,12 @@ class IDVService { Validation.isString(sessionId, 'sessionId'); Validation.isString(mediaId, 'mediaId'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(mediaContentPath(sessionId, mediaId)) - .withQueryParam('sdkId', this.sdkId) - .withMethod('DELETE') - .build(); + .withMethod('DELETE'); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute(true) @@ -224,17 +239,16 @@ class IDVService { * @returns {Promise} */ getSupportedDocuments(includeNonLatin) { - const requestBuilder = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint('/supported-documents') .withGet(); if (includeNonLatin) { - requestBuilder.withQueryParam('includeNonLatin', true); + builder.withQueryParam('includeNonLatin', true); } - const request = requestBuilder.build(); + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -254,14 +268,13 @@ class IDVService { Validation.isString(sessionId, 'sessionId'); Validation.instanceOf(createFaceCaptureResourcePayload, CreateFaceCaptureResourcePayload, 'createFaceCaptureResourcePayload'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(`sessions/${sessionId}/resources/face-capture`) - .withQueryParam('sdkId', this.sdkId) .withPost() - .withPayload(new Payload(createFaceCaptureResourcePayload)) - .build(); + .withPayload(new Payload(createFaceCaptureResourcePayload)); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -288,14 +301,13 @@ class IDVService { Validation.isString(resourceId, 'resourceId'); Validation.instanceOf(uploadFaceCaptureImagePayload, UploadFaceCaptureImagePayload, 'uploadFaceCaptureImagePayload'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(`/sessions/${sessionId}/resources/face-capture/${resourceId}/image`) - .withQueryParam('sdkId', this.sdkId) .withPut() - .withPayload(new Payload(uploadFaceCaptureImagePayload, ContentType.FORM_DATA)) - .build(); + .withPayload(new Payload(uploadFaceCaptureImagePayload, ContentType.FORM_DATA)); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -312,13 +324,12 @@ class IDVService { getSessionConfiguration(sessionId) { Validation.isString(sessionId, 'sessionId'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(`/sessions/${sessionId}/configuration`) - .withQueryParam('sdkId', this.sdkId) - .withGet() - .build(); + .withGet(); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -335,13 +346,12 @@ class IDVService { getSessionTrackedDevices(sessionId) { Validation.isString(sessionId, 'sessionId'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(`/sessions/${sessionId}/tracked-devices`) - .withQueryParam('sdkId', this.sdkId) - .withGet() - .build(); + .withGet(); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute() @@ -361,13 +371,12 @@ class IDVService { deleteSessionTrackedDevices(sessionId) { Validation.isString(sessionId, 'sessionId'); - const request = new RequestBuilder() - .withPemString(this.pem.toString()) + const builder = new RequestBuilder() .withBaseUrl(this.apiUrl) .withEndpoint(`/sessions/${sessionId}/tracked-devices`) - .withQueryParam('sdkId', this.sdkId) - .withMethod('DELETE') - .build(); + .withMethod('DELETE'); + + const request = this.applyAuthToBuilder(builder).build(); return new Promise((resolve, reject) => { request.execute(true) diff --git a/src/request/request.builder.js b/src/request/request.builder.js index 4212dd7fb..a5b7b8450 100644 --- a/src/request/request.builder.js +++ b/src/request/request.builder.js @@ -1,13 +1,13 @@ 'use strict'; const fs = require('fs'); -const { v4: uuid } = require('uuid'); const yotiCommon = require('../yoti_common'); const { YotiRequest } = require('./request'); const Validation = require('../yoti_common/validation'); const yotiPackage = require('../../package.json'); const { ContentType } = require('./constants'); +const SignedRequestStrategy = require('../auth/signed.request.strategy'); const SDK_IDENTIFIER = 'Node'; @@ -77,6 +77,16 @@ class RequestBuilder { return this.withPemString(fs.readFileSync(filePath, 'utf8')); } + /** + * @param {Object} authStrategy + * + * @returns {RequestBuilder} + */ + withAuthStrategy(authStrategy) { + this.authStrategy = authStrategy; + return this; + } + /** * @param {string} name * @param {string} value @@ -143,30 +153,6 @@ class RequestBuilder { return this; } - /** - * Default request headers. - * - * @param {string} messageSignature - */ - getDefaultHeaders(messageSignature) { - const defaultHeaders = { - 'X-Yoti-Auth-Digest': messageSignature, - 'X-Yoti-SDK': SDK_IDENTIFIER, - 'X-Yoti-SDK-Version': `${SDK_IDENTIFIER}-${yotiPackage.version}`, - Accept: ContentType.JSON, - }; - - if (this.payload) { - if (this.payload.getContentType() === ContentType.FORM_DATA) { - defaultHeaders['Content-Type'] = `${ContentType.FORM_DATA}; boundary=${this.payload.getRawData().getBoundary()}`; - } else { - defaultHeaders['Content-Type'] = this.payload.getContentType(); - } - } - - return defaultHeaders; - } - /** * @returns {YotiRequest} */ @@ -174,19 +160,21 @@ class RequestBuilder { if (!this.baseUrl) { throw new Error('Base URL must be specified'); } - if (!this.pem) { - throw new Error('PEM file path or string must be provided'); + + let strategy; + if (this.authStrategy) { + strategy = this.authStrategy; + } else if (this.pem) { + strategy = new SignedRequestStrategy(this.pem); + } else { + throw new Error('PEM or auth strategy must be provided'); } - // Merge provided query params with nonce and timestamp. const queryString = buildQueryString(Object.assign( + {}, this.queryParams, - { - nonce: uuid(), - timestamp: Date.now(), - } + strategy.createQueryParams() )); - // Build endpoint and url. const endpointPath = `${this.endpoint}?${queryString}`; @@ -196,19 +184,27 @@ class RequestBuilder { payloadBase64 = `&${this.payload.getBase64Payload()}`; } - // Get message signature. - const messageSignature = yotiCommon.getRSASignatureForMessage( - `${this.method}&${endpointPath}${payloadBase64}`, - this.pem + const authHeaders = strategy.createAuthHeaders( + this.method, + endpointPath, + payloadBase64 ); - // Merge custom headers with default headers. - const headers = Object.assign( - this.getDefaultHeaders(messageSignature), - this.headers - ); + const sdkHeaders = { + 'X-Yoti-SDK': SDK_IDENTIFIER, + 'X-Yoti-SDK-Version': `${SDK_IDENTIFIER}-${yotiPackage.version}`, + Accept: ContentType.JSON, + }; + + if (this.payload) { + if (this.payload.getContentType() === ContentType.FORM_DATA) { + sdkHeaders['Content-Type'] = `${ContentType.FORM_DATA}; boundary=${this.payload.getRawData().getBoundary()}`; + } else { + sdkHeaders['Content-Type'] = this.payload.getContentType(); + } + } - // Build full url. + const headers = Object.assign(sdkHeaders, authHeaders, this.headers); const url = `${this.baseUrl}${endpointPath}`; return new YotiRequest(this.method, url, headers, this.payload); diff --git a/src/yoti_common/constants.js b/src/yoti_common/constants.js index b838a2eaf..8e99ac8f9 100644 --- a/src/yoti_common/constants.js +++ b/src/yoti_common/constants.js @@ -2,6 +2,7 @@ module.exports = Object.freeze({ API_BASE_URL: 'https://api.yoti.com', + AUTH_API_BASE_URL: 'https://auth.api.yoti.com', ON_PEP_LIST_ATTR: 'on_pep_list', ON_FRAUD_LIST_ATTR: 'on_fraud_list', ON_WATCH_LIST_ATTR: 'on_watch_list', diff --git a/tests/auth/auth.token.generator.spec.js b/tests/auth/auth.token.generator.spec.js new file mode 100644 index 000000000..9bc3fab23 --- /dev/null +++ b/tests/auth/auth.token.generator.spec.js @@ -0,0 +1,98 @@ +'use strict'; + +const nock = require('nock'); +const jwt = require('jsonwebtoken'); +const fs = require('fs'); +const AuthTokenGenerator = require('../../src/auth/auth.token.generator'); + +const PEM_STRING = fs.readFileSync('./tests/sample-data/keys/node-sdk-test.pem', 'utf8'); +const SDK_ID = 'test-sdk-id'; +const AUTH_URL = 'https://auth.example.com/v1/oauth/token'; + +describe('AuthTokenGenerator', () => { + describe('constructor', () => { + it('should throw if sdkId is not a string', () => { + expect(() => new AuthTokenGenerator(123, PEM_STRING)).toThrow(TypeError); + }); + + it('should throw if pem is empty', () => { + expect(() => new AuthTokenGenerator(SDK_ID, '')).toThrow(); + }); + + it('should accept valid parameters', () => { + expect(() => new AuthTokenGenerator(SDK_ID, PEM_STRING)).not.toThrow(); + }); + + it('should accept custom auth URL', () => { + expect(() => new AuthTokenGenerator(SDK_ID, PEM_STRING, { authUrl: AUTH_URL })).not.toThrow(); + }); + }); + + describe('#generate', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('should throw if scopes is empty', async () => { + const generator = new AuthTokenGenerator(SDK_ID, PEM_STRING, { authUrl: AUTH_URL }); + await expect(generator.generate([])).rejects.toThrow('At least one scope must be provided'); + }); + + it('should throw if scopes is not provided', async () => { + const generator = new AuthTokenGenerator(SDK_ID, PEM_STRING, { authUrl: AUTH_URL }); + await expect(generator.generate()).rejects.toThrow('At least one scope must be provided'); + }); + + it('should exchange JWT assertion for access token', async () => { + const mockResponse = { + access_token: 'test-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'idv:sessions', + }; + + const scope = nock(AUTH_URL.replace('/v1/oauth/token', '')) + .post('/v1/oauth/token') + .reply(200, mockResponse); + + const generator = new AuthTokenGenerator(SDK_ID, PEM_STRING, { authUrl: AUTH_URL }); + const response = await generator.generate(['idv:sessions']); + + expect(response.getAccessToken()).toBe('test-access-token'); + expect(response.getTokenType()).toBe('Bearer'); + expect(response.getExpiresIn()).toBe(3600); + expect(response.getScope()).toBe('idv:sessions'); + expect(scope.isDone()).toBe(true); + }); + + it('should send correct JWT claims', async () => { + let capturedBody; + + nock(AUTH_URL.replace('/v1/oauth/token', '')) + .post('/v1/oauth/token', (body) => { + capturedBody = body; + return true; + }) + .reply(200, { access_token: 'token', token_type: 'Bearer', expires_in: 3600 }); + + const generator = new AuthTokenGenerator(SDK_ID, PEM_STRING, { authUrl: AUTH_URL }); + await generator.generate(['scope1', 'scope2']); + + expect(capturedBody).toHaveProperty('grant_type', 'client_credentials'); + expect(capturedBody).toHaveProperty('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + expect(capturedBody).toHaveProperty('client_assertion'); + expect(capturedBody).toHaveProperty('scope', 'scope1 scope2'); + + const decoded = jwt.decode(capturedBody.client_assertion, { complete: true }); + expect(decoded.header.alg).toBe('PS384'); + expect(decoded.header.typ).toBe('JWT'); + expect(decoded.payload.iss).toBe(`sdk:${SDK_ID}`); + expect(decoded.payload.sub).toBe(`sdk:${SDK_ID}`); + expect(decoded.payload.aud).toBe(AUTH_URL); + expect(decoded.payload).toHaveProperty('jti'); + expect(decoded.payload).toHaveProperty('iat'); + expect(decoded.payload).toHaveProperty('exp'); + expect(decoded.payload.exp - decoded.payload.iat).toBe(300); + }); + }); +}); diff --git a/tests/auth/auth.token.strategy.spec.js b/tests/auth/auth.token.strategy.spec.js new file mode 100644 index 000000000..a563bb018 --- /dev/null +++ b/tests/auth/auth.token.strategy.spec.js @@ -0,0 +1,39 @@ +'use strict'; + +const AuthTokenStrategy = require('../../src/auth/auth.token.strategy'); + +describe('AuthTokenStrategy', () => { + describe('constructor', () => { + it('should throw if token is not a string', () => { + expect(() => new AuthTokenStrategy(123)).toThrow(TypeError); + }); + + it('should throw if token is undefined', () => { + expect(() => new AuthTokenStrategy()).toThrow(TypeError); + }); + + it('should accept a valid string token', () => { + expect(() => new AuthTokenStrategy('some-token')).not.toThrow(); + }); + }); + + describe('#createAuthHeaders', () => { + it('should return Authorization Bearer header', () => { + const strategy = new AuthTokenStrategy('my-access-token'); + const headers = strategy.createAuthHeaders(); + + expect(headers).toEqual({ + Authorization: 'Bearer my-access-token', + }); + }); + }); + + describe('#createQueryParams', () => { + it('should return empty object', () => { + const strategy = new AuthTokenStrategy('my-access-token'); + const params = strategy.createQueryParams(); + + expect(params).toEqual({}); + }); + }); +}); diff --git a/tests/auth/signed.request.strategy.spec.js b/tests/auth/signed.request.strategy.spec.js new file mode 100644 index 000000000..7783c1c80 --- /dev/null +++ b/tests/auth/signed.request.strategy.spec.js @@ -0,0 +1,69 @@ +'use strict'; + +const fs = require('fs'); +const SignedRequestStrategy = require('../../src/auth/signed.request.strategy'); + +const PEM_STRING = fs.readFileSync('./tests/sample-data/keys/node-sdk-test.pem', 'utf8'); + +describe('SignedRequestStrategy', () => { + describe('constructor', () => { + it('should throw if pem is empty', () => { + expect(() => new SignedRequestStrategy('')).toThrow(); + }); + + it('should throw if pem is null', () => { + expect(() => new SignedRequestStrategy(null)).toThrow(); + }); + + it('should accept a valid PEM string', () => { + expect(() => new SignedRequestStrategy(PEM_STRING)).not.toThrow(); + }); + }); + + describe('#createAuthHeaders', () => { + it('should return X-Yoti-Auth-Digest header', () => { + const strategy = new SignedRequestStrategy(PEM_STRING); + const headers = strategy.createAuthHeaders('GET', '/test?nonce=abc×tamp=123', ''); + + expect(headers).toHaveProperty('X-Yoti-Auth-Digest'); + expect(typeof headers['X-Yoti-Auth-Digest']).toBe('string'); + expect(headers['X-Yoti-Auth-Digest'].length).toBeGreaterThan(0); + }); + + it('should produce consistent digests for same input', () => { + const strategy = new SignedRequestStrategy(PEM_STRING); + const headers1 = strategy.createAuthHeaders('GET', '/test?nonce=abc', ''); + const headers2 = strategy.createAuthHeaders('GET', '/test?nonce=abc', ''); + + expect(headers1['X-Yoti-Auth-Digest']).toBe(headers2['X-Yoti-Auth-Digest']); + }); + + it('should produce different digests for different input', () => { + const strategy = new SignedRequestStrategy(PEM_STRING); + const headers1 = strategy.createAuthHeaders('GET', '/test?nonce=abc', ''); + const headers2 = strategy.createAuthHeaders('POST', '/test?nonce=abc', ''); + + expect(headers1['X-Yoti-Auth-Digest']).not.toBe(headers2['X-Yoti-Auth-Digest']); + }); + }); + + describe('#createQueryParams', () => { + it('should return nonce and timestamp', () => { + const strategy = new SignedRequestStrategy(PEM_STRING); + const params = strategy.createQueryParams(); + + expect(params).toHaveProperty('nonce'); + expect(params).toHaveProperty('timestamp'); + expect(typeof params.nonce).toBe('string'); + expect(typeof params.timestamp).toBe('number'); + }); + + it('should generate unique nonces', () => { + const strategy = new SignedRequestStrategy(PEM_STRING); + const params1 = strategy.createQueryParams(); + const params2 = strategy.createQueryParams(); + + expect(params1.nonce).not.toBe(params2.nonce); + }); + }); +}); diff --git a/tests/client/idv.client.spec.js b/tests/client/idv.client.spec.js index f8e67194c..05dbca213 100644 --- a/tests/client/idv.client.spec.js +++ b/tests/client/idv.client.spec.js @@ -383,3 +383,31 @@ describe.each([ }); }); }); + +describe('IDVClient constructor', () => { + describe('mutual exclusivity', () => { + it('should throw when authToken and sdkId are both provided', () => { + expect(() => new IDVClient( + 'some-sdk-id', + null, + { authToken: 'some-token' } + )).toThrow('Must not supply sdkId or PEM when using an authentication token'); + }); + + it('should throw when authToken and pem are both provided', () => { + expect(() => new IDVClient( + null, + PEM_STRING, + { authToken: 'some-token' } + )).toThrow('Must not supply sdkId or PEM when using an authentication token'); + }); + + it('should accept authToken without sdkId or pem', () => { + expect(() => new IDVClient( + null, + null, + { authToken: 'some-token' } + )).not.toThrow(); + }); + }); +}); diff --git a/tests/request/request.builder.spec.js b/tests/request/request.builder.spec.js index 3c2421dfc..0ad1bf7f4 100644 --- a/tests/request/request.builder.spec.js +++ b/tests/request/request.builder.spec.js @@ -1,7 +1,7 @@ const nock = require('nock'); const fs = require('fs'); -const { RequestBuilder, Payload } = require('../..'); +const { RequestBuilder, Payload, AuthTokenStrategy } = require('../..'); const yotiPackage = require('../../package.json'); const PEM_FILE_PATH = './tests/sample-data/keys/node-sdk-test.pem'; @@ -51,12 +51,14 @@ describe('RequestBuilder', () => { expect(request).toHaveHeaders(DEFAULT_HEADERS); }); - it('should require a PEM string or file', () => { + it('should require a PEM or auth strategy', () => { expect(() => { new RequestBuilder() .withBaseUrl(API_BASE_URL) + .withEndpoint(API_ENDPOINT) + .withGet() .build(); - }).toThrow(new Error('PEM file path or string must be provided')); + }).toThrow(new Error('PEM or auth strategy must be provided')); }); it('should require a base url', () => { @@ -177,6 +179,37 @@ describe('RequestBuilder', () => { }).toThrow(new TypeError('Header name must be a string')); }); }); + describe('#withAuthStrategy', () => { + it('should build request with token auth strategy', () => { + const strategy = new AuthTokenStrategy('my-token'); + const request = new RequestBuilder() + .withBaseUrl(API_BASE_URL) + .withEndpoint(API_ENDPOINT) + .withAuthStrategy(strategy) + .withGet() + .build(); + + expect(request.getHeaders().Authorization).toBe('Bearer my-token'); + expect(request.getHeaders()['X-Yoti-SDK']).toBe('Node'); + expect(request.getHeaders()['X-Yoti-Auth-Digest']).toBeUndefined(); + expect(request.getUrl()).not.toContain('nonce='); + expect(request.getUrl()).not.toContain('timestamp='); + }); + + it('should prioritize explicit authStrategy over PEM', () => { + const strategy = new AuthTokenStrategy('my-token'); + const request = new RequestBuilder() + .withBaseUrl(API_BASE_URL) + .withEndpoint(API_ENDPOINT) + .withPemString(PEM_STRING) + .withAuthStrategy(strategy) + .withGet() + .build(); + + expect(request.getHeaders().Authorization).toBe('Bearer my-token'); + expect(request.getHeaders()['X-Yoti-Auth-Digest']).toBeUndefined(); + }); + }); }); expect.extend({ diff --git a/types/config/index.d.ts b/types/config/index.d.ts index 21bb7de1d..a740c8a95 100644 --- a/types/config/index.d.ts +++ b/types/config/index.d.ts @@ -2,4 +2,5 @@ export const yoti: { connectApi: string; idvApi: string; digitalIdentityApi: string; + authApi: string; }; diff --git a/types/config/yoti.d.ts b/types/config/yoti.d.ts index 7b06a1571..9f7e4f2c5 100644 --- a/types/config/yoti.d.ts +++ b/types/config/yoti.d.ts @@ -1,3 +1,4 @@ export let connectApi: string; export let idvApi: string; export let digitalIdentityApi: string; +export let authApi: string; diff --git a/types/index.d.ts b/types/index.d.ts index 36bf13b03..a267deff8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -54,10 +54,13 @@ import { AdvancedIdentityProfileSchemeConfigBuilder } from "./src/idv_service"; import { AdvancedIdentityProfileBuilder } from "./src/idv_service"; import { AdvancedIdentityProfileSchemeBuilder } from "./src/idv_service"; import { AdvancedIdentityProfileRequirementsBuilder } from "./src/idv_service"; +import { AuthTokenStrategy } from "./src/auth"; +import { AuthTokenGenerator } from "./src/auth"; +import { CreateAuthenticationTokenResponse } from "./src/auth"; export declare namespace internals { export { IDVService }; export { YotiCommon }; export { YotiRequest }; export { IDVError }; } -export { YotiClient as Client, IDVClient, DigitalIdentityClient, IDVConstants, AmlAddress, AmlProfile, DigitalIdentityBuilders, DynamicScenarioBuilder, DynamicPolicyBuilder, WantedAttributeBuilder, ExtensionBuilder, LocationConstraintExtensionBuilder, ThirdPartyAttributeExtensionBuilder, TransactionalFlowExtensionBuilder, WantedAnchorBuilder, ConstraintsBuilder, SourceConstraintBuilder, RequestBuilder, Payload, YotiDate, constants, SessionSpecificationBuilder, NotificationConfigBuilder, SdkConfigBuilder, RequestedDocumentAuthenticityCheckBuilder, RequestedIdDocumentComparisonCheckBuilder, RequestedThirdPartyIdentityCheckBuilder, RequestedWatchlistScreeningCheckBuilder, RequestedWatchlistAdvancedCaCheckBuilder, RequestedFaceMatchCheckBuilder, RequestedFaceComparisonCheckBuilder, RequestedLivenessCheckBuilder, RequestedTextExtractionTaskBuilder, RequestedSupplementaryDocTextExtractionTaskBuilder, RequiredIdDocumentBuilder, RequiredSupplementaryDocumentBuilder, DocumentRestrictionsFilterBuilder, DocumentRestrictionBuilder, OrthogonalRestrictionsFilterBuilder, ProofOfAddressObjectiveBuilder, RequestedCustomAccountWatchlistAdvancedCaConfigBuilder, RequestedYotiAccountWatchlistAdvancedCaConfigBuilder, RequestedExactMatchingStrategyBuilder, RequestedFuzzyMatchingStrategyBuilder, RequestedSearchProfileSourcesBuilder, RequestedTypeListSourcesBuilder, CreateFaceCaptureResourcePayloadBuilder, UploadFaceCaptureImagePayloadBuilder, AdvancedIdentityProfileSchemeConfigBuilder, AdvancedIdentityProfileBuilder, AdvancedIdentityProfileSchemeBuilder, AdvancedIdentityProfileRequirementsBuilder }; +export { YotiClient as Client, IDVClient, DigitalIdentityClient, IDVConstants, AmlAddress, AmlProfile, DigitalIdentityBuilders, DynamicScenarioBuilder, DynamicPolicyBuilder, WantedAttributeBuilder, ExtensionBuilder, LocationConstraintExtensionBuilder, ThirdPartyAttributeExtensionBuilder, TransactionalFlowExtensionBuilder, WantedAnchorBuilder, ConstraintsBuilder, SourceConstraintBuilder, RequestBuilder, Payload, YotiDate, constants, SessionSpecificationBuilder, NotificationConfigBuilder, SdkConfigBuilder, RequestedDocumentAuthenticityCheckBuilder, RequestedIdDocumentComparisonCheckBuilder, RequestedThirdPartyIdentityCheckBuilder, RequestedWatchlistScreeningCheckBuilder, RequestedWatchlistAdvancedCaCheckBuilder, RequestedFaceMatchCheckBuilder, RequestedFaceComparisonCheckBuilder, RequestedLivenessCheckBuilder, RequestedTextExtractionTaskBuilder, RequestedSupplementaryDocTextExtractionTaskBuilder, RequiredIdDocumentBuilder, RequiredSupplementaryDocumentBuilder, DocumentRestrictionsFilterBuilder, DocumentRestrictionBuilder, OrthogonalRestrictionsFilterBuilder, ProofOfAddressObjectiveBuilder, RequestedCustomAccountWatchlistAdvancedCaConfigBuilder, RequestedYotiAccountWatchlistAdvancedCaConfigBuilder, RequestedExactMatchingStrategyBuilder, RequestedFuzzyMatchingStrategyBuilder, RequestedSearchProfileSourcesBuilder, RequestedTypeListSourcesBuilder, CreateFaceCaptureResourcePayloadBuilder, UploadFaceCaptureImagePayloadBuilder, AdvancedIdentityProfileSchemeConfigBuilder, AdvancedIdentityProfileBuilder, AdvancedIdentityProfileSchemeBuilder, AdvancedIdentityProfileRequirementsBuilder, AuthTokenStrategy, AuthTokenGenerator, CreateAuthenticationTokenResponse }; diff --git a/types/src/auth/auth.token.generator.d.ts b/types/src/auth/auth.token.generator.d.ts new file mode 100644 index 000000000..043c981bc --- /dev/null +++ b/types/src/auth/auth.token.generator.d.ts @@ -0,0 +1,36 @@ +export = AuthTokenGenerator; +/** + * Generates authentication tokens via OAuth2 client_credentials grant. + * + * @class AuthTokenGenerator + */ +declare class AuthTokenGenerator { + /** + * @param {string} sdkId + * @param {string|Buffer} pem + * @param {{authUrl?: string}} options + */ + constructor(sdkId: string, pem: string | Buffer, { authUrl }?: { + authUrl?: string; + }); + /** @private */ + private sdkId; + /** @private */ + private pem; + /** @private */ + private authUrl; + /** + * Generate an authentication token with the given scopes. + * + * @param {string[]} scopes + * + * @returns {Promise} + */ + generate(scopes: string[]): Promise; + /** + * @private + * @returns {string} + */ + private createAssertion; +} +import CreateAuthenticationTokenResponse = require("./create.authentication.token.response"); diff --git a/types/src/auth/auth.token.strategy.d.ts b/types/src/auth/auth.token.strategy.d.ts new file mode 100644 index 000000000..9af4f03a3 --- /dev/null +++ b/types/src/auth/auth.token.strategy.d.ts @@ -0,0 +1,18 @@ +export = AuthTokenStrategy; +/** + * Bearer token authentication strategy. + * + * @class AuthTokenStrategy + */ +declare class AuthTokenStrategy { + /** + * @param {string} token + */ + constructor(token: string); + /** @private */ + private token; + createAuthHeaders(): { + Authorization: string; + }; + createQueryParams(): {}; +} diff --git a/types/src/auth/create.authentication.token.response.d.ts b/types/src/auth/create.authentication.token.response.d.ts new file mode 100644 index 000000000..83b1d0614 --- /dev/null +++ b/types/src/auth/create.authentication.token.response.d.ts @@ -0,0 +1,45 @@ +export = CreateAuthenticationTokenResponse; +/** + * Response from the authentication token endpoint. + * + * @class CreateAuthenticationTokenResponse + */ +declare class CreateAuthenticationTokenResponse { + /** + * @param {Object} response + * @param {string} response.access_token + * @param {string} response.token_type + * @param {number} response.expires_in + * @param {string} response.scope + */ + constructor(response: { + access_token: string; + token_type: string; + expires_in: number; + scope: string; + }); + /** @private */ + private accessToken; + /** @private */ + private tokenType; + /** @private */ + private expiresIn; + /** @private */ + private scope; + /** + * @returns {string} + */ + getAccessToken(): string; + /** + * @returns {string} + */ + getTokenType(): string; + /** + * @returns {number} + */ + getExpiresIn(): number; + /** + * @returns {string} + */ + getScope(): string; +} diff --git a/types/src/auth/index.d.ts b/types/src/auth/index.d.ts new file mode 100644 index 000000000..a48e4fc43 --- /dev/null +++ b/types/src/auth/index.d.ts @@ -0,0 +1,5 @@ +import AuthTokenStrategy = require("./auth.token.strategy"); +import SignedRequestStrategy = require("./signed.request.strategy"); +import AuthTokenGenerator = require("./auth.token.generator"); +import CreateAuthenticationTokenResponse = require("./create.authentication.token.response"); +export { AuthTokenStrategy, SignedRequestStrategy, AuthTokenGenerator, CreateAuthenticationTokenResponse }; diff --git a/types/src/auth/signed.request.strategy.d.ts b/types/src/auth/signed.request.strategy.d.ts new file mode 100644 index 000000000..32378dc24 --- /dev/null +++ b/types/src/auth/signed.request.strategy.d.ts @@ -0,0 +1,30 @@ +export = SignedRequestStrategy; +/** + * Signed request authentication strategy. + * + * @class SignedRequestStrategy + */ +declare class SignedRequestStrategy { + /** + * @param {string} pem + */ + constructor(pem: string); + /** @private */ + private pem; + /** + * @param {string} method + * @param {string} endpointPath + * @param {string} payloadBase64 + * + * @returns {Object.} + */ + createAuthHeaders(method: string, endpointPath: string, payloadBase64: string): { + [x: string]: string; + }; + /** + * @returns {Object.} + */ + createQueryParams(): { + [x: string]: string | number; + }; +} diff --git a/types/src/client/idv.client.d.ts b/types/src/client/idv.client.d.ts index 9a9698951..6f92709d6 100644 --- a/types/src/client/idv.client.d.ts +++ b/types/src/client/idv.client.d.ts @@ -8,12 +8,13 @@ export = IDVClient; */ declare class IDVClient { /** - * @param {string} sdkId - * @param {string|Buffer} pem - * @param {{apiUrl?: string}} options + * @param {?string} sdkId + * @param {?(string|Buffer)} pem + * @param {{apiUrl?: string, authToken?: string}} options */ - constructor(sdkId: string, pem: string | Buffer, { apiUrl }?: { + constructor(sdkId: string | null, pem: (string | Buffer) | null, { apiUrl, authToken }?: { apiUrl?: string; + authToken?: string; }); /** @private */ private idvService; diff --git a/types/src/idv_service/idv.service.d.ts b/types/src/idv_service/idv.service.d.ts index 6b6246599..52d30c8d7 100644 --- a/types/src/idv_service/idv.service.d.ts +++ b/types/src/idv_service/idv.service.d.ts @@ -6,19 +6,30 @@ export = IDVService; */ declare class IDVService { /** - * @param {string} sdkId - * @param {string|Buffer} pem - * @param {{apiUrl?: string}} options + * @param {?string} sdkId + * @param {?(string|Buffer)} pem + * @param {{apiUrl?: string, authStrategy?: Object}} options */ - constructor(sdkId: string, pem: string | Buffer, { apiUrl }?: { + constructor(sdkId: string | null, pem: (string | Buffer) | null, { apiUrl, authStrategy }?: { apiUrl?: string; + authStrategy?: any; }); - /** @protected */ - protected sdkId: string; - /** @protected */ - protected pem: string | Buffer; - /** @protected */ - protected apiUrl: string; + /** @private */ + private authStrategy; + /** @private */ + private sdkId; + /** @private */ + private pem; + /** @private */ + private apiUrl; + /** + * Applies authentication to a request builder. + * + * @param {RequestBuilder} builder + * @returns {RequestBuilder} + * @private + */ + private applyAuthToBuilder; /** * Uses the supplied session specification to create a session * diff --git a/types/src/request/request.builder.d.ts b/types/src/request/request.builder.d.ts index 5eeac4658..fbae516dd 100644 --- a/types/src/request/request.builder.d.ts +++ b/types/src/request/request.builder.d.ts @@ -35,6 +35,13 @@ export class RequestBuilder { * @returns {RequestBuilder} */ withPemFilePath(filePath: string): RequestBuilder; + /** + * @param {Object} authStrategy + * + * @returns {RequestBuilder} + */ + withAuthStrategy(authStrategy: any): RequestBuilder; + authStrategy: any; /** * @param {string} name * @param {string} value @@ -75,17 +82,6 @@ export class RequestBuilder { * @returns {RequestBuilder} */ withQueryParam(name: string, value: string | boolean | number): RequestBuilder; - /** - * Default request headers. - * - * @param {string} messageSignature - */ - getDefaultHeaders(messageSignature: string): { - 'X-Yoti-Auth-Digest': string; - 'X-Yoti-SDK': string; - 'X-Yoti-SDK-Version': string; - Accept: string; - }; /** * @returns {YotiRequest} */ diff --git a/types/src/yoti_common/constants.d.ts b/types/src/yoti_common/constants.d.ts index cb8b10cac..445b52b51 100644 --- a/types/src/yoti_common/constants.d.ts +++ b/types/src/yoti_common/constants.d.ts @@ -1,5 +1,6 @@ declare const _exports: Readonly<{ API_BASE_URL: "https://api.yoti.com"; + AUTH_API_BASE_URL: "https://auth.api.yoti.com"; ON_PEP_LIST_ATTR: "on_pep_list"; ON_FRAUD_LIST_ATTR: "on_fraud_list"; ON_WATCH_LIST_ATTR: "on_watch_list";