UNPKG

@unito/integration-cli

Version:

Integration CLI

314 lines (313 loc) 14.7 kB
"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;