@unito/integration-cli
Version:
Integration CLI
481 lines (480 loc) • 30.4 kB
JavaScript
"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);
});
});
});
});