Skip to content

Commit 79fbbd9

Browse files
committed
feat: move auth service to platform
1 parent 773b1a0 commit 79fbbd9

29 files changed

Lines changed: 1180 additions & 1475 deletions

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ show_missing = true
108108
source = ["src"]
109109

110110
[tool.uv.sources]
111-
uipath-core = { path = "../uipath-core", editable = true }
111+
uipath-core = { path = "../uipath-core", editable = false }
112112

113113
[[tool.uv.index]]
114114
name = "testpypi"

packages/uipath-platform/src/uipath/platform/_uipath.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
QueuesService,
3333
)
3434
from .resource_catalog import ResourceCatalogService
35+
from .studio_web import StudioWebService
3536

3637

3738
def _has_valid_client_credentials(
@@ -164,3 +165,7 @@ def agenthub(self) -> AgentHubService:
164165
@property
165166
def automation_tracker(self) -> AutomationTrackerService:
166167
return AutomationTrackerService(self._config, self._execution_context)
168+
169+
@property
170+
def studio_web(self) -> StudioWebService:
171+
return StudioWebService(self._config, self._execution_context)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""UiPath Auth package.
2+
3+
Provides reusable authentication building blocks: token acquisition,
4+
token management, portal API calls, OIDC configuration, and URL utilities.
5+
"""
6+
7+
from ._auth_service import AuthService
8+
from ._errors import AuthenticationError
9+
from ._models import (
10+
AccessTokenData,
11+
AuthConfig,
12+
AuthorizationRequest,
13+
OrganizationInfo,
14+
TenantInfo,
15+
TenantsAndOrganizationInfoResponse,
16+
)
17+
from ._url_utils import build_service_url, extract_org_tenant, resolve_domain
18+
from ._utils import (
19+
get_auth_data,
20+
get_parsed_token_data,
21+
parse_access_token,
22+
update_auth_file,
23+
)
24+
25+
__all__ = [
26+
"AuthService",
27+
"AuthenticationError",
28+
"AuthConfig",
29+
"AuthorizationRequest",
30+
"AccessTokenData",
31+
"TenantInfo",
32+
"OrganizationInfo",
33+
"TenantsAndOrganizationInfoResponse",
34+
"build_service_url",
35+
"extract_org_tenant",
36+
"resolve_domain",
37+
"get_auth_data",
38+
"get_parsed_token_data",
39+
"parse_access_token",
40+
"update_auth_file",
41+
]
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import json
2+
import logging
3+
import os
4+
import time
5+
from functools import cached_property
6+
from urllib.parse import urlencode
7+
8+
import httpx
9+
10+
from uipath.platform.common._http_config import get_httpx_client_kwargs
11+
from uipath.platform.common.auth import TokenData
12+
13+
from ._errors import AuthenticationError
14+
from ._models import (
15+
AuthConfig,
16+
AuthorizationRequest,
17+
TenantsAndOrganizationInfoResponse,
18+
)
19+
from ._oidc_utils import (
20+
_select_config_file,
21+
generate_code_verifier_and_challenge,
22+
get_state_param,
23+
)
24+
from ._url_utils import build_service_url
25+
from ._utils import parse_access_token
26+
27+
_logger = logging.getLogger(__name__)
28+
29+
30+
class AuthService:
31+
"""Service for UiPath OAuth2 authentication and portal API operations.
32+
33+
Provides the full OAuth2 Authorization Code + PKCE flow for obtaining
34+
user tokens, as well as token refresh and tenant/organization discovery.
35+
36+
This is a standalone service that does not inherit from ``BaseService``
37+
because it operates before an access token is available (i.e., it is used
38+
to *obtain* the token that other services require).
39+
40+
Args:
41+
domain: The UiPath domain (e.g., ``https://cloud.uipath.com``).
42+
43+
Examples:
44+
**Obtain a user token using the OAuth2 PKCE flow:**
45+
46+
```python
47+
import asyncio
48+
from uipath.platform.auth import AuthService
49+
50+
auth = AuthService("https://cloud.uipath.com")
51+
52+
# 1. Build the authorization URL
53+
redirect_uri = "http://localhost:8104/oidc/login"
54+
auth_request = auth.get_authorization_url(redirect_uri)
55+
print(f"Open this URL in your browser: {auth_request.url}")
56+
57+
# 2. After user authorizes, exchange the code for tokens
58+
token_data = asyncio.run(
59+
auth.exchange_authorization_code(
60+
code="<authorization_code>",
61+
code_verifier=auth_request.code_verifier,
62+
redirect_uri=redirect_uri,
63+
)
64+
)
65+
print(f"Access token: {token_data.access_token}")
66+
```
67+
68+
**Refresh an expired token:**
69+
70+
```python
71+
import asyncio
72+
from uipath.platform.auth import AuthService
73+
74+
auth = AuthService("https://cloud.uipath.com")
75+
76+
# ensure_valid_token returns the same token if still valid,
77+
# or refreshes it automatically if expired
78+
refreshed = asyncio.run(auth.ensure_valid_token(token_data))
79+
```
80+
81+
**Discover available tenants:**
82+
83+
```python
84+
import asyncio
85+
from uipath.platform.auth import AuthService
86+
87+
auth = AuthService("https://cloud.uipath.com")
88+
info = asyncio.run(
89+
auth.get_tenants_and_organizations(token_data.access_token)
90+
)
91+
for tenant in info["tenants"]:
92+
print(f"{tenant['name']} ({tenant['id']})")
93+
```
94+
"""
95+
96+
def __init__(self, domain: str):
97+
self.domain = domain
98+
99+
@cached_property
100+
def auth_config(self) -> AuthConfig:
101+
"""Get the OIDC auth configuration for this domain.
102+
103+
The configuration is automatically selected based on the domain
104+
and the server version (cloud vs. on-premise 25.10).
105+
The result is cached after the first access.
106+
107+
Returns:
108+
AuthConfig with client_id and scope.
109+
"""
110+
config_file = _select_config_file(self.domain)
111+
config_path = os.path.join(os.path.dirname(__file__), config_file)
112+
with open(config_path, "r") as f:
113+
raw = json.load(f)
114+
return AuthConfig(client_id=raw["client_id"], scope=raw["scope"])
115+
116+
def get_authorization_url(self, redirect_uri: str) -> AuthorizationRequest:
117+
"""Build the authorization URL for the OAuth2 PKCE flow.
118+
119+
Generates a PKCE code verifier/challenge pair and a random state
120+
parameter, then constructs the full authorization URL.
121+
122+
Args:
123+
redirect_uri: The redirect URI for the OAuth callback
124+
(e.g., ``http://localhost:8104/oidc/login``).
125+
126+
Returns:
127+
AuthorizationRequest containing the authorization URL,
128+
the code verifier (needed for token exchange), and the state.
129+
130+
Examples:
131+
```python
132+
from uipath.platform.auth import AuthService
133+
134+
auth = AuthService("https://cloud.uipath.com")
135+
request = auth.get_authorization_url("http://localhost:8104/oidc/login")
136+
137+
# Open request.url in the browser
138+
# After redirect, use request.code_verifier to exchange the code
139+
```
140+
"""
141+
code_verifier, code_challenge = generate_code_verifier_and_challenge()
142+
state = get_state_param()
143+
query_params = {
144+
"client_id": self.auth_config.client_id,
145+
"redirect_uri": redirect_uri,
146+
"response_type": "code",
147+
"scope": self.auth_config.scope,
148+
"state": state,
149+
"code_challenge": code_challenge,
150+
"code_challenge_method": "S256",
151+
}
152+
url = build_service_url(
153+
self.domain, f"/identity_/connect/authorize?{urlencode(query_params)}"
154+
)
155+
return AuthorizationRequest(url=url, code_verifier=code_verifier, state=state)
156+
157+
async def exchange_authorization_code(
158+
self, code: str, code_verifier: str, redirect_uri: str
159+
) -> TokenData:
160+
"""Exchange an authorization code for tokens (PKCE flow).
161+
162+
Args:
163+
code: The authorization code received from the OAuth callback.
164+
code_verifier: The PKCE code verifier from ``get_authorization_url``.
165+
redirect_uri: The redirect URI (must match the one used in the auth URL).
166+
167+
Returns:
168+
TokenData with access_token, refresh_token, expires_in, etc.
169+
170+
Raises:
171+
AuthenticationError: If the token exchange fails.
172+
173+
Examples:
174+
```python
175+
import asyncio
176+
from uipath.platform.auth import AuthService
177+
178+
auth = AuthService("https://cloud.uipath.com")
179+
token_data = asyncio.run(
180+
auth.exchange_authorization_code(
181+
code="abc123",
182+
code_verifier=auth_request.code_verifier,
183+
redirect_uri="http://localhost:8104/oidc/login",
184+
)
185+
)
186+
```
187+
"""
188+
url = build_service_url(self.domain, "/identity_/connect/token")
189+
data = {
190+
"grant_type": "authorization_code",
191+
"code": code,
192+
"code_verifier": code_verifier,
193+
"redirect_uri": redirect_uri,
194+
"client_id": self.auth_config.client_id,
195+
}
196+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
197+
async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client:
198+
response = await client.post(url, data=data, headers=headers)
199+
200+
if response.status_code >= 400:
201+
raise AuthenticationError(
202+
f"Failed to exchange authorization code: {response.status_code}"
203+
)
204+
205+
return TokenData.model_validate(response.json())
206+
207+
async def ensure_valid_token(self, token_data: TokenData) -> TokenData:
208+
"""Check if the token is still valid; refresh it if expired.
209+
210+
Parses the JWT ``exp`` claim from the access token. If the token
211+
is still valid, returns it as-is. If expired, uses the refresh
212+
token to obtain a new one.
213+
214+
Args:
215+
token_data: The current token data to validate.
216+
217+
Returns:
218+
The same TokenData if still valid, or a freshly refreshed one.
219+
220+
Raises:
221+
AuthenticationError: If no refresh token is available or the
222+
refresh request fails.
223+
224+
Examples:
225+
```python
226+
import asyncio
227+
from uipath.platform.auth import AuthService, get_auth_data
228+
229+
auth = AuthService("https://cloud.uipath.com")
230+
current_token = get_auth_data()
231+
valid_token = asyncio.run(auth.ensure_valid_token(current_token))
232+
```
233+
"""
234+
claims = parse_access_token(token_data.access_token)
235+
exp = claims.get("exp")
236+
237+
if exp is not None and float(exp) > time.time():
238+
return token_data
239+
240+
if not token_data.refresh_token:
241+
raise AuthenticationError("No refresh token found. Please re-authenticate.")
242+
243+
return await self._refresh_access_token(token_data.refresh_token)
244+
245+
async def get_tenants_and_organizations(
246+
self, access_token: str
247+
) -> TenantsAndOrganizationInfoResponse:
248+
"""Get available tenants and organization info for the authenticated user.
249+
250+
Args:
251+
access_token: A valid access token.
252+
253+
Returns:
254+
Response containing a list of tenants and the organization info.
255+
256+
Raises:
257+
AuthenticationError: If the access token is invalid or the
258+
request fails.
259+
260+
Examples:
261+
```python
262+
import asyncio
263+
from uipath.platform.auth import AuthService
264+
265+
auth = AuthService("https://cloud.uipath.com")
266+
info = asyncio.run(
267+
auth.get_tenants_and_organizations(token_data.access_token)
268+
)
269+
org = info["organization"]
270+
print(f"Organization: {org['name']}")
271+
for tenant in info["tenants"]:
272+
print(f" Tenant: {tenant['name']} ({tenant['id']})")
273+
```
274+
"""
275+
claims = parse_access_token(access_token)
276+
prt_id = claims.get("prt_id")
277+
278+
url = build_service_url(
279+
self.domain,
280+
f"/{prt_id}/portal_/api/filtering/leftnav/tenantsAndOrganizationInfo",
281+
)
282+
async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client:
283+
response = await client.get(
284+
url, headers={"Authorization": f"Bearer {access_token}"}
285+
)
286+
287+
if response.status_code == 401:
288+
raise AuthenticationError(
289+
"Unauthorized: access token is invalid or expired."
290+
)
291+
292+
if response.status_code >= 400:
293+
raise AuthenticationError(
294+
f"Failed to get tenants and organizations: {response.status_code} {response.text}"
295+
)
296+
297+
return response.json()
298+
299+
async def _refresh_access_token(self, refresh_token: str) -> TokenData:
300+
"""Refresh an access token using a refresh token."""
301+
url = build_service_url(self.domain, "/identity_/connect/token")
302+
data = {
303+
"grant_type": "refresh_token",
304+
"refresh_token": refresh_token,
305+
"client_id": self.auth_config.client_id,
306+
}
307+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
308+
async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client:
309+
response = await client.post(url, data=data, headers=headers)
310+
311+
if response.status_code == 401:
312+
raise AuthenticationError(
313+
"Unauthorized: refresh token is invalid or expired."
314+
)
315+
316+
if response.status_code >= 400:
317+
raise AuthenticationError(
318+
f"Failed to refresh token: {response.status_code}"
319+
)
320+
321+
return TokenData.model_validate(response.json())
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class AuthenticationError(Exception):
2+
"""Raised when authentication fails or token operations cannot be completed."""
3+
4+
pass

0 commit comments

Comments
 (0)