@unito/integration-cli
Version:
Integration CLI
314 lines (313 loc) • 14.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HTML_SUCCESS_MSG = exports.HTML_ERROR_MSG = exports.open = void 0;
const tslib_1 = require("tslib");
const express_1 = tslib_1.__importDefault(require("express"));
const cors_1 = tslib_1.__importDefault(require("cors"));
const openUrl = tslib_1.__importStar(require("openurl"));
const ngrok_1 = tslib_1.__importDefault(require("@ngrok/ngrok"));
const IntegrationsPlatformClient = tslib_1.__importStar(require("./integrationsPlatform"));
const configurationTypes_1 = require("../configurationTypes");
const template_1 = require("../resources/template");
const errors_1 = require("../errors");
const globalConfiguration_1 = require("../resources/globalConfiguration");
// It allows to stub openUrl library in the test
exports.open = openUrl;
const HTML_ERROR_MSG = (errorMsg = '') => `<!doctype html><head><title>Unito</title></head><body style="text-align:center"> An unknown error occured. ${errorMsg}</body>`;
exports.HTML_ERROR_MSG = HTML_ERROR_MSG;
exports.HTML_SUCCESS_MSG = `<!doctype html><head><title>Unito</title></head><body style="text-align:center"> Redirected to the CLI successfully </body>`;
const AUTHORIZATION_RESPONSE_QUERY_PARAMS = ['code', 'state', 'error', 'error_description', 'error_uri'];
class OAuth2Service {
environment;
server = null;
clientId;
clientSecret;
providerAuthorizationUrl;
tokenUrl;
scopes;
grantType;
requestContentType;
oauth2Response;
serverUrl = '';
tokenRequestParameters;
refreshRequestParameters;
credentialPayload;
/**
* Constructs an instance of OAuthHelper.
* @param clientId The client ID for your OAuth application.
* @param clientSecret The client secret for your OAuth application.
* @param authorizationUrl The URL for the authorization endpoint of the provider.
* @param scopes The scopes required for the OAuth authorization.
* @param providerTokenUrl The URL for the token endpoint of the provider.
*/
constructor(authorizationInfo, environment = globalConfiguration_1.Environment.Production, credentialPayload) {
const { clientId, clientSecret, authorizationUrl, scopes, tokenUrl, grantType, requestContentType, refreshRequestParameters, tokenRequestParameters, } = authorizationInfo;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.providerAuthorizationUrl = authorizationUrl;
this.scopes = scopes.map(scope => scope.name);
this.tokenUrl = tokenUrl;
this.grantType = grantType ?? configurationTypes_1.GrantType.AUTHORIZATION_CODE;
this.requestContentType = requestContentType ?? configurationTypes_1.RequestContentType.URL_ENCODED;
this.tokenRequestParameters = tokenRequestParameters;
this.refreshRequestParameters = refreshRequestParameters;
this.environment = environment;
this.credentialPayload = credentialPayload;
if (!Object.values(configurationTypes_1.RequestContentType).includes(this.requestContentType)) {
throw new errors_1.UnsupportedContentTypeError(`Request content type not supported: ${this.requestContentType}`);
}
}
/**
* Initiate the authorization flow and redirects the user to the provider's authorization page.
*/
async authorize() {
if (!this.providerAuthorizationUrl) {
throw new errors_1.InvalidRequestContentTypeError('authorizationUrl must be defined in .unito.json');
}
const authorizationParams = new URLSearchParams();
if (this.clientId) {
authorizationParams.set('client_id', this.clientId);
}
if (this.scopes) {
authorizationParams.set('scope', this.scopes.join(' '));
}
const state = Buffer.from(JSON.stringify({
cliCallbackUrl: `${this.serverUrl}/oauth2/callback`,
})).toString('base64');
authorizationParams.set('state', state);
authorizationParams.set('response_type', 'code');
authorizationParams.set('redirect_uri', `${IntegrationsPlatformClient.Servers[this.environment]}/credentials/new/oauth2/callback-cli`);
const delimiter = this.providerAuthorizationUrl.includes('?') ? '&' : '?';
const authorizationUrlTemplate = `${this.providerAuthorizationUrl}${delimiter}${authorizationParams.toString()}`;
const authorizationUrl = (0, template_1.expandTemplate)(authorizationUrlTemplate, {
...(this.credentialPayload ?? {}),
...Object.fromEntries(authorizationParams.entries()),
}, { urlEncodeVariables: true });
console.log(' Calling the following authorization URL: \n ', authorizationUrl);
exports.open.open(authorizationUrl);
}
/**
* Handles the callback request from the provider and stores the authorization code.
* @param req The express Request object.
* @param res The express Response object.
*/
async handleCallback(req, res) {
// Authorization Response: https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2
// const state = req.state as string;
// Error response: https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1
const error = req.query.error;
if (error) {
res.setHeader('Content-Type', 'text/html');
res.send((0, exports.HTML_ERROR_MSG)(error));
return;
}
// We keep all the non-standard query parameters of the authorization response
// so they can be used later on for the access token request (see tokenRequestParameters).
const authorizationResponseVariables = {};
for (const [key, value] of Object.entries(req.query)) {
if (!AUTHORIZATION_RESPONSE_QUERY_PARAMS.includes(key)) {
authorizationResponseVariables[`authorizationResponse.${key}`] = value?.toString() ?? '';
}
}
const templateVariables = { ...(this.credentialPayload ?? {}), ...authorizationResponseVariables };
templateVariables.clientId ??= this.clientId;
templateVariables.clientSecret ??= this.clientSecret;
const tokenRequestPayload = {
...Object.fromEntries(Object.entries(this.tokenRequestParameters?.body ?? {}).map(([key, value]) => [
key,
typeof value === 'string' ? (0, template_1.expandTemplate)(value, templateVariables, { urlEncodeVariables: false }) : value,
])),
grant_type: this.grantType,
code: req.query.code,
redirect_uri: `${IntegrationsPlatformClient.Servers[this.environment]}/credentials/new/oauth2/callback-cli`,
...(this.clientId && { client_id: this.clientId }),
...(this.clientSecret && { client_secret: this.clientSecret }),
};
if (this.grantType === configurationTypes_1.GrantType.JWT_BEARER && this.clientSecret) {
tokenRequestPayload.client_assertion = this.clientSecret;
tokenRequestPayload.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
tokenRequestPayload.assertion = req.query.code;
}
const tokenRequestHeaders = {
'Content-Type': this.requestContentType,
...Object.fromEntries(Object.entries(this.tokenRequestParameters?.header ?? {}).map(([key, value]) => [
key,
(0, template_1.expandTemplate)(String(value), templateVariables, { urlEncodeVariables: false }),
])),
};
try {
const tokenResponse = await fetch((0, template_1.expandTemplate)(this.tokenUrl, templateVariables, { urlEncodeVariables: true }), {
headers: tokenRequestHeaders,
body: this.encodeBody(tokenRequestPayload, this.requestContentType),
method: 'POST',
});
const response = await this.parseOAuth2Response(tokenResponse);
this.oauth2Response = {
accessToken: response.access_token,
refreshToken: response.refresh_token,
};
res.setHeader('Content-Type', 'text/html');
res.send(exports.HTML_SUCCESS_MSG);
}
catch (error) {
const err = error;
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/html');
res.send((0, exports.HTML_ERROR_MSG)(err?.message));
}
throw new errors_1.FailedToRetrieveAccessTokenError(err?.message, err);
}
}
async parseOAuth2Response(response) {
const contentType = response.headers.get('content-type');
let responseObj = {};
// Try JSON first if content-type indicates JSON
if (contentType?.includes('application/json')) {
try {
responseObj = await response.json();
}
catch (e) {
// Fallback to text if JSON parse fails
console.warn('Failed to parse JSON response, falling back to form-urlencoded');
}
}
else {
// Handle form-urlencoded
const text = await response.text();
try {
// Try to parse as JSON anyway (some servers send wrong content-type)
responseObj = JSON.parse(text);
}
catch (e) {
// Parse as form-urlencoded
responseObj = Object.fromEntries(new URLSearchParams(text));
}
}
if (responseObj.error || response.status !== 200) {
const errorMsg = responseObj.error_description || responseObj.error;
throw new errors_1.FailedToRetrieveAccessTokenError(errorMsg, responseObj);
}
return responseObj;
}
/**
* Waits for the authorization code to be set.
* @returns A promise that resolves when the code is set.
*/
/* istanbul ignore next */
async callbackIsDone() {
if (this.oauth2Response) {
return this.oauth2Response;
}
else {
return new Promise(resolve => {
const interval = setInterval(() => {
if (this.oauth2Response) {
clearInterval(interval);
resolve(this.oauth2Response);
}
}, 100);
});
}
}
encodeBody(bodyData, contentType) {
switch (contentType) {
case configurationTypes_1.RequestContentType.URL_ENCODED:
return new URLSearchParams(bodyData).toString();
case configurationTypes_1.RequestContentType.JSON:
return JSON.stringify(bodyData);
}
}
async updateToken(refreshToken) {
if (!Object.values(configurationTypes_1.RequestContentType).includes(this.requestContentType)) {
throw new errors_1.InvalidRequestContentTypeError(`Request content type not supported: ${this.requestContentType}`);
}
const bodyData = {
grant_type: 'refresh_token',
refresh_token: refreshToken,
};
if (this.clientId) {
bodyData.client_id = this.clientId;
}
if (this.clientSecret) {
bodyData.client_secret = this.clientSecret;
}
if (this.grantType === configurationTypes_1.GrantType.JWT_BEARER && this.clientSecret) {
bodyData.client_assertion = this.clientSecret;
bodyData.assertion = refreshToken;
bodyData.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
bodyData.redirect_uri = `${IntegrationsPlatformClient.Servers[this.environment]}/credentials/new/oauth2/callback-cli`;
}
const templateVariables = structuredClone(this.credentialPayload ?? {});
templateVariables.clientId ??= this.clientId;
templateVariables.clientSecret ??= this.clientSecret;
const tokenRequestHeaders = {
'Content-Type': this.requestContentType,
...(this.refreshRequestParameters?.header ?? {}),
};
for (const [headerKey, headerValue] of Object.entries(this.tokenRequestParameters?.header ?? {})) {
tokenRequestHeaders[headerKey] = (0, template_1.expandTemplate)(String(headerValue), templateVariables, {
urlEncodeVariables: false,
});
}
try {
const tokenResponse = await fetch((0, template_1.expandTemplate)(this.tokenUrl, templateVariables, { urlEncodeVariables: true }), {
headers: {
'Content-Type': this.requestContentType,
...(tokenRequestHeaders ?? {}),
},
body: this.encodeBody(bodyData, this.requestContentType),
method: 'POST',
});
const response = await this.parseOAuth2Response(tokenResponse);
return {
accessToken: response.access_token,
refreshToken: response.refresh_token,
};
}
catch (error) {
const err = error;
throw new errors_1.FailedToRetrieveAccessTokenError(err?.message, err);
}
}
/**
* Starts the Express server for handling OAuth callbacks.
* @returns The URL of the server.
*/
/* istanbul ignore next */
async startServer() {
const app = (0, express_1.default)();
const PORT = process.env.OAUTH2_PORT ?? 9002;
const listener = await ngrok_1.default.forward({ addr: Number(PORT), authtoken_from_env: true });
const url = listener.url();
if (url) {
this.serverUrl = url;
console.log(` Ngrok started at: ${url}`);
}
else {
throw new Error('Ngrok failed to start: URL is null');
}
app.use((0, cors_1.default)({ credentials: true, origin: true }));
app.use(express_1.default.json());
app.get('/health', (_req, res) => {
res.send('pong');
});
app.get('/oauth2/callback', (req, res) => this.handleCallback(req, res));
this.server = app.listen(PORT, () => {
console.log(` Listening at port ${PORT}`);
});
return this.serverUrl;
}
/**
* Stops the Express server.
*/
/* istanbul ignore next */
async stopServer() {
if (this.server) {
await ngrok_1.default.disconnect();
this.server.close(() => {
console.log(' Server has stopped');
});
}
}
}
exports.default = OAuth2Service;