UNPKG

@unito/integration-cli

Version:

Integration CLI

481 lines (480 loc) 30.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); /* eslint-disable @typescript-eslint/no-explicit-any */ const strict_1 = tslib_1.__importDefault(require("node:assert/strict")); const sinon_1 = tslib_1.__importDefault(require("sinon")); const oauth2_1 = tslib_1.__importStar(require("../../src/services/oauth2")), oauth2Namespace = oauth2_1; const configurationTypes_1 = require("../../src/configurationTypes"); const errors_1 = require("../../src/errors"); const globalConfiguration_1 = require("../../src/resources/globalConfiguration"); describe('OAuth2Helper', () => { let oauth2Helper; let openSpy; let fetchStub; describe('constructor', () => { it('throws an error if the request content type is not supported', async () => { try { new oauth2_1.default({ scopes: [], requestContentType: 'random' }); } catch (err) { (0, strict_1.default)(err instanceof errors_1.UnsupportedContentTypeError); } }); }); describe('method', () => { beforeEach(() => { const authorizationInfo = { clientId: 'your-client-id', clientSecret: 'your-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }, { name: 'scope2' }], tokenUrl: 'https://provider.com/oauth/token', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, responseContentType: configurationTypes_1.RequestContentType.JSON, tokenRequestParameters: { header: { Authorization: 'custom', }, }, }; oauth2Helper = new oauth2_1.default(authorizationInfo, globalConfiguration_1.Environment.Production, { foo: 'fooValue', bar: 'barValue' }); fetchStub = sinon_1.default.stub().resolves({ json: sinon_1.default.stub().resolves({}), status: 200, text: sinon_1.default.stub().resolves(''), headers: new Headers(), }); sinon_1.default.stub(oauth2Helper, 'startServer').resolves('http://localhost:5050'); sinon_1.default.stub(oauth2Helper, 'stopServer'); sinon_1.default.replace(global, 'fetch', fetchStub); sinon_1.default.replace(oauth2Namespace, 'open', { open: (_url) => undefined }); openSpy = sinon_1.default.stub(oauth2Namespace.open, 'open'); }); afterEach(() => { sinon_1.default.restore(); }); describe('authorize', () => { it('opens the authorization URL', async () => { await oauth2Helper.authorize(); sinon_1.default.assert.calledOnce(openSpy); sinon_1.default.assert.calledWith(openSpy, 'https://provider.com/oauth/authorize?client_id=your-client-id&scope=scope1+scope2&state=eyJjbGlDYWxsYmFja1VybCI6Ii9vYXV0aDIvY2FsbGJhY2sifQ%3D%3D&response_type=code&redirect_uri=https%3A%2F%2Fintegrations-platform.unito.io%2Fcredentials%2Fnew%2Foauth2%2Fcallback-cli'); }); it('maintains the pre-existing query parameters in authorization URL', async () => { oauth2Helper['providerAuthorizationUrl'] = 'https://provider.com/oauth/authorize?query1=value1&query2=value2'; await oauth2Helper.authorize(); sinon_1.default.assert.calledOnce(openSpy); sinon_1.default.assert.calledWith(openSpy, 'https://provider.com/oauth/authorize?query1=value1&query2=value2&client_id=your-client-id&scope=scope1+scope2&state=eyJjbGlDYWxsYmFja1VybCI6Ii9vYXV0aDIvY2FsbGJhY2sifQ%3D%3D&response_type=code&redirect_uri=https%3A%2F%2Fintegrations-platform.unito.io%2Fcredentials%2Fnew%2Foauth2%2Fcallback-cli'); }); it('supports dynamic params', async () => { oauth2Helper['providerAuthorizationUrl'] = 'https://pro-{+foo}-der.com/oauth/authorize?query1={+bar}'; await oauth2Helper.authorize(); sinon_1.default.assert.calledOnce(openSpy); sinon_1.default.assert.calledWith(openSpy, 'https://pro-fooValue-der.com/oauth/authorize?query1=barValue&client_id=your-client-id&scope=scope1+scope2&state=eyJjbGlDYWxsYmFja1VybCI6Ii9vYXV0aDIvY2FsbGJhY2sifQ%3D%3D&response_type=code&redirect_uri=https%3A%2F%2Fintegrations-platform.unito.io%2Fcredentials%2Fnew%2Foauth2%2Fcallback-cli'); }); }); describe('handleCallback', () => { it('retrieves the access token and return it in the response', async () => { const code = 'test-code'; const req = { query: { code } }; const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub() }; await oauth2Helper['handleCallback'](req, res); sinon_1.default.assert.calledOnce(fetchStub); sinon_1.default.assert.calledWith(fetchStub, 'https://provider.com/oauth/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'custom', }, body: sinon_1.default.match.string, method: 'POST', }); sinon_1.default.assert.calledOnce(res.send); sinon_1.default.assert.calledWith(res.send, oauth2Namespace.HTML_SUCCESS_MSG); }); it('handles dynamic variables with values from provider response and credential payload', async () => { oauth2Helper['tokenUrl'] = 'https://{+foo}.com/oauth/token?specialParam={+authorizationResponse.responseValue}'; const code = 'test-code'; const req = { query: { code, responseValue: 'DynamicValue' } }; const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub() }; await oauth2Helper['handleCallback'](req, res); sinon_1.default.assert.calledOnce(fetchStub); sinon_1.default.assert.calledWith(fetchStub, 'https://fooValue.com/oauth/token?specialParam=DynamicValue', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'custom', }, body: sinon_1.default.match.string, method: 'POST', }); sinon_1.default.assert.calledOnce(res.send); sinon_1.default.assert.calledWith(res.send, oauth2Namespace.HTML_SUCCESS_MSG); }); it('handles errors - fetch exception', async () => { const code = 'test-code'; const req = { query: { code } }; const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub() }; fetchStub.rejects(new Error('Failed to retrieve access token')); try { await oauth2Helper['handleCallback'](req, res); } catch (err) { (0, strict_1.default)(err instanceof errors_1.FailedToRetrieveAccessTokenError); } sinon_1.default.assert.calledOnce(res.send); sinon_1.default.assert.calledWith(res.send, oauth2Namespace.HTML_ERROR_MSG('Failed to retrieve access token')); }); it('handles errors - response', async () => { const code = 'test-code'; const req = { query: { code } }; const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub() }; fetchStub.resolves({ status: 500, headers: new Headers({ 'content-type': 'application/x-www-form-urlencoded' }), text: sinon_1.default.stub().resolves(''), }); try { await oauth2Helper['handleCallback'](req, res); } catch (err) { (0, strict_1.default)(err instanceof errors_1.FailedToRetrieveAccessTokenError); } sinon_1.default.assert.calledOnce(res.send); sinon_1.default.assert.calledWith(res.send, oauth2Namespace.HTML_ERROR_MSG()); }); describe('template variables for clientId and clientSecret', () => { const setupHelperWithStubs = (authorizationInfo, credentialPayload) => { oauth2Helper = new oauth2_1.default(authorizationInfo, globalConfiguration_1.Environment.Production, credentialPayload); sinon_1.default.stub(oauth2Helper, 'startServer').resolves('http://localhost:5050'); sinon_1.default.stub(oauth2Helper, 'stopServer'); }; afterEach(() => { sinon_1.default.restore(); }); const performCallback = async () => { const req = { query: { code: 'test-code' } }; const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub() }; await oauth2Helper['handleCallback'](req, res); return { req, res }; }; it('expands clientId and clientSecret in headers', async () => { // What matters: Template variables {+clientId} and {+clientSecret} in headers // should be replaced with actual service-level client credentials setupHelperWithStubs({ clientId: 'my-client-id', clientSecret: 'my-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://provider.com/oauth/token', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, tokenRequestParameters: { header: { 'X-Client-ID': '{+clientId}', 'X-Client-Secret': '{+clientSecret}' }, }, }); await performCallback(); // What matters: The fetch call should have expanded the template variables in headers sinon_1.default.assert.calledWith(fetchStub, 'https://provider.com/oauth/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Client-ID': 'my-client-id', // Template {+clientId} expanded 'X-Client-Secret': 'my-client-secret', // Template {+clientSecret} expanded }, body: sinon_1.default.match.string, method: 'POST', }); }); it('expands clientId and clientSecret in body', async () => { // What matters: Template variables in request body should be expanded // while preserving other static parameters setupHelperWithStubs({ clientId: 'my-client-id', clientSecret: 'my-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://provider.com/oauth/token', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, tokenRequestParameters: { body: { custom_client_id: '{+clientId}', custom_client_secret: '{+clientSecret}', additional_param: 'static_value', }, }, }); await performCallback(); const requestBody = fetchStub.getCall(0).args[1].body; const bodyParams = new URLSearchParams(requestBody); // What matters: Template variables in body are expanded to actual values strict_1.default.equal(bodyParams.get('custom_client_id'), 'my-client-id'); strict_1.default.equal(bodyParams.get('custom_client_secret'), 'my-client-secret'); // What matters: Static parameters are preserved unchanged strict_1.default.equal(bodyParams.get('additional_param'), 'static_value'); }); it('expands clientId and clientSecret in URL', async () => { // What matters: Template variables in token URL should be expanded // and properly URL-encoded as query parameters setupHelperWithStubs({ clientId: 'my-client-id', clientSecret: 'my-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://provider.com/oauth/token?client_id={+clientId}&secret={+clientSecret}', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, }); await performCallback(); // What matters: The URL template variables are expanded in the fetch call sinon_1.default.assert.calledWith(fetchStub, 'https://provider.com/oauth/token?client_id=my-client-id&secret=my-client-secret', sinon_1.default.match.object); }); it('allows credential payload to override service values', async () => { // What matters: Credential payload values should take precedence over // service-level clientId/clientSecret when expanding templates setupHelperWithStubs({ clientId: 'default-client-id', // Service-level values clientSecret: 'default-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://provider.com/oauth/token', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, tokenRequestParameters: { header: { 'X-Client-ID': '{+clientId}', 'X-Client-Secret': '{+clientSecret}' }, }, }, { clientId: 'override-client-id', clientSecret: 'override-client-secret' }); await performCallback(); // What matters: Template expansion uses the credential payload override values sinon_1.default.assert.calledWith(fetchStub, 'https://provider.com/oauth/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Client-ID': 'override-client-id', // Uses credential payload value 'X-Client-Secret': 'override-client-secret', // Uses credential payload value }, body: sinon_1.default.match.string, method: 'POST', }); }); }); it('form-urlencoded error responses', async () => { const code = 'test-code'; const req = { query: { code } }; const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub(), headersSent: false }; const errorText = 'error=incorrect_client_credentials&error_description=Invalid+credentials'; fetchStub.resolves({ status: 400, headers: new Headers({ 'content-type': 'application/x-www-form-urlencoded' }), text: sinon_1.default.stub().resolves(errorText), }); try { await oauth2Helper['handleCallback'](req, res); strict_1.default.fail('Should have thrown an error'); } catch (err) { (0, strict_1.default)(err instanceof errors_1.FailedToRetrieveAccessTokenError); (0, strict_1.default)(err.message.includes('Invalid credentials')); } sinon_1.default.assert.calledOnce(res.send); sinon_1.default.assert.calledWith(res.send, oauth2Namespace.HTML_ERROR_MSG('Invalid credentials')); }); it('handles JSON error responses', async () => { const code = 'test-code'; const req = { query: { code } }; const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub(), headersSent: false }; const errorJson = { error: 'invalid_request', error_description: 'Bad request' }; fetchStub.resolves({ status: 400, headers: new Headers({ 'content-type': 'application/json' }), json: sinon_1.default.stub().resolves(errorJson), text: sinon_1.default.stub().resolves(JSON.stringify(errorJson)), }); try { await oauth2Helper['handleCallback'](req, res); strict_1.default.fail('Should have thrown an error'); } catch (err) { (0, strict_1.default)(err instanceof errors_1.FailedToRetrieveAccessTokenError); (0, strict_1.default)(err.message.includes('Bad request')); } sinon_1.default.assert.calledOnce(res.send); sinon_1.default.assert.calledWith(res.send, oauth2Namespace.HTML_ERROR_MSG('Bad request')); }); }); describe('updateToken', () => { const setupSuccessfulRefreshResponse = () => { const refreshToken = 'test-refresh-token'; const accessToken = 'new-access-token'; const fetchResponse = { access_token: accessToken, refresh_token: refreshToken }; fetchStub.resolves({ status: 200, json: sinon_1.default.stub().resolves(fetchResponse), text: sinon_1.default.stub().resolves(''), headers: new Headers({ 'content-type': 'application/json' }), }); return { refreshToken, accessToken }; }; it('retrieves the access token when a refresh token is available', async () => { const { refreshToken, accessToken } = setupSuccessfulRefreshResponse(); const result = await oauth2Helper.updateToken(refreshToken); strict_1.default.deepEqual(result, { accessToken, refreshToken }); sinon_1.default.assert.calledOnce(fetchStub); // Assert that the refresh token is actually sent in the request body const fetchCall = fetchStub.getCall(0); const requestBody = fetchCall.args[1].body; const bodyParams = new URLSearchParams(requestBody); strict_1.default.equal(bodyParams.get('refresh_token'), refreshToken); strict_1.default.equal(bodyParams.get('grant_type'), 'refresh_token'); }); describe('template variables for clientId and clientSecret', () => { const createHelperWithTemplateHeaders = (clientId, clientSecret, credentialPayload) => { return new oauth2_1.default({ clientId, clientSecret, authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://provider.com/oauth/token', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, tokenRequestParameters: { header: { 'X-Client-ID': '{+clientId}', 'X-Client-Secret': '{+clientSecret}' }, }, }, globalConfiguration_1.Environment.Production, credentialPayload); }; it('expands template variables in headers', async () => { // What matters: Template variables in tokenRequestParameters headers // should be expanded during token refresh operations oauth2Helper = createHelperWithTemplateHeaders('refresh-client-id', 'refresh-client-secret'); const { refreshToken } = setupSuccessfulRefreshResponse(); await oauth2Helper.updateToken(refreshToken); // What matters: Fetch call for refresh token includes expanded template variables sinon_1.default.assert.calledWith(fetchStub, 'https://provider.com/oauth/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Client-ID': 'refresh-client-id', // Template expanded 'X-Client-Secret': 'refresh-client-secret', // Template expanded }, body: sinon_1.default.match.string, method: 'POST', }); }); it('allows credential payload override', async () => { // What matters: During token refresh, credential payload values should // override service-level values for template expansion oauth2Helper = createHelperWithTemplateHeaders('default-id', 'default-secret', { clientId: 'override-refresh-client-id', // Override values clientSecret: 'override-refresh-client-secret', }); const { refreshToken } = setupSuccessfulRefreshResponse(); await oauth2Helper.updateToken(refreshToken); // What matters: Template expansion uses credential payload override values for refresh sinon_1.default.assert.calledWith(fetchStub, 'https://provider.com/oauth/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Client-ID': 'override-refresh-client-id', // Uses override value 'X-Client-Secret': 'override-refresh-client-secret', // Uses override value }, body: sinon_1.default.match.string, method: 'POST', }); }); }); it('includes refreshRequestParameters headers in token request', async () => { oauth2Helper = new oauth2_1.default({ clientId: 'test-client-id', clientSecret: 'test-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://provider.com/oauth/token', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, tokenRequestParameters: { header: { 'X-Token-Header': 'token-value' }, }, refreshRequestParameters: { header: { 'X-Refresh-Header': 'refresh-value', 'X-Special-Auth': 'special-token' }, }, }, globalConfiguration_1.Environment.Production); const { refreshToken } = setupSuccessfulRefreshResponse(); await oauth2Helper.updateToken(refreshToken); // What matters: Both tokenRequestParameters and refreshRequestParameters headers // should be present in the token refresh request sinon_1.default.assert.calledWith(fetchStub, 'https://provider.com/oauth/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Token-Header': 'token-value', // From tokenRequestParameters.header 'X-Refresh-Header': 'refresh-value', // From refreshRequestParameters.header 'X-Special-Auth': 'special-token', // From refreshRequestParameters.header }, body: sinon_1.default.match.string, method: 'POST', }); }); it('throws an error when failing to refresh the access token', async () => { const refreshToken = 'test-refresh-token'; fetchStub.rejects(new Error('Failed to retrieve access token')); const response = oauth2Helper.updateToken(refreshToken); await strict_1.default.rejects(response, errors_1.FailedToRetrieveAccessTokenError); sinon_1.default.assert.calledOnce(fetchStub); }); it("throws an error if we don't support the request content type", async () => { const refreshToken = 'test-refresh-token'; oauth2Helper['requestContentType'] = 'random'; const response = oauth2Helper.updateToken(refreshToken); await strict_1.default.rejects(response, errors_1.InvalidRequestContentTypeError); }); describe('token URL template expansion', () => { it('expands template variables in token URL during refresh', async () => { const credentialPayload = { domain: 'test-domain' }; oauth2Helper = new oauth2_1.default({ clientId: 'test-client-id', clientSecret: 'test-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://{+domain}.provider.com/oauth/token', // Template URL grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, }, globalConfiguration_1.Environment.Production, credentialPayload); const { refreshToken } = setupSuccessfulRefreshResponse(); await oauth2Helper.updateToken(refreshToken); // Assert that the token URL was expanded with the domain sinon_1.default.assert.calledWith(fetchStub, 'https://test-domain.provider.com/oauth/token', sinon_1.default.match.object); }); it('expands multiple template variables in token URL', async () => { const credentialPayload = { domain: 'my-company', environment: 'staging', }; oauth2Helper = new oauth2_1.default({ clientId: 'test-client-id', clientSecret: 'test-client-secret', authorizationUrl: 'https://provider.com/oauth/authorize', scopes: [{ name: 'scope1' }], tokenUrl: 'https://{+domain}.{+environment}.provider.com/oauth/token', grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE, requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED, }, globalConfiguration_1.Environment.Production, credentialPayload); const { refreshToken } = setupSuccessfulRefreshResponse(); await oauth2Helper.updateToken(refreshToken); // Assert that both template variables were expanded sinon_1.default.assert.calledWith(fetchStub, 'https://my-company.staging.provider.com/oauth/token', sinon_1.default.match.object); }); }); }); describe('encodeBody', () => { it('encodes body data as URL-encoded when content type is URL_ENCODED', () => { const bodyData = { key1: 'value1', key2: 'value2' }; const contentType = configurationTypes_1.RequestContentType.URL_ENCODED; const expectedEncodedBody = 'key1=value1&key2=value2'; const encodedBody = oauth2Helper['encodeBody'](bodyData, contentType); strict_1.default.equal(encodedBody, expectedEncodedBody); }); it('encodes body data as JSON when content type is JSON', () => { const bodyData = { key1: 'value1', key2: 'value2' }; const contentType = configurationTypes_1.RequestContentType.JSON; const expectedEncodedBody = JSON.stringify(bodyData); const encodedBody = oauth2Helper['encodeBody'](bodyData, contentType); strict_1.default.equal(encodedBody, expectedEncodedBody); }); }); }); });