Skip to content

Commit 2872493

Browse files
Merge branch 'ceng-355-httpsgithubcomcloudsmith-iocloudsmith-clipull171' of github.com:cloudsmith-io/cloudsmith-cli into ceng-355-httpsgithubcomcloudsmith-iocloudsmith-clipull171
2 parents cb080c4 + 14c7778 commit 2872493

File tree

17 files changed

+1011
-101
lines changed

17 files changed

+1011
-101
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99

1010
## [Unreleased]
1111

12+
## [1.3.1] - 2024-10-08
13+
14+
### Fixed
15+
16+
- Missing dependency from `setup.py` file ([#177](https://github.com/cloudsmith-io/cloudsmith-cli/pull/177))
17+
18+
## [1.3.0] - 2024-10-08
19+
20+
### Added
21+
22+
- The `auth` command, enabling users to authenticate against the API with their organization's configured SAML provider ([#174](https://github.com/cloudsmith-io/cloudsmith-cli/pull/174))
23+
1224
## [1.2.5] - 2024-06-11
1325

1426
### Added

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Please see the [changelog](https://github.com/cloudsmith-io/cloudsmith-cli/blob/
2828

2929
The CLI currently supports the following commands (and sub-commands):
3030

31+
- `auth`: Authenticate the CLI against an organization's SAML configuration.
3132
- `check`: Check rate limits and service status.
3233
- `copy`|`cp`: Copy a package to another repository.
3334
- `delete`|`rm`: Delete a package from a repository.
@@ -169,11 +170,26 @@ You can specify the following configuration options:
169170
- `api_key`: The API key for authenticating with the API.
170171

171172

172-
### Getting Your API Key
173+
### Authenticating
173174

174175
You'll need to provide authentication to Cloudsmith for any CLI actions that result in accessing private data or making changes to resources (such as pushing a new package to a repository)..
175176

176-
With the CLI this is simple to do. You can retrieve your API key using the `cloudsmith login` command:
177+
#### SAML authentication
178+
179+
You can authenticate using your organization's SAML provider, if configured, with the `cloudsmith auth` command:
180+
```
181+
cloudsmith auth --owner example
182+
Beginning authentication for the example org ...
183+
Opening your organization's SAML IDP URL in your browser: https://example.com/some-saml-idp
184+
185+
Starting webserver to begin authentication ...
186+
187+
Authentication complete
188+
```
189+
190+
#### Getting Your API Key
191+
192+
You can retrieve your API key using the `cloudsmith login` command:
177193

178194
```
179195
cloudsmith login

cloudsmith_cli/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""CLI/Commands - Import all commands."""
22

33
from . import (
4+
auth,
45
check,
56
copy,
67
delete,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""CLI/Commands - Authenticate the user."""
2+
import webbrowser
3+
4+
import click
5+
6+
from .. import decorators, validators
7+
from ..exceptions import handle_api_exceptions
8+
from ..saml import get_idp_url
9+
from ..webserver import AuthenticationWebRequestHandler, AuthenticationWebServer
10+
from .main import main
11+
12+
13+
@main.command(aliases=["auth"])
14+
@click.option(
15+
"-o",
16+
"--owner",
17+
metavar="OWNER",
18+
required=True,
19+
callback=validators.validate_owner,
20+
prompt=True,
21+
help="The name of the Cloudsmith organization to authenticate with.",
22+
)
23+
@decorators.common_cli_config_options
24+
@decorators.common_cli_output_options
25+
@decorators.initialise_api
26+
@click.pass_context
27+
def authenticate(ctx, opts, owner):
28+
"""Authenticate to Cloudsmith using the org's SAML setup."""
29+
owner = owner[0]
30+
api_host = opts.api_config.host
31+
32+
click.echo(
33+
"Beginning authentication for the {owner} org ... ".format(
34+
owner=click.style(owner, bold=True)
35+
)
36+
)
37+
38+
context_message = "Failed to authenticate via SSO!"
39+
with handle_api_exceptions(ctx, opts=opts, context_msg=context_message):
40+
idp_url = get_idp_url(api_host, owner)
41+
click.echo(
42+
"Opening your organization's SAML IDP URL in your browser: %(idp_url)s"
43+
% {"idp_url": click.style(idp_url, bold=True)}
44+
)
45+
click.echo()
46+
webbrowser.open(idp_url)
47+
click.echo("Starting webserver to begin authentication ... ")
48+
49+
auth_server = AuthenticationWebServer(
50+
("127.0.0.1", 12400),
51+
AuthenticationWebRequestHandler,
52+
api_host=api_host,
53+
owner=owner,
54+
)
55+
auth_server.handle_request()
56+
57+
click.echo()
58+
click.secho("Authentication complete", fg="green")

cloudsmith_cli/cli/commands/login.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
"""CLI/Commands - Get an API token."""
22

33
import collections
4-
import getpass
54
import stat
65

76
import click
8-
import keyring
97

108
from ...core.api.user import get_user_token
119
from ...core.utils import get_help_website
@@ -103,30 +101,6 @@ def create_config_files(ctx, opts, api_key):
103101
return create, has_errors
104102

105103

106-
def get_username():
107-
return getpass.getuser()
108-
109-
110-
def store_access_token(access_token):
111-
username = get_username()
112-
keyring.set_password("cloudsmith_cli-access_token", username, access_token)
113-
114-
115-
def get_access_token():
116-
username = get_username()
117-
return keyring.get_password("cloudsmith_cli-access_token", username)
118-
119-
120-
def store_refresh_token(refresh_token):
121-
username = get_username()
122-
keyring.set_password("cloudsmith_cli-refresh_token", username, refresh_token)
123-
124-
125-
def get_refresh_token():
126-
username = get_username()
127-
return keyring.get_password("cloudsmith_cli-refresh_token", username)
128-
129-
130104
@main.command(aliases=["token"])
131105
@click.option(
132106
"-l",

cloudsmith_cli/cli/exceptions.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import click
88

99
from ..core.api.exceptions import ApiException
10+
from ..core.keyring import get_access_token
1011

1112

1213
@contextlib.contextmanager
@@ -132,6 +133,10 @@ def get_401_error_hint(ctx, opts, exc):
132133
"you don't have the permission to perform this action."
133134
)
134135

136+
access_token = get_access_token(opts.api_host)
137+
if access_token:
138+
return "Since you have an SSO access token set, this probably means that it has expired. Try getting a new token with 'cloudsmith auth', then try again."
139+
135140
if ctx.info_name == "token":
136141
# This is already the token command
137142
return (
@@ -141,9 +146,9 @@ def get_401_error_hint(ctx, opts, exc):
141146
)
142147

143148
return (
144-
"You don't have an API key set, but it seems this action "
149+
"You don't have an API key or access token set, but it seems this action "
145150
"requires authentication - Try getting your API key via "
146-
"'cloudsmith token' first then try again."
151+
"'cloudsmith token', or access token via 'cloudsmith auth', then try again."
147152
)
148153

149154

cloudsmith_cli/cli/saml.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from urllib.parse import urlencode
2+
3+
import requests
4+
5+
from ..core.api.exceptions import ApiException
6+
7+
8+
def get_idp_url(api_host, owner):
9+
org_saml_url = "{api_host}/orgs/{owner}/saml/?{params}".format(
10+
api_host=api_host,
11+
owner=owner,
12+
params=urlencode({"redirect_url": "http://localhost:12400"}),
13+
)
14+
15+
org_saml_response = requests.get(org_saml_url, timeout=30)
16+
17+
try:
18+
org_saml_response.raise_for_status()
19+
except requests.RequestException as exc:
20+
raise ApiException(
21+
org_saml_response.status_code,
22+
headers=exc.response.headers,
23+
body=exc.response.content,
24+
)
25+
26+
return org_saml_response.json().get("redirect_url")
27+
28+
29+
def exchange_2fa_token(api_host, two_factor_token, totp_token):
30+
exchange_data = {"two_factor_token": two_factor_token, "totp_token": totp_token}
31+
exchange_url = "{api_host}/user/two-factor/".format(api_host=api_host)
32+
33+
exchange_response = requests.post(
34+
exchange_url,
35+
data=exchange_data,
36+
headers={
37+
"Authorization": "Bearer {two_factor_token}".format(
38+
two_factor_token=two_factor_token
39+
)
40+
},
41+
timeout=30,
42+
)
43+
44+
try:
45+
exchange_response.raise_for_status()
46+
except requests.RequestException as exc:
47+
raise ApiException(
48+
exchange_response.status_code,
49+
headers=exc.response.headers,
50+
body=exc.response.content,
51+
)
52+
53+
exchange_data = exchange_response.json()
54+
access_token = exchange_data.get("access_token")
55+
refresh_token = exchange_data.get("refresh_token")
56+
57+
return (access_token, refresh_token)
58+
59+
60+
def refresh_access_token(api_host, access_token, refresh_token):
61+
data = {"refresh_token": refresh_token}
62+
url = "{api_host}/user/refresh-token/".format(api_host=api_host)
63+
64+
response = requests.post(
65+
url,
66+
data=data,
67+
headers={
68+
"Authorization": "Bearer {access_token}".format(access_token=access_token)
69+
},
70+
timeout=30,
71+
)
72+
73+
try:
74+
response.raise_for_status()
75+
except requests.RequestException as exc:
76+
raise ApiException(
77+
response.status_code,
78+
headers=exc.response.headers,
79+
body=exc.response.content,
80+
)
81+
82+
response_data = response.json()
83+
access_token = response_data.get("access_token")
84+
refresh_token = response_data.get("refresh_token")
85+
86+
return (access_token, refresh_token)

0 commit comments

Comments
 (0)