UNPKG

zapier-platform-cli

Version:

The CLI for managing integrations in Zapier Developer Platform.

291 lines (262 loc) 9.18 kB
const crypto = require('node:crypto'); const http = require('node:http'); const _ = require('lodash'); const debug = require('debug')('zapier:invoke'); const { localAppCommand } = require('../../../../utils/local'); const { startSpinner, endSpinner } = require('../../../../utils/display'); const { appendEnv } = require('../env'); const { customLogger } = require('../logger'); const { formatFieldDisplay } = require('../prompts'); /** * Prompts the user for authentication field values. * Handles password fields with hidden input. * @param {import('../../../ZapierBaseCommand')} command - The command instance for prompting * @param {Array<Object>} authFields - Array of auth field definitions * @returns {Promise<Object>} Object containing field keys and user-provided values */ const promptForAuthFields = async (command, authFields) => { const authData = {}; for (const field of authFields) { if (field.computed) { continue; } const message = formatFieldDisplay(field) + ':'; let value; if (field.type === 'password') { value = await command.promptHidden(message, true); } else { value = await command.prompt(message, { useStderr: true }); } authData[field.key] = value; } return authData; }; /** * Initializes basic authentication by prompting for username and password. * @param {import('../../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @returns {Promise<Object>} Auth data with username and password * @throws {Error} If in non-interactive mode */ const startBasicAuth = async (command, context) => { if (context.nonInteractive) { throw new Error( 'The `auth start` subcommand for "basic" authentication type only works in interactive mode.', ); } return promptForAuthFields(command, [ { key: 'username', label: 'Username', required: true, }, { key: 'password', label: 'Password', required: true, }, ]); }; /** * Initializes custom authentication by prompting for configured auth fields. * @param {import('../../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @returns {Promise<Object>} Auth data with field values * @throws {Error} If in non-interactive mode */ const startCustomAuth = async (command, context) => { if (context.nonInteractive) { throw new Error( 'The `auth start` subcommand for "custom" authentication type only works in interactive mode.', ); } return promptForAuthFields( command, context.appDefinition.authentication.fields, ); }; /** * Initializes OAuth2 authentication. * Prompts for CLIENT_ID/SECRET if needed, starts a local HTTP server, * opens the browser for authorization, and exchanges the code for tokens. * @param {import('../../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @returns {Promise<Object>} Auth data with access token and other OAuth2 fields */ const startOAuth2 = async (command, context) => { const env = {}; if (!process.env.CLIENT_ID || !process.env.CLIENT_SECRET) { if (context.nonInteractive) { throw new Error( 'CLIENT_ID and CLIENT_SECRET must be set in the .env file in non-interactive mode.', ); } else { console.warn( 'CLIENT_ID and CLIENT_SECRET are required for OAuth2, ' + "but they are not found in the .env file. I'll prompt you for them now.", ); } } if (!process.env.CLIENT_ID) { env.CLIENT_ID = await command.prompt('CLIENT_ID:', { useStderr: true }); process.env.CLIENT_ID = env.CLIENT_ID; } if (!process.env.CLIENT_SECRET) { env.CLIENT_SECRET = await command.prompt('CLIENT_SECRET:', { useStderr: true, }); process.env.CLIENT_SECRET = env.CLIENT_SECRET; } if (!_.isEmpty(env)) { // Save envs so the user won't have to re-enter them if the command fails await appendEnv(env); console.warn('CLIENT_ID and CLIENT_SECRET saved to .env file.'); } startSpinner('Invoking authentication.oauth2Config.authorizeUrl'); const stateParam = crypto.randomBytes(20).toString('hex'); let authorizeUrl = await localAppCommand({ command: 'execute', method: 'authentication.oauth2Config.authorizeUrl', bundle: { inputData: { response_type: 'code', redirect_uri: context.redirectUri, state: stateParam, }, }, zcacheTestObj: context.zcacheTestObj, customLogger, calledFromCliInvoke: true, }); if (!authorizeUrl.includes('&scope=')) { const scope = context.appDefinition.authentication.oauth2Config.scope; if (scope) { authorizeUrl += `&scope=${encodeURIComponent(scope)}`; } } debug('authorizeUrl:', authorizeUrl); endSpinner(); startSpinner('Starting local HTTP server'); let resolveCode; const codePromise = new Promise((resolve) => { resolveCode = resolve; }); const server = http.createServer((req, res) => { // Parse the request URL to extract the query parameters const code = new URL(req.url, context.redirectUri).searchParams.get('code'); if (code) { resolveCode(code); debug(`Received code '${code}' from ${req.headers.referer}`); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end( 'Parameter `code` received successfully. Go back to the terminal to continue.', ); } else { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end( 'Error: Did not receive `code` query parameter. ' + 'Did you have the right CLIENT_ID and CLIENT_SECRET? ' + 'Or did your server respond properly?', ); } }); await new Promise((resolve) => { server.listen(context.port, resolve); }); endSpinner(); startSpinner('Opening browser to authorize (press Ctrl-C to exit on error)'); const { default: open } = await import('open'); open(authorizeUrl); const code = await codePromise; endSpinner(); startSpinner('Closing local HTTP server'); await new Promise((resolve) => { server.close(resolve); }); debug('Local HTTP server closed'); endSpinner(); startSpinner('Invoking authentication.oauth2Config.getAccessToken'); const authData = await localAppCommand({ command: 'execute', method: 'authentication.oauth2Config.getAccessToken', bundle: { authData: {}, inputData: { code, redirect_uri: context.redirectUri, }, }, zcacheTestObj: context.zcacheTestObj, customLogger, calledFromCliInvoke: true, }); endSpinner(); return authData; }; /** * Initializes session authentication. * Prompts for auth fields and then calls the session config perform method. * @param {import('../../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @returns {Promise<Object>} Combined auth data and session data * @throws {Error} If in non-interactive mode */ const startSessionAuth = async (command, context) => { if (context.nonInteractive) { throw new Error( 'The `auth start` subcommand for "session" authentication type only works in interactive mode.', ); } const authData = await promptForAuthFields( command, context.appDefinition.authentication.fields, ); startSpinner('Invoking authentication.sessionConfig.perform'); const sessionData = await localAppCommand({ command: 'execute', method: 'authentication.sessionConfig.perform', bundle: { authData, }, zcacheTestObj: context.zcacheTestObj, customLogger, calledFromCliInvoke: true, }); endSpinner(); return { ...authData, ...sessionData }; }; /** * Main entry point for initializing authentication. * Routes to the appropriate auth type handler based on the app definition. * @param {import('../../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @returns {Promise<Object|null>} Auth data or null if no authentication needed * @throws {Error} If the authentication type is not supported */ const startAuth = async (command, context) => { const authentication = context.appDefinition.authentication; if (!authentication) { console.warn( "Your integration doesn't seem to need authentication. " + "If that isn't true, the app definition should have " + 'an `authentication` object at the root level.', ); return null; } switch (authentication.type) { case 'basic': return startBasicAuth(command, context); case 'custom': return startCustomAuth(command, context); case 'oauth2': return startOAuth2(command, context); case 'session': return startSessionAuth(command, context); default: // TODO: Add support for 'digest' and 'oauth1' throw new Error( `This command doesn't support authentication type "${authentication.type}".`, ); } }; module.exports = { startAuth };