diff --git a/README.md b/README.md index add3fe07..3ba29c35 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Once the package has been built and signed it will be published to npm. The comm - [`sf env var unset`](#sf-env-var-unset) - [`sf generate function`](#sf-generate-function) - [`sf login functions`](#sf-login-functions) +- [`sf login functions device`](#sf-login-functions-device) - [`sf login functions jwt`](#sf-login-functions-jwt) - [`sf logout functions`](#sf-logout-functions) - [`sf run function`](#sf-run-function) @@ -518,6 +519,40 @@ EXAMPLES $ sf login functions ``` +## `sf login functions device` + +Login using device flow instead of default web-based flow. This will authenticate you with both sf and Salesforce Functions. + +``` +USAGE + $ sf login functions device [--json] [-l | ] [-a ] [-d] [-v] + +FLAGS + -a, --alias= Alias for the org. + -d, --set-default Set the org as the default that all org-related commands run against. + -l, --instance-url= The login URL of the instance the org lives on. + -v, --set-default-dev-hub Set the org as the default Dev Hub for scratch org creation. + +GLOBAL FLAGS + --json Format output as json. + +DESCRIPTION + Login using device flow instead of default web-based flow. This will authenticate you with both sf and Salesforce + Functions. + + Use this command when executing from a script. + +EXAMPLES + Log in using device flow: + + $ sf login functions device + + Log in and specify the org alias and URL, set as default org and default Dev Hub, and format output as JSON: + + $ sf login functions device --alias org-alias --set-default --set-default-dev-hub --instance-url \ + https://path/to/instance --json +``` + ## `sf login functions jwt` Login using JWT instead of default web-based flow. This will authenticate you with both sf and Salesforce Functions. diff --git a/command-snapshot.json b/command-snapshot.json index 84f1bb17..38ade3c1 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -89,6 +89,12 @@ "flags": [], "alias": [] }, + { + "command": "login:functions:device", + "plugin": "@salesforce/plugin-functions", + "flags": ["alias", "instance-url", "instanceurl", "json", "set-default", "set-default-dev-hub"], + "alias": [] + }, { "command": "login:functions:jwt", "plugin": "@salesforce/plugin-functions", diff --git a/messages/login.functions.device.md b/messages/login.functions.device.md new file mode 100644 index 00000000..a798f3a1 --- /dev/null +++ b/messages/login.functions.device.md @@ -0,0 +1,41 @@ +# summary + +Login using device flow instead of default web-based flow. This will authenticate you with both sf and Salesforce Functions. + +# description + +Use this command when executing from a script. + +# examples + +- Log in using device flow: + + <%= config.bin %> <%= command.id %> + +- Log in and specify the org alias and URL, set as default org and default Dev Hub, and format output as JSON: + + <%= config.bin %> <%= command.id %> --alias org-alias --set-default --set-default-dev-hub --instance-url https://path/to/instance --json + +# flags.instance-url.summary + +The login URL of the instance the org lives on. + +# flags.json.summary + +Format output as JSON. + +# flags.alias.summary + +Alias for the org. + +# flags.set-default.summary + +Set the org as the default that all org-related commands run against. + +# flags.set-default-dev-hub.summary + +Set the org as the default Dev Hub for scratch org creation. + +# flags.instanceurl.deprecation + +--instanceurl is deprecated and will be removed in a future release. Please use --instance-url going forward. diff --git a/src/commands/login/functions/device.ts b/src/commands/login/functions/device.ts new file mode 100644 index 00000000..fb00375f --- /dev/null +++ b/src/commands/login/functions/device.ts @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { Flags } from '@oclif/core'; +import { DeviceOauthService, Messages, OAuth2Config } from '@salesforce/core'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import * as Transport from 'jsforce/lib/transport'; +import { cli } from 'cli-ux'; +import Command from '../../../lib/base'; +import { herokuVariant } from '../../../lib/heroku-variant'; + +// This is a public Oauth client created expressly for the purpose of headless auth in the functions CLI. +// It does not require a client secret, is marked as public in the database and scoped accordingly +const PUBLIC_CLIENT_ID = '1e9cdca9-cec7-4dbf-ae84-408694b22bac'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/plugin-functions', 'login.functions.device'); + +export default class DeviceLogin extends Command { + static summary = messages.getMessage('summary'); + + static description = messages.getMessage('description'); + + static examples = messages.getMessages('examples'); + + static flags = { + 'instance-url': Flags.string({ + char: 'l', + description: messages.getMessage('flags.instance-url.summary'), + exclusive: ['instanceurl'], + }), + instanceurl: Flags.string({ + char: 'l', + description: messages.getMessage('flags.instance-url.summary'), + exclusive: ['instance-url'], + hidden: true, + }), + alias: Flags.string({ + char: 'a', + description: messages.getMessage('flags.alias.summary'), + }), + 'set-default': Flags.boolean({ + char: 'd', + description: messages.getMessage('flags.set-default.summary'), + }), + 'set-default-dev-hub': Flags.boolean({ + char: 'v', + description: messages.getMessage('flags.set-default-dev-hub.summary'), + }), + }; + + async run() { + const { flags } = await this.parse(DeviceLogin); + this.postParseHook(flags); + + // We support both versions of the flag here for the sake of backward compat + const instanceUrl = flags['instance-url'] ?? flags.instanceurl; + + if (flags.instanceurl) { + this.warn(messages.getMessage('flags.instanceurl.deprecation')); + } + + cli.action.start('Logging in via device flow'); + + const oauthConfig: OAuth2Config = { loginUrl: instanceUrl }; + const deviceOauthService: DeviceOauthService = await DeviceOauthService.create(oauthConfig); + const loginData = await deviceOauthService.requestDeviceLogin(); + + this.log(`Log in at: ${loginData.verification_uri}?user_code=${loginData.user_code}`); + + const approval = await deviceOauthService.awaitDeviceApproval(loginData); + if (!approval) { + this.error('401 Unauthorized'); + } + + const authInfo = await deviceOauthService.authorizeAndSave(approval); + + await authInfo.handleAliasAndDefaultSettings({ + alias: flags.alias, + setDefault: flags['set-default'], + setDefaultDevHub: flags['set-default-dev-hub'], + }); + await authInfo.save(); + + // Obtain sfdx access token from Auth info + const authFields = authInfo.getFields(true); + const token = authFields.accessToken; + + // Fire off request to /oauth/tokens on the heroku side with JWT in the payload and + // obtain heroku access_token. This is configurable so that we can also target staging + const herokuClientId = process.env.SALESFORCE_FUNCTIONS_PUBLIC_OAUTH_CLIENT_ID ?? PUBLIC_CLIENT_ID; + + let rawResponse; + + try { + rawResponse = await new Transport().httpRequest({ + method: 'POST', + url: `${process.env.SALESFORCE_FUNCTIONS_API || 'https://api.heroku.com'}/oauth/tokens`, + body: JSON.stringify({ + client: { + id: herokuClientId, + }, + grant: { + type: 'urn:ietf:params:oauth:grant-type:token-exchange', + }, + subject_token: token, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + }), + headers: { ...herokuVariant('salesforce_sso') }, + }); + } catch (e: any) { + const error = e as Error; + + if (error.message?.includes('404')) { + this.error('No functions connection'); + } + + if (error.message?.includes('403')) { + this.error('User has not been provisioned yet, try $ sf login functions'); + } + + this.error(error); + } + const data = JSON.parse(rawResponse.body); + const bearerToken = data.access_token.token; + + // We have to blow away the auth and API client objects so that they'll fully reinitialize with + // the new heroku credentials we're about to generate + this.resetClientAuth(); + + this.stateAggregator.tokens.set(Command.TOKEN_BEARER_KEY, { + token: bearerToken, + url: this.identityUrl.toString(), + user: authInfo.getUsername(), + }); + + await this.stateAggregator.tokens.write(); + cli.action.stop(); + + return { + username: authFields.username, + sfdxAccessToken: token, + functionsAccessToken: bearerToken, + instanceUrl: authFields.instanceUrl, + orgId: authFields.orgId, + privateKey: authFields.privateKey, + }; + } +}