Skip to content

Commit 706a01b

Browse files
committed
feat(auth): add support for custom urls
1 parent 9f88719 commit 706a01b

8 files changed

Lines changed: 1034 additions & 861 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.1.23"
3+
version = "2.1.24"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.10"

src/uipath/_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import click
55

6-
from .cli_auth import auth as auth # type: ignore
6+
from .cli_auth import auth as auth
77
from .cli_deploy import deploy as deploy # type: ignore
88
from .cli_eval import eval as eval # type: ignore
99
from .cli_init import init as init # type: ignore

src/uipath/_cli/_auth/_oidc_utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,8 @@ def get_auth_url(domain: str) -> tuple[str, str, str]:
6565
}
6666

6767
query_string = urlencode(query_params)
68-
url = f"https://{domain}.uipath.com/identity_/connect/authorize?{query_string}"
68+
if domain.startswith("http"):
69+
url = f"{domain}/identity_/connect/authorize?{query_string}"
70+
else:
71+
url = f"https://{domain}.uipath.com/identity_/connect/authorize?{query_string}"
6972
return url, code_verifier, state

src/uipath/_cli/_auth/_portal_service.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ def get_tenants_and_organizations(self) -> TenantsAndOrganizationInfoResponse:
6565
if self._client is None:
6666
raise RuntimeError("HTTP client is not initialized")
6767

68-
url = f"https://{self.domain}.uipath.com/{self.prt_id}/portal_/api/filtering/leftnav/tenantsAndOrganizationInfo"
68+
if self.domain and self.domain.startswith("http"):
69+
url = f"{self.domain}/{self.prt_id}/portal_/api/filtering/leftnav/tenantsAndOrganizationInfo"
70+
else:
71+
url = f"https://{self.domain}.uipath.com/{self.prt_id}/portal_/api/filtering/leftnav/tenantsAndOrganizationInfo"
6972
response = self._client.get(
7073
url, headers={"Authorization": f"Bearer {self.access_token}"}
7174
)
@@ -213,12 +216,19 @@ def select_tenant(
213216
account_name = tenants_and_organizations["organization"]["name"]
214217
console.info(f"Selected tenant: {click.style(tenant_name, fg='cyan')}")
215218

219+
if domain.startswith("http"):
220+
base_url = domain
221+
else:
222+
base_url = f"https://{domain if domain else 'cloud'}.uipath.com"
223+
224+
uipath_url = f"{base_url}/{account_name}/{tenant_name}"
225+
216226
update_env_file(
217227
{
218-
"UIPATH_URL": f"https://{domain if domain else 'alpha'}.uipath.com/{account_name}/{tenant_name}",
228+
"UIPATH_URL": uipath_url,
219229
"UIPATH_TENANT_ID": tenants_and_organizations["tenants"][tenant_idx]["id"],
220230
"UIPATH_ORGANIZATION_ID": tenants_and_organizations["organization"]["id"],
221231
}
222232
)
223233

224-
return f"https://{domain if domain else 'alpha'}.uipath.com/{account_name}/{tenant_name}"
234+
return uipath_url

src/uipath/_cli/_auth/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,12 @@ <h1 class="auth-title" id="main-title">Authenticate CLI</h1>
477477
formData.append('client_id', '__PY_REPLACE_CLIENT_ID__');
478478
formData.append('code_verifier', codeVerifier);
479479

480-
const response = await fetch('https://__PY_REPLACE_DOMAIN__.uipath.com/identity_/connect/token', {
480+
const domain = '__PY_REPLACE_DOMAIN__';
481+
const tokenUrl = domain.startsWith('http')
482+
? `${domain}/identity_/connect/token`
483+
: `https://${domain}.uipath.com/identity_/connect/token`;
484+
485+
const response = await fetch(tokenUrl, {
481486
method: 'POST',
482487
headers: {
483488
'Content-Type': 'application/x-www-form-urlencoded'

src/uipath/_cli/cli_auth.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
# type: ignore
21
import asyncio
32
import json
43
import os
54
import socket
65
import webbrowser
6+
from urllib.parse import urlparse
77

88
import click
99
from dotenv import load_dotenv
@@ -33,10 +33,10 @@ def is_port_in_use(port: int) -> bool:
3333

3434
def set_port():
3535
auth_config = get_auth_config()
36-
port = auth_config.get("port", 8104)
37-
port_option_one = auth_config.get("portOptionOne", 8104)
38-
port_option_two = auth_config.get("portOptionTwo", 8055)
39-
port_option_three = auth_config.get("portOptionThree", 42042)
36+
port = int(auth_config.get("port", 8104))
37+
port_option_one = int(auth_config.get("portOptionOne", 8104)) # type: ignore
38+
port_option_two = int(auth_config.get("portOptionTwo", 8055)) # type: ignore
39+
port_option_three = int(auth_config.get("portOptionThree", 42042)) # type: ignore
4040
if is_port_in_use(port):
4141
if is_port_in_use(port_option_one):
4242
if is_port_in_use(port_option_two):
@@ -85,12 +85,15 @@ def set_port():
8585
def auth(
8686
domain,
8787
force: None | bool = False,
88-
client_id: str = None,
89-
client_secret: str = None,
90-
base_url: str = None,
88+
client_id: str | None = None,
89+
client_secret: str | None = None,
90+
base_url: str | None = None,
9191
):
9292
"""Authenticate with UiPath Cloud Platform.
9393
94+
The domain for authentication is determined by the UIPATH_URL environment variable if set.
95+
Otherwise, it can be specified with --cloud (default), --staging, or --alpha flags.
96+
9497
Interactive mode (default): Opens browser for OAuth authentication.
9598
Unattended mode: Use --client-id, --client-secret and --base-url for client credentials flow.
9699
@@ -99,6 +102,17 @@ def auth(
99102
- Set REQUESTS_CA_BUNDLE to specify a custom CA bundle for SSL verification
100103
- Set UIPATH_DISABLE_SSL_VERIFY to disable SSL verification (not recommended)
101104
"""
105+
uipath_url = os.getenv("UIPATH_URL")
106+
if uipath_url and domain == "cloud": # "cloud" is the default
107+
parsed_url = urlparse(uipath_url)
108+
if parsed_url.scheme and parsed_url.netloc:
109+
domain = f"{parsed_url.scheme}://{parsed_url.netloc}"
110+
else:
111+
console.error(
112+
f"Malformed UIPATH_URL: '{uipath_url}'. Please ensure it includes both scheme and netloc (e.g., 'https://cloud.uipath.com')."
113+
)
114+
return
115+
102116
# Check if client credentials are provided for unattended authentication
103117
if client_id and client_secret:
104118
if not base_url:

tests/cli/test_auth.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import os
2+
from unittest.mock import AsyncMock, patch
3+
4+
import pytest
5+
from click.testing import CliRunner
6+
7+
from uipath._cli.cli_auth import auth
8+
9+
"""
10+
Unit tests for the 'uipath auth' command.
11+
12+
This test suite covers the following scenarios for the authentication logic:
13+
14+
1. **UIPATH_URL Environment Variable**:
15+
Ensures the `auth` command correctly uses the domain from the `UIPATH_URL`
16+
environment variable when no specific environment flag is used.
17+
18+
2. **--alpha Flag**:
19+
Verifies that the `--alpha` flag correctly uses the 'alpha' environment and
20+
overrides the `UIPATH_URL` environment variable if it is set.
21+
22+
3. **--staging Flag**:
23+
Verifies that the `--staging` flag correctly uses the 'staging' environment and
24+
overrides the `UIPATH_URL` environment variable if it is set.
25+
26+
4. **--cloud Flag**:
27+
Checks that the `--cloud` flag works as expected, using the 'cloud' environment,
28+
when no `UIPATH_URL` environment variable is set.
29+
30+
5. **Default Behavior**:
31+
Confirms that the command defaults to the 'cloud' environment when no flags
32+
or environment variables are provided.
33+
"""
34+
35+
36+
@pytest.mark.parametrize(
37+
"scenario_name, cli_args, env_vars, expected_url_part, expected_select_tenant_return",
38+
[
39+
(
40+
"auth_with_uipath_url_env_variable",
41+
["--force"],
42+
{"UIPATH_URL": "https://custom.automationsuite.org/org/tenant"},
43+
"https://custom.automationsuite.org/identity_/connect/authorize",
44+
"https://custom.automationsuite.org/DefaultOrg/DefaultTenant",
45+
),
46+
(
47+
"auth_with_uipath_url_env_variable_with_trailing_slash",
48+
["--force"],
49+
{"UIPATH_URL": "https://custom.uipath.com/org/tenant/"},
50+
"https://custom.uipath.com/identity_/connect/authorize",
51+
"https://custom.uipath.com/DefaultOrg/DefaultTenant",
52+
),
53+
(
54+
"auth_with_alpha_flag",
55+
["--alpha", "--force"],
56+
{"UIPATH_URL": "https://custom.uipath.com/org/tenant"},
57+
"https://alpha.uipath.com/identity_/connect/authorize",
58+
"https://alpha.uipath.com/DefaultOrg/DefaultTenant",
59+
),
60+
(
61+
"auth_with_staging_flag",
62+
["--staging", "--force"],
63+
{"UIPATH_URL": "https://custom.uipath.com/org/tenant"},
64+
"https://staging.uipath.com/identity_/connect/authorize",
65+
"https://staging.uipath.com/DefaultOrg/DefaultTenant",
66+
),
67+
(
68+
"auth_with_cloud_flag",
69+
["--cloud", "--force"],
70+
{},
71+
"https://cloud.uipath.com/identity_/connect/authorize",
72+
"https://cloud.uipath.com/DefaultOrg/DefaultTenant",
73+
),
74+
(
75+
"auth_default_to_cloud",
76+
["--force"],
77+
{},
78+
"https://cloud.uipath.com/identity_/connect/authorize",
79+
"https://cloud.uipath.com/DefaultOrg/DefaultTenant",
80+
),
81+
],
82+
ids=[
83+
"uipath_url_env",
84+
"alpha_flag_overrides_env",
85+
"staging_flag_overrides_env",
86+
"cloud_flag",
87+
"default_to_cloud",
88+
"uipath_url_env_with_trailing_slash",
89+
],
90+
)
91+
def test_auth_scenarios(
92+
scenario_name, cli_args, env_vars, expected_url_part, expected_select_tenant_return
93+
):
94+
"""
95+
Test 'uipath auth' with different configurations.
96+
"""
97+
runner = CliRunner()
98+
with (
99+
patch("uipath._cli.cli_auth.webbrowser.open") as mock_open,
100+
patch("uipath._cli.cli_auth.HTTPServer") as mock_server,
101+
patch("uipath._cli.cli_auth.PortalService") as mock_portal_service,
102+
):
103+
mock_server.return_value.start = AsyncMock(
104+
return_value={"access_token": "test_token"}
105+
)
106+
mock_portal_service.return_value.__enter__.return_value.get_tenants_and_organizations.return_value = {
107+
"tenants": [{"name": "DefaultTenant", "id": "tenant-id"}],
108+
"organization": {"name": "DefaultOrg", "id": "org-id"},
109+
}
110+
mock_portal_service.return_value.__enter__.return_value.select_tenant.return_value = expected_select_tenant_return
111+
112+
with runner.isolated_filesystem():
113+
for key, value in env_vars.items():
114+
os.environ[key] = value
115+
116+
result = runner.invoke(auth, cli_args)
117+
118+
for key in env_vars:
119+
del os.environ[key]
120+
121+
assert result.exit_code == 0, (
122+
f"Scenario '{scenario_name}' failed with exit code {result.exit_code}: {result.output}"
123+
)
124+
mock_open.assert_called_once()
125+
call_args = mock_open.call_args[0][0]
126+
assert expected_url_part in call_args
127+
128+
129+
def test_auth_with_malformed_url():
130+
"""
131+
Test that 'uipath auth' handles a malformed UIPATH_URL gracefully.
132+
"""
133+
runner = CliRunner()
134+
with runner.isolated_filesystem():
135+
os.environ["UIPATH_URL"] = "custom.uipath.com"
136+
result = runner.invoke(auth, ["--force"])
137+
del os.environ["UIPATH_URL"]
138+
139+
assert result.exit_code == 1
140+
assert "Malformed UIPATH_URL" in result.output
141+
assert "custom.uipath.com" in result.output

0 commit comments

Comments
 (0)