Skip to content

Commit d455d0d

Browse files
committed
refactor: cli auth
1 parent 84ae313 commit d455d0d

19 files changed

Lines changed: 3316 additions & 1318 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# UiPath Coded Agent: Asset Value Checker
2+
3+
This project demonstrates how to create a Python-based UiPath Coded Agent that retrieves an asset from Orchestrator and validates its IntValue against custom rules.
4+
5+
## Overview
6+
7+
The agent uses the UiPath Python SDK to:
8+
9+
* Connect to UiPath Orchestrator as an external application
10+
* Authenticate via the Client Credentials flow (Client ID + Client Secret)
11+
* Retrieve a specific asset from a given folder
12+
* Check whether the asset has an integer value and validate it against a range (100–1000)
13+
* Return a descriptive message with the validation result
14+
15+
## How to Set Up
16+
17+
### Step 1: Install UiPath Python SDK
18+
19+
1. Open it with your prefered editor
20+
2. In terminal run:
21+
```bash
22+
uv init
23+
uv add uipath
24+
uv run uipath init
25+
```
26+
27+
### Step 2: Configure Environment Variables
28+
```bash
29+
UIPATH_CLIENT_SECRET=your-client-secret
30+
```
31+
32+
### Step 3: Understanding the Event Flow
33+
34+
When this agent runs, it will:
35+
1. Pass the configured input values (`asset_name` and `folder_path`) into the agent
36+
2. Connect to Orchestrator using Client Credentials (Client ID + Client Secret)
37+
3. Retrieve the specified asset from the given folder
38+
4. Check whether the asset contains an IntValue and validates it against the allowed range (100–1000)
39+
5. Return a message with the validation result
40+
41+
### Step 4: Publish Your Coded Agent
42+
43+
1. Use `uipath pack` and `uipath publish` to create and publish the package
44+
2. Create an Orchestrator Automation from the published process
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import dataclasses
2+
import dotenv
3+
import logging
4+
import os
5+
6+
from typing import Optional
7+
from uipath import UiPath
8+
from uipath.tracing import traced
9+
10+
dotenv.load_dotenv()
11+
logger = logging.getLogger(__name__)
12+
13+
UIPATH_CLIENT_ID = "your-client-id"
14+
UIPATH_SCOPE = "OR.Administration OR.Folders OR.Assets"
15+
UIPATH_CLIENT_SECRET = os.getenv("UIPATH_CLIENT_SECRET")
16+
17+
uipath = UiPath(
18+
client_id=UIPATH_CLIENT_ID,
19+
client_secret=UIPATH_CLIENT_SECRET,
20+
scope=UIPATH_SCOPE,
21+
)
22+
23+
@dataclasses.dataclass
24+
class AgentInput:
25+
"""Input data structure for the UiPath agent.
26+
27+
Attributes:
28+
asset_name (str): The name of the UiPath asset.
29+
folder_path (str): The folder path where the asset is located.
30+
"""
31+
asset_name: str
32+
folder_path: str
33+
34+
def get_asset(name: str, folder_path: str) -> Optional[object]:
35+
"""Retrieve an asset from UiPath.
36+
37+
Args:
38+
name (str): The asset name.
39+
folder_path (str): The UiPath folder path.
40+
41+
Returns:
42+
Optional[object]: The asset object if found, else None.
43+
"""
44+
return uipath.assets.retrieve(name=name, folder_path=folder_path)
45+
46+
def check_asset(asset: object) -> str:
47+
"""Check if an asset's IntValue is within a valid range.
48+
49+
Args:
50+
asset (object): The asset object.
51+
52+
Returns:
53+
str: Result message depending on asset state.
54+
"""
55+
if asset is None:
56+
return "Asset not found."
57+
58+
int_value = getattr(asset, "int_value", None)
59+
if int_value is None:
60+
return "Asset does not have an IntValue."
61+
62+
if 100 <= int_value <= 1000:
63+
return f"Asset '{asset.name}' has a valid IntValue: {int_value}"
64+
else:
65+
return f"Asset '{asset.name}' has an out-of-range IntValue: {int_value}"
66+
67+
@traced()
68+
def main(input: AgentInput) -> str:
69+
"""Main entry point for the agent.
70+
71+
Args:
72+
input (AgentInput): The input containing asset details.
73+
74+
Returns:
75+
str: Message with the result of the asset check.
76+
"""
77+
asset = get_asset(input.asset_name, input.folder_path)
78+
return check_asset(asset)
79+
80+
if __name__ == "__main__":
81+
input_data = AgentInput(asset_name="test-asset", folder_path="TestFolder")
82+
result = main(input_data)
83+
print(result)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[project]
2+
name = "asset-modifier-agent"
3+
version = "0.0.1"
4+
description = "asset-modifier-agent"
5+
authors = [{ name = "John Doe", email = "john.doe@myemail.com" }]
6+
dependencies = [
7+
"ipykernel>=6.30.1",
8+
"uipath>=2.1.62",
9+
]
10+
requires-python = ">=3.10"

samples/asset-modifier-agent/uv.lock

Lines changed: 1708 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 83 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,16 @@
11
import asyncio
2-
import json
32
import os
43
import webbrowser
5-
from socket import AF_INET, SOCK_STREAM, error, socket
64
from typing import Optional
7-
from urllib.parse import urlparse
85

96
from uipath._cli._auth._auth_server import HTTPServer
10-
from uipath._cli._auth._client_credentials import ClientCredentialsService
7+
from uipath._cli._auth._models import TokenData
118
from uipath._cli._auth._oidc_utils import OidcUtils
12-
from uipath._cli._auth._portal_service import (
13-
PortalService,
14-
get_tenant_id,
15-
select_tenant,
16-
)
17-
from uipath._cli._auth._url_utils import set_force_flag
18-
from uipath._cli._auth._utils import update_auth_file, update_env_file
9+
from uipath._cli._auth._portal_service import PortalService
10+
from uipath._cli._auth._url_utils import extract_org_tenant, resolve_domain
11+
from uipath._cli._auth._utils import update_env_file
1912
from uipath._cli._utils._console import ConsoleLogger
13+
from uipath._services import ExternalApplicationService
2014

2115

2216
class AuthService:
@@ -25,35 +19,20 @@ def __init__(
2519
environment: str,
2620
*,
2721
force: bool,
28-
client_id: Optional[str],
29-
client_secret: Optional[str],
30-
base_url: Optional[str],
31-
tenant: Optional[str],
32-
scope: Optional[str],
22+
client_id: Optional[str] = None,
23+
client_secret: Optional[str] = None,
24+
base_url: Optional[str] = None,
25+
tenant: Optional[str] = None,
26+
scope: Optional[str] = None,
3327
):
3428
self._force = force
3529
self._console = ConsoleLogger()
36-
self._domain = self._get_domain(environment)
3730
self._client_id = client_id
3831
self._client_secret = client_secret
3932
self._base_url = base_url
4033
self._tenant = tenant
34+
self._domain = resolve_domain(self._base_url, environment, self._force)
4135
self._scope = scope
42-
set_force_flag(self._force)
43-
44-
def _get_domain(self, environment: str) -> str:
45-
# only search env var if not force authentication
46-
if not self._force:
47-
uipath_url = os.getenv("UIPATH_URL")
48-
if uipath_url and environment == "cloud": # "cloud" is the default
49-
parsed_url = urlparse(uipath_url)
50-
if parsed_url.scheme and parsed_url.netloc:
51-
environment = f"{parsed_url.scheme}://{parsed_url.netloc}"
52-
else:
53-
self._console.error(
54-
f"Malformed UIPATH_URL: '{uipath_url}'. Please ensure it includes both scheme and netloc (e.g., 'https://cloud.uipath.com')."
55-
)
56-
return environment
5736

5837
def authenticate(self) -> None:
5938
if self._client_id and self._client_secret:
@@ -62,103 +41,103 @@ def authenticate(self) -> None:
6241

6342
self._authenticate_authorization_code()
6443

65-
def _authenticate_client_credentials(self) -> None:
44+
def _authenticate_client_credentials(self):
6645
if not self._base_url:
6746
self._console.error(
6847
"--base-url is required when using client credentials authentication."
6948
)
7049
return
71-
self._console.hint("Using client credentials authentication.")
72-
credentials_service = ClientCredentialsService(self._base_url)
73-
credentials_service.authenticate(
50+
51+
organization_name, tenant_name = extract_org_tenant(self._base_url)
52+
if not (organization_name and tenant_name):
53+
self._console.error(
54+
"--base-url must include both organization and tenant, "
55+
"e.g., 'https://cloud.uipath.com/{organization}/{tenant}'."
56+
)
57+
return
58+
59+
app_service = ExternalApplicationService(self._domain)
60+
token_data = app_service.get_token_data(
7461
self._client_id, # type: ignore
7562
self._client_secret, # type: ignore
7663
self._scope,
7764
)
7865

66+
self._tenant = tenant_name
67+
with PortalService(
68+
self._domain, access_token=token_data["access_token"]
69+
) as portal_service:
70+
self._finalize_auth(portal_service, token_data)
71+
7972
def _authenticate_authorization_code(self) -> None:
8073
with PortalService(self._domain) as portal_service:
81-
if not self._force:
82-
# use existing env vars
83-
if (
84-
os.getenv("UIPATH_URL")
85-
and os.getenv("UIPATH_TENANT_ID")
86-
and os.getenv("UIPATH_ORGANIZATION_ID")
87-
):
88-
try:
89-
portal_service.ensure_valid_token()
90-
return
91-
except Exception:
92-
self._console.error(
93-
"Authentication token is invalid. Please reauthenticate using the '--force' flag.",
94-
)
74+
if not self._force and self._can_reuse_existing_token(portal_service):
75+
return
76+
9577
auth_url, code_verifier, state = OidcUtils.get_auth_url(self._domain)
96-
webbrowser.open(auth_url, 1)
97-
auth_config = OidcUtils.get_auth_config()
78+
self._open_browser(auth_url)
9879

99-
self._console.link(
100-
"If a browser window did not open, please open the following URL in your browser:",
101-
auth_url,
102-
)
80+
auth_config = OidcUtils.get_auth_config()
10381
server = HTTPServer(port=auth_config["port"])
10482
token_data = asyncio.run(server.start(state, code_verifier, self._domain))
105-
10683
if not token_data:
10784
self._console.error(
10885
"Authentication failed. Please try again.",
10986
)
87+
return
11088

111-
portal_service.update_token_data(token_data)
112-
update_auth_file(token_data)
113-
access_token = token_data["access_token"]
114-
update_env_file({"UIPATH_ACCESS_TOKEN": access_token})
89+
self._finalize_auth(portal_service, token_data)
11590

116-
tenants_and_organizations = portal_service.get_tenants_and_organizations()
91+
def _finalize_auth(
92+
self,
93+
portal_service: PortalService,
94+
token_data: TokenData,
95+
) -> None:
96+
portal_service.update_token_data(token_data)
97+
98+
tenant_info = (
99+
portal_service.retrieve_tenant(self._tenant)
100+
if self._tenant
101+
else portal_service.select_tenant()
102+
)
117103

118-
if self._tenant:
119-
base_url = get_tenant_id(
120-
self._domain, self._tenant, tenants_and_organizations
121-
)
122-
else:
123-
base_url = select_tenant(self._domain, tenants_and_organizations)
104+
tenant_id = tenant_info["tenant_id"]
105+
organization_id = tenant_info["organization_id"]
106+
uipath_url = portal_service.build_tenant_url()
107+
108+
update_env_file(
109+
{
110+
"UIPATH_ACCESS_TOKEN": token_data["access_token"],
111+
"UIPATH_URL": uipath_url,
112+
"UIPATH_TENANT_ID": tenant_id,
113+
"UIPATH_ORGANIZATION_ID": organization_id,
114+
}
115+
)
124116

117+
try:
118+
portal_service.enable_studio_web(uipath_url)
119+
except Exception:
120+
self._console.error("Could not prepare the environment. Please try again.")
121+
122+
def _can_reuse_existing_token(self, portal_service: PortalService) -> bool:
123+
if (
124+
os.getenv("UIPATH_URL")
125+
and os.getenv("UIPATH_TENANT_ID")
126+
and os.getenv("UIPATH_ORGANIZATION_ID")
127+
):
125128
try:
126-
portal_service.post_auth(base_url)
129+
portal_service.ensure_valid_token()
130+
return True
127131
except Exception:
128132
self._console.error(
129-
"Could not prepare the environment. Please try again.",
133+
"Authentication token is invalid. Please reauthenticate using the '--force' flag."
130134
)
131-
132-
def set_port(self):
133-
def is_port_in_use(target_port: int) -> bool:
134-
with socket(AF_INET, SOCK_STREAM) as s:
135-
try:
136-
s.bind(("localhost", target_port))
137-
s.close()
138-
return False
139-
except error:
140-
return True
141-
142-
auth_config = OidcUtils.get_auth_config()
143-
port = int(auth_config.get("port", 8104))
144-
port_option_one = int(auth_config.get("portOptionOne", 8104)) # type: ignore
145-
port_option_two = int(auth_config.get("portOptionTwo", 8055)) # type: ignore
146-
port_option_three = int(auth_config.get("portOptionThree", 42042)) # type: ignore
147-
if is_port_in_use(port):
148-
if is_port_in_use(port_option_one):
149-
if is_port_in_use(port_option_two):
150-
if is_port_in_use(port_option_three):
151-
self._console.error(
152-
"All configured ports are in use. Please close applications using ports or configure different ports."
153-
)
154-
else:
155-
port = port_option_three
156-
else:
157-
port = port_option_two
158-
else:
159-
port = port_option_one
160-
auth_config["port"] = port
161-
with open(
162-
os.path.join(os.path.dirname(__file__), "..", "auth_config.json"), "w"
163-
) as f:
164-
json.dump(auth_config, f)
135+
return False
136+
137+
def _open_browser(self, url: str) -> None:
138+
# Try to open browser. Always print the fallback link.
139+
webbrowser.open(url, new=1)
140+
self._console.link(
141+
"If a browser window did not open, please open the following URL in your browser:",
142+
url,
143+
)

0 commit comments

Comments
 (0)