Skip to content

Commit df065e3

Browse files
committed
feat: Support logout command
1 parent b925b65 commit df065e3

4 files changed

Lines changed: 688 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ Avatar URL: 'https://app.box.com/api/avatar/large/77777'
209209
* [`box integration-mappings`](docs/integration-mappings.md) - List Slack integration mappings
210210
* [`box legal-hold-policies`](docs/legal-hold-policies.md) - List legal hold policies
211211
* [`box login`](docs/login.md) - Sign in with OAuth 2.0 and create a new environment (or update an existing one with --reauthorize).
212+
* [`box logout`](docs/logout.md) - Revoke the access token and clear local token cache.
212213
* [`box metadata-cascade-policies`](docs/metadata-cascade-policies.md) - List the metadata cascade policies on a folder
213214
* [`box metadata-query`](docs/metadata-query.md) - Create a search using SQL-like syntax to return items that match specific metadata
214215
* [`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/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)