Skip to content

Commit c05cc6f

Browse files
Cache tokens minted by DatabricksOidcTokenSource (databricks#1460)
## What `DatabricksOidcTokenSource` (Workload Identity Federation / account-wide token federation) minted a fresh `/oidc/v1/token` exchange on **every** authenticated API call. Its `token()` always called `_exchange_id_token()`, which constructed a brand-new `oauth.ClientCredentials` each time — discarding the `Refreshable` caching/locking/refresh machinery already built into it. This change makes `DatabricksOidcTokenSource` extend `oauth.Refreshable` and moves the exchange logic into `refresh()`, so the minted token is cached and reused until it is stale or expired (mirroring the existing M2M/PAT flows). Each refresh re-fetches the ID token from the `id_token_source`, so short-lived **rotating** ID tokens are handled transparently. ## Why The previous behaviour: - added an extra RTT to every request, - amplified transient federation-policy errors by the number of API calls, - and hit OIDC token-endpoint rate limits. A real-world 470 MiB Volume upload minted dozens of tokens in under 30s, with bursts of 6–7 concurrent mints. ## Tests Added unit tests in `tests/test_oidc.py`: - token is minted once and reused across calls (caching), - an expired token triggers a re-exchange that fetches a **fresh** ID token (rotation), - missing host still raises `ValueError`. All existing auth tests pass (`test_oauth`, `test_oidc`, `test_credentials_provider`, `test_oidc_token_supplier`). Added a `NEXT_CHANGELOG.md` Bug Fixes entry. Signed-off-by: Hector Castejon Diaz <hector.castejon@databricks.com>
1 parent 528878e commit c05cc6f

3 files changed

Lines changed: 99 additions & 6 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
### Bug Fixes
1010

11+
* Cache tokens minted by `DatabricksOidcTokenSource` (Workload Identity Federation / account-wide token federation). Previously a fresh `/oidc/v1/token` exchange was performed on every authenticated API call, adding latency, amplifying transient federation-policy errors, and hitting OIDC token-endpoint rate limits. The token source now reuses the cached token until it is stale or expired, fetching a fresh ID token on each refresh to support rotation.
12+
1113
### Documentation
1214

1315
### Breaking Changes

databricks/sdk/oidc.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,15 @@ def id_token(self) -> IdToken:
123123
return IdToken(jwt=token)
124124

125125

126-
class DatabricksOidcTokenSource(oauth.TokenSource):
126+
class DatabricksOidcTokenSource(oauth.Refreshable):
127127
"""A TokenSource which exchanges a token using Workload Identity Federation.
128128
129+
The exchanged token is cached and reused across calls. It is only refreshed
130+
when it is stale or expired, mirroring the M2M/PAT auth flows. This avoids
131+
minting a fresh token on every authenticated API call. Each refresh fetches a
132+
fresh ID token from the ``id_token_source``, so short-lived (rotating) ID
133+
tokens are handled transparently.
134+
129135
Parameters
130136
----------
131137
host : str
@@ -164,15 +170,20 @@ def __init__(
164170
self._client_id = client_id
165171
self._account_id = account_id
166172
self._audience = audience
167-
self._disable_async = disable_async
168173
self._scopes = scopes
174+
# Refreshable.__init__ stores disable_async as self._disable_async, which
175+
# _exchange_id_token reads — no need to duplicate it here.
176+
super().__init__(disable_async=disable_async)
177+
178+
def refresh(self) -> oauth.Token:
179+
"""Mint a fresh token by exchanging the ID token.
169180
170-
def token(self) -> oauth.Token:
171-
"""Get a token by exchanging the ID token.
181+
Called by the base :class:`oauth.Refreshable` only when the cached token
182+
is missing, stale, or expired. The result is cached by the base class.
172183
173184
Returns
174185
-------
175-
dict
186+
oauth.Token
176187
The exchanged token.
177188
178189
Raises

tests/test_oidc.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from dataclasses import dataclass
2+
from datetime import datetime, timedelta, timezone
23
from typing import Optional, Tuple
34

45
import pytest
56

6-
from databricks.sdk import oidc
7+
from databricks.sdk import oauth, oidc
78

89

910
@dataclass
@@ -107,3 +108,82 @@ def test_file_id_token_source(test_case: FileTestCase, tmp_path):
107108
source.id_token()
108109
else:
109110
assert source.id_token() == test_case.want
111+
112+
113+
class _CountingIdTokenSource(oidc.IdTokenSource):
114+
"""An IdTokenSource that counts how many times it is invoked and returns a
115+
fresh jwt each time, so we can assert ID-token rotation on refresh."""
116+
117+
def __init__(self):
118+
self.calls = 0
119+
120+
def id_token(self) -> oidc.IdToken:
121+
self.calls += 1
122+
return oidc.IdToken(jwt=f"id-token-{self.calls}")
123+
124+
125+
def _make_token_source(id_token_source, exchanges, expiry):
126+
"""Build a DatabricksOidcTokenSource whose exchange step is stubbed to record
127+
the ID token it received and return a token with the given expiry."""
128+
ts = oidc.DatabricksOidcTokenSource(
129+
host="https://test.cloud.databricks.com",
130+
token_endpoint="https://test.cloud.databricks.com/oidc/v1/token",
131+
id_token_source=id_token_source,
132+
client_id="test-client-id",
133+
)
134+
135+
def _exchange(id_token: oidc.IdToken) -> oauth.Token:
136+
exchanges.append(id_token.jwt)
137+
return oauth.Token(access_token=f"exchanged-{id_token.jwt}", token_type="Bearer", expiry=expiry)
138+
139+
ts._exchange_id_token = _exchange
140+
return ts
141+
142+
143+
def test_databricks_oidc_token_source_caches_token():
144+
# A valid token (1h TTL) should be minted once and reused across calls,
145+
# rather than exchanging the ID token on every token() call.
146+
id_token_source = _CountingIdTokenSource()
147+
exchanges = []
148+
ts = _make_token_source(
149+
id_token_source,
150+
exchanges,
151+
expiry=datetime.now(tz=timezone.utc) + timedelta(hours=1),
152+
)
153+
154+
first = ts.token()
155+
for _ in range(5):
156+
again = ts.token()
157+
assert again.access_token == first.access_token
158+
159+
assert exchanges == ["id-token-1"]
160+
assert id_token_source.calls == 1
161+
162+
163+
def test_databricks_oidc_token_source_refreshes_when_expired():
164+
# An already-expired token must trigger a re-exchange, and the refresh must
165+
# fetch a *fresh* ID token (rotation), not reuse the original jwt.
166+
id_token_source = _CountingIdTokenSource()
167+
exchanges = []
168+
ts = _make_token_source(
169+
id_token_source,
170+
exchanges,
171+
expiry=datetime.now(tz=timezone.utc) - timedelta(seconds=1),
172+
)
173+
174+
ts.token()
175+
ts.token()
176+
177+
assert exchanges == ["id-token-1", "id-token-2"]
178+
assert id_token_source.calls == 2
179+
180+
181+
def test_databricks_oidc_token_source_missing_host_raises():
182+
ts = oidc.DatabricksOidcTokenSource(
183+
host="",
184+
token_endpoint="https://test.cloud.databricks.com/oidc/v1/token",
185+
id_token_source=_CountingIdTokenSource(),
186+
client_id="test-client-id",
187+
)
188+
with pytest.raises(ValueError, match="missing Host"):
189+
ts.token()

0 commit comments

Comments
 (0)