diff --git a/README.md b/README.md index 7f670b7d..1c5e98d1 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ To sign out from the current environment, run: box logout ``` -This revokes the access token on Box and clears the local token cache. For OAuth, run `box login` to authorize again. For CCG and JWT, a new token is fetched automatically on the next command. Use `-f` to skip the confirmation prompt, or `--on-revoke-failure=clear` / `--on-revoke-failure=abort` to control behavior when token revocation fails. See [`box logout`](docs/logout.md) for full details. +This revokes the access token on Box and clears the local token cache. For OAuth, run `box login` to authorize again. For CCG, JWT, and Platform Account, a new token is fetched automatically on the next command. Use `-f` to skip the confirmation prompt, or `--on-revoke-failure=clear` / `--on-revoke-failure=abort` to control behavior when token revocation fails. See [`box logout`](docs/logout.md) for full details. ## Secure Storage @@ -233,7 +233,7 @@ Avatar URL: 'https://app.box.com/api/avatar/large/77777' * [`box storage-policies`](docs/storage-policies.md) - List storage policies * [`box tasks`](docs/tasks.md) - Manage tasks * [`box terms-of-service`](docs/terms-of-service.md) - List terms of services for your enterprise -* [`box tokens`](docs/tokens.md) - Get a token. Returns the service account token by default +* [`box tokens`](docs/tokens.md) - Get a token. Returns the service account token by default. * [`box trash`](docs/trash.md) - List all items in trash * [`box update`](docs/update.md) - Update the BoxCLI using GitHub * [`box users`](docs/users.md) - List all Box users diff --git a/docs/authentication.md b/docs/authentication.md index 015e00e2..673cc842 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -8,6 +8,7 @@ overview of how the Box API handles authentication. - [Developer Token](#developer-token) - [Server Auth with JWT](#server-auth-with-jwt) - [Server Auth with CCG](#server-auth-with-ccg) + - [Platform Account Auth](#platform-account-auth) - [OAuth 2.0 Login (`box login`)](#oauth-20-login-box-login) - [Option 1: Official Box CLI App](#option-1-official-box-cli-app) - [Option 2: Your Own Platform App](#option-2-your-own-platform-app) @@ -27,6 +28,7 @@ Use this quick comparison to choose the right method: | OAuth 2.0 (`box login`) | Interactive user workflows | Yes (browser auth) | `box login -d` (quick start) | | JWT | Server-to-server automation | No | `box configure:environments:add /path/to/config.json` | | CCG | Server-to-server automation (no keypair) | No | `box configure:environments:add /path/to/config.json --ccg-auth` | +| Platform Account | Automation that must always act as one Platform Account | No | `box configure:environments:add /path/to/config.json --platform-account-auth` | ### Developer Token @@ -123,6 +125,62 @@ By default, CCG authenticates as the Service Account. To make API calls as an Ap box configure:environments:add /path/to/config.json --ccg-auth --ccg-user "USER_ID" ``` +### Platform Account Auth + +Platform Account authentication allows your application to authenticate as a single **Platform Account**. Like JWT, it uses a public/private key pair and does not require user interaction, but every command always runs as the Platform Account from the config file. + +**Key characteristics:** + +- The identity is fixed by the config file. Switching users is not supported: the `--as-user` flag, a default As-User (`box configure:environments:switch-user`), the `as-user` field in bulk input, and `box tokens:get --user-id` are rejected. +- Tokens cannot be downscoped or exchanged, so `box tokens:exchange` is not supported. +- Tokens are requested and refreshed automatically, like for JWT. +- An Enterprise ID is not required. + +**Setup:** + +1. Download the credentials file for the Platform Account. It has the following structure: + +```json +{ + "boxAppSettings": { + "clientID": "your_client_id", + "clientSecret": "your_client_secret", + "appAuth": { + "publicKeyID": "your_public_key_id", + "privateKey": "-----BEGIN ENCRYPTED PRIVATE KEY-----\n...\n-----END ENCRYPTED PRIVATE KEY-----\n", + "passphrase": "your_passphrase" + } + }, + "userID": "your_platform_account_user_id" +} +``` + +2. Add the environment with the `--platform-account-auth` flag: + +```bash +box configure:environments:add /path/to/config.json --platform-account-auth --name 'YOUR_ENVIRONMENT_NAME' +``` + +> **Tip:** If you keep the private key in a separate file, remove `privateKey` from the config file and pass `--private-key-path /path/to/private_key.pem`. + +3. If you have multiple environments, set the active one: + +```bash +box configure:environments:set-current +``` + +To check which token the CLI uses, run `box tokens:get`. It returns a token for the Platform Account. + +**Updating the credentials:** + +To use a new credentials file, for example after rotating the key pair, point the environment to it: + +```bash +box configure:environments:update 'YOUR_ENVIRONMENT_NAME' --config-file-path /path/to/new_config.json +``` + +The Client ID and the Platform Account ID are read again from the new file. The `--user-id` flag is not supported for Platform Account environments. + ### OAuth 2.0 Login (`box login`) The `box login` command authenticates you with Box using OAuth 2.0. It starts a local callback server, opens your browser for authorization, and stores the resulting tokens in a named environment. diff --git a/docs/configure.md b/docs/configure.md index 274ccce6..6d8ac082 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -15,12 +15,12 @@ Configure the Box CLI ## `box configure:environments:add PATH` -Add a new Box environment from a Box app config file (JWT or CCG). +Add a new Box environment from a Box app config file (JWT, CCG, or Platform Account). ``` USAGE $ box configure:environments:add PATH [--no-color] [-h] [-v] [-q] [--private-key-path ] [--set-as-current] [-n ] - [--ccg-user --ccg-auth] + [--ccg-user [--ccg-auth | --platform-account-auth]] ARGUMENTS PATH @@ -30,6 +30,7 @@ ARGUMENTS CCG: create this JSON file yourself using values from your application in Developer Console (Client ID and Client Secret from Configuration tab, Enterprise ID from General Settings tab). + Platform Account: use the credentials file downloaded for the Platform Account. FLAGS -h, --help @@ -58,6 +59,13 @@ FLAGS --no-color Turn off colors for logging + --platform-account-auth + Add a Platform Account environment. Commands will always run as the Platform Account from the config file; switching + users, --as-user and token downscoping are not supported. + Use the credentials file downloaded for the Platform Account. + Required fields: boxAppSettings.clientID, boxAppSettings.clientSecret, boxAppSettings.appAuth.publicKeyID, + boxAppSettings.appAuth.privateKey (or --private-key-path), boxAppSettings.appAuth.passphrase, userID. + --private-key-path= Provide a path to application private key @@ -65,7 +73,7 @@ FLAGS Set this new environment as your current environment DESCRIPTION - Add a new Box environment from a Box app config file (JWT or CCG). + Add a new Box environment from a Box app config file (JWT, CCG, or Platform Account). Open your application in Box Developer Console to get/create config data: https://cloud.app.box.com/developers/console @@ -78,6 +86,8 @@ EXAMPLES $ box configure:environments:add ./config.json --name production --set-as-current $ box configure:environments:add ./config.json --ccg-auth --name ci-bot + + $ box configure:environments:add ./platform_account_config.json --platform-account-auth --name my-agent ``` _See code: [src/commands/configure/environments/add.js](https://github.com/box/boxcli/blob/v4.10.1/src/commands/configure/environments/add.js)_ diff --git a/docs/logout.md b/docs/logout.md index d87850d6..62dcfe69 100644 --- a/docs/logout.md +++ b/docs/logout.md @@ -4,7 +4,7 @@ Revoke the access token and clear local token cache. For OAuth, run `box login` to authorize again. -For CCG and JWT, a new token is fetched automatically on the next command. +For CCG, JWT, and Platform Account, a new token is fetched automatically on the next command. Use -f and --on-revoke-failure=clear or --on-revoke-failure=abort to skip the interactive prompt. @@ -32,7 +32,7 @@ DESCRIPTION Revoke the access token and clear local token cache. For OAuth, run `box login` to authorize again. - For CCG and JWT, a new token is fetched automatically on the next command. + For CCG, JWT, and Platform Account, a new token is fetched automatically on the next command. Use -f and --on-revoke-failure=clear or --on-revoke-failure=abort to skip the interactive prompt. ``` diff --git a/docs/tokens.md b/docs/tokens.md index 0648b88d..b0e3c299 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -1,7 +1,9 @@ `box tokens` ============ -Get a token. Returns the service account token by default +Get a token. Returns the service account token by default. + +Not supported for Platform Account environments. * [`box tokens:exchange SCOPE`](#box-tokensexchange-scope) * [`box tokens:get`](#box-tokensget) @@ -9,7 +11,7 @@ Get a token. Returns the service account token by default ## `box tokens:exchange SCOPE` -Get a token. Returns the service account token by default +Get a token. Returns the service account token by default. ``` USAGE @@ -30,14 +32,16 @@ FLAGS --no-color Turn off colors for logging DESCRIPTION - Get a token. Returns the service account token by default + Get a token. Returns the service account token by default. + + Not supported for Platform Account environments. ``` _See code: [src/commands/tokens/exchange.js](https://github.com/box/boxcli/blob/v4.10.1/src/commands/tokens/exchange.js)_ ## `box tokens:get` -Generate a new access token. Returns a service account token for the default environment unless --user-id is specified. +Generate a new access token. Returns a service account token for the default environment unless --user-id is specified. For Platform Account environments, returns a token for the Platform Account. ``` USAGE @@ -52,7 +56,7 @@ FLAGS DESCRIPTION Generate a new access token. Returns a service account token for the default environment unless --user-id is - specified. + specified. For Platform Account environments, returns a token for the Platform Account. EXAMPLES $ box tokens:get diff --git a/package-lock.json b/package-lock.json index 6277e007..2cd90d6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@oclif/table": "^0.5.8", "@octokit/rest": "^22.0.0", "archiver": "^8.0.0", + "bluebird": "^3.7.2", "box-node-sdk": "^4.11.1", "chalk": "^2.4.1", "cli-progress": "^2.1.0", diff --git a/package.json b/package.json index d29a8a82..46724e88 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@oclif/table": "^0.5.8", "@octokit/rest": "^22.0.0", "archiver": "^8.0.0", + "bluebird": "^3.7.2", "box-node-sdk": "^4.11.1", "chalk": "^2.4.1", "cli-progress": "^2.1.0", diff --git a/src/box-command.js b/src/box-command.js index 0cd1e4e5..c3d44b55 100644 --- a/src/box-command.js +++ b/src/box-command.js @@ -30,6 +30,13 @@ const BoxTSSDK = require('box-node-sdk/sdk-gen'); const BoxTsErrors = require('box-node-sdk/sdk-gen/box/errors'); const BoxCLIError = require('./cli-error'); const CLITokenCache = require('./token-cache'); +const PlatformAccountSessionAdapter = require('./platform-account-session-adapter'); +const { + isPlatformAccountEnvironment, + createFixedPrincipalError, + assertPlatformAccountPrincipalIsFixed, + createPlatformAccountAuth, +} = require('./platform-account'); const PaginationUtilities = require('./pagination-utils'); const utils = require('./util'); const pkg = require('../package.json'); @@ -61,6 +68,7 @@ const KEY_MAPPINGS = { outputJson: 'Output JSON', clientId: 'Client ID', enterpriseId: 'Enterprise ID', + userId: 'User ID', boxConfigFilePath: 'Box Config File Path', hasInLinePrivateKey: 'Has Inline Private Key', privateKeyPath: 'Private Key Path', @@ -115,6 +123,24 @@ function getDebugErrorDetails(error) { }; } +/** + * Build the CLI error message for a TS SDK API error response. + * OAuth2 token endpoint errors use `error` and `error_description` instead of the regular API error fields. + * + * @param {Object} responseInfo The response info of a BoxApiError + * @returns {string} The error message + */ +function formatApiErrorMessage(responseInfo) { + const body = responseInfo.body; + if (typeof body.error !== 'string') { + return `Unexpected API Response [${body.status} ${body.message} | ${body.request_id}] ${body.code} - ${body.message}`; + } + const message = `Unexpected API Response [${responseInfo.statusCode}] ${body.error}`; + return body.error_description + ? `${message} - ${body.error_description}` + : message; +} + /** * Parse a string value from CSV into the correct boolean value * @param {string|boolean} value The value to parse @@ -482,6 +508,11 @@ class BoxCommand extends Command { this.client.asSelf(); } } else { + if (this.usesPlatformAccountAuth()) { + throw createFixedPrincipalError( + 'The as-user bulk input field' + ); + } this.client.asUser(asUser.value); DEBUG.init('Impersonating user ID %s', asUser.value); } @@ -811,6 +842,11 @@ class BoxCommand extends Command { client = ccgUser ? sdk.getCCGClientForUser(ccgUser) : sdk.getAnonymousClient(); + } else if (isPlatformAccountEnvironment(environment)) { + client = this._createPlatformAccountClient( + environmentsObj.default, + environment + ); } else if ( environmentsObj.default && environmentsObj.environments[environmentsObj.default].authMethod === @@ -994,6 +1030,11 @@ class BoxCommand extends Command { auth: ccgAuth, }); client = this._configureTsSdk(client, SDK_CONFIG); + } else if (isPlatformAccountEnvironment(environment)) { + client = this._getPlatformAccountTsClient( + environmentsObj.default, + environment + ); } else if ( environmentsObj.default && environmentsObj.environments[environmentsObj.default].authMethod === @@ -1097,6 +1138,117 @@ class BoxCommand extends Command { return client; } + /** + * Whether this command authenticates as the Platform Account of the current environment. + * Only meaningful after client setup. + * + * @returns {boolean} True for Platform Account environments not overridden by --token + */ + usesPlatformAccountAuth() { + return Boolean(this._platformAccountAuth); + } + + /** + * Request a new access token for the Platform Account of the current environment. + * + * @returns {Promise} The new access token, also stored in the token cache + * @throws {BoxCLIError} If the command does not authenticate as a Platform Account + */ + requestPlatformAccountToken() { + if (!this.usesPlatformAccountAuth()) { + throw new BoxCLIError( + 'The current environment does not use Platform Account authentication' + ); + } + return this._platformAccountAuth.refreshToken( + this.tsClient.networkSession + ); + } + + /** + * Create the legacy SDK client for a Platform Account environment. + * + * The legacy SDK has no Platform Account auth, so the client's session is + * replaced with an adapter that gets tokens from the generated SDK auth. + * Token requests use the TS client's network session, so they honor the + * same proxy, base URL and header settings as the TS client. + * + * @param {string} environmentName Name of the current environment + * @param {Object} environment The current environment + * @returns {BoxClient} The legacy SDK client + * @private + */ + _createPlatformAccountClient(environmentName, environment) { + DEBUG.init('Using Platform Account environment %s', environmentName); + const auth = this._getPlatformAccountAuth(environmentName, environment); + const tsClient = this._getPlatformAccountTsClient( + environmentName, + environment + ); + const sdk = new BoxSDK({ + clientID: auth.config.clientId, + clientSecret: auth.config.clientSecret, + ...SDK_CONFIG, + }); + this._configureSdk(sdk, { ...SDK_CONFIG }); + this.sdk = sdk; + + const client = sdk.getBasicClient(''); + client._session = new PlatformAccountSessionAdapter(auth, { + expiryBufferMS: sdk.config.expiredBufferMS, + networkSession: tsClient.networkSession, + }); + return client; + } + + /** + * Get the TS SDK client for a Platform Account environment. + * Built once per command and shared with the legacy client's token requests. + * + * @param {string} environmentName Name of the current environment + * @param {Object} environment The current environment + * @returns {BoxTSSDK.BoxClient} The configured TS SDK client + * @private + */ + _getPlatformAccountTsClient(environmentName, environment) { + if (!this._platformAccountTsClient) { + const auth = this._getPlatformAccountAuth( + environmentName, + environment + ); + this._platformAccountTsClient = this._configureTsSdk( + new BoxTSSDK.BoxClient({ auth }), + SDK_CONFIG + ); + } + return this._platformAccountTsClient; + } + + /** + * Get the Platform Account auth for the current environment, after + * rejecting options that would make it act as another user. + * Built once per command, so the legacy and TS clients share it. + * + * @param {string} environmentName Name of the current environment + * @param {Object} environment The current environment + * @returns {BoxTSSDK.BoxPlatformAccountAuth} The Platform Account auth + * @private + */ + _getPlatformAccountAuth(environmentName, environment) { + assertPlatformAccountPrincipalIsFixed({ + environmentName, + environment, + asUser: this.flags['as-user'], + }); + if (!this._platformAccountAuth) { + this._platformAccountAuth = createPlatformAccountAuth( + environmentName, + environment + ); + } + return this._platformAccountAuth; + } + /** * Configures SDK by using values from settings.json file * @param {*} sdk to configure @@ -1662,7 +1814,7 @@ class BoxCommand extends Command { 'invalid_grant - Refresh token has expired': 'Your refresh token has expired. \nPlease run this command "box login --name --reauthorize" to reauthorize selected environment and then run your command again.', 'Expired Auth: Auth code or refresh token has expired': - 'Authentication failed: token is invalid or expired. OAuth: run "box login --reauthorize". JWT/CCG: tokens are refreshed automatically, so this usually means app credentials or environment configuration must be fixed. You can also provide a fresh token with --token.', + 'Authentication failed: token is invalid or expired. OAuth: run "box login --reauthorize". JWT/CCG/Platform Account: tokens are refreshed automatically, so this usually means app credentials or environment configuration must be fixed. You can also provide a fresh token with --token.', }; for (const key in messageMap) { @@ -1682,15 +1834,18 @@ class BoxCommand extends Command { */ async catch(err) { const AUTH_FAILED_HINT = - 'Authentication failed: token is invalid or expired. OAuth: run "box login --reauthorize". JWT/CCG: tokens are refreshed automatically, so a 401 usually means app credentials or environment configuration must be fixed. You can also provide a fresh token with --token.'; + 'Authentication failed: token is invalid or expired. OAuth: run "box login --reauthorize". JWT/CCG/Platform Account: tokens are refreshed automatically, so a 401 usually means app credentials or environment configuration must be fixed. You can also provide a fresh token with --token.'; if ( err instanceof BoxTsErrors.BoxApiError && err.responseInfo && err.responseInfo.body ) { const responseInfo = err.responseInfo; - let errorMessage = `Unexpected API Response [${responseInfo.body.status} ${responseInfo.body.message} | ${responseInfo.body.request_id}] ${responseInfo.body.code} - ${responseInfo.body.message}`; - if (responseInfo.body.status === 401) { + let errorMessage = formatApiErrorMessage(responseInfo); + if ( + responseInfo.body.status === 401 || + responseInfo.statusCode === 401 + ) { errorMessage += `\n${AUTH_FAILED_HINT}`; } err = new BoxCLIError(errorMessage, err); diff --git a/src/commands/configure/environments/add.js b/src/commands/configure/environments/add.js index d99ba943..862dcda5 100644 --- a/src/commands/configure/environments/add.js +++ b/src/commands/configure/environments/add.js @@ -6,6 +6,10 @@ const fs = require('node:fs'); const BoxCLIError = require('../../../cli-error'); const chalk = require('chalk'); const utilities = require('../../../util'); +const { + PLATFORM_ACCOUNT_AUTH_METHOD, + validatePlatformAccountConfig, +} = require('../../../platform-account'); class EnvironmentsAddCommand extends BoxCommand { async run() { @@ -24,8 +28,13 @@ class EnvironmentsAddCommand extends BoxCommand { } const isCCG = flags['ccg-auth']; + const isPlatformAccount = flags['platform-account-auth']; - utilities.validateConfigObject(configObject, isCCG); + if (isPlatformAccount) { + validatePlatformAccountConfig(configObject); + } else { + utilities.validateConfigObject(configObject, isCCG); + } let newEnvironment = { clientId: configObject.boxAppSettings.clientID, @@ -80,7 +89,7 @@ class EnvironmentsAddCommand extends BoxCommand { ); } } - if (!configObject.enterpriseID) { + if (!isPlatformAccount && !configObject.enterpriseID) { throw new BoxCLIError( 'Your environment does not have an enterprise ID' ); @@ -115,6 +124,12 @@ class EnvironmentsAddCommand extends BoxCommand { newEnvironment.authMethod = 'ccg'; } + if (isPlatformAccount) { + newEnvironment.authMethod = PLATFORM_ACCOUNT_AUTH_METHOD; + newEnvironment.enterpriseId = configObject.enterpriseID ?? null; + newEnvironment.userId = configObject.userID; + } + environmentsObject.environments[environmentName] = newEnvironment; await this.updateEnvironments(environmentsObject); this.info( @@ -127,7 +142,7 @@ class EnvironmentsAddCommand extends BoxCommand { EnvironmentsAddCommand.noClient = true; EnvironmentsAddCommand.description = - 'Add a new Box environment from a Box app config file (JWT or CCG).\n' + + 'Add a new Box environment from a Box app config file (JWT, CCG, or Platform Account).\n' + 'Open your application in Box Developer Console to get/create config data:\n' + 'https://cloud.app.box.com/developers/console\n' + '\n' + @@ -137,6 +152,7 @@ EnvironmentsAddCommand.examples = [ 'box configure:environments:add ~/Downloads/my_app_config.json', 'box configure:environments:add ./config.json --name production --set-as-current', 'box configure:environments:add ./config.json --ccg-auth --name ci-bot', + 'box configure:environments:add ./platform_account_config.json --platform-account-auth --name my-agent', ]; EnvironmentsAddCommand.flags = { @@ -158,6 +174,7 @@ EnvironmentsAddCommand.flags = { 'Add a CCG environment that will use a service account.\n' + 'Open your application in Box Developer Console and create this config JSON yourself.\n' + 'Required fields: boxAppSettings.clientID, boxAppSettings.clientSecret, enterpriseID.', + exclusive: ['platform-account-auth'], }), 'ccg-user': Flags.string({ description: @@ -167,6 +184,16 @@ EnvironmentsAddCommand.flags = { 'In`Configuration` tab, in section `Advanced Features` select `Generate user access tokens`. \n' + 'Do not forget to re-authorize application if it was already authorized.', dependsOn: ['ccg-auth'], + exclusive: ['platform-account-auth'], + }), + 'platform-account-auth': Flags.boolean({ + description: + 'Add a Platform Account environment. Commands will always run as the Platform Account from the config file; ' + + 'switching users, --as-user and token downscoping are not supported.\n' + + 'Use the credentials file downloaded for the Platform Account.\n' + + 'Required fields: boxAppSettings.clientID, boxAppSettings.clientSecret, boxAppSettings.appAuth.publicKeyID, ' + + 'boxAppSettings.appAuth.privateKey (or --private-key-path), boxAppSettings.appAuth.passphrase, userID.', + exclusive: ['ccg-auth', 'ccg-user'], }), }; @@ -181,7 +208,8 @@ EnvironmentsAddCommand.args = { 'https://cloud.app.box.com/developers/console\n' + 'CCG: create this JSON file yourself using values from your application\n' + 'in Developer Console (Client ID and Client Secret from Configuration tab,\n' + - 'Enterprise ID from General Settings tab).', + 'Enterprise ID from General Settings tab).\n' + + 'Platform Account: use the credentials file downloaded for the Platform Account.', }), }; diff --git a/src/commands/configure/environments/switch-user.js b/src/commands/configure/environments/switch-user.js index 14f7c927..bf446af9 100644 --- a/src/commands/configure/environments/switch-user.js +++ b/src/commands/configure/environments/switch-user.js @@ -3,6 +3,10 @@ const BoxCommand = require('../../../box-command'); const { Flags, Args } = require('@oclif/core'); const chalk = require('chalk'); +const { + isPlatformAccountEnvironment, + createFixedPrincipalError, +} = require('../../../platform-account'); class EnvironmentsSwitchUserCommand extends BoxCommand { async run() { @@ -12,6 +16,10 @@ class EnvironmentsSwitchUserCommand extends BoxCommand { let environment = environmentsObject.environments[environmentsObject.default]; + if (args.userID && isPlatformAccountEnvironment(environment)) { + throw createFixedPrincipalError('Switching users'); + } + if (flags.default && !args.userID) { environment.useDefaultAsUser = false; } else if (args.userID && !flags.default) { diff --git a/src/commands/configure/environments/update.js b/src/commands/configure/environments/update.js index efddfe3d..47985c7e 100644 --- a/src/commands/configure/environments/update.js +++ b/src/commands/configure/environments/update.js @@ -5,6 +5,11 @@ const { Flags, Args } = require('@oclif/core'); const fs = require('node:fs'); const BoxCLIError = require('../../../cli-error'); const utilities = require('../../../util'); +const { + isPlatformAccountEnvironment, + validatePlatformAccountConfig, + createFixedPrincipalError, +} = require('../../../platform-account'); class EnvironmentsUpdateCommand extends BoxCommand { async run() { @@ -20,6 +25,11 @@ class EnvironmentsUpdateCommand extends BoxCommand { return; } + const isPlatformAccount = isPlatformAccountEnvironment(environment); + if (isPlatformAccount && flags['user-id']) { + throw createFixedPrincipalError('The --user-id flag'); + } + if (flags['config-file-path']) { let configObject; try { @@ -33,7 +43,11 @@ class EnvironmentsUpdateCommand extends BoxCommand { ); } - utilities.validateConfigObject(configObject); + if (isPlatformAccount) { + validatePlatformAccountConfig(configObject); + } else { + utilities.validateConfigObject(configObject); + } if ( !configObject.boxAppSettings.appAuth.privateKey && @@ -49,6 +63,11 @@ class EnvironmentsUpdateCommand extends BoxCommand { environment.hasInLinePrivateKey = true; } environment.boxConfigFilePath = flags['config-file-path']; + if (isPlatformAccount) { + environment.clientId = configObject.boxAppSettings.clientID; + environment.enterpriseId = configObject.enterpriseID ?? null; + environment.userId = configObject.userID; + } } if (flags.name) { environment.name = flags.name; diff --git a/src/commands/logout.js b/src/commands/logout.js index e5117df3..de4224f4 100644 --- a/src/commands/logout.js +++ b/src/commands/logout.js @@ -198,7 +198,7 @@ OAuthLogoutCommand.description = [ 'Revoke the access token and clear local token cache.', '', 'For OAuth, run `box login` to authorize again.', - 'For CCG and JWT, a new token is fetched automatically on the next command.', + 'For CCG, JWT, and Platform Account, a new token is fetched automatically on the next command.', '', 'Use -f and --on-revoke-failure=clear or --on-revoke-failure=abort to skip the interactive prompt.', ].join('\n'); diff --git a/src/commands/tokens/exchange.js b/src/commands/tokens/exchange.js index 300e3c85..9f173c0e 100644 --- a/src/commands/tokens/exchange.js +++ b/src/commands/tokens/exchange.js @@ -2,11 +2,18 @@ const BoxCommand = require('../../box-command'); const { Flags, Args } = require('@oclif/core'); +const { + createTokenExchangeNotSupportedError, +} = require('../../platform-account'); class TokensExchangeCommand extends BoxCommand { async run() { const { flags, args } = await this.parse(TokensExchangeCommand); + if (this.usesPlatformAccountAuth()) { + throw createTokenExchangeNotSupportedError(); + } + let client = this.client; if (flags.token) { client = this.sdk.getBasicClient(flags.token); @@ -31,8 +38,11 @@ class TokensExchangeCommand extends BoxCommand { } } -TokensExchangeCommand.description = - 'Get a token. Returns the service account token by default'; +TokensExchangeCommand.description = [ + 'Get a token. Returns the service account token by default.', + '', + 'Not supported for Platform Account environments.', +].join('\n'); TokensExchangeCommand.args = { scope: Args.string({ diff --git a/src/commands/tokens/get.js b/src/commands/tokens/get.js index 1e343128..711267d0 100644 --- a/src/commands/tokens/get.js +++ b/src/commands/tokens/get.js @@ -2,12 +2,22 @@ const BoxCommand = require('../../box-command'); const { Flags } = require('@oclif/core'); +const { createFixedPrincipalError } = require('../../platform-account'); class TokensGetCommand extends BoxCommand { async run() { const { flags } = await this.parse(TokensGetCommand); let token; + if (this.usesPlatformAccountAuth()) { + if (flags['user-id']) { + throw createFixedPrincipalError('The --user-id flag'); + } + token = await this.requestPlatformAccountToken(); + this.output(token.accessToken); + return; + } + token = await (flags['user-id'] ? this.sdk.getAppUserTokens(flags['user-id']) : this.sdk.getEnterpriseAppAuthTokens()); @@ -16,7 +26,7 @@ class TokensGetCommand extends BoxCommand { } TokensGetCommand.description = - 'Generate a new access token. Returns a service account token for the default environment unless --user-id is specified.'; + 'Generate a new access token. Returns a service account token for the default environment unless --user-id is specified. For Platform Account environments, returns a token for the Platform Account.'; TokensGetCommand.examples = [ 'box tokens:get', 'box tokens:get --user-id 12345', diff --git a/src/platform-account-session-adapter.js b/src/platform-account-session-adapter.js new file mode 100644 index 00000000..a69c2807 --- /dev/null +++ b/src/platform-account-session-adapter.js @@ -0,0 +1,134 @@ +'use strict'; + +const BluebirdPromise = require('bluebird'); +const { createTokenExchangeNotSupportedError } = require('./platform-account'); + +/** + * Legacy SDK session that obtains tokens from the generated SDK's + * `BoxPlatformAccountAuth`, so commands still using the legacy `BoxClient` + * work in Platform Account environments without a second implementation of + * the auth protocol. + * + * The legacy client chains Bluebird-only methods (`asCallback`) on everything + * a session returns, so every public method returns a Bluebird promise. + */ +class PlatformAccountSessionAdapter { + /** + * @param {BoxPlatformAccountAuth} auth Generated SDK auth. Its token storage is shared with the generated SDK client. + * @param {Object} options Adapter options + * @param {number} options.expiryBufferMS Tokens expiring within this window are treated as expired + * @param {NetworkSession} options.networkSession Generated SDK network session used for token requests + */ + constructor(auth, { expiryBufferMS, networkSession }) { + this._auth = auth; + this._expiryBufferMS = expiryBufferMS; + this._networkSession = networkSession; + this._refreshPromise = null; + this._issuedToken = null; + } + + /** + * Get a valid access token, requesting a new one when the cached token is + * missing or about to expire. + * + * @returns {Promise} Bluebird promise resolving to the access token + */ + getAccessToken() { + return BluebirdPromise.resolve(this._getValidToken()).then( + (token) => token.accessToken + ); + } + + /** + * Revoke the current access token and remove it from the token cache. + * + * @returns {Promise} Bluebird promise resolving when the token is revoked + */ + revokeTokens() { + this._issuedToken = null; + return BluebirdPromise.resolve( + this._auth.revokeToken(this._networkSession) + ); + } + + /** + * Platform Account tokens are bound to a fixed principal and cannot be + * downscoped or exchanged. + * + * @returns {Promise} Bluebird promise that always rejects + */ + exchangeToken() { + return BluebirdPromise.reject(createTokenExchangeNotSupportedError()); + } + + /** + * Called by the legacy client when the API rejects the token as expired. + * Clears the token cache so the next request gets a new token, then + * rethrows, matching the built-in legacy sessions. + * + * @param {Error} error The expired-token error from the API + * @returns {Promise} Bluebird promise rejecting with the given error + */ + handleExpiredTokensError(error) { + this._issuedToken = null; + return BluebirdPromise.resolve(this._auth.tokenStorage.clear()).then( + () => { + throw error; + } + ); + } + + async _getValidToken() { + const cachedToken = await this._auth.tokenStorage.get(); + if (cachedToken && !this._isExpired(cachedToken)) { + return cachedToken; + } + return this._requestNewToken(); + } + + _requestNewToken() { + if (!this._refreshPromise) { + this._refreshPromise = Promise.resolve( + this._auth.refreshToken(this._networkSession) + ) + .then((token) => { + this._issuedToken = { + accessToken: token.accessToken, + expiresAtMS: Date.now() + token.expiresIn * 1000, + }; + return token; + }) + .finally(() => { + this._refreshPromise = null; + }); + } + return this._refreshPromise; + } + + /** + * The CLI token cache stamps tokens with their acquisition time. Storages + * that keep only the raw token (in-memory storage when token caching is + * disabled) give no way to tell its age, so such a token is trusted only + * when this adapter requested it. + * + * @param {Object} token Token read from token storage + * @returns {boolean} True if the token must not be used + * @private + */ + _isExpired(token) { + let expiresAtMS; + if ( + typeof token.acquiredAtMS === 'number' && + typeof token.accessTokenTTLMS === 'number' + ) { + expiresAtMS = token.acquiredAtMS + token.accessTokenTTLMS; + } else if (this._issuedToken?.accessToken === token.accessToken) { + expiresAtMS = this._issuedToken.expiresAtMS; + } else { + return true; + } + return Date.now() + this._expiryBufferMS >= expiresAtMS; + } +} + +module.exports = PlatformAccountSessionAdapter; diff --git a/src/platform-account.js b/src/platform-account.js new file mode 100644 index 00000000..580d2c25 --- /dev/null +++ b/src/platform-account.js @@ -0,0 +1,191 @@ +'use strict'; + +const fs = require('node:fs'); +const _ = require('lodash'); +const BoxTSSDK = require('box-node-sdk/sdk-gen'); +const BoxCLIError = require('./cli-error'); +const CLITokenCache = require('./token-cache'); +const DEBUG = require('./debug'); + +const PLATFORM_ACCOUNT_AUTH_METHOD = 'platformAccount'; + +const REQUIRED_PLATFORM_ACCOUNT_CONFIG_VALUES = Object.freeze([ + 'boxAppSettings.clientID', + 'boxAppSettings.clientSecret', + 'boxAppSettings.appAuth.publicKeyID', + 'boxAppSettings.appAuth.passphrase', + 'userID', +]); + +/** + * Check whether a CLI environment authenticates as a Platform Account + * + * @param {Object} [environment] A CLI environment + * @returns {boolean} True for Platform Account environments + */ +function isPlatformAccountEnvironment(environment) { + return environment?.authMethod === PLATFORM_ACCOUNT_AUTH_METHOD; +} + +/** + * Ensure a Platform Account config file contains the values needed to authenticate. + * The private key is validated separately, since it can also come from a separate file. + * + * @param {Object} configObj Parsed Platform Account config file + * @returns {void} + * @throws {BoxCLIError} If a required value is missing + */ +function validatePlatformAccountConfig(configObj) { + const checkProp = _.propertyOf(configObj); + const missingProp = REQUIRED_PLATFORM_ACCOUNT_CONFIG_VALUES.find( + (key) => !checkProp(key) + ); + if (missingProp) { + throw new BoxCLIError(`Config object missing key ${missingProp}`); + } +} + +/** + * Error for operations that would act as someone other than the Platform Account. + * Platform Account environments are bound to one principal, so every action is attributable to it. + * + * @param {string} operation Description of the rejected operation, e.g. "The --as-user flag" + * @returns {BoxCLIError} The error to throw + */ +function createFixedPrincipalError(operation) { + return new BoxCLIError( + `${operation} is not supported for Platform Account environments. Commands always run as the Platform Account from the environment's config file.` + ); +} + +/** + * Error for token exchange (downscoping), which Platform Accounts do not support. + * + * @returns {BoxCLIError} The error to throw + */ +function createTokenExchangeNotSupportedError() { + return new BoxCLIError( + 'Token exchange and downscoping are not supported for Platform Account environments' + ); +} + +/** + * Reject options that would make a Platform Account environment act as another user. + * + * @param {Object} options Options + * @param {string} options.environmentName Name of the current environment + * @param {Object} options.environment The current environment + * @param {string} [options.asUser] Value of the --as-user flag + * @returns {void} + * @throws {BoxCLIError} If an As-User is requested or configured + */ +function assertPlatformAccountPrincipalIsFixed({ + environmentName, + environment, + asUser, +}) { + if (asUser) { + throw createFixedPrincipalError('The --as-user flag'); + } + if (environment.useDefaultAsUser) { + throw new BoxCLIError( + `Environment "${environmentName}" has a default As-User set, which is not supported for Platform Account environments. Run "box configure:environments:switch-user --default" to remove it.` + ); + } +} + +/** + * Read and validate the Platform Account config file, including the private + * key stored in a separate file when the environment uses one. + * The file is validated on every use, since it can change after the environment was added. + * + * @param {Object} environment The current environment + * @returns {Object} Parsed config file + * @throws {BoxCLIError} If a file cannot be read or the config is missing a required value + */ +function readPlatformAccountConfig(environment) { + const configFilePath = environment.boxConfigFilePath; + let configObj; + try { + configObj = JSON.parse(fs.readFileSync(configFilePath)); + } catch (error) { + throw new BoxCLIError( + `Could not read Platform Account config file ${configFilePath}`, + error + ); + } + + try { + validatePlatformAccountConfig(configObj); + } catch (error) { + throw new BoxCLIError( + `Invalid Platform Account config file ${configFilePath}: ${error.message}` + ); + } + + if (!environment.hasInLinePrivateKey) { + try { + configObj.boxAppSettings.appAuth.privateKey = fs.readFileSync( + environment.privateKeyPath, + 'utf8' + ); + DEBUG.init( + 'Loaded Platform Account private key from %s', + environment.privateKeyPath + ); + } catch (error) { + throw new BoxCLIError( + `Could not read private key file ${environment.privateKeyPath}`, + error + ); + } + } + + if (!configObj.boxAppSettings.appAuth.privateKey) { + throw new BoxCLIError( + `Invalid Platform Account config file ${configFilePath}: Config object missing key boxAppSettings.appAuth.privateKey. Add the private key to the config file, or set a private key file with "box configure:environments:update --private-key-path ".` + ); + } + return configObj; +} + +/** + * Build the generated SDK Platform Account auth for an environment. + * Tokens are cached in the CLI token cache unless the environment disables token caching. + * + * @param {string} environmentName Name of the environment + * @param {Object} environment The environment + * @returns {BoxTSSDK.BoxPlatformAccountAuth} The Platform Account auth + * @throws {BoxCLIError} If the config file cannot be read or is invalid + */ +function createPlatformAccountAuth(environmentName, environment) { + const configObj = readPlatformAccountConfig(environment); + const tokenStorage = + environment.cacheTokens === false + ? undefined + : new CLITokenCache(environmentName); + let config; + try { + config = BoxTSSDK.PlatformAccountConfig.fromConfigJsonString( + JSON.stringify(configObj), + tokenStorage + ); + } catch (error) { + throw new BoxCLIError( + `Invalid Platform Account config file ${environment.boxConfigFilePath}`, + error + ); + } + return new BoxTSSDK.BoxPlatformAccountAuth({ config }); +} + +module.exports = { + PLATFORM_ACCOUNT_AUTH_METHOD, + isPlatformAccountEnvironment, + validatePlatformAccountConfig, + createFixedPrincipalError, + createTokenExchangeNotSupportedError, + assertPlatformAccountPrincipalIsFixed, + readPlatformAccountConfig, + createPlatformAccountAuth, +}; diff --git a/src/token-cache.js b/src/token-cache.js index a4b7cac8..dd75611c 100644 --- a/src/token-cache.js +++ b/src/token-cache.js @@ -175,9 +175,10 @@ class CLITokenCache { } /** - * Delete the token from both secure storage and file system - * @param {Function} callback The callback to pass results to - * @returns {void} + * Delete the token from both secure storage and file system. + * Without a callback, returns a promise, as the TS SDK token storage contract requires. + * @param {Function} [callback] The callback to pass results to + * @returns {Promise|void} A promise when called without a callback */ clear(callback) { const promises = []; @@ -235,11 +236,17 @@ class CLITokenCache { }) ); - Promise.all(promises) - .then(() => callback()) - .catch((error) => - callback(new BoxCLIError('Failed to delete token cache', error)) - ); + const cleared = (async () => { + try { + await Promise.all(promises); + } catch (error) { + throw new BoxCLIError('Failed to delete token cache', error); + } + })(); + if (typeof callback !== 'function') { + return cleared; + } + cleared.then(() => callback(), callback); } /** diff --git a/test/commands/configure-environments-platform-account.test.js b/test/commands/configure-environments-platform-account.test.js new file mode 100644 index 00000000..e4d638ff --- /dev/null +++ b/test/commands/configure-environments-platform-account.test.js @@ -0,0 +1,359 @@ +'use strict'; + +const { test } = require('@oclif/test'); +const { assert } = require('chai'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const BoxCommand = require('../../src/box-command'); + +const FIXED_PRINCIPAL_MESSAGE = + "is not supported for Platform Account environments. Commands always run as the Platform Account from the environment's config file."; + +function createPlatformAccountConfig({ + userID = '33333', + enterpriseID = '11111', + privateKey = '-----BEGIN ENCRYPTED PRIVATE KEY-----\nkey\n-----END ENCRYPTED PRIVATE KEY-----\n', +} = {}) { + return { + boxAppSettings: { + clientID: 'platform-account-client-id', + clientSecret: 'platform-account-client-secret', + appAuth: { + publicKeyID: 'platform-account-key-id', + ...(privateKey ? { privateKey } : {}), + passphrase: 'platform-account-passphrase', + }, + }, + ...(enterpriseID ? { enterpriseID } : {}), + ...(userID ? { userID } : {}), + }; +} + +function platformAccountEnvironment(overrides = {}) { + return { + name: 'agent', + authMethod: 'platformAccount', + clientId: 'platform-account-client-id', + enterpriseId: '11111', + userId: '33333', + boxConfigFilePath: '/path/to/config.json', + hasInLinePrivateKey: true, + privateKeyPath: null, + defaultAsUserId: null, + useDefaultAsUser: false, + cacheTokens: true, + ...overrides, + }; +} + +describe('Configure Platform Account environments', function () { + // Files are created while the suite is defined, because oclif test chains + // evaluate command arguments before mocha hooks run. + const tempDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'boxcli-platform-account-env-') + ); + const writeConfig = (name, config) => { + const filePath = path.join(tempDirectory, name); + fs.writeFileSync(filePath, JSON.stringify(config)); + return filePath; + }; + const configPath = writeConfig( + 'config.json', + createPlatformAccountConfig() + ); + const configWithoutEnterprisePath = writeConfig( + 'config_without_enterprise.json', + createPlatformAccountConfig({ enterpriseID: null }) + ); + const configWithoutUserPath = writeConfig( + 'config_without_user.json', + createPlatformAccountConfig({ userID: null }) + ); + const configWithoutKeyPath = writeConfig( + 'config_without_key.json', + createPlatformAccountConfig({ privateKey: null }) + ); + const updatedConfigPath = writeConfig( + 'updated_config.json', + createPlatformAccountConfig({ userID: '55555', enterpriseID: null }) + ); + const privateKeyPath = path.join(tempDirectory, 'private_key.pem'); + fs.writeFileSync(privateKeyPath, 'private key'); + + let environments; + let savedEnvironments; + + function environmentTest() { + return test + .stub(BoxCommand.prototype, 'getEnvironments', (stub) => + stub.callsFake(() => Promise.resolve(environments)) + ) + .stub(BoxCommand.prototype, 'updateEnvironments', (stub) => + stub.callsFake((updatedEnvironments) => { + savedEnvironments = updatedEnvironments; + return Promise.resolve(); + }) + ); + } + + after(function () { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + }); + + beforeEach(function () { + // A non-empty store, so CLI setup does not write a default (empty) environments config + environments = { + default: null, + environments: { jwt: { name: 'jwt' } }, + }; + savedEnvironments = undefined; + }); + + describe('configure:environments:add --platform-account-auth', function () { + environmentTest() + .stderr() + .command([ + 'configure:environments:add', + configPath, + '--platform-account-auth', + '--name=agent', + ]) + .it( + 'should store a Platform Account environment as the default', + (context) => { + assert.include( + context.stderr, + 'Successfully added CLI environment "agent"' + ); + assert.equal(savedEnvironments.default, 'agent'); + assert.deepEqual( + savedEnvironments.environments.agent, + platformAccountEnvironment({ + boxConfigFilePath: configPath, + }) + ); + } + ); + + environmentTest() + .stderr() + .command([ + 'configure:environments:add', + configWithoutEnterprisePath, + '--platform-account-auth', + '--name=agent', + ]) + .it( + 'should not require an enterprise ID, since authentication uses the Platform Account user ID', + () => { + assert.isNull( + savedEnvironments.environments.agent.enterpriseId + ); + assert.equal( + savedEnvironments.environments.agent.userId, + '33333' + ); + } + ); + + environmentTest() + .stderr() + .command([ + 'configure:environments:add', + configWithoutKeyPath, + '--platform-account-auth', + '--name=agent', + `--private-key-path=${privateKeyPath}`, + ]) + .it('should accept the private key from a separate file', () => { + const environment = savedEnvironments.environments.agent; + assert.isFalse(environment.hasInLinePrivateKey); + assert.equal(environment.privateKeyPath, privateKeyPath); + }); + + environmentTest() + .stderr() + .command([ + 'configure:environments:add', + configWithoutUserPath, + '--platform-account-auth', + '--name=agent', + '--no-color', + ]) + .it( + 'should reject a config file without the Platform Account user ID', + (context) => { + assert.include( + context.stderr, + 'Config object missing key userID' + ); + assert.isUndefined(savedEnvironments); + } + ); + + environmentTest() + .stderr() + .command([ + 'configure:environments:add', + configWithoutKeyPath, + '--platform-account-auth', + '--name=agent', + '--no-color', + ]) + .it( + 'should reject a config file without a private key when no private key path is given', + (context) => { + assert.include( + context.stderr, + 'Your environment does not have a private key' + ); + assert.isUndefined(savedEnvironments); + } + ); + + environmentTest() + .stderr() + .command([ + 'configure:environments:add', + configPath, + '--platform-account-auth', + '--ccg-auth', + '--no-color', + ]) + .it( + 'should not combine Platform Account and CCG auth', + (context) => { + assert.include( + context.stderr, + '--ccg-auth=true cannot also be provided when using --platform-account-auth' + ); + assert.isUndefined(savedEnvironments); + } + ); + }); + + describe('configure:environments:switch-user', function () { + environmentTest() + .do(() => { + environments = { + default: 'agent', + environments: { agent: platformAccountEnvironment() }, + }; + }) + .stderr() + .command([ + 'configure:environments:switch-user', + '44444', + '--no-color', + ]) + .it( + 'should reject switching the user of a Platform Account environment', + (context) => { + assert.include( + context.stderr, + `Switching users ${FIXED_PRINCIPAL_MESSAGE}` + ); + assert.isUndefined(savedEnvironments); + } + ); + + environmentTest() + .do(() => { + environments = { + default: 'agent', + environments: { + agent: platformAccountEnvironment({ + useDefaultAsUser: true, + defaultAsUserId: '44444', + }), + }, + }; + }) + .stderr() + .command(['configure:environments:switch-user', '--default']) + .it( + 'should allow switching back to the Platform Account itself', + () => { + assert.isFalse( + savedEnvironments.environments.agent.useDefaultAsUser + ); + } + ); + }); + + describe('configure:environments:update', function () { + environmentTest() + .do(() => { + environments = { + default: 'agent', + environments: { agent: platformAccountEnvironment() }, + }; + }) + .stderr() + .command([ + 'configure:environments:update', + '--user-id=44444', + '--no-color', + ]) + .it( + 'should reject setting a default As-User for a Platform Account environment', + (context) => { + assert.include( + context.stderr, + `The --user-id flag ${FIXED_PRINCIPAL_MESSAGE}` + ); + assert.isUndefined(savedEnvironments); + } + ); + + environmentTest() + .do(() => { + environments = { + default: 'agent', + environments: { agent: platformAccountEnvironment() }, + }; + }) + .stdout() + .command([ + 'configure:environments:update', + `--config-file-path=${updatedConfigPath}`, + ]) + .it( + 'should validate a new config file as a Platform Account config and refresh the stored IDs', + () => { + const environment = savedEnvironments.environments.agent; + assert.equal( + environment.boxConfigFilePath, + updatedConfigPath + ); + assert.equal(environment.userId, '55555'); + assert.isNull(environment.enterpriseId); + } + ); + + environmentTest() + .do(() => { + environments = { + default: 'agent', + environments: { agent: platformAccountEnvironment() }, + }; + }) + .stderr() + .command([ + 'configure:environments:update', + `--config-file-path=${configWithoutUserPath}`, + '--no-color', + ]) + .it( + 'should reject a new config file without the Platform Account user ID', + (context) => { + assert.include( + context.stderr, + 'Config object missing key userID' + ); + assert.isUndefined(savedEnvironments); + } + ); + }); +}); diff --git a/test/platform-account-auth.test.js b/test/platform-account-auth.test.js new file mode 100644 index 00000000..9086346c --- /dev/null +++ b/test/platform-account-auth.test.js @@ -0,0 +1,460 @@ +'use strict'; + +const { test } = require('@oclif/test'); +const { assert } = require('chai'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const sinon = require('sinon'); +const BoxTSSDK = require('box-node-sdk/sdk-gen'); +const BoxCommand = require('../src/box-command'); +const { TEST_API_ROOT } = require('./helpers/test-helper'); + +const CLIENT_ID = 'platform-account-client-id'; +const CLIENT_SECRET = 'platform-account-client-secret'; +const PUBLIC_KEY_ID = 'platform-account-key-id'; +const PASSPHRASE = 'platform-account-passphrase'; +const USER_ID = '33333'; +const ENTERPRISE_ID = '11111'; +const ACCESS_TOKEN = 'platform-account-access-token'; +const ENVIRONMENT_NAME = 'platform-account-env'; +const FIXED_PRINCIPAL_MESSAGE = + "is not supported for Platform Account environments. Commands always run as the Platform Account from the environment's config file."; + +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: PASSPHRASE, + }, +}); + +function createConfig({ withPrivateKey = true, withUserId = true } = {}) { + return { + boxAppSettings: { + clientID: CLIENT_ID, + clientSecret: CLIENT_SECRET, + appAuth: { + publicKeyID: PUBLIC_KEY_ID, + ...(withPrivateKey ? { privateKey } : {}), + passphrase: PASSPHRASE, + }, + }, + enterpriseID: ENTERPRISE_ID, + ...(withUserId ? { userID: USER_ID } : {}), + }; +} + +function writeTempFile(directory, name, content) { + const filePath = path.join(directory, name); + fs.writeFileSync(filePath, content); + return filePath; +} + +function decodeBase64Url(segment) { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); +} + +/** + * Assert the token request is a Platform Account JWT bearer grant signed with + * the configured private key for the configured Platform Account. + * + * @param {Object} body Parsed form body of the token request + * @returns {boolean} True, so nock accepts the request + */ +function assertPlatformAccountTokenRequest(body) { + assert.equal( + body.grant_type, + 'urn:ietf:params:oauth:grant-type:jwt-bearer' + ); + assert.equal(body.client_id, CLIENT_ID); + assert.equal(body.client_secret, CLIENT_SECRET); + + const header = decodeBase64Url(body.assertion.split('.')[0]); + assert.equal(header.alg, 'RS256'); + assert.equal(header.typ, 'platform-account+jwt'); + + const claims = jwt.verify(body.assertion, publicKey, { + algorithms: ['RS256'], + audience: 'https://api.box.com/oauth2/token', + issuer: CLIENT_ID, + subject: USER_ID, + }); + assert.equal(claims.box_sub_type, 'user'); + assert.isString(claims.jti); + assert.isAbove(claims.exp, claims.iat); + return true; +} + +function mockPlatformAccountTokenRequest(api) { + return api + .post('/oauth2/token', assertPlatformAccountTokenRequest) + .reply(200, { + access_token: ACCESS_TOKEN, + expires_in: 3600, + token_type: 'bearer', + restricted_to: [], + }); +} + +describe('Platform Account authentication', function () { + let tempDirectory; + let configFilePath; + let environments; + + function useEnvironment(overrides = {}) { + environments = { + default: ENVIRONMENT_NAME, + environments: { + [ENVIRONMENT_NAME]: { + name: ENVIRONMENT_NAME, + authMethod: 'platformAccount', + clientId: CLIENT_ID, + enterpriseId: ENTERPRISE_ID, + userId: USER_ID, + boxConfigFilePath: configFilePath, + hasInLinePrivateKey: true, + privateKeyPath: null, + defaultAsUserId: null, + useDefaultAsUser: false, + cacheTokens: false, + ...overrides, + }, + }, + }; + } + + function platformAccountTest() { + return test.stub(BoxCommand.prototype, 'getEnvironments', (stub) => + stub.callsFake(() => Promise.resolve(environments)) + ); + } + + before(function () { + tempDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'boxcli-platform-account-') + ); + configFilePath = writeTempFile( + tempDirectory, + 'platform_account_config.json', + JSON.stringify(createConfig()) + ); + }); + + after(function () { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + }); + + beforeEach(function () { + useEnvironment(); + }); + + describe('legacy SDK client commands', function () { + platformAccountTest() + .nock(TEST_API_ROOT, (api) => + mockPlatformAccountTokenRequest(api) + .get('/2.0/users/me') + .matchHeader('Authorization', `Bearer ${ACCESS_TOKEN}`) + .reply(200, { type: 'user', id: USER_ID }) + ) + .stdout() + .command(['users:get', 'me', '--json']) + .it( + 'should authenticate as the Platform Account from the config file', + (context) => { + assert.equal(JSON.parse(context.stdout).id, USER_ID); + } + ); + + platformAccountTest() + .do(() => { + const keyPath = writeTempFile( + tempDirectory, + 'private_key.pem', + privateKey + ); + const configWithoutKeyPath = writeTempFile( + tempDirectory, + 'platform_account_config_without_key.json', + JSON.stringify(createConfig({ withPrivateKey: false })) + ); + useEnvironment({ + boxConfigFilePath: configWithoutKeyPath, + hasInLinePrivateKey: false, + privateKeyPath: keyPath, + }); + }) + .nock(TEST_API_ROOT, (api) => + mockPlatformAccountTokenRequest(api) + .get('/2.0/users/me') + .matchHeader('Authorization', `Bearer ${ACCESS_TOKEN}`) + .reply(200, { type: 'user', id: USER_ID }) + ) + .stdout() + .command(['users:get', 'me', '--json']) + .it( + 'should sign with the private key from the environment private key path', + (context) => { + assert.equal(JSON.parse(context.stdout).id, USER_ID); + } + ); + }); + + describe('TypeScript SDK client commands', function () { + platformAccountTest() + .nock(TEST_API_ROOT, (api) => + mockPlatformAccountTokenRequest(api) + .get('/2.0/hubs/12345') + .matchHeader('Authorization', `Bearer ${ACCESS_TOKEN}`) + .reply(200, { type: 'hubs', id: '12345' }) + ) + .stdout() + .command(['hubs:get', '12345', '--json']) + .it( + 'should authenticate as the Platform Account from the config file', + (context) => { + assert.equal(JSON.parse(context.stdout).id, '12345'); + } + ); + }); + + describe('--token flag', function () { + platformAccountTest() + .nock(TEST_API_ROOT, (api) => + api + .get('/2.0/users/me') + .matchHeader('Authorization', 'Bearer explicit-token') + .reply(200, { type: 'user', id: '44444' }) + ) + .stdout() + .command(['users:get', 'me', '--json', '--token=explicit-token']) + .it( + 'should take precedence over the Platform Account environment', + (context) => { + assert.equal(JSON.parse(context.stdout).id, '44444'); + } + ); + }); + + describe('fixed principal guardrails', function () { + platformAccountTest() + .stderr() + .command(['users:get', 'me', '--as-user=44444', '--no-color']) + .it('should reject the --as-user flag', (context) => { + assert.include( + context.stderr, + `The --as-user flag ${FIXED_PRINCIPAL_MESSAGE}` + ); + }); + + platformAccountTest() + .do(() => + useEnvironment({ + useDefaultAsUser: true, + defaultAsUserId: '44444', + }) + ) + .stderr() + .command(['users:get', 'me', '--no-color']) + .it( + 'should reject an environment with a default As-User', + (context) => { + assert.include( + context.stderr, + `Environment "${ENVIRONMENT_NAME}" has a default As-User set, which is not supported for Platform Account environments. Run "box configure:environments:switch-user --default" to remove it.` + ); + } + ); + }); + + describe('configuration errors', function () { + platformAccountTest() + .do(() => { + const invalidConfigPath = writeTempFile( + tempDirectory, + 'platform_account_config_without_user.json', + JSON.stringify(createConfig({ withUserId: false })) + ); + useEnvironment({ boxConfigFilePath: invalidConfigPath }); + }) + .stderr() + .command(['users:get', 'me', '--no-color']) + .it( + 'should report which value is missing from the Platform Account config file', + (context) => { + assert.include( + context.stderr, + 'Invalid Platform Account config file' + ); + assert.include( + context.stderr, + 'Config object missing key userID' + ); + } + ); + + platformAccountTest() + .do(() => + useEnvironment({ + boxConfigFilePath: path.join(tempDirectory, 'missing.json'), + }) + ) + .stderr() + .command(['users:get', 'me', '--no-color']) + .it('should report a missing config file', (context) => { + assert.include( + context.stderr, + 'Could not read Platform Account config file' + ); + }); + }); + + describe('token request errors', function () { + function mockRejectedTokenRequest(api) { + return api.post('/oauth2/token').reply(400, { + error: 'invalid_grant', + error_description: 'Platform Account not found', + }); + } + + platformAccountTest() + .nock(TEST_API_ROOT, mockRejectedTokenRequest) + .stderr() + .command(['users:get', 'me', '--no-color']) + .it( + 'should report the OAuth error from a legacy client command', + (context) => { + assert.include( + context.stderr, + 'Unexpected API Response [400] invalid_grant - Platform Account not found' + ); + } + ); + + platformAccountTest() + .nock(TEST_API_ROOT, mockRejectedTokenRequest) + .stderr() + .command(['tokens:get', '--no-color']) + .it( + 'should report the OAuth error from a TS client command', + (context) => { + assert.include( + context.stderr, + 'Unexpected API Response [400] invalid_grant - Platform Account not found' + ); + } + ); + }); + + describe('proxy settings', function () { + const PROXY = Object.freeze({ + url: 'http://proxy.example.com:8080', + username: 'proxy-user', + password: 'proxy-password', + }); + let refreshTokenStub; + + function proxyTest() { + return platformAccountTest() + .stub(BoxCommand.prototype, '_loadSettings', (stub) => + stub.resolves({ enableProxy: true, proxy: PROXY }) + ) + .do(() => { + refreshTokenStub = sinon + .stub( + BoxTSSDK.BoxPlatformAccountAuth.prototype, + 'refreshToken' + ) + .rejects(new Error('token request captured')); + }) + .finally(() => { + refreshTokenStub?.restore(); + refreshTokenStub = undefined; + }); + } + + function getTokenRequestNetworkSession() { + assert.isTrue(refreshTokenStub.calledOnce); + return refreshTokenStub.firstCall.args[0]; + } + + proxyTest() + .stderr() + .command(['users:get', 'me', '--no-color']) + .it( + 'should send legacy client token requests through the configured proxy', + () => { + assert.deepEqual( + getTokenRequestNetworkSession().proxyConfig, + PROXY + ); + } + ); + + proxyTest() + .stderr() + .command(['tokens:get', '--no-color']) + .it( + 'should send TS client token requests through the configured proxy', + () => { + assert.deepEqual( + getTokenRequestNetworkSession().proxyConfig, + PROXY + ); + } + ); + }); + + describe('tokens commands', function () { + platformAccountTest() + .nock(TEST_API_ROOT, (api) => mockPlatformAccountTokenRequest(api)) + .stdout() + .command(['tokens:get']) + .it( + 'tokens:get should output a new Platform Account token', + (context) => { + assert.equal(context.stdout, `${ACCESS_TOKEN}${os.EOL}`); + } + ); + + platformAccountTest() + .stderr() + .command(['tokens:get', '--user-id=44444', '--no-color']) + .it('tokens:get should reject --user-id', (context) => { + assert.include( + context.stderr, + `The --user-id flag ${FIXED_PRINCIPAL_MESSAGE}` + ); + }); + + platformAccountTest() + .stderr() + .command(['tokens:exchange', 'item_preview', '--no-color']) + .it('tokens:exchange should reject downscoping', (context) => { + assert.include( + context.stderr, + 'Token exchange and downscoping are not supported for Platform Account environments' + ); + }); + + platformAccountTest() + .stderr() + .command([ + 'tokens:exchange', + 'item_preview', + '--user-id=44444', + '--no-color', + ]) + .it('tokens:exchange should reject --user-id', (context) => { + assert.include( + context.stderr, + 'Token exchange and downscoping are not supported for Platform Account environments' + ); + }); + }); +}); diff --git a/test/platform-account-session-adapter.test.js b/test/platform-account-session-adapter.test.js new file mode 100644 index 00000000..4b460443 --- /dev/null +++ b/test/platform-account-session-adapter.test.js @@ -0,0 +1,254 @@ +'use strict'; + +const { assert } = require('chai'); +const sinon = require('sinon'); +const PlatformAccountSessionAdapter = require('../src/platform-account-session-adapter'); + +const ONE_HOUR_MS = 60 * 60 * 1000; +const EXPIRY_BUFFER_MS = 60 * 1000; + +const NETWORK_SESSION = Object.freeze({ name: 'configured network session' }); + +function createTokenStorage(initialToken) { + let token = initialToken; + return { + get: sinon.stub().callsFake(async () => token), + store: sinon.stub().callsFake(async (newToken) => { + token = newToken; + }), + clear: sinon.stub().callsFake(async () => { + token = undefined; + }), + }; +} + +const DEFAULT_ISSUED_TOKEN = Object.freeze({ + accessToken: 'new-token', + expiresIn: 3600, +}); + +function createAuth(tokenStorage, issuedToken = DEFAULT_ISSUED_TOKEN) { + return { + tokenStorage, + refreshToken: sinon.stub().callsFake(async () => { + await tokenStorage.store(issuedToken); + return issuedToken; + }), + revokeToken: sinon.stub().resolves(), + downscopeToken: sinon.stub().rejects(new Error('must not be called')), + }; +} + +function createAdapter(auth) { + return new PlatformAccountSessionAdapter(auth, { + expiryBufferMS: EXPIRY_BUFFER_MS, + networkSession: NETWORK_SESSION, + }); +} + +describe('PlatformAccountSessionAdapter', function () { + describe('getAccessToken()', function () { + it('returns a fresh token from the CLI token cache without requesting a new one', async function () { + const auth = createAuth( + createTokenStorage({ + accessToken: 'cached-token', + accessTokenTTLMS: ONE_HOUR_MS, + acquiredAtMS: Date.now(), + }) + ); + + const accessToken = await createAdapter(auth).getAccessToken(); + + assert.equal(accessToken, 'cached-token'); + assert.isFalse(auth.refreshToken.called); + }); + + it('requests a new token when the cached token expires within the expiry buffer', async function () { + const auth = createAuth( + createTokenStorage({ + accessToken: 'stale-token', + accessTokenTTLMS: ONE_HOUR_MS, + acquiredAtMS: + Date.now() - ONE_HOUR_MS + EXPIRY_BUFFER_MS / 2, + }) + ); + + const accessToken = await createAdapter(auth).getAccessToken(); + + assert.equal(accessToken, 'new-token'); + assert.isTrue(auth.refreshToken.calledOnce); + }); + + it('requests a new token when the cache is empty', async function () { + const auth = createAuth(createTokenStorage()); + + const accessToken = await createAdapter(auth).getAccessToken(); + + assert.equal(accessToken, 'new-token'); + assert.isTrue(auth.refreshToken.calledOnce); + }); + + it('reuses a token it requested itself when the storage keeps no timestamps', async function () { + const auth = createAuth(createTokenStorage()); + const adapter = createAdapter(auth); + + await adapter.getAccessToken(); + const accessToken = await adapter.getAccessToken(); + + assert.equal(accessToken, 'new-token'); + assert.isTrue(auth.refreshToken.calledOnce); + }); + + it('requests a new token for a stored token without timestamps that it did not request', async function () { + const auth = createAuth( + createTokenStorage({ + accessToken: 'unknown-age-token', + expiresIn: 3600, + }) + ); + + const accessToken = await createAdapter(auth).getAccessToken(); + + assert.equal(accessToken, 'new-token'); + assert.isTrue(auth.refreshToken.calledOnce); + }); + + it('shares a single token request between concurrent callers', async function () { + const auth = createAuth(createTokenStorage()); + const adapter = createAdapter(auth); + + const tokens = await Promise.all([ + adapter.getAccessToken(), + adapter.getAccessToken(), + adapter.getAccessToken(), + ]); + + assert.deepEqual(tokens, ['new-token', 'new-token', 'new-token']); + assert.isTrue(auth.refreshToken.calledOnce); + }); + + it('requests tokens through the configured network session', async function () { + const auth = createAuth(createTokenStorage()); + + await createAdapter(auth).getAccessToken(); + + assert.strictEqual( + auth.refreshToken.firstCall.args[0], + NETWORK_SESSION + ); + }); + + it('propagates token request failures and retries on the next call', async function () { + const auth = createAuth(createTokenStorage()); + auth.refreshToken.onFirstCall().rejects(new Error('invalid_grant')); + const adapter = createAdapter(auth); + + try { + await adapter.getAccessToken(); + assert.fail('Expected getAccessToken to reject'); + } catch (error) { + assert.equal(error.message, 'invalid_grant'); + } + const accessToken = await adapter.getAccessToken(); + + assert.equal(accessToken, 'new-token'); + assert.isTrue(auth.refreshToken.calledTwice); + }); + + it('returns a Bluebird promise, as the legacy client chains asCallback() on it', function () { + const auth = createAuth(createTokenStorage()); + + const result = createAdapter(auth).getAccessToken(); + + assert.isFunction(result.asCallback); + return result; + }); + }); + + describe('revokeTokens()', function () { + it('revokes through the generated SDK auth using the configured network session', async function () { + const auth = createAuth(createTokenStorage()); + + await createAdapter(auth).revokeTokens(); + + assert.isTrue(auth.revokeToken.calledOnce); + assert.strictEqual( + auth.revokeToken.firstCall.args[0], + NETWORK_SESSION + ); + }); + + it('requests a new token after revoking a token it requested itself', async function () { + const auth = createAuth(createTokenStorage()); + auth.revokeToken.callsFake(() => auth.tokenStorage.clear()); + const adapter = createAdapter(auth); + + await adapter.getAccessToken(); + await adapter.revokeTokens(); + await adapter.getAccessToken(); + + assert.isTrue(auth.refreshToken.calledTwice); + }); + }); + + describe('exchangeToken()', function () { + it('rejects because Platform Account tokens cannot be downscoped', async function () { + const auth = createAuth(createTokenStorage()); + + const result = createAdapter(auth).exchangeToken( + ['item_preview'], + 'https://api.box.com/2.0/files/123' + ); + + assert.isFunction(result.asCallback); + try { + await result; + assert.fail('Expected exchangeToken to reject'); + } catch (error) { + assert.equal( + error.message, + 'Token exchange and downscoping are not supported for Platform Account environments' + ); + } + assert.isFalse(auth.downscopeToken.called); + }); + }); + + describe('handleExpiredTokensError()', function () { + it('clears the token cache and rethrows the original error', async function () { + const auth = createAuth( + createTokenStorage({ + accessToken: 'revoked-token', + accessTokenTTLMS: ONE_HOUR_MS, + acquiredAtMS: Date.now(), + }) + ); + const expiredTokenError = new Error( + 'Expired Auth: Auth code or refresh token has expired' + ); + + try { + await createAdapter(auth).handleExpiredTokensError( + expiredTokenError + ); + assert.fail('Expected handleExpiredTokensError to reject'); + } catch (error) { + assert.strictEqual(error, expiredTokenError); + } + assert.isTrue(auth.tokenStorage.clear.calledOnce); + }); + + it('requests a new token on the next call after an expired-token error', async function () { + const auth = createAuth(createTokenStorage()); + const adapter = createAdapter(auth); + + await adapter.getAccessToken(); + await adapter + .handleExpiredTokensError(new Error('expired')) + .catch(() => {}); + await adapter.getAccessToken(); + + assert.isTrue(auth.refreshToken.calledTwice); + }); + }); +}); diff --git a/test/platform-account.test.js b/test/platform-account.test.js new file mode 100644 index 00000000..063fb29f --- /dev/null +++ b/test/platform-account.test.js @@ -0,0 +1,280 @@ +'use strict'; + +const { assert } = require('chai'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const BoxTSSDK = require('box-node-sdk/sdk-gen'); +const { InMemoryTokenStorage } = require('box-node-sdk/sdk-gen/box'); +const BoxCLIError = require('../src/cli-error'); +const CLITokenCache = require('../src/token-cache'); +const { + isPlatformAccountEnvironment, + validatePlatformAccountConfig, + assertPlatformAccountPrincipalIsFixed, + readPlatformAccountConfig, + createPlatformAccountAuth, +} = require('../src/platform-account'); + +const PRIVATE_KEY = + '-----BEGIN ENCRYPTED PRIVATE KEY-----\nkey\n-----END ENCRYPTED PRIVATE KEY-----\n'; + +function createConfig({ withPrivateKey = true, withUserId = true } = {}) { + return { + boxAppSettings: { + clientID: 'platform-account-client-id', + clientSecret: 'platform-account-client-secret', + appAuth: { + publicKeyID: 'platform-account-key-id', + ...(withPrivateKey ? { privateKey: PRIVATE_KEY } : {}), + passphrase: 'platform-account-passphrase', + }, + }, + ...(withUserId ? { userID: '33333' } : {}), + }; +} + +describe('Platform Account helpers', function () { + let tempDirectory; + + beforeEach(function () { + tempDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'boxcli-platform-account-') + ); + }); + + afterEach(function () { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + }); + + function writeFile(name, content) { + const filePath = path.join(tempDirectory, name); + fs.writeFileSync(filePath, content); + return filePath; + } + + function createEnvironment(overrides = {}) { + return { + authMethod: 'platformAccount', + boxConfigFilePath: writeFile( + 'config.json', + JSON.stringify(createConfig()) + ), + hasInLinePrivateKey: true, + privateKeyPath: null, + useDefaultAsUser: false, + cacheTokens: true, + ...overrides, + }; + } + + describe('isPlatformAccountEnvironment()', function () { + it('should recognize Platform Account environments only', function () { + assert.isTrue( + isPlatformAccountEnvironment({ authMethod: 'platformAccount' }) + ); + assert.isFalse(isPlatformAccountEnvironment({ authMethod: 'jwt' })); + assert.isFalse(isPlatformAccountEnvironment()); + }); + }); + + describe('validatePlatformAccountConfig()', function () { + it('should accept a complete config without an enterprise ID', function () { + assert.doesNotThrow(() => + validatePlatformAccountConfig(createConfig()) + ); + }); + + it('should reject a config without the Platform Account user ID', function () { + assert.throws( + () => + validatePlatformAccountConfig( + createConfig({ withUserId: false }) + ), + BoxCLIError, + 'Config object missing key userID' + ); + }); + }); + + describe('assertPlatformAccountPrincipalIsFixed()', function () { + it('should allow an environment without As-User', function () { + assert.doesNotThrow(() => + assertPlatformAccountPrincipalIsFixed({ + environmentName: 'agent', + environment: createEnvironment(), + }) + ); + }); + + it('should reject the --as-user flag', function () { + assert.throws( + () => + assertPlatformAccountPrincipalIsFixed({ + environmentName: 'agent', + environment: createEnvironment(), + asUser: '44444', + }), + BoxCLIError, + 'The --as-user flag is not supported for Platform Account environments' + ); + }); + + it('should reject a default As-User on the environment', function () { + assert.throws( + () => + assertPlatformAccountPrincipalIsFixed({ + environmentName: 'agent', + environment: createEnvironment({ + useDefaultAsUser: true, + defaultAsUserId: '44444', + }), + }), + BoxCLIError, + 'Environment "agent" has a default As-User set' + ); + }); + }); + + describe('readPlatformAccountConfig()', function () { + it('should read a config with an inline private key', function () { + const configObj = readPlatformAccountConfig(createEnvironment()); + + assert.deepEqual(configObj, createConfig()); + }); + + it('should load the private key from a separate file', function () { + const environment = createEnvironment({ + boxConfigFilePath: writeFile( + 'config_without_key.json', + JSON.stringify(createConfig({ withPrivateKey: false })) + ), + hasInLinePrivateKey: false, + privateKeyPath: writeFile('private_key.pem', PRIVATE_KEY), + }); + + const configObj = readPlatformAccountConfig(environment); + + assert.equal( + configObj.boxAppSettings.appAuth.privateKey, + PRIVATE_KEY + ); + }); + + it('should reject a missing config file', function () { + const boxConfigFilePath = path.join(tempDirectory, 'missing.json'); + + assert.throws( + () => + readPlatformAccountConfig( + createEnvironment({ boxConfigFilePath }) + ), + BoxCLIError, + `Could not read Platform Account config file ${boxConfigFilePath}` + ); + }); + + it('should reject a config file changed to miss the Platform Account user ID', function () { + const boxConfigFilePath = writeFile( + 'config_without_user.json', + JSON.stringify(createConfig({ withUserId: false })) + ); + + assert.throws( + () => + readPlatformAccountConfig( + createEnvironment({ boxConfigFilePath }) + ), + BoxCLIError, + `Invalid Platform Account config file ${boxConfigFilePath}: Config object missing key userID` + ); + }); + + it('should reject a config file without a private key when no private key file is set', function () { + const boxConfigFilePath = writeFile( + 'config_without_key.json', + JSON.stringify(createConfig({ withPrivateKey: false })) + ); + + assert.throws( + () => + readPlatformAccountConfig( + createEnvironment({ boxConfigFilePath }) + ), + BoxCLIError, + `Invalid Platform Account config file ${boxConfigFilePath}: Config object missing key boxAppSettings.appAuth.privateKey` + ); + }); + + it('should reject a missing private key file', function () { + const privateKeyPath = path.join(tempDirectory, 'missing.pem'); + + assert.throws( + () => + readPlatformAccountConfig( + createEnvironment({ + hasInLinePrivateKey: false, + privateKeyPath, + }) + ), + BoxCLIError, + `Could not read private key file ${privateKeyPath}` + ); + }); + }); + + describe('createPlatformAccountAuth()', function () { + it('should build the auth from the config file', function () { + const auth = createPlatformAccountAuth( + 'agent', + createEnvironment() + ); + + assert.instanceOf(auth, BoxTSSDK.BoxPlatformAccountAuth); + assert.equal(auth.config.clientId, 'platform-account-client-id'); + assert.equal( + auth.config.clientSecret, + 'platform-account-client-secret' + ); + assert.equal(auth.config.userId, '33333'); + assert.equal(auth.config.jwtKeyId, 'platform-account-key-id'); + assert.equal(auth.config.privateKey, PRIVATE_KEY); + }); + + it('should cache tokens in the CLI token cache of the environment', function () { + const auth = createPlatformAccountAuth( + 'agent', + createEnvironment() + ); + + assert.instanceOf(auth.tokenStorage, CLITokenCache); + assert.equal(auth.tokenStorage.environmentName, 'agent'); + }); + + it('should keep tokens in memory when token caching is disabled', function () { + const auth = createPlatformAccountAuth( + 'agent', + createEnvironment({ cacheTokens: false }) + ); + + assert.instanceOf(auth.tokenStorage, InMemoryTokenStorage); + }); + + it('should validate the config file before building the auth', function () { + const boxConfigFilePath = writeFile( + 'invalid_config.json', + JSON.stringify({ userID: '33333' }) + ); + + assert.throws( + () => + createPlatformAccountAuth( + 'agent', + createEnvironment({ boxConfigFilePath }) + ), + BoxCLIError, + `Invalid Platform Account config file ${boxConfigFilePath}: Config object missing key boxAppSettings.clientID` + ); + }); + }); +}); diff --git a/test/token-cache.test.js b/test/token-cache.test.js index 6da729eb..2d1c428d 100644 --- a/test/token-cache.test.js +++ b/test/token-cache.test.js @@ -386,6 +386,39 @@ describe('CLITokenCache', function () { }); }); + it('should return a promise when clearing without a callback, as the TS SDK token storage contract requires', async function () { + const cacheWithoutSecure = new CLITokenCache('promise-clear-env'); + cacheWithoutSecure.supportsSecureStorage = false; + const unlinkStub = sinon.stub(utilities, 'unlinkAsync').resolves(); + + try { + await cacheWithoutSecure.clear(); + expect(unlinkStub.calledOnceWith(cacheWithoutSecure.filePath)) + .to.be.true; + } finally { + unlinkStub.restore(); + } + }); + + it('should reject the returned promise when clearing without a callback fails', async function () { + const cacheWithoutSecure = new CLITokenCache('promise-clear-env'); + cacheWithoutSecure.supportsSecureStorage = false; + const unlinkStub = sinon.stub(utilities, 'unlinkAsync').rejects( + Object.assign(new Error('Permission denied'), { + code: 'EACCES', + }) + ); + + try { + await cacheWithoutSecure.clear(); + expect.fail('Expected clear() to reject'); + } catch (error) { + expect(error.message).to.equal('Failed to delete token cache'); + } finally { + unlinkStub.restore(); + } + }); + it('should report secure storage deletion failures when clearing', function (done) { if (!tokenCache.supportsSecureStorage) { this.skip();