Skip to content

Commit 2b94af1

Browse files
feat(core): add KeyAuth.close() to stop the token refresh thread (#10303)
relates to #10253 --------- Co-authored-by: cgoetz-inovex <carlo.goetz@inovex.de>
1 parent 1ff0ab5 commit 2b94af1

50 files changed

Lines changed: 182 additions & 60 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
## Release (2026-MM-DD)
22

33
- `core`:
4+
- [v0.4.0](core/CHANGELOG.md#v040)
5+
- **Feature:** Add `KeyAuth.close()` to stop the background token refresh thread and its worker, so a discarded instance no longer keeps running and requesting tokens
46
- [v0.3.1](core/CHANGELOG.md#v031)
57
- **Fix:** Refresh access tokens five minutes before expiration instead of after, and wait for in-progress refreshes before authorizing requests
68
- `alb`:

‎core/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## v0.4.0
2+
- **Feature:** Add `KeyAuth.close()` to stop the background token refresh thread and its worker, so a discarded instance (for example after key rotation) no longer keeps running and requesting tokens
3+
14
## v0.3.1
25
- **Fix:** Refresh access tokens five minutes before expiration instead of after, and wait for in-progress refreshes before authorizing requests
36

‎core/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "stackit-core"
3-
version = "v0.3.1"
3+
version = "v0.4.0"
44
description = "Core functionality for the STACKIT SDK for Python"
55
authors = [{ name = "STACKIT Developer Tools", email = "developer-tools@stackit.cloud" }]
66
requires-python = ">=3.10,<4.0"

‎core/src/stackit/core/auth_methods/key_auth.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class KeyAuth(AuthBase):
5454
executor: ThreadPoolExecutor
5555
refresh_future: Optional[futures.Future]
5656
service_account_key: ServiceAccountKey
57+
_stop: threading.Event
58+
_refresh_thread: Optional[threading.Thread]
5759

5860
def __init__(
5961
self,
@@ -66,6 +68,8 @@ def __init__(
6668
self.executor = ThreadPoolExecutor(max_workers=1)
6769
self.access_token = None
6870
self.refresh_future = None
71+
self._stop = threading.Event()
72+
self._refresh_thread = None
6973
self.__create_initial_token()
7074
self.__fetch_token_from_endpoint()
7175
self.__start_token_refresh_task()
@@ -92,6 +96,24 @@ def __call__(self, r: Request) -> Request:
9296
r.headers["Authorization"] = f"Bearer {self.access_token}"
9397
return r
9498

99+
def close(self) -> None:
100+
"""Stop the background token refresh and release its worker.
101+
102+
Call this when the instance is no longer needed, for example after
103+
replacing it during key rotation in a long-running process. Without it
104+
the refresh thread keeps running and keeps requesting tokens. When it
105+
returns, no refresh is running and none will start. Safe to call more
106+
than once.
107+
"""
108+
# Set under the lock so the refresh loop cannot submit after shutdown.
109+
with self.lock:
110+
self._stop.set()
111+
atexit.unregister(self.__shutdown)
112+
thread = self._refresh_thread
113+
if thread is not None and thread is not threading.current_thread():
114+
thread.join()
115+
self.executor.shutdown(wait=True, cancel_futures=True)
116+
95117
def __create_initial_token(self) -> None:
96118
payload = {
97119
"iss": self.service_account_key.credentials.issuer,
@@ -126,9 +148,10 @@ def __fetch_token_from_endpoint(self) -> None:
126148

127149
def __start_token_refresh_task(self):
128150
def token_refresh_task():
129-
while True:
130-
time.sleep(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds())
151+
while not self._stop.wait(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds()):
131152
with self.lock:
153+
if self._stop.is_set():
154+
return
132155
if self.refresh_future is not None and self.refresh_future.done():
133156
self.refresh_future.result()
134157
if self.__is_token_expired(self.access_token) and (
@@ -139,6 +162,7 @@ def token_refresh_task():
139162
thread = threading.Thread(target=token_refresh_task)
140163
thread.daemon = True
141164
thread.start()
165+
self._refresh_thread = thread
142166

143167
def __refresh_token(self):
144168
with self.lock:
@@ -156,6 +180,8 @@ def __refresh_token(self):
156180

157181
last_exception = None
158182
for _ in range(self.MAX_REFRESH_RETRIES):
183+
if self._stop.is_set():
184+
return
159185
try:
160186
response = requests.post(self.token_endpoint, data=body, timeout=self.timeout)
161187
response.raise_for_status()

‎core/tests/core/test_auth.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from pathlib import Path, PurePath
33
from threading import Event, Lock, Thread
44

5+
import time
6+
from datetime import timedelta
7+
58
import pytest
69
import json
710
import jwt
@@ -308,6 +311,7 @@ def test_call_propagates_token_refresh_failure(self, refresh_already_failed):
308311
auth.refresh_token = jwt.encode({"exp": 4_000_000_000}, "x" * 32, algorithm="HS256")
309312
auth.token_endpoint = KeyAuth.DEFAULT_TOKEN_ENDPOINT
310313
auth.refresh_future = None
314+
auth._stop = Event()
311315
original_token = auth.access_token
312316
request = Request("GET", "https://example.com")
313317
refresh_error = requests.RequestException("refresh failed")
@@ -382,3 +386,90 @@ def result(self):
382386

383387
assert not call_thread.is_alive()
384388
assert request.headers["Authorization"] == f"Bearer {fresh_token}"
389+
390+
391+
class TestKeyAuthClose:
392+
"""A discarded KeyAuth must be able to stop its background token refresh."""
393+
394+
@staticmethod
395+
def _make_auth(service_account_key_file_json, mock_post, access_token):
396+
service_account_key = ServiceAccountKey.model_validate_json(service_account_key_file_json)
397+
service_account_key.credentials.private_key = "test-private-key"
398+
response = Mock()
399+
response.raise_for_status.return_value = None
400+
response.json.return_value = {
401+
"access_token": access_token,
402+
"refresh_token": jwt.encode({"exp": 4102444800}, "secret", algorithm="HS256"),
403+
}
404+
mock_post.return_value = response
405+
return KeyAuth(service_account_key)
406+
407+
def test_close_stops_the_refresh_thread_and_further_token_requests(self, service_account_key_file_json):
408+
def set_initial_token(auth):
409+
auth.initial_token = "test-initial-token"
410+
411+
expired = jwt.encode({"exp": 1}, "secret", algorithm="HS256")
412+
with (
413+
patch.object(KeyAuth, "_KeyAuth__create_initial_token", new=set_initial_token),
414+
patch.object(KeyAuth, "TOKEN_EXPIRY_CHECK_INTERVAL", timedelta(milliseconds=10)),
415+
patch("requests.post") as mock_post,
416+
):
417+
# An access token that is always expired keeps the refresh loop busy.
418+
auth = self._make_auth(service_account_key_file_json, mock_post, expired)
419+
thread = auth._refresh_thread
420+
assert thread is not None and thread.is_alive()
421+
422+
auth.close()
423+
424+
assert not thread.is_alive()
425+
requests_after_close = mock_post.call_count
426+
time.sleep(0.1) # ten refresh intervals
427+
assert mock_post.call_count == requests_after_close
428+
429+
def test_close_waits_for_a_refresh_in_progress(self, service_account_key_file_json):
430+
def set_initial_token(auth):
431+
auth.initial_token = "test-initial-token"
432+
433+
expired = jwt.encode({"exp": 1}, "secret", algorithm="HS256")
434+
refresh_started = Event()
435+
with (
436+
patch.object(KeyAuth, "_KeyAuth__create_initial_token", new=set_initial_token),
437+
patch.object(KeyAuth, "TOKEN_EXPIRY_CHECK_INTERVAL", timedelta(milliseconds=10)),
438+
patch("requests.post") as mock_post,
439+
):
440+
auth = self._make_auth(service_account_key_file_json, mock_post, expired)
441+
initial_response = mock_post.return_value
442+
443+
def slow_refresh(*args, **kwargs):
444+
refresh_started.set()
445+
time.sleep(0.2)
446+
return initial_response
447+
448+
mock_post.side_effect = slow_refresh
449+
assert refresh_started.wait(timeout=5)
450+
451+
auth.close()
452+
453+
assert auth.refresh_future is not None and auth.refresh_future.done()
454+
requests_after_close = mock_post.call_count
455+
time.sleep(0.1)
456+
assert mock_post.call_count == requests_after_close
457+
458+
def test_close_is_idempotent(self, service_account_key_file_json):
459+
def set_initial_token(auth):
460+
auth.initial_token = "test-initial-token"
461+
462+
valid = jwt.encode({"exp": 4102444800}, "secret", algorithm="HS256")
463+
with (
464+
patch.object(KeyAuth, "_KeyAuth__create_initial_token", new=set_initial_token),
465+
patch("requests.post") as mock_post,
466+
patch("stackit.core.auth_methods.key_auth.atexit") as mock_atexit,
467+
):
468+
auth = self._make_auth(service_account_key_file_json, mock_post, valid)
469+
470+
auth.close()
471+
auth.close()
472+
473+
assert not auth._refresh_thread.is_alive()
474+
assert auth.executor._shutdown
475+
mock_atexit.unregister.assert_called_with(auth._KeyAuth__shutdown)

‎core/uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎services/alb/uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎services/albwaf/uv.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎services/authorization/uv.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎services/automation/uv.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)