Skip to content

Commit 9827027

Browse files
Revoke OAuth token on logout; report default status on login
logout now revokes the refresh token at the authorization server (RFC 7009) before removing the saved configuration; the local entry is removed even if revocation fails. login reports whether the new login became the default and, when it did not, prints the command to make it the default. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 11efb4c commit 9827027

7 files changed

Lines changed: 114 additions & 18 deletions

File tree

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Python 3.8 or later. You can install Python from [https://www.python.org/](http
2525
cld login
2626
```
2727
28-
This opens your browser to authorize the CLI, then saves the login as a configuration (named after the cloud) and sets it as the default. No API secret is stored on disk — the saved login holds a short-lived token that the CLI refreshes automatically.
28+
This opens your browser to authorize the CLI, then saves the login as a configuration (named after the cloud) and sets it as the default. The CLI refreshes the token automatically, and you can remove the login at any time with `cld logout`.
2929
3030
**Option B — Set your CLOUDINARY\_URL environment variable.** For example:
3131
* On Mac or Linux:<br>`export CLOUDINARY_URL=cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyzA@cloud_name`
@@ -58,7 +58,7 @@ Usage: cld [cli options] [command] [command options] [method] [method parameters
5858
```
5959
cld --help # Lists available commands.
6060
cld login # Logs in to a Cloudinary account via OAuth in your browser.
61-
cld logout # Removes a saved OAuth login.
61+
cld logout # Revokes and removes a saved OAuth login.
6262
cld search --help # Shows usage for the Search API.
6363
cld admin # Lists Admin API methods.
6464
cld uploader # Lists Upload API methods.
@@ -255,7 +255,7 @@ Whereas using the saved configuration "accountx":
255255
cld -C accountx admin usage
256256
```
257257

258-
_**Caution:** A saved API-key configuration stores your API secret in a local file. An OAuth login (see below) avoids this by storing a short-lived, auto-refreshed token instead._
258+
_**Caution:** Creating a saved configuration may put your credentials at risk as they are stored in a local plain text file. This applies to both API-key configurations and OAuth logins._
259259

260260
You can create, delete and list saved configurations using the `config` command.
261261

@@ -272,11 +272,13 @@ Instead of saving an API key and secret, you can log in to a Cloudinary account
272272
```
273273
cld login # Log in and save the configuration (named after the cloud).
274274
cld login my-account # Save the login under a specific name.
275-
cld logout # Choose a saved OAuth login to remove.
276-
cld logout my-account # Remove a specific saved OAuth login.
275+
cld logout # Choose a saved OAuth login to log out of.
276+
cld logout my-account # Log out of a specific saved OAuth login.
277277
```
278278

279-
Once saved, an OAuth login is selected with `-C <name>` just like any other saved configuration.
279+
The first login becomes the default automatically. When other configurations already exist, the new login is saved but not made the default; `cld login` tells you so and prints the command to make it the default. Once saved, an OAuth login is selected with `-C <name>` just like any other saved configuration.
280+
281+
`cld logout` revokes the login's token at the server and removes the saved configuration. If the token cannot be revoked (for example, you are offline), the saved configuration is still removed.
280282

281283
### Choosing a default configuration
282284

cloudinary_cli/auth/__init__.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def login(region=None, name=None, set_default=False):
3737
"""
3838
Run the interactive browser login and persist the resulting session as a named config entry.
3939
40-
Returns the saved config name, or None on failure.
40+
Returns (config_name, is_default), where is_default is True when this login was made the default
41+
configuration (explicitly with set_default, or automatically as the sole login).
4142
"""
4243
if name and is_reserved_config_name(name):
4344
raise RuntimeError(f"'{name}' is a reserved configuration name.")
@@ -48,9 +49,10 @@ def login(region=None, name=None, set_default=False):
4849
config_name = name or _derive_config_name(session.cloud_name, region)
4950
update_config({config_name: to_cloudinary_url(session)})
5051

51-
if set_default or _should_auto_default(config_name):
52+
is_default = bool(set_default or _should_auto_default(config_name))
53+
if is_default:
5254
set_default_config(config_name)
53-
return config_name
55+
return config_name, is_default
5456

5557

5658
def _should_auto_default(name):
@@ -71,14 +73,37 @@ def _should_auto_default(name):
7173

7274

7375
def logout(name):
74-
"""Remove a saved OAuth login by name. Returns "removed", "not_found", or "not_oauth"."""
76+
"""
77+
Log out of a saved OAuth login by name: revoke its refresh token at the authorization server,
78+
then remove the saved configuration. The local entry is always removed even if revocation fails
79+
(offline, server error), so logout never leaves a stale entry behind.
80+
81+
Returns "removed" (revoked and removed), "revoke_failed" (removed locally but the token could not
82+
be revoked), "not_found", or "not_oauth".
83+
"""
7584
saved = load_config()
7685
if name not in saved:
7786
return "not_found"
7887
if not is_oauth_url(saved[name]):
7988
return "not_oauth"
89+
90+
revoked = _revoke_login(name, saved[name])
8091
remove_config_keys(name)
81-
return "removed"
92+
return "removed" if revoked else "revoke_failed"
93+
94+
95+
def _revoke_login(name, url):
96+
"""Best-effort revocation of a saved login's refresh token. Returns True on success (or when
97+
there is nothing to revoke), False if the revoke request failed."""
98+
session = from_cloudinary_url(url)
99+
if not session.refresh_token:
100+
return True
101+
try:
102+
flow.revoke(session.refresh_token, session.region)
103+
return True
104+
except requests.RequestException as e:
105+
log_exception(e, debug_message=f"Could not revoke the OAuth token for '{name}'")
106+
return False
82107

83108

84109
def refresh_url_if_stale(name, url, force=False):

cloudinary_cli/auth/flow.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from cloudinary_cli.defaults import (
1111
oauth_authorize_url_for_region,
1212
oauth_token_url_for_region,
13+
oauth_revoke_url_for_region,
1314
OAUTH_CLIENT_ID,
1415
OAUTH_SCOPES,
1516
OAUTH_HTTP_TIMEOUT_SECONDS,
@@ -58,3 +59,14 @@ def refresh(refresh_token, region):
5859
}, timeout=OAUTH_HTTP_TIMEOUT_SECONDS)
5960
resp.raise_for_status()
6061
return resp.json()
62+
63+
64+
def revoke(token, region, token_type_hint="refresh_token"):
65+
"""Revoke a token at the authorization server (RFC 7009). Revoking the refresh token ends the
66+
offline-access grant so it can no longer mint new access tokens."""
67+
resp = requests.post(oauth_revoke_url_for_region(region), data={
68+
"token": token,
69+
"token_type_hint": token_type_hint,
70+
"client_id": OAUTH_CLIENT_ID,
71+
}, timeout=OAUTH_HTTP_TIMEOUT_SECONDS)
72+
resp.raise_for_status()

cloudinary_cli/core/auth.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,22 @@
1616
"config is given.")
1717
def login(name, region, set_default):
1818
try:
19-
config_name = run_login(region=region, name=name, set_default=set_default)
19+
config_name, is_default = run_login(region=region, name=name, set_default=set_default)
2020
except Exception as e:
2121
log_exception(e, "Login failed")
2222
return False
2323

2424
logger.info(f"Logged in. Saved as '{config_name}'.")
25-
logger.info(f"Example usage: cld -C {config_name} <command>")
25+
if is_default:
26+
logger.info(f"This is now the default configuration. Run `cld <command>` to use it, "
27+
f"or `cld -C {config_name} <command>` to select it explicitly.")
28+
else:
29+
logger.info(f"Run `cld -C {config_name} <command>` to use it, "
30+
f"or make it the default with `cld config -d {config_name}`.")
2631
return True
2732

2833

29-
@command("logout", help="Log out by removing a saved OAuth configuration. "
34+
@command("logout", help="Log out: revoke a saved OAuth login's token and remove its configuration. "
3035
"Run without a name to choose from the saved logins.")
3136
@argument("name", required=False)
3237
def logout(name):
@@ -39,7 +44,10 @@ def logout(name):
3944

4045
status = run_logout(name)
4146
if status == "removed":
42-
logger.info(f"Logged out of '{name}'.")
47+
logger.info(f"Logged out of '{name}'. Its token was revoked and the saved login removed.")
48+
elif status == "revoke_failed":
49+
logger.warning(f"Removed '{name}', but could not revoke its token at the server "
50+
f"(it may still be valid until it expires).")
4351
elif status == "not_oauth":
4452
logger.error(f"'{name}' is not an OAuth login; refusing to remove it. "
4553
f"Use `config -rm {name}` to delete a saved configuration.")

cloudinary_cli/defaults.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ def oauth_token_url_for_region(region):
6161
return f'{oauth_base_url_for_region(region)}/oauth2/token'
6262

6363

64+
def oauth_revoke_url_for_region(region):
65+
return f'{oauth_base_url_for_region(region)}/oauth2/revoke'
66+
67+
6468
CLOUDINARY_REGION = normalize_region(os.environ.get('CLOUDINARY_REGION'))
6569

6670
# Public PKCE client (no secret). Overridable for testing against a non-prod authorization server

test/test_auth_flow.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,15 @@ def test_refresh_posts_refresh_token(self):
5555
self.assertEqual("refresh_token", data["grant_type"])
5656
self.assertEqual("rt_abc", data["refresh_token"])
5757
self.assertIn("timeout", post.call_args.kwargs)
58+
59+
def test_revoke_posts_token_to_revoke_endpoint(self):
60+
resp = MagicMock()
61+
with patch("cloudinary_cli.auth.flow.requests.post", return_value=resp) as post:
62+
flow.revoke("rt_abc", "api-eu")
63+
self.assertEqual("https://oauth.cloudinary.com/oauth2/revoke", post.call_args.args[0])
64+
data = post.call_args.kwargs["data"]
65+
self.assertEqual("rt_abc", data["token"])
66+
self.assertEqual("refresh_token", data["token_type_hint"])
67+
self.assertIn("client_id", data)
68+
self.assertIn("timeout", post.call_args.kwargs)
69+
resp.raise_for_status.assert_called_once()

test/test_cli_config_oauth.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,21 @@ def test_removes_oauth_login(self):
4242
from cloudinary_cli.auth import logout
4343
saved = {"eu-cloud": _oauth_url()}
4444
with patch("cloudinary_cli.auth.load_config", return_value=saved), \
45-
patch("cloudinary_cli.auth.remove_config_keys") as remove:
45+
patch("cloudinary_cli.auth.remove_config_keys") as remove, \
46+
patch("cloudinary_cli.auth.flow.revoke") as revoke:
4647
self.assertEqual("removed", logout("eu-cloud"))
4748
remove.assert_called_once_with("eu-cloud")
49+
revoke.assert_called_once_with("rt_secret_value", "api-eu")
50+
51+
def test_revoke_failure_still_removes_locally(self):
52+
import requests
53+
from cloudinary_cli.auth import logout
54+
saved = {"eu-cloud": _oauth_url()}
55+
with patch("cloudinary_cli.auth.load_config", return_value=saved), \
56+
patch("cloudinary_cli.auth.remove_config_keys") as remove, \
57+
patch("cloudinary_cli.auth.flow.revoke", side_effect=requests.ConnectionError()):
58+
self.assertEqual("revoke_failed", logout("eu-cloud"))
59+
remove.assert_called_once_with("eu-cloud") # local entry removed despite revoke failure
4860

4961
def test_refuses_non_oauth_config(self):
5062
from cloudinary_cli.auth import logout
@@ -71,7 +83,8 @@ def test_lists_only_oauth_and_removes_selected(self):
7183
saved = {"mykey": "cloudinary://key:secret@cloud",
7284
"cloud-a": _oauth_url("cloud-a"), "cloud-b": _oauth_url("cloud-b")}
7385
with patch("cloudinary_cli.auth.load_config", return_value=saved), \
74-
patch("cloudinary_cli.auth.remove_config_keys") as remove:
86+
patch("cloudinary_cli.auth.remove_config_keys") as remove, \
87+
patch("cloudinary_cli.auth.flow.revoke"):
7588
result = self.runner.invoke(cli, ["logout"], input="2\n")
7689
self.assertIn("cloud-a", result.output)
7790
self.assertIn("cloud-b", result.output)
@@ -150,8 +163,17 @@ def test_auto_default_when_sole_config_no_env_no_default(self):
150163
with self._patches({"eu-cloud": _oauth_url()}), \
151164
patch("cloudinary_cli.auth.set_default_config") as set_default, \
152165
patch("cloudinary_cli.auth.get_default_config_name", return_value=None):
153-
auth.login(region="eu", name="eu-cloud")
166+
name, is_default = auth.login(region="eu", name="eu-cloud")
154167
set_default.assert_called_once_with("eu-cloud")
168+
self.assertEqual(("eu-cloud", True), (name, is_default))
169+
170+
def test_returns_not_default_when_other_configs_exist(self):
171+
from cloudinary_cli import auth
172+
with self._patches({"eu-cloud": _oauth_url(), "other": _oauth_url("other")}), \
173+
patch("cloudinary_cli.auth.set_default_config"), \
174+
patch("cloudinary_cli.auth.get_default_config_name", return_value=None):
175+
name, is_default = auth.login(region="eu", name="eu-cloud")
176+
self.assertEqual(("eu-cloud", False), (name, is_default))
155177

156178
def test_no_auto_default_when_other_configs_exist(self):
157179
from cloudinary_cli import auth
@@ -184,6 +206,17 @@ def test_reserved_name_rejected(self):
184206
with self.assertRaises(RuntimeError):
185207
auth.login(region="eu", name="__default__")
186208

209+
def test_cli_message_when_default(self):
210+
with patch("cloudinary_cli.core.auth.run_login", return_value=("tttt", True)):
211+
result = CliRunner().invoke(cli, ["login", "tttt"])
212+
self.assertIn("default configuration", result.output)
213+
214+
def test_cli_message_when_not_default_shows_how_to_default(self):
215+
with patch("cloudinary_cli.core.auth.run_login", return_value=("tttt", False)):
216+
result = CliRunner().invoke(cli, ["login", "tttt"])
217+
self.assertIn("cld -C tttt", result.output)
218+
self.assertIn("cld config -d tttt", result.output) # how to make it default
219+
187220

188221
class TestConfigSecretMasking(_RestoresSdkConfig):
189222
"""show_cloudinary_config must never print a secret in the clear."""

0 commit comments

Comments
 (0)