Skip to content

Commit 87231df

Browse files
cosminachoclaude
andcommitted
fix: accept non-JWT access tokens (e.g. reference tokens)
UiPath access tokens are not guaranteed to be JWTs — Coded Agents can authenticate with opaque tokens (e.g. reference tokens). PlatformSettings previously rejected any non-JWT token at construction with "Invalid access token: expected JWT with at least 2 dot-separated parts" because validation assumed every token is a JWT. Token introspection is now best-effort, with no assumptions about token shape (no prefix sniffing): - Add try_parse_access_token(), which returns the JWT payload or None. - is_token_expired() returns False when the token is not a parseable JWT. - The settings validator only extracts client_id when the token parses as a JWT; otherwise client_id stays None. Bumps core and langchain to 1.13.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3c8ebcc commit 87231df

10 files changed

Lines changed: 118 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to `uipath_llm_client` (core package) will be documented in this file.
44

5+
## [1.13.1] - 2026-06-09
6+
7+
### Fixed
8+
- `PlatformSettings` now accepts non-JWT access tokens (e.g. opaque UiPath reference tokens) for `UIPATH_ACCESS_TOKEN`. Previously any token that was not a parseable JWT failed validation with "Invalid access token: expected JWT with at least 2 dot-separated parts". Token introspection is now best-effort: `is_token_expired` returns `False` when the token is not a parseable JWT, and the settings validator only extracts `client_id` when the token is a parseable JWT. Added `try_parse_access_token` helper in `uipath.llm_client.settings.platform.utils`.
9+
510
## [1.13.0] - 2026-05-27
611

712
### Added

packages/uipath_langchain_client/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to `uipath_langchain_client` will be documented in this file.
44

5+
## [1.13.1] - 2026-06-09
6+
7+
### Fixed
8+
- Picks up the core `uipath-llm-client` 1.13.1 fix allowing non-JWT access tokens (e.g. opaque UiPath reference tokens) as `UIPATH_ACCESS_TOKEN`, so LangChain clients built on `PlatformSettings` no longer fail validation with "Invalid access token: expected JWT with at least 2 dot-separated parts".
9+
510
## [1.13.0] - 2026-05-27
611

712
### Changed

packages/uipath_langchain_client/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
88
"langchain>=1.2.15,<2.0.0",
9-
"uipath-llm-client>=1.13.0,<2.0.0",
9+
"uipath-llm-client>=1.13.1,<2.0.0",
1010
]
1111

1212
[project.optional-dependencies]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__title__ = "UiPath LangChain Client"
22
__description__ = "A Python client for interacting with UiPath's LLM services via LangChain."
3-
__version__ = "1.13.0"
3+
__version__ = "1.13.1"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__title__ = "UiPath LLM Client"
22
__description__ = "A Python client for interacting with UiPath's LLM services."
3-
__version__ = "1.13.0"
3+
__version__ = "1.13.1"

src/uipath/llm_client/settings/platform/settings.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@
3030

3131
from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings
3232
from uipath.llm_client.settings.constants import ApiType, RoutingMode
33-
from uipath.llm_client.settings.platform.utils import is_token_expired, parse_access_token
33+
from uipath.llm_client.settings.platform.utils import (
34+
is_token_expired,
35+
try_parse_access_token,
36+
)
3437

3538

3639
class PlatformBaseSettings(UiPathBaseSettings):
@@ -81,8 +84,11 @@ def validate_environment(self) -> Self:
8184
"Access token is expired. Try running `uipath auth` to refresh the token."
8285
)
8386

84-
parsed_token_data = parse_access_token(access_token)
85-
self.client_id = parsed_token_data.get("client_id")
87+
# The access token may be any form, not just a JWT (e.g. opaque
88+
# reference tokens). Only extract client_id when it is a parseable JWT.
89+
parsed_token_data = try_parse_access_token(access_token)
90+
if parsed_token_data is not None:
91+
self.client_id = parsed_token_data.get("client_id")
8692
return self
8793

8894
@staticmethod

src/uipath/llm_client/settings/platform/utils.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import binascii
23
import json
34
import time
45
from typing import Any
@@ -26,16 +27,41 @@ def parse_access_token(access_token: str) -> dict[str, Any]:
2627
raise ValueError(f"Invalid access token: failed to decode payload: {e}") from e
2728

2829

30+
def try_parse_access_token(access_token: str) -> dict[str, Any] | None:
31+
"""Best-effort parse of an access token's JWT payload.
32+
33+
Access tokens are not guaranteed to be JWTs — UiPath also issues opaque
34+
tokens (e.g. reference tokens) that carry no client-readable claims. This
35+
returns the decoded payload when the token is a parseable JWT, or ``None``
36+
when it is not, instead of raising.
37+
38+
Args:
39+
access_token: An access token string of any form.
40+
41+
Returns:
42+
The decoded payload as a dictionary, or ``None`` if the token is not a
43+
parseable JWT.
44+
"""
45+
try:
46+
return parse_access_token(access_token)
47+
except (ValueError, binascii.Error):
48+
return None
49+
50+
2951
def is_token_expired(token: str) -> bool:
30-
"""Check whether a JWT access token has expired.
52+
"""Check whether an access token has expired.
3153
3254
Args:
33-
token: A JWT token string.
55+
token: An access token string of any form.
3456
3557
Returns:
36-
True if the token is expired, False if it is still valid or has no ``exp`` claim.
58+
True if the token is a JWT with an ``exp`` claim in the past; False if
59+
it is still valid, has no ``exp`` claim, or is an opaque token whose
60+
expiry cannot be inspected.
3761
"""
38-
token_data = parse_access_token(token)
62+
token_data = try_parse_access_token(token)
63+
if token_data is None:
64+
return False
3965
exp = token_data.get("exp")
4066
if exp is None:
4167
return False

tests/core/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,14 @@ def platform_env_vars():
5757

5858
@pytest.fixture
5959
def mock_platform_auth():
60-
"""Patches is_token_expired and parse_access_token for PlatformSettings tests."""
60+
"""Patches is_token_expired and try_parse_access_token for PlatformSettings tests."""
6161
with (
6262
patch(
6363
"uipath.llm_client.settings.platform.settings.is_token_expired",
6464
return_value=False,
6565
),
6666
patch(
67-
"uipath.llm_client.settings.platform.settings.parse_access_token",
67+
"uipath.llm_client.settings.platform.settings.try_parse_access_token",
6868
return_value={"client_id": "test-client-id"},
6969
),
7070
):

tests/core/features/settings/test_platform.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ def test_validation_fails_on_expired_token(self):
236236
return_value=True,
237237
),
238238
patch(
239-
"uipath.llm_client.settings.platform.settings.parse_access_token",
239+
"uipath.llm_client.settings.platform.settings.try_parse_access_token",
240240
return_value={"client_id": "test-client-id"},
241241
),
242242
):
@@ -250,6 +250,27 @@ def test_validation_fails_on_expired_token(self):
250250
with pytest.raises(ValueError, match="Access token is expired"):
251251
PlatformSettings()
252252

253+
@pytest.mark.parametrize(
254+
"token",
255+
["rt_abc123", "some-opaque-token", "not.a.valid.jwt"],
256+
)
257+
def test_non_jwt_token_is_accepted(self, token):
258+
"""Non-JWT access tokens (e.g. opaque reference tokens) are accepted.
259+
260+
The token may be any form; client_id is only extracted when it is a
261+
parseable JWT, otherwise it stays None.
262+
"""
263+
env = {
264+
"UIPATH_ACCESS_TOKEN": token,
265+
"UIPATH_URL": "https://cloud.uipath.com/org/tenant",
266+
"UIPATH_TENANT_ID": "test-tenant-id",
267+
"UIPATH_ORGANIZATION_ID": "test-org-id",
268+
}
269+
with patch.dict(os.environ, env, clear=True):
270+
settings = PlatformSettings()
271+
assert settings.access_token.get_secret_value() == token
272+
assert settings.client_id is None
273+
253274
def test_validate_byo_model_is_noop(self, platform_env_vars, mock_platform_auth):
254275
"""Test validate_byo_model does nothing (no-op)."""
255276
with patch.dict(os.environ, platform_env_vars, clear=True):

tests/core/features/test_platform_utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
import pytest
88

9-
from uipath.llm_client.settings.platform.utils import is_token_expired, parse_access_token
9+
from uipath.llm_client.settings.platform.utils import (
10+
is_token_expired,
11+
parse_access_token,
12+
try_parse_access_token,
13+
)
1014

1115

1216
class TestParseAccessToken:
@@ -69,3 +73,40 @@ def test_missing_exp_claim(self):
6973
token = f"header.{encoded_payload}.signature"
7074

7175
assert is_token_expired(token) is False
76+
77+
@pytest.mark.parametrize(
78+
"token",
79+
[
80+
"rt_abc123", # opaque reference token
81+
"not-a-jwt-token", # no dot-separated parts
82+
"header.!!!invalid-base64!!!.signature", # undecodable payload
83+
"", # empty
84+
],
85+
)
86+
def test_opaque_token_not_expired(self, token):
87+
# Tokens that are not parseable JWTs cannot be introspected, so they
88+
# are never treated as expired (and must not raise during parsing).
89+
assert is_token_expired(token) is False
90+
91+
92+
class TestTryParseAccessToken:
93+
def test_valid_jwt_returns_payload(self):
94+
payload = {"sub": "user-123", "client_id": "abc"}
95+
encoded_payload = (
96+
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
97+
)
98+
token = f"header.{encoded_payload}.signature"
99+
100+
assert try_parse_access_token(token) == payload
101+
102+
@pytest.mark.parametrize(
103+
"token",
104+
[
105+
"rt_abc123", # opaque reference token
106+
"not-a-jwt-token", # no dot-separated parts
107+
"header.!!!invalid-base64!!!.signature", # undecodable payload (binascii.Error)
108+
"", # empty
109+
],
110+
)
111+
def test_non_jwt_returns_none(self, token):
112+
assert try_parse_access_token(token) is None

0 commit comments

Comments
 (0)