Skip to content

Commit 13a0d10

Browse files
committed
feat: add client credentials unattended auth flow
1 parent 6af2bb0 commit 13a0d10

File tree

2 files changed

+189
-2
lines changed

2 files changed

+189
-2
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
if self.domain == "alpha":
22+
return "https://alpha.uipath.com/identity_/connect/token"
23+
elif self.domain == "staging":
24+
return "https://staging.uipath.com/identity_/connect/token"
25+
else: # cloud (default)
26+
return "https://cloud.uipath.com/identity_/connect/token"
27+
28+
def extract_domain_from_base_url(self, base_url: str) -> str:
29+
"""Extract domain from base URL.
30+
31+
Args:
32+
base_url: The base URL to extract domain from
33+
34+
Returns:
35+
The domain (alpha, staging, or cloud)
36+
"""
37+
try:
38+
parsed = urlparse(base_url)
39+
hostname = parsed.hostname
40+
41+
if hostname:
42+
if "alpha.uipath.com" in hostname:
43+
return "alpha"
44+
elif "staging.uipath.com" in hostname:
45+
return "staging"
46+
elif "cloud.uipath.com" in hostname:
47+
return "cloud"
48+
49+
# Default to cloud if we can't determine
50+
return "cloud"
51+
except Exception:
52+
# Default to cloud if parsing fails
53+
return "cloud"
54+
55+
def authenticate(
56+
self, client_id: str, client_secret: str, scope: str = "OR.Execution"
57+
) -> Optional[TokenData]:
58+
"""Authenticate using client credentials flow.
59+
60+
Args:
61+
client_id: The client ID for authentication
62+
client_secret: The client secret for authentication
63+
scope: The scope for the token (default: OR.Execution)
64+
65+
Returns:
66+
Token data if successful, None otherwise
67+
"""
68+
token_url = self.get_token_url()
69+
70+
data = {
71+
"grant_type": "client_credentials",
72+
"client_id": client_id,
73+
"client_secret": client_secret,
74+
"scope": scope,
75+
}
76+
77+
try:
78+
with httpx.Client(timeout=30.0) as client:
79+
response = client.post(token_url, data=data)
80+
81+
if response.status_code == 200:
82+
token_data = response.json()
83+
# Convert to our TokenData format
84+
return {
85+
"access_token": token_data["access_token"],
86+
"token_type": token_data.get("token_type", "Bearer"),
87+
"expires_in": token_data.get("expires_in", 3600),
88+
"scope": token_data.get("scope", scope),
89+
# Client credentials flow doesn't provide these, but we need them for compatibility
90+
"refresh_token": "",
91+
"id_token": "",
92+
}
93+
elif response.status_code == 400:
94+
console.error("Invalid client credentials or request parameters.")
95+
return None
96+
elif response.status_code == 401:
97+
console.error("Unauthorized: Invalid client credentials.")
98+
return None
99+
else:
100+
console.error(
101+
f"Authentication failed: {response.status_code} - {response.text}"
102+
)
103+
return None
104+
105+
except httpx.RequestError as e:
106+
console.error(f"Network error during authentication: {e}")
107+
return None
108+
except Exception as e:
109+
console.error(f"Unexpected error during authentication: {e}")
110+
return None
111+
112+
def setup_environment(self, token_data: TokenData, base_url: str):
113+
"""Setup environment variables for client credentials authentication.
114+
115+
Args:
116+
token_data: The token data from authentication
117+
base_url: The base URL for the UiPath instance
118+
"""
119+
parsed_access_token = parse_access_token(token_data["access_token"])
120+
121+
env_vars = {
122+
"UIPATH_ACCESS_TOKEN": token_data["access_token"],
123+
"UIPATH_URL": base_url,
124+
"UIPATH_ORGANIZATION_ID": parsed_access_token.get("prt_id", ""),
125+
"UIPATH_TENANT_ID": "",
126+
}
127+
128+
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)