Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/test-federated-credentials.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: uipath - Federated Credentials

on:
pull_request:
branches: [ main ]

permissions:
contents: read
id-token: write

jobs:
federated-login:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: uipath-ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
env:
CLIENT_ID: ${{ secrets.CLOUD_TEST_CLIENT_ID }}
BASE_URL: ${{ secrets.CLOUD_BASE_URL }}
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Install dependencies
working-directory: packages/uipath
run: uv sync

- name: Get OIDC token
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: core.exportVariable('UIPATH_CLIENT_ASSERTION', await core.getIDToken('https://uipath/uipath-python-ci'))

- name: Log in with uipath auth
working-directory: packages/uipath
run: |
uv run uipath auth --client-id "$CLIENT_ID" --base-url "$BASE_URL"
set -a && . ./.env && set +a
uv run python -c "
import asyncio
from uipath.platform import UiPath
result = asyncio.run(UiPath().llm.chat_completions([{'role': 'user', 'content': 'Say ok'}]))
print('CLI session: LLM replied', result.choices[0].message.content)
"

- name: Log in with UiPath(client_assertion=...)
working-directory: packages/uipath
env:
UIPATH_URL: ${{ secrets.CLOUD_BASE_URL }}
run: |
rm -f .env
uv run python -c "
import asyncio
import os
from uipath.platform import UiPath
sdk = UiPath(base_url=os.environ['BASE_URL'], client_id=os.environ['CLIENT_ID'], client_assertion=os.environ['UIPATH_CLIENT_ASSERTION'])
result = asyncio.run(sdk.llm.chat_completions([{'role': 'user', 'content': 'Say ok'}]))
print('SDK: LLM replied', result.choices[0].message.content)
"
2 changes: 1 addition & 1 deletion packages/uipath-platform/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ sdk.automation_tracker # AutomationTrackerService
Credentials can be provided via constructor parameters or environment variables (`UIPATH_URL`, `UIPATH_ACCESS_TOKEN`). Two auth modes are supported:

- **User authentication** — provide `base_url` and `secret` (access token), or set them via env vars
- **S2S (service-to-service)** — provide `client_id` and `client_secret` (both required together), optionally with `scope`; uses `ExternalApplicationService` internally to exchange for an access token
- **S2S (service-to-service)** — provide `client_id` with either `client_secret` or `client_assertion` (an OIDC token for federated credentials), optionally with `scope`; uses `ExternalApplicationService` internally to exchange for an access token

Configuration is validated with Pydantic; missing `base_url` raises `BaseUrlMissingError`, missing `secret` raises `SecretMissingError`.

Expand Down
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.2.40"
version = "0.2.41"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
23 changes: 16 additions & 7 deletions packages/uipath-platform/src/uipath/platform/_uipath.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@


def _has_valid_client_credentials(
client_id: Optional[str], client_secret: Optional[str]
client_id: str | None, client_secret: str | None, client_assertion: str | None
) -> bool:
if bool(client_id) != bool(client_secret):
raise ValueError("Both client_id and client_secret must be provided together.")
return bool(client_id and client_secret)
if client_secret and client_assertion:
raise ValueError("Provide either client_secret or client_assertion, not both.")
if bool(client_id) != bool(client_secret or client_assertion):
raise ValueError(
"client_id must be provided together with client_secret or client_assertion."
)
return bool(client_id)


class UiPath:
Expand All @@ -57,14 +61,19 @@ def __init__(
secret: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
client_assertion: str | None = None,
scope: Optional[str] = None,
debug: bool = False,
) -> None:
try:
if _has_valid_client_credentials(client_id, client_secret):
assert client_id and client_secret
if _has_valid_client_credentials(
client_id, client_secret, client_assertion
):
assert client_id
service = ExternalApplicationService(base_url)
token_data = service.get_token_data(client_id, client_secret, scope)
token_data = service.get_token_data(
client_id, client_secret, scope, client_assertion=client_assertion
)
base_url, secret = service._base_url, token_data.access_token
else:
base_url, secret = resolve_config_from_env(base_url, secret)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,20 @@ def _translate_auth_errors(self) -> Iterator[None]:
raise Exception(f"Unexpected error during authentication: {e}") from e

def get_token_data(
self, client_id: str, client_secret: str, scope: Optional[str] = "OR.Execution"
self,
client_id: str,
client_secret: str | None = None,
scope: Optional[str] = "OR.Execution",
*,
client_assertion: str | None = None,
) -> TokenData:
"""Authenticate using client credentials flow.

Args:
client_id: The client ID for authentication
client_secret: The client secret for authentication
scope: The scope for the token (default: OR.Execution)
client_assertion: An OIDC token from a federated identity provider, used instead of the client secret

Returns:
Token data if successful
Expand All @@ -117,17 +123,24 @@ def get_token_data(
client_id=client_id,
client_secret=client_secret,
scope=scope,
client_assertion=client_assertion,
)

async def get_token_data_async(
self, client_id: str, client_secret: str, scope: Optional[str] = "OR.Execution"
self,
client_id: str,
client_secret: str | None = None,
scope: Optional[str] = "OR.Execution",
*,
client_assertion: str | None = None,
) -> TokenData:
"""Authenticate using client credentials flow.

Args:
client_id: The client ID for authentication
client_secret: The client secret for authentication
scope: The scope for the token (default: OR.Execution)
client_assertion: An OIDC token from a federated identity provider, used instead of the client secret

Returns:
Token data if successful
Expand All @@ -137,4 +150,5 @@ async def get_token_data_async(
client_id=client_id,
client_secret=client_secret,
scope=scope,
client_assertion=client_assertion,
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from ..common._http_config import get_httpx_client_kwargs
from ..common.auth import TokenData

JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"


class IdentityService:
"""Service for interacting with the UiPath Identity server."""
Expand Down Expand Up @@ -82,15 +84,18 @@ async def refresh_access_token_async(
def get_client_credentials_token(
self,
client_id: str,
client_secret: str,
client_secret: str | None = None,
scope: Optional[str] = "OR.Execution",
*,
client_assertion: str | None = None,
) -> TokenData:
"""Obtain an access token using client credentials grant.

Args:
client_id: The client ID of the application.
client_secret: The client secret of the application.
scope: The requested OAuth scopes (optional, default: "OR.Execution").
client_assertion: An OIDC token from a federated identity provider, sent instead of the client secret.

Returns:
TokenData containing the access token and related information.
Expand All @@ -99,14 +104,9 @@ def get_client_credentials_token(
httpx.HTTPStatusError: If the server returns a non-2xx response.
httpx.ConnectError: If there is a network connectivity issue.
"""
scope = scope or "OR.Execution"
url = f"{self._domain}/identity_/connect/token"
data = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
}
url, data = self._client_credentials_request(
client_id, client_secret, scope, client_assertion
)

with httpx.Client(**get_httpx_client_kwargs()) as client:
response = client.post(url, data=data)
Expand All @@ -116,15 +116,18 @@ def get_client_credentials_token(
async def get_client_credentials_token_async(
self,
client_id: str,
client_secret: str,
client_secret: str | None = None,
scope: Optional[str] = "OR.Execution",
*,
client_assertion: str | None = None,
) -> TokenData:
"""Obtain an access token using client credentials grant.

Args:
client_id: The client ID of the application.
client_secret: The client secret of the application.
scope: The requested OAuth scopes (optional, default: "OR.Execution").
client_assertion: An OIDC token from a federated identity provider, sent instead of the client secret.

Returns:
TokenData containing the access token and related information.
Expand All @@ -133,16 +136,35 @@ async def get_client_credentials_token_async(
httpx.HTTPStatusError: If the server returns a non-2xx response.
httpx.ConnectError: If there is a network connectivity issue.
"""
scope = scope or "OR.Execution"
url = f"{self._domain}/identity_/connect/token"
data = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
}
url, data = self._client_credentials_request(
client_id, client_secret, scope, client_assertion
)

async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client:
response = await client.post(url, data=data)
response.raise_for_status()
return TokenData.model_validate(response.json())

def _client_credentials_request(
self,
client_id: str,
client_secret: str | None,
scope: str | None,
client_assertion: str | None,
) -> tuple[str, dict[str, str]]:
if bool(client_secret) == bool(client_assertion):
raise ValueError(
"Exactly one of client_secret or client_assertion must be provided."
)
data = {
"grant_type": "client_credentials",
"client_id": client_id,
"scope": scope or "OR.Execution",
}
if client_assertion:
data["client_assertion"] = client_assertion
data["client_assertion_type"] = JWT_BEARER_ASSERTION_TYPE
else:
assert client_secret
data["client_secret"] = client_secret
return f"{self._domain}/identity_/connect/token", data
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ async def test_delegates_to_identity_service(self, use_async: bool):
client_id="my-id",
client_secret="my-secret",
scope="OR.Execution",
client_assertion=None,
)
else:
with patch.object(
Expand All @@ -196,6 +197,7 @@ async def test_delegates_to_identity_service(self, use_async: bool):
client_id="my-id",
client_secret="my-secret",
scope="OR.Execution",
client_assertion=None,
)
assert result.access_token == "delegated-token"

Expand Down
48 changes: 48 additions & 0 deletions packages/uipath-platform/tests/services/test_identity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,51 @@ async def test_client_credentials_success(

assert token.access_token == "cc-access-token"
assert token.scope == "OR.Execution"

@pytest.mark.parametrize("use_async", [False, True])
async def test_client_assertion_replaces_secret(
self, httpx_mock: HTTPXMock, use_async: bool
):
domain = "https://cloud.uipath.com"
httpx_mock.add_response(
url=f"{domain}/identity_/connect/token",
method="POST",
status_code=200,
json={"access_token": "fed-access-token", "token_type": "Bearer"},
)

service = IdentityService(domain)
if use_async:
token = await service.get_client_credentials_token_async(
client_id="app-client-id", client_assertion="the.oidc.jwt"
)
else:
token = service.get_client_credentials_token(
client_id="app-client-id", client_assertion="the.oidc.jwt"
)

assert token.access_token == "fed-access-token"
request = httpx_mock.get_request()
assert request is not None
form = dict(httpx.QueryParams(request.content.decode()))
assert form == {
"grant_type": "client_credentials",
"client_id": "app-client-id",
"scope": "OR.Execution",
"client_assertion": "the.oidc.jwt",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
}

@pytest.mark.parametrize(
"secret,assertion", [(None, None), ("app-client-secret", "the.oidc.jwt")]
)
def test_requires_exactly_one_credential(
self, secret: str | None, assertion: str | None
):
service = IdentityService("https://cloud.uipath.com")
with pytest.raises(ValueError):
service.get_client_credentials_token(
client_id="app-client-id",
client_secret=secret,
client_assertion=assertion,
)
Loading
Loading