Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1582cde
Send X-Authorization header after JWT login
irynamatveieva Jul 29, 2026
b9f252b
Address PR review: reject mixed auth modes, dedupe header install, gu…
irynamatveieva Aug 3, 2026
aea18bb
Address re-review: tighten auth arg validation, share JWT factories, …
irynamatveieva Aug 3, 2026
d52c725
Address third review: symmetric auth validation, pin editions, tidy s…
irynamatveieva Aug 3, 2026
c304eed
Address fourth review: drop vestigial auth_type, sync docstrings, doc…
irynamatveieva Aug 3, 2026
4dda84f
Address fifth review: correct token= pairing docs, guard docs overlay
irynamatveieva Aug 3, 2026
6eec0f2
Address sixth review: fail loudly on missing docs, honour None contract
irynamatveieva Aug 3, 2026
5180dfa
Validate tb-examples at its common/ source rather than the ce copy
irynamatveieva Aug 3, 2026
40d19a2
Address seventh review: symmetric docs discovery, heading-anchored se…
irynamatveieva Aug 3, 2026
fe2be8b
Address eighth review: close the device-row hole, assert emitted headers
irynamatveieva Aug 3, 2026
bbf84bf
Address ninth review: anchor heading matches, finish the annotation pass
irynamatveieva Aug 3, 2026
028f86c
Address tenth review: block waiters on in-flight refresh, reject empt…
irynamatveieva Aug 4, 2026
3e8fb5d
Address eleventh review: bound the auth round-trip, share the edition…
irynamatveieva Aug 4, 2026
835fef0
Address twelfth review: make the auth timeout a real ceiling, unify e…
irynamatveieva Aug 4, 2026
a0f7869
Address thirteenth review: keep auth redirects working, fail loudly o…
irynamatveieva Aug 4, 2026
b04fdf0
Address fourteenth review: stop following auth redirects
irynamatveieva Aug 4, 2026
4695ed7
Address fifteenth review: pin raise_on_redirect, tie the remedy to a …
irynamatveieva Aug 4, 2026
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ client = ThingsboardClient(
)
```

**No authentication:** All auth arguments are optional. Omit them to get a client that
sends no `X-Authorization` header, for use with the `/api/noauth` endpoints.

```python
client = ThingsboardClient("http://localhost:9090")
```

The three authenticated modes are mutually exclusive — passing more than one raises
`ValueError`, as does passing `username=` without `password=` or vice versa, or
`refresh_token=` without `token=`. `token=` on its own is valid; it simply means the
token is never refreshed.

An empty string is also rejected, so `api_key=os.environ.get("TB_API_KEY", "")` raises
rather than building a client that silently sends no credentials. Omit the auth
arguments entirely for an unauthenticated client.

## Resource cleanup

Use the client as a context manager so `close()` is called automatically on exit:
Expand Down
34 changes: 34 additions & 0 deletions ce/docs/tb-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,40 @@ from tb_ce_client import ThingsboardClient
client = ThingsboardClient("http://localhost:9090", api_key="your-api-key")
```

## Pre-existing Token

Injects an externally obtained JWT; no login call is made.

```python
from tb_ce_client import ThingsboardClient

client = ThingsboardClient(
"http://localhost:9090",
token="eyJhbGciOi...",
refresh_token="eyJhbGciOi...",
)
```

## No Authentication

All auth arguments are optional. Omit them for a client that sends no
`X-Authorization` header, for use with the `/api/noauth` endpoints.

```python
from tb_ce_client import ThingsboardClient

client = ThingsboardClient("http://localhost:9090")
```

The three authenticated modes above are mutually exclusive — passing more than one
raises `ValueError`, as does passing `username=` without `password=` or vice versa, or
`refresh_token=` without `token=`. `token=` on its own is valid; it simply means the
token is never refreshed.

An empty string is also rejected, so `api_key=os.environ.get("TB_API_KEY", "")` raises
rather than building a client that silently sends no credentials. Omit the auth
arguments entirely for an unauthenticated client.

## Context Manager

```python
Expand Down
168 changes: 140 additions & 28 deletions ce/tb_ce_client/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,60 @@
# Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms)
AVG_REQUEST_TIMEOUT_MS = 30_000

# Wall-clock ceiling for a single raw auth call. urllib3 defaults to no timeout at all,
# and every API thread now blocks behind an in-flight refresh, so an unresponsive auth
# endpoint would otherwise hang the whole process rather than one thread.
#
# Applied as Timeout(total=...) rather than a bare float, which would set connect and
# read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one
# request — no retry, no redirect — total is the per-call ceiling; urllib3 gives each
# attempt its own budget, so any allowance there would multiply this number.
#
# A blocked thread can wait twice this: _do_refresh_token falls back to _do_login, which
# is a second call. That is the worst case behind one refresh, and the fallback is what
# recovers an expired refresh token, so the 2x is deliberate rather than a leak.
DEFAULT_AUTH_TIMEOUT_MS = 30_000

# Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that
# exactly one request goes out and DEFAULT_AUTH_TIMEOUT_MS means what it says.
#
# No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults
# allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are
# not idempotent, and the timeout exists precisely to bound how long every other thread
# sits blocked, so a single attempt is the deliberate trade — a caller wanting tolerance
# should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that
# would let us have both.
#
# No redirects either, which is a departure from the generated RESTClientObject:
# - urllib3 clones the timeout per hop instead of drawing down a shared budget, so
# following N redirects costs (1 + N) x auth_timeout_ms on the one round-trip every
# other API thread is blocked behind.
# - the body is re-sent to whatever Location names, with no same-origin restriction,
# so a redirect out of the configured server hands username/password to a third host
# and _do_login would install the token it returns.
# The case this gives up is a deployment that redirects auth (a proxy forcing https,
# path normalisation). Failing loudly is the better answer there: on an http -> https
# redirect the credentials have already gone out in cleartext, so the fix is to point url=
# at the redirect target's base URL, which _raw_post's error tells the caller to do.
#
# total is set explicitly: it defaults to 10, and leaving it there would contradict the
# "exactly one request" this whole block is for, even though the per-class zeros already
# exhaust first.
#
# Spelled redirect=False rather than redirect=0, which is not the same thing: Retry
# normalises False to 0 *and* clears raise_on_redirect, and that is what lets a 3xx come
# back as a response for the status check below to report. With redirect=0 the same reply
# raises MaxRetryError("too many redirects") instead and the remedy never reaches the
# caller. Both spellings leave .redirect == 0, so only raise_on_redirect tells them apart.
_AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False)

# Security scheme name and prefixes dictated by the generated configuration.py.
# Keep them in one place so a spec regeneration that renames the scheme has a
# single owner instead of literals scattered across client.py and _auth.py.
_SECURITY_SCHEME = "ApiKeyForm"
_JWT_PREFIX = "Bearer"
_API_KEY_PREFIX = "ApiKey"


# ---------------------------------------------------------------------------
# _TokenInfo
Expand All @@ -51,8 +105,8 @@ class _TokenInfo:

def __init__(
self,
token,
refresh_token,
token: "str | None",
refresh_token: "str | None",
token_exp_ts: int,
refresh_exp_ts: int,
clock_diff: int,
Expand Down Expand Up @@ -101,27 +155,46 @@ class _AuthManager:

Mirrors the Java ThingsboardClient.java inner AuthManager class.

Thread safety: a threading.Lock protects the _refreshing flag so that only
one concurrent API thread triggers a refresh call. Other threads wait at the
lock and skip the refresh once the first thread completes.
Thread safety: a threading.Condition guards the _refreshing flag so that only
one concurrent API thread performs a refresh. The others block until that
refresh finishes and then use its result — they must not proceed meanwhile,
since the token they would send is the expired one being replaced.

The auth mode is decided once in __init__ and never re-derived per request.
"""

def __init__(self, base_url: str, auth_type: str, api_key=None):
def __init__(
self,
base_url: str,
api_key: "str | None" = None,
auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS,
):
"""
Args:
base_url: ThingsBoard server URL (e.g. "http://tb-server:9090").
Trailing slashes are stripped.
auth_type: Either 'jwt' (username/password or token) or 'api_key'.
api_key: The API key string when auth_type='api_key', else None.
base_url: ThingsBoard server URL (e.g. "http://tb-server:9090").
Trailing slashes are stripped.
api_key: The API key string for API key auth, or None for JWT auth
(username/password or an externally supplied token).
auth_timeout_ms: Ceiling for a single /api/auth call, in milliseconds.
Bounds how long every other thread can be blocked behind a
refresh, so it is a knob a slow on-prem server or a
latency-sensitive caller will want to change.
"""
# urllib3.Timeout rejects a non-positive total, but it would only raise inside
# _raw_post — where _do_refresh_token and _do_login catch Exception and log —
# so a bad value would construct fine and then silently never refresh.
if auth_timeout_ms <= 0:
raise ValueError(f"auth_timeout_ms must be positive; got {auth_timeout_ms}")
self._auth_timeout_s = auth_timeout_ms / 1000
self._base_url = base_url.rstrip("/")
self._auth_type = auth_type
self._lock = threading.Lock()
self._is_api_key = api_key is not None
self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX
self._refresh_state = threading.Condition()
self._refreshing = False
self._username = None
self._password = None

if auth_type == "api_key":
if self._is_api_key:
self._token_info = _TokenInfo(api_key, None, -1, -1, 0)
else:
self._token_info = _TokenInfo.EMPTY # type: ignore[attr-defined]
Expand All @@ -136,33 +209,49 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str)
self._password = password
self._token_info = self._build_token_info(token, refresh_token)

def set_external_token(self, token: str, refresh_token=None) -> None:
"""Set a pre-existing token without storing login credentials."""
self._token_info = self._build_token_info(token, refresh_token or "")
def set_external_token(self, token: str, refresh_token: "str | None" = None) -> None:
"""Set a pre-existing token without storing login credentials.

refresh_token is passed through as-is so that omitting it leaves
get_refresh_token() returning None, as its docstring promises.
"""
self._token_info = self._build_token_info(token, refresh_token)

def get_token(self):
def get_token(self) -> "str | None":
"""Return the current access token, or None if not yet set."""
return self._token_info.token

def get_refresh_token(self):
def get_refresh_token(self) -> "str | None":
"""Return the current refresh token, or None if not available."""
return self._token_info.refresh_token

def install_header(self, configuration) -> None:
"""Write the current token into configuration's X-Authorization slots.

Configuration.auth_settings() emits the header only when the security
scheme is already present in configuration.api_key, so the slot has to be
seeded at construction time before the hook can ever take over.
"""
token = self._token_info.token
if not token:
# No auth configured (e.g. /api/noauth usage) — leave the slot absent
# so auth_settings() emits no header at all.
return
configuration.api_key[_SECURITY_SCHEME] = token
configuration.api_key_prefix[_SECURITY_SCHEME] = self._header_prefix

def hook(self, configuration) -> None:
"""refresh_api_key_hook implementation.

Called by Configuration.get_api_key_with_prefix() immediately before
every API request assembles its X-Authorization header. Checks token
expiry and refreshes if needed, then updates configuration.api_key.
"""
if self._auth_type != "jwt":
if self._is_api_key:
# API key auth — hook is a no-op; the key is set at construction time
return
self._refresh_if_needed()
token = self._token_info.token
if token:
configuration.api_key["ApiKeyForm"] = token
configuration.api_key_prefix["ApiKeyForm"] = "Bearer"
self.install_header(configuration)

# ------------------------------------------------------------------
# Internal refresh logic
Expand All @@ -171,9 +260,14 @@ def hook(self, configuration) -> None:
def _refresh_if_needed(self) -> None:
"""Check token expiry and trigger refresh if the estimated server time
exceeds the token expiry (with AVG_REQUEST_TIMEOUT buffer)."""
with self._lock:
with self._refresh_state:
if self._refreshing:
# Another thread is already refreshing — skip
# Another thread is already refreshing. Block rather than return:
# returning here would send the expired token that thread is busy
# replacing, and nothing retries the resulting 401.
self._refresh_state.wait_for(lambda: not self._refreshing)
# Its outcome is ours. Refreshing again on failure would multiply one
# failed round-trip by however many threads were waiting.
return
info = self._token_info
if info.token is None or info.token_exp_ts < 0:
Expand All @@ -196,8 +290,9 @@ def _refresh_if_needed(self) -> None:
elif self._username:
self._do_login()
finally:
with self._lock:
with self._refresh_state:
self._refreshing = False
self._refresh_state.notify_all()

def _do_refresh_token(self, info: "_TokenInfo") -> None:
"""POST to /api/auth/token with the refresh token. Falls back to login on error."""
Expand Down Expand Up @@ -226,19 +321,36 @@ def _raw_post(self, path: str, body: bytes) -> dict:
if we used ApiClient here, the hook would fire again while already
inside the hook, causing infinite recursion (mirrors Java's pattern
of using a separate raw HttpClient for AuthManager calls).

Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path
for every thread waiting on a refresh, so it makes exactly one request — no
retry, no redirect. See _AUTH_RETRIES for why both trades are deliberate.
"""
http = urllib3.PoolManager()
response = http.request(
"POST",
self._base_url + path,
body=body,
headers={"Content-Type": "application/json"},
timeout=urllib3.Timeout(total=self._auth_timeout_s),
retries=_AUTH_RETRIES,
)
if response.status != 200:
raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}")
# A redirect arrives here rather than being followed — say so, since the
# remedy is specific and not guessable from the status alone. Gated on
# get_redirect_location() rather than the 3xx range because that is the
# predicate urllib3 itself would have followed: it covers 301/302/303/307/308
# and excludes 300 and 304, which carry no Location worth chasing.
hint = (
"; auth requests do not follow redirects, so set url= to the redirect "
"target's base URL instead."
if response.get_redirect_location()
else ""
)
raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}")
return json.loads(response.data)

def _build_token_info(self, token: str, refresh_token: str) -> "_TokenInfo":
def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo":
"""Parse JWT claims from token and refresh_token; compute clock_diff."""
now_ms = int(time.time() * 1000)
token_exp = _parse_jwt_claim_ms(token, "exp")
Expand Down
Loading
Loading