Skip to content

Commit 842e308

Browse files
authored
feat: Support logout command (#635)
1 parent b925b65 commit 842e308

File tree

6 files changed

+828
-4
lines changed

6 files changed

+828
-4
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Among other features, Box CLI includes the following functionality:
2626
- [Linux & Node install](#linux--node-install)
2727
- [Quick Login with the Official Box CLI App](#quick-login-with-the-official-box-cli-app)
2828
- [CLI and Server Authentication with JWT](#cli-and-server-authentication-with-jwt)
29+
- [Logout](#logout)
2930
- [Secure Storage](#secure-storage)
3031
- [What is Stored Securely](#what-is-stored-securely)
3132
- [Platform Support](#platform-support)
@@ -105,6 +106,15 @@ Successfully added CLI environment "ManualKey"
105106
[oauth-guide]: https://developer.box.com/guides/cli/quick-start/
106107
[jwt-guide]: https://developer.box.com/guides/cli/cli-docs/jwt-cli/
107108

109+
### Logout
110+
111+
To sign out from the current environment, run:
112+
113+
```bash
114+
box logout
115+
```
116+
117+
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.
108118

109119
## Secure Storage
110120

@@ -209,6 +219,7 @@ Avatar URL: 'https://app.box.com/api/avatar/large/77777'
209219
* [`box integration-mappings`](docs/integration-mappings.md) - List Slack integration mappings
210220
* [`box legal-hold-policies`](docs/legal-hold-policies.md) - List legal hold policies
211221
* [`box login`](docs/login.md) - Sign in with OAuth 2.0 and create a new environment (or update an existing one with --reauthorize).
222+
* [`box logout`](docs/logout.md) - Revoke the access token and clear local token cache.
212223
* [`box metadata-cascade-policies`](docs/metadata-cascade-policies.md) - List the metadata cascade policies on a folder
213224
* [`box metadata-query`](docs/metadata-query.md) - Create a search using SQL-like syntax to return items that match specific metadata
214225
* [`box metadata-templates`](docs/metadata-templates.md) - Get all metadata templates in your Enterprise

docs/logout.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
`box logout`
2+
============
3+
4+
Revoke the access token and clear local token cache.
5+
6+
For OAuth, run `box login` to authorize again.
7+
For CCG and JWT, a new token is fetched automatically on the next command.
8+
9+
Use -f and --on-revoke-failure=clear or --on-revoke-failure=abort to skip the interactive prompt.
10+
11+
* [`box logout`](#box-logout)
12+
13+
## `box logout`
14+
15+
Revoke the access token and clear local token cache.
16+
17+
```
18+
USAGE
19+
$ box logout [--no-color] [-h] [-v] [-q] [-f] [--on-revoke-failure clear|abort]
20+
21+
FLAGS
22+
-f, --force Skip confirmation prompt
23+
-h, --help Show CLI help
24+
-q, --quiet Suppress any non-error output to stderr
25+
-v, --verbose Show verbose output, which can be helpful for debugging
26+
--no-color Turn off colors for logging
27+
--on-revoke-failure=<option> On revoke failure: "clear" clears local cache only, "abort" exits without clearing.
28+
Skips prompt.
29+
<options: clear|abort>
30+
31+
DESCRIPTION
32+
Revoke the access token and clear local token cache.
33+
34+
For OAuth, run `box login` to authorize again.
35+
For CCG and JWT, a new token is fetched automatically on the next command.
36+
37+
Use -f and --on-revoke-failure=clear or --on-revoke-failure=abort to skip the interactive prompt.
38+
```
39+
40+
_See code: [src/commands/logout.js](https://github.com/box/boxcli/blob/v4.5.0/src/commands/logout.js)_

src/commands/login.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const {
2424
const GENERIC_OAUTH_CLIENT_ID = 'udz8zp4yue87uk9dzq4xk425kkwvqvh1';
2525
const GENERIC_OAUTH_CLIENT_SECRET = 'iZ1MbvC3ZaF25nbJli7IsKdRHAxfu3fn';
2626
const SUPPORTED_DEFAULT_APP_PORTS = [3000, 3001, 4000, 5000, 8080];
27+
const DEFAULT_ENVIRONMENT_NAME = 'oauth';
2728

2829
async function promptForClientCredentials(inquirerModule) {
2930
const clientIdPrompt = {
@@ -75,14 +76,24 @@ class OAuthLoginCommand extends BoxCommand {
7576
let environment;
7677

7778
if (this.flags.reauthorize) {
79+
let targetEnvName = this.flags.name;
7880
if (
7981
!Object.hasOwn(environmentsObject.environments, this.flags.name)
8082
) {
81-
this.info(chalk`{red The "${this.flags.name}" environment does not exist}`);
82-
return;
83+
const currentEnv = environmentsObject.environments[environmentsObject.default];
84+
if (
85+
this.flags.name === DEFAULT_ENVIRONMENT_NAME &&
86+
environmentsObject.default &&
87+
currentEnv?.authMethod === 'oauth20'
88+
) {
89+
targetEnvName = environmentsObject.default;
90+
} else {
91+
this.info(chalk`{red The "${this.flags.name}" environment does not exist}`);
92+
return;
93+
}
8394
}
8495

85-
environment = environmentsObject.environments[this.flags.name];
96+
environment = environmentsObject.environments[targetEnvName];
8697
if (environment.authMethod !== 'oauth20') {
8798
this.info(chalk`{red The selected environment is not of type oauth20}`);
8899
return;
@@ -366,7 +377,7 @@ OAuthLoginCommand.flags = {
366377
name: Flags.string({
367378
char: 'n',
368379
description: 'Set a name for the environment',
369-
default: 'oauth',
380+
default: DEFAULT_ENVIRONMENT_NAME,
370381
}),
371382
port: Flags.integer({
372383
char: 'p',

src/commands/logout.js

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
'use strict';
2+
3+
const BoxCommand = require('../box-command');
4+
const BoxSDK = require('box-node-sdk').default;
5+
const CLITokenCache = require('../token-cache');
6+
const chalk = require('chalk');
7+
const inquirer = require('inquirer');
8+
const pkg = require('../../package.json');
9+
const { Flags } = require('@oclif/core');
10+
11+
const SDK_CONFIG = Object.freeze({
12+
analyticsClient: { version: pkg.version },
13+
request: {
14+
headers: { 'User-Agent': `Box CLI v${pkg.version}` },
15+
},
16+
});
17+
18+
function isInvalidTokenResponse(response) {
19+
return (
20+
response?.statusCode === 400 &&
21+
response?.body?.error === 'invalid_token'
22+
);
23+
}
24+
25+
function isSuccessResponse(response) {
26+
return response?.statusCode === 200;
27+
}
28+
29+
function getRevokeErrorMessage(thrownError, response) {
30+
if (thrownError) {
31+
return (
32+
thrownError.message ||
33+
'Unexpected error. Cannot connect to Box servers.'
34+
);
35+
}
36+
return (
37+
response?.body?.error_description ||
38+
`Request failed with status ${response?.statusCode ?? response?.status}` ||
39+
'Unknown error'
40+
);
41+
}
42+
43+
class OAuthLogoutCommand extends BoxCommand {
44+
async run() {
45+
const environmentsObj = await this.getEnvironments();
46+
const currentEnv = environmentsObj?.default;
47+
48+
const environment = currentEnv
49+
? environmentsObj.environments[currentEnv]
50+
: null;
51+
52+
if (!currentEnv || !environment) {
53+
this.error(
54+
'No current environment found. Nothing to log out from.'
55+
);
56+
}
57+
58+
const tokenCache = new CLITokenCache(currentEnv);
59+
const tokenInfo = await tokenCache.get();
60+
const accessToken = tokenInfo?.accessToken;
61+
if (!accessToken) {
62+
this.info(
63+
chalk`{green You are already logged out from "${currentEnv}" environment.}`
64+
);
65+
return;
66+
}
67+
68+
if (!this.flags.force) {
69+
const confirmed = await this.confirm(
70+
`Do you want to logout from "${currentEnv}" environment?`,
71+
false
72+
);
73+
if (!confirmed) {
74+
this.info(chalk`{yellow Logout cancelled.}`);
75+
return;
76+
}
77+
}
78+
79+
await this.revokeAndClearSession(
80+
accessToken,
81+
tokenCache,
82+
currentEnv,
83+
environment
84+
);
85+
}
86+
87+
async revokeAndClearSession(
88+
accessToken,
89+
tokenCache,
90+
currentEnv,
91+
environment
92+
) {
93+
while (true) {
94+
let response;
95+
let thrownError;
96+
const { clientId, clientSecret } =
97+
this.getClientCredentials(environment);
98+
if (!clientId || !clientSecret) {
99+
thrownError = new Error('Invalid client credentials.');
100+
response = undefined;
101+
} else {
102+
const sdk = new BoxSDK({
103+
clientID: clientId,
104+
clientSecret,
105+
...SDK_CONFIG,
106+
});
107+
try {
108+
response = await sdk.revokeTokens(accessToken);
109+
} catch (error) {
110+
thrownError = error;
111+
}
112+
}
113+
114+
if (isSuccessResponse(response)) {
115+
break;
116+
}
117+
118+
if (isInvalidTokenResponse(response)) {
119+
this.info(
120+
chalk`{yellow Access token is already invalid. Clearing local session.}`
121+
);
122+
break;
123+
}
124+
125+
const action = await this.promptRevokeFailureAction(
126+
thrownError,
127+
response
128+
);
129+
130+
if (action === 'abort') {
131+
this.info(
132+
chalk`{yellow Logout aborted. Token was not revoked and remains cached.}`
133+
);
134+
return;
135+
}
136+
if (action === 'clear') {
137+
break;
138+
}
139+
}
140+
141+
await new Promise((resolve, reject) => {
142+
tokenCache.clear((err) => (err ? reject(err) : resolve()));
143+
});
144+
this.info(
145+
chalk`{green Successfully logged out from "${currentEnv}" environment.}`
146+
);
147+
}
148+
149+
getClientCredentials(environment) {
150+
if (environment.boxConfigFilePath) {
151+
try {
152+
const fs = require('node:fs');
153+
const configObj = JSON.parse(
154+
fs.readFileSync(environment.boxConfigFilePath)
155+
);
156+
return {
157+
clientId: configObj?.boxAppSettings?.clientID ?? '',
158+
clientSecret: configObj?.boxAppSettings?.clientSecret ?? '',
159+
};
160+
} catch {
161+
// fall through to environment
162+
}
163+
}
164+
return {
165+
clientId: environment.clientId ?? '',
166+
clientSecret: environment.clientSecret ?? '',
167+
};
168+
}
169+
170+
async promptRevokeFailureAction(thrownError, response) {
171+
const onRevokeFailure = this.flags['on-revoke-failure'];
172+
if (onRevokeFailure) {
173+
return onRevokeFailure;
174+
}
175+
const result = await inquirer.prompt([
176+
{
177+
type: 'list',
178+
name: 'action',
179+
message: chalk`Could not revoke token: {red ${getRevokeErrorMessage(thrownError, response)}}\nWhat would you like to do?`,
180+
choices: [
181+
{ name: 'Try revoking again', value: 'retry' },
182+
{
183+
name: 'Clear local session only (token remains valid on Box)',
184+
value: 'clear',
185+
},
186+
{ name: 'Abort', value: 'abort' },
187+
],
188+
},
189+
]);
190+
return result.action;
191+
}
192+
}
193+
194+
// @NOTE: This command skips client setup, since it may be used when token is expired
195+
OAuthLogoutCommand.noClient = true;
196+
197+
OAuthLogoutCommand.description = [
198+
'Revoke the access token and clear local token cache.',
199+
'',
200+
'For OAuth, run `box login` to authorize again.',
201+
'For CCG and JWT, a new token is fetched automatically on the next command.',
202+
'',
203+
'Use -f and --on-revoke-failure=clear or --on-revoke-failure=abort to skip the interactive prompt.',
204+
].join('\n');
205+
206+
OAuthLogoutCommand.flags = {
207+
...BoxCommand.minFlags,
208+
force: Flags.boolean({
209+
char: 'f',
210+
description: 'Skip confirmation prompt',
211+
}),
212+
'on-revoke-failure': Flags.string({
213+
description:
214+
'On revoke failure: "clear" clears local cache only, "abort" exits without clearing. Skips prompt.',
215+
options: ['clear', 'abort'],
216+
}),
217+
};
218+
219+
module.exports = OAuthLogoutCommand;

0 commit comments

Comments
 (0)