Skip to content

Commit 391c058

Browse files
authored
Merge pull request #404 from UiPath/feat/client_credentials_auth_flow
feat: add client credentials unattended auth flow
2 parents a0400cd + ef9e00d commit 391c058

2 files changed

Lines changed: 208 additions & 2 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from typing import Optional
2+
from urllib.parse import urlparse
3+
4+
import httpx
5+
6+
from .._utils._console import ConsoleLogger
7+
from ._models import TokenData
8+
from ._utils import parse_access_token, update_env_file
9+
10+
console = ConsoleLogger()
11+
12+
13+
class ClientCredentialsService:
14+
"""Service for client credentials authentication flow."""
15+
16+
def __init__(self, domain: str):
17+
self.domain = domain
18+
19+
def get_token_url(self) -> str:
20+
"""Get the token URL for the specified domain."""
21+
match self.domain:
22+
case "alpha":
23+
return "https://alpha.uipath.com/identity_/connect/token"
24+
case "staging":
25+
return "https://staging.uipath.com/identity_/connect/token"
26+
case _: # cloud (default)
27+
return "https://cloud.uipath.com/identity_/connect/token"
28+
29+
def _is_valid_domain_or_subdomain(self, hostname: str, domain: str) -> bool:
30+
"""Check if hostname is either an exact match or a valid subdomain of the domain.
31+
32+
Args:
33+
hostname: The hostname to check
34+
domain: The domain to validate against
35+
36+
Returns:
37+
True if hostname is valid domain or subdomain, False otherwise
38+
"""
39+
return hostname == domain or hostname.endswith(f".{domain}")
40+
41+
def extract_domain_from_base_url(self, base_url: str) -> str:
42+
"""Extract domain from base URL.
43+
44+
Args:
45+
base_url: The base URL to extract domain from
46+
47+
Returns:
48+
The domain (alpha, staging, or cloud)
49+
"""
50+
try:
51+
parsed = urlparse(base_url)
52+
hostname = parsed.hostname
53+
54+
if hostname:
55+
match hostname:
56+
case h if self._is_valid_domain_or_subdomain(h, "alpha.uipath.com"):
57+
return "alpha"
58+
case h if self._is_valid_domain_or_subdomain(
59+
h, "staging.uipath.com"
60+
):
61+
return "staging"
62+
case h if self._is_valid_domain_or_subdomain(h, "cloud.uipath.com"):
63+
return "cloud"
64+
65+
# Default to cloud if we can't determine
66+
return "cloud"
67+
except Exception:
68+
# Default to cloud if parsing fails
69+
return "cloud"
70+
71+
def authenticate(
72+
self, client_id: str, client_secret: str, scope: str = "OR.Execution"
73+
) -> Optional[TokenData]:
74+
"""Authenticate using client credentials flow.
75+
76+
Args:
77+
client_id: The client ID for authentication
78+
client_secret: The client secret for authentication
79+
scope: The scope for the token (default: OR.Execution)
80+
81+
Returns:
82+
Token data if successful, None otherwise
83+
"""
84+
token_url = self.get_token_url()
85+
86+
data = {
87+
"grant_type": "client_credentials",
88+
"client_id": client_id,
89+
"client_secret": client_secret,
90+
"scope": scope,
91+
}
92+
93+
try:
94+
with httpx.Client(timeout=30.0) as client:
95+
response = client.post(token_url, data=data)
96+
97+
match response.status_code:
98+
case 200:
99+
token_data = response.json()
100+
# Convert to our TokenData format
101+
return {
102+
"access_token": token_data["access_token"],
103+
"token_type": token_data.get("token_type", "Bearer"),
104+
"expires_in": token_data.get("expires_in", 3600),
105+
"scope": token_data.get("scope", scope),
106+
# Client credentials flow doesn't provide these, but we need them for compatibility
107+
"refresh_token": "",
108+
"id_token": "",
109+
}
110+
case 400:
111+
console.error(
112+
"Invalid client credentials or request parameters."
113+
)
114+
return None
115+
case 401:
116+
console.error("Unauthorized: Invalid client credentials.")
117+
return None
118+
case _:
119+
console.error(
120+
f"Authentication failed: {response.status_code} - {response.text}"
121+
)
122+
return None
123+
124+
except httpx.RequestError as e:
125+
console.error(f"Network error during authentication: {e}")
126+
return None
127+
except Exception as e:
128+
console.error(f"Unexpected error during authentication: {e}")
129+
return None
130+
131+
def setup_environment(self, token_data: TokenData, base_url: str):
132+
"""Setup environment variables for client credentials authentication.
133+
134+
Args:
135+
token_data: The token data from authentication
136+
base_url: The base URL for the UiPath instance
137+
"""
138+
parsed_access_token = parse_access_token(token_data["access_token"])
139+
140+
env_vars = {
141+
"UIPATH_ACCESS_TOKEN": token_data["access_token"],
142+
"UIPATH_URL": base_url,
143+
"UIPATH_ORGANIZATION_ID": parsed_access_token.get("prt_id", ""),
144+
"UIPATH_TENANT_ID": "",
145+
}
146+
147+
update_env_file(env_vars)

src/uipath/_cli/cli_auth.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ..telemetry import track
1111
from ._auth._auth_server import HTTPServer
12+
from ._auth._client_credentials import ClientCredentialsService
1213
from ._auth._oidc_utils import get_auth_config, get_auth_url
1314
from ._auth._portal_service import PortalService, select_tenant
1415
from ._auth._utils import update_auth_file, update_env_file
@@ -64,9 +65,67 @@ def set_port():
6465
required=False,
6566
help="Force new token",
6667
)
68+
@click.option(
69+
"--client-id",
70+
required=False,
71+
help="Client ID for client credentials authentication (unattended mode)",
72+
)
73+
@click.option(
74+
"--client-secret",
75+
required=False,
76+
help="Client secret for client credentials authentication (unattended mode)",
77+
)
78+
@click.option(
79+
"--base-url",
80+
required=False,
81+
help="Base URL for the UiPath tenant instance (required for client credentials)",
82+
)
6783
@track
68-
def auth(domain, force: None | bool = False):
69-
"""Authenticate with UiPath Cloud Platform."""
84+
def auth(
85+
domain,
86+
force: None | bool = False,
87+
client_id: str = None,
88+
client_secret: str = None,
89+
base_url: str = None,
90+
):
91+
"""Authenticate with UiPath Cloud Platform.
92+
93+
Interactive mode (default): Opens browser for OAuth authentication.
94+
Unattended mode: Use --client-id, --client-secret and --base-url for client credentials flow.
95+
"""
96+
# Check if client credentials are provided for unattended authentication
97+
if client_id and client_secret:
98+
if not base_url:
99+
console.error(
100+
"--base-url is required when using client credentials authentication."
101+
)
102+
return
103+
104+
with console.spinner("Authenticating with client credentials ..."):
105+
# Create service instance
106+
credentials_service = ClientCredentialsService(domain)
107+
108+
# If base_url is provided, extract domain from it to override the CLI domain parameter
109+
if base_url:
110+
extracted_domain = credentials_service.extract_domain_from_base_url(
111+
base_url
112+
)
113+
credentials_service.domain = extracted_domain
114+
115+
token_data = credentials_service.authenticate(client_id, client_secret)
116+
117+
if token_data:
118+
credentials_service.setup_environment(token_data, base_url)
119+
console.success(
120+
"Client credentials authentication successful.",
121+
)
122+
else:
123+
console.error(
124+
"Client credentials authentication failed. Please check your credentials.",
125+
)
126+
return
127+
128+
# Interactive authentication flow (existing logic)
70129
with console.spinner("Authenticating with UiPath ..."):
71130
portal_service = PortalService(domain)
72131

0 commit comments

Comments
 (0)