diff --git a/.github/workflows/test-federated-credentials.yml b/.github/workflows/test-federated-credentials.yml new file mode 100644 index 000000000..b68f418a2 --- /dev/null +++ b/.github/workflows/test-federated-credentials.yml @@ -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) + " diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 21896676f..8d7037b98 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -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`. diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index eac9d3092..c3e51954e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 98af7b8b6..526274ff6 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -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: @@ -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) diff --git a/packages/uipath-platform/src/uipath/platform/external_applications/_external_application_service.py b/packages/uipath-platform/src/uipath/platform/external_applications/_external_application_service.py index 6fac449d4..d8390ef8a 100644 --- a/packages/uipath-platform/src/uipath/platform/external_applications/_external_application_service.py +++ b/packages/uipath-platform/src/uipath/platform/external_applications/_external_application_service.py @@ -100,7 +100,12 @@ 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. @@ -108,6 +113,7 @@ def get_token_data( 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 @@ -117,10 +123,16 @@ 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. @@ -128,6 +140,7 @@ async def get_token_data_async( 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 @@ -137,4 +150,5 @@ async def get_token_data_async( client_id=client_id, client_secret=client_secret, scope=scope, + client_assertion=client_assertion, ) diff --git a/packages/uipath-platform/src/uipath/platform/identity/_identity_service.py b/packages/uipath-platform/src/uipath/platform/identity/_identity_service.py index 6a803a1f5..acde4aa74 100644 --- a/packages/uipath-platform/src/uipath/platform/identity/_identity_service.py +++ b/packages/uipath-platform/src/uipath/platform/identity/_identity_service.py @@ -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.""" @@ -82,8 +84,10 @@ 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. @@ -91,6 +95,7 @@ def get_client_credentials_token( 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. @@ -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) @@ -116,8 +116,10 @@ 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. @@ -125,6 +127,7 @@ async def get_client_credentials_token_async( 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. @@ -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 diff --git a/packages/uipath-platform/tests/services/test_external_application_service.py b/packages/uipath-platform/tests/services/test_external_application_service.py index 1fca91b32..7aded25ec 100644 --- a/packages/uipath-platform/tests/services/test_external_application_service.py +++ b/packages/uipath-platform/tests/services/test_external_application_service.py @@ -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( @@ -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" diff --git a/packages/uipath-platform/tests/services/test_identity_service.py b/packages/uipath-platform/tests/services/test_identity_service.py index 2ebbc09c9..70848bda7 100644 --- a/packages/uipath-platform/tests/services/test_identity_service.py +++ b/packages/uipath-platform/tests/services/test_identity_service.py @@ -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, + ) diff --git a/packages/uipath-platform/tests/test_uipath_client_credentials.py b/packages/uipath-platform/tests/test_uipath_client_credentials.py new file mode 100644 index 000000000..7bae1c55d --- /dev/null +++ b/packages/uipath-platform/tests/test_uipath_client_credentials.py @@ -0,0 +1,54 @@ +from unittest.mock import patch + +import pytest + +from uipath.platform import UiPath +from uipath.platform.common.auth import TokenData + +BASE_URL = "https://cloud.uipath.com/org/tenant" +GET_TOKEN_DATA = "uipath.platform._uipath.ExternalApplicationService.get_token_data" + + +@pytest.mark.parametrize( + "client_secret,client_assertion", + [("app-client-secret", None), (None, "the.oidc.jwt")], +) +def test_client_credentials_exchange_for_token( + client_secret: str | None, client_assertion: str | None +): + with patch( + GET_TOKEN_DATA, return_value=TokenData(access_token="app-token") + ) as mock_get_token: + sdk = UiPath( + base_url=BASE_URL, + client_id="app-client-id", + client_secret=client_secret, + client_assertion=client_assertion, + ) + + mock_get_token.assert_called_once_with( + "app-client-id", client_secret, None, client_assertion=client_assertion + ) + assert sdk._config.secret == "app-token" + assert sdk._config.base_url == BASE_URL + + +@pytest.mark.parametrize( + "client_id,client_secret,client_assertion", + [ + ("app-client-id", None, None), + (None, "app-client-secret", None), + (None, None, "the.oidc.jwt"), + ("app-client-id", "app-client-secret", "the.oidc.jwt"), + ], +) +def test_incomplete_or_conflicting_credentials_raise( + client_id: str | None, client_secret: str | None, client_assertion: str | None +): + with pytest.raises(ValueError): + UiPath( + base_url=BASE_URL, + client_id=client_id, + client_secret=client_secret, + client_assertion=client_assertion, + ) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 498094a3c..5c59bdb8e 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.40" +version = "0.2.41" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 0fdddfe13..787518e9b 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -65,6 +65,30 @@ Treat `--client-secret` as a credential. In CI, prefer reading it from a secret /// +/// info | Unattended Authentication (Federated Credentials) + +CI providers such as GitHub Actions and Azure DevOps issue a short-lived OIDC token to each job. Pass it with `--client-assertion` instead of `--client-secret` and no long-lived secret has to be stored in the pipeline. The token can also be read from the `UIPATH_CLIENT_ASSERTION` environment variable. + +**Setup:** + +1. Create an External Application without a secret and grant it the scopes you need. +2. Register a federated credential on it whose issuer, audience and subject exactly match the claims of your CI provider's token. For GitHub Actions the issuer is `https://token.actions.githubusercontent.com` and the subject looks like `repo:/:ref:refs/heads/main`. See the [uip CLI authentication docs](https://docs.uipath.com/uipath-cli/standalone/latest/user-guide/authentication#flow-3-%E2%80%94-federated-credentials-(workload-identity)) for the commands. + +**Example:** + + +```shell +> uipath auth --client-id 12345678-c4c5-4f1f-93ff-4f5ab47d57ea \ + --client-assertion "$OIDC_TOKEN" \ + --base-url https://cloud.uipath.com/your-org/your-tenant \ + --scope OR.Folders.Read +✓ Authentication successful. +``` + +**Configuring the same flow in code:** pass `client_assertion` instead of `client_secret` to `UiPath(...)`. The [`federated-credentials-ci` sample](https://github.com/UiPath/uipath-python/tree/main/packages/uipath/samples/federated-credentials-ci) runs pytest against Orchestrator from a GitHub Actions workflow. + +/// + --- ::: mkdocs-click diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index e0b493573..a1f249104 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.14.41" +version = "2.14.42" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.33, <0.6.0", "uipath-runtime>=0.13.5, <0.14.0", - "uipath-platform>=0.2.37, <0.3.0", + "uipath-platform>=0.2.41, <0.3.0", "uipath-ipc>=2.5.1, <2.6.0", "click>=8.3.3, <9.0.0", "httpx>=0.28.1", diff --git a/packages/uipath/samples/federated-credentials-ci/README.md b/packages/uipath/samples/federated-credentials-ci/README.md new file mode 100644 index 000000000..611d38293 --- /dev/null +++ b/packages/uipath/samples/federated-credentials-ci/README.md @@ -0,0 +1,43 @@ +# Federated credentials in CI + +Run tests against UiPath Orchestrator from a CI pipeline without storing a client secret. The pipeline gets a short-lived OIDC token from its provider (GitHub Actions here) and exchanges it for a UiPath access token. + +## How it works + +1. UiPath Identity holds a federated credential on an external application: "accept tokens from this issuer, with this audience and subject". +2. The CI job asks its provider for an OIDC token and passes it as the client assertion. +3. Identity checks the token signature against the issuer's public keys, checks the claims against the federated credential, and issues an access token with the application's scopes. + +## Setup + +Create an external application without a secret and register a federated credential that trusts your repository, using the [uip CLI](https://docs.uipath.com/uipath-cli/standalone/latest/user-guide/authentication#flow-3-%E2%80%94-federated-credentials-(workload-identity)): + +```bash +uip admin external-apps create "my-ci-app" --no-secret --app-scope "OR.Folders.Read" +uip admin external-apps federated-credentials create \ + --name "GitHub Actions main" \ + --issuer "https://token.actions.githubusercontent.com" \ + --audience "https://uipath/" \ + --subject "repo:/:ref:refs/heads/main" +``` + +Copy [github-workflow.yml](github-workflow.yml) to `.github/workflows/` in that repository and fill in `UIPATH_URL` and `UIPATH_CLIENT_ID`. + +## Usage + +From code, pass the token to the SDK: + +```python +UiPath( + base_url=os.environ["UIPATH_URL"], + client_id=os.environ["UIPATH_CLIENT_ID"], + client_assertion=os.environ["UIPATH_CLIENT_ASSERTION"], + scope="OR.Folders.Read", +) +``` + +From the CLI, `uipath auth` reads the token from `UIPATH_CLIENT_ASSERTION` (or `--client-assertion`) and writes the session to `.env` for the commands that follow (`uipath run`, `uipath eval`, `uipath pack`): + +```bash +uipath auth --base-url "$UIPATH_URL" --client-id "$UIPATH_CLIENT_ID" --scope OR.Folders.Read +``` diff --git a/packages/uipath/samples/federated-credentials-ci/github-workflow.yml b/packages/uipath/samples/federated-credentials-ci/github-workflow.yml new file mode 100644 index 000000000..a151b367d --- /dev/null +++ b/packages/uipath/samples/federated-credentials-ci/github-workflow.yml @@ -0,0 +1,25 @@ +name: orchestrator-tests + +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + test: + runs-on: ubuntu-latest + env: + UIPATH_URL: https://cloud.uipath.com// + UIPATH_CLIENT_ID: + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - name: Get OIDC token + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: core.exportVariable('UIPATH_CLIENT_ASSERTION', await core.getIDToken(`https://uipath/${process.env.UIPATH_CLIENT_ID}`)) + - run: uv sync + - run: uv run pytest diff --git a/packages/uipath/samples/federated-credentials-ci/pyproject.toml b/packages/uipath/samples/federated-credentials-ci/pyproject.toml new file mode 100644 index 000000000..354faa543 --- /dev/null +++ b/packages/uipath/samples/federated-credentials-ci/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "federated-credentials-ci" +version = "0.0.1" +description = "Run tests against UiPath Orchestrator from CI with federated credentials, no stored secret." +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +dependencies = [ + "uipath", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "pytest", +] diff --git a/packages/uipath/samples/federated-credentials-ci/tests/test_orchestrator.py b/packages/uipath/samples/federated-credentials-ci/tests/test_orchestrator.py new file mode 100644 index 000000000..efc7d8db2 --- /dev/null +++ b/packages/uipath/samples/federated-credentials-ci/tests/test_orchestrator.py @@ -0,0 +1,18 @@ +import os + +from uipath.platform import UiPath + + +def test_list_folders(): + sdk = UiPath( + base_url=os.environ["UIPATH_URL"], + client_id=os.environ["UIPATH_CLIENT_ID"], + client_assertion=os.environ["UIPATH_CLIENT_ASSERTION"], + scope="OR.Folders.Read", + ) + + response = sdk.api_client.request( + "GET", "/orchestrator_/odata/Folders", params={"$top": 1} + ) + + assert response.status_code == 200 diff --git a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py index b594e8fe1..ec8d6d78c 100644 --- a/packages/uipath/src/uipath/_cli/_auth/_auth_service.py +++ b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py @@ -28,6 +28,7 @@ def __init__( force: bool, client_id: str | None = None, client_secret: str | None = None, + client_assertion: str | None = None, base_url: str | None = None, tenant: str | None = None, scope: str | None = None, @@ -36,26 +37,27 @@ def __init__( self._console = ConsoleLogger() self._client_id = client_id self._client_secret = client_secret + self._client_assertion = client_assertion self._base_url = base_url self._tenant = tenant self._domain = resolve_domain(self._base_url, environment) self._scope = scope async def authenticate(self) -> None: - if self._client_id and self._client_secret: + if self._client_id and (self._client_secret or self._client_assertion): await self._authenticate_client_credentials() return await self._authenticate_authorization_code() async def _authenticate_client_credentials(self): - if not (self._client_id and self._client_secret): - raise ValueError("Client ID and Client Secret must be provided.") + assert self._client_id external_app_service = ExternalApplicationService(self._base_url) token_data = await external_app_service.get_token_data_async( self._client_id, self._client_secret, self._scope, + client_assertion=self._client_assertion, ) organization_name, tenant_name = extract_org_tenant( diff --git a/packages/uipath/src/uipath/_cli/cli_auth.py b/packages/uipath/src/uipath/_cli/cli_auth.py index 753ba9e85..2be4458dc 100644 --- a/packages/uipath/src/uipath/_cli/cli_auth.py +++ b/packages/uipath/src/uipath/_cli/cli_auth.py @@ -29,6 +29,12 @@ required=False, help="Client secret for client credentials authentication (unattended mode)", ) +@click.option( + "--client-assertion", + required=False, + envvar="UIPATH_CLIENT_ASSERTION", + help="OIDC token from a federated identity provider (e.g. GitHub Actions), used instead of `--client-secret`. Can also be set via `UIPATH_CLIENT_ASSERTION`", +) @click.option( "--base-url", required=False, @@ -50,6 +56,7 @@ def auth( force: bool = False, client_id: str | None = None, client_secret: str | None = None, + client_assertion: str | None = None, base_url: str | None = None, tenant: str | None = None, scope: str | None = None, @@ -66,6 +73,8 @@ def auth( - Interactive (default): Opens a browser window for OAuth authentication. - Unattended: Uses the client credentials flow. Requires ``--client-id``, ``--client-secret``, ``--base-url``, and ``--scope``. + - Federated (workload identity): like unattended, but ``--client-assertion`` + (an OIDC token from the CI provider) replaces ``--client-secret``. **Environment Variables:** @@ -83,12 +92,21 @@ def auth( $ uipath auth --base-url https://cloud.uipath.com/organization/tenant --client-id 00000000-0000-0000-0000-000000000000 --client-secret 'secret_value_here' + Federated login using an OIDC token from the CI provider: + + $ uipath auth --base-url https://cloud.uipath.com/organization/tenant --client-id 00000000-0000-0000-0000-000000000000 --client-assertion "$OIDC_TOKEN" + """ + if (client_secret or client_assertion) and not client_id: + raise click.UsageError( + "--client-id is required with --client-secret or --client-assertion." + ) auth_service = AuthService( environment=environment, force=force, client_id=client_id, client_secret=client_secret, + client_assertion=client_assertion, base_url=base_url, tenant=tenant, scope=scope, diff --git a/packages/uipath/tests/cli/test_auth.py b/packages/uipath/tests/cli/test_auth.py index e400b29dd..b39823204 100644 --- a/packages/uipath/tests/cli/test_auth.py +++ b/packages/uipath/tests/cli/test_auth.py @@ -5,6 +5,7 @@ from click.testing import CliRunner from uipath._cli import cli +from uipath.platform.common import TokenData """ Unit tests for the 'uipath auth' command. @@ -218,3 +219,68 @@ def test_auth_with_tenant_flag(self): mock_open.assert_called_once() session.resolve_tenant_info.assert_called_once_with("MyTenantName") + + def test_auth_with_client_assertion(self): + """ + Test that --client-assertion runs the client credentials flow + with the OIDC token instead of a client secret. + """ + runner = CliRunner() + with ( + patch( + "uipath._cli._auth._auth_service.ExternalApplicationService" + ) as mock_service, + patch("uipath._cli._auth._auth_service.AuthSession") as mock_auth_session, + patch( + "uipath._cli._auth._auth_service.get_parsed_token_data", + return_value={"prt_id": "org-id"}, + ), + ): + service = mock_service.return_value + service._base_url = "https://cloud.uipath.com/MyOrg/MyTenant" + service.get_token_data_async = AsyncMock( + return_value=TokenData(access_token="fed-token") + ) + mock_auth_session.return_value.resolve_tenant_info = AsyncMock( + return_value={"tenant_id": "tenant-id"} + ) + with runner.isolated_filesystem(): + result = runner.invoke( + cli, + [ + "auth", + "--base-url", + "https://cloud.uipath.com/MyOrg/MyTenant", + "--client-id", + "app-client-id", + "--client-assertion", + "the.oidc.jwt", + ], + ) + + assert result.exit_code == 0, result.output + service.get_token_data_async.assert_awaited_once_with( + "app-client-id", + None, + "OR.Execution", + client_assertion="the.oidc.jwt", + ) + with open(".env") as f: + assert "UIPATH_ACCESS_TOKEN=fed-token" in f.read() + + @pytest.mark.parametrize( + "credential_args", + [["--client-secret", "secret"], ["--client-assertion", "the.oidc.jwt"]], + ) + def test_auth_credential_without_client_id_fails(self, credential_args): + """ + Test that a secret or assertion without --client-id is rejected + instead of falling back to the interactive browser flow. + """ + runner = CliRunner() + with patch("uipath._cli._auth._auth_service.webbrowser.open") as mock_open: + result = runner.invoke(cli, ["auth", *credential_args]) + + assert result.exit_code == 2 + assert "--client-id is required" in result.output + mock_open.assert_not_called() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 1be243190..35b2525e2 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -7,10 +7,10 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P2D" [options.exclude-newer-package] +uipath-core = false uipath-ipc = false -uipath-runtime = false uipath-platform = false -uipath-core = false +uipath-runtime = false [[package]] name = "aiohappyeyeballs" @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.41" +version = "2.14.42" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.40" +version = "0.2.41" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },