Skip to content

Commit 11feb33

Browse files
committed
feat: add license handshake and x-descope-license header
Adds a mgmt.license.get() endpoint that calls /v1/mgmt/license and returns the rate limit tier. On client init (when a management key is configured), the SDK runs a fire-and-forget handshake in a daemon thread to cache the tier on the HTTP client. Subsequent management requests carry the cached value in the x-descope-license header so Cloudflare can apply the correct rate limit bucket per customer tier. Tier values: tier1 (free), tier2 (pro), tier3 (growth), tier4 (enterprise). Handshake failure is non-fatal, the SDK continues without the header. The backend interceptor skips license-header validation for the GetLicense endpoint itself, so the initial fetch is safe before the tier is cached. Ref: descope/etc#14245
1 parent b8df67e commit 11feb33

6 files changed

Lines changed: 142 additions & 0 deletions

File tree

descope/descope_client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
import threading
45
import warnings
56
from typing import Iterable
67

@@ -106,6 +107,27 @@ def __init__(
106107
self._auth_http_client = auth_http_client
107108
self._mgmt_http_client = mgmt_http_client
108109

110+
# Fire-and-forget license handshake. Populates the rate limit tier so
111+
# subsequent management requests carry the x-descope-license header.
112+
# Backend skips license-header validation for the GetLicense endpoint
113+
# itself, so the initial request is safe even before the tier is cached.
114+
if mgmt_http_client.management_key:
115+
threading.Thread(
116+
target=self._fetch_rate_limit_tier,
117+
daemon=True,
118+
name="descope-license-handshake",
119+
).start()
120+
121+
def _fetch_rate_limit_tier(self) -> None:
122+
try:
123+
resp = self._mgmt._license.get()
124+
tier = resp.get("rateLimitTier") if isinstance(resp, dict) else None
125+
if tier:
126+
self._mgmt_http_client.rate_limit_tier = tier
127+
except Exception:
128+
# Handshake failure is non-fatal, SDK continues without the header.
129+
pass
130+
109131
@property
110132
def mgmt(self):
111133
return self._mgmt

descope/http_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ def __init__(
174174
self.management_key = management_key
175175
self.verbose = verbose
176176
self._thread_local = threading.local()
177+
# Populated by the license handshake when a management key is configured.
178+
# Sent in the x-descope-license header so Cloudflare can apply the right
179+
# rate limit bucket per customer tier.
180+
self.rate_limit_tier: str | None = None
177181

178182
# Setup SSL verification for httpx (backwards compatibility with requests)
179183
self.client_verify: bool | ssl.SSLContext = False
@@ -400,4 +404,6 @@ def _get_default_headers(self, pswd: str | None = None):
400404
if self.management_key:
401405
bearer = f"{bearer}:{self.management_key}"
402406
headers["Authorization"] = f"Bearer {bearer}"
407+
if self.rate_limit_tier:
408+
headers["x-descope-license"] = self.rate_limit_tier
403409
return headers

descope/management/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ class MgmtV1:
282282
mgmt_key_delete_path = "/v1/mgmt/managementkey/delete"
283283
mgmt_key_search_path = "/v1/mgmt/managementkey/search"
284284

285+
# license
286+
license_get_path = "/v1/mgmt/license"
287+
285288

286289
class MgmtSignUpOptions:
287290
def __init__(

descope/management/license.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from __future__ import annotations
2+
3+
from descope._http_base import HTTPBase
4+
from descope.management.common import MgmtV1
5+
6+
7+
class License(HTTPBase):
8+
def get(self) -> dict:
9+
"""
10+
Fetch the rate limit tier for the project's company license.
11+
12+
Returns a dict with a ``rateLimitTier`` field whose value is one of
13+
``tier1`` (free), ``tier2`` (pro), ``tier3`` (growth), or ``tier4``
14+
(enterprise). The SDK sends this value in the ``x-descope-license``
15+
header on every management request so Cloudflare can apply the right
16+
rate limit bucket.
17+
"""
18+
response = self._http.get(MgmtV1.license_get_path)
19+
return response.json()

descope/mgmt.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from descope.management.flow import Flow
1212
from descope.management.group import Group
1313
from descope.management.jwt import JWT
14+
from descope.management.license import License
1415
from descope.management.management_key import ManagementKey
1516
from descope.management.outbound_application import (
1617
OutboundApplication,
@@ -45,6 +46,7 @@ def __init__(self, http_client: HTTPClient, auth: Auth, fga_cache_url: Optional[
4546
self._flow = Flow(http_client)
4647
self._group = Group(http_client)
4748
self._jwt = JWT(http_client, auth=auth)
49+
self._license = License(http_client)
4850
self._management_key = ManagementKey(http_client)
4951
self._outbound_application = OutboundApplication(http_client)
5052
self._outbound_application_by_token = OutboundApplicationByToken(http_client)
@@ -94,6 +96,11 @@ def jwt(self):
9496
self._ensure_management_key("jwt")
9597
return self._jwt
9698

99+
@property
100+
def license(self):
101+
self._ensure_management_key("license")
102+
return self._license
103+
97104
@property
98105
def permission(self):
99106
self._ensure_management_key("permission")

tests/management/test_license.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from unittest import mock
2+
from unittest.mock import patch
3+
4+
from descope import AuthException, DescopeClient
5+
from descope.common import DEFAULT_TIMEOUT_SECONDS
6+
from descope.management.common import MgmtV1
7+
8+
from .. import common
9+
from ..testutils import SSLMatcher
10+
11+
12+
class TestLicense(common.DescopeTest):
13+
def setUp(self) -> None:
14+
super().setUp()
15+
self.dummy_project_id = "dummy"
16+
self.dummy_management_key = "key"
17+
self.public_key_dict = {
18+
"alg": "ES384",
19+
"crv": "P-384",
20+
"kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4",
21+
"kty": "EC",
22+
"use": "sig",
23+
"x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s",
24+
"y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0",
25+
}
26+
27+
def test_get_failure(self):
28+
client = DescopeClient(
29+
self.dummy_project_id,
30+
self.public_key_dict,
31+
False,
32+
self.dummy_management_key,
33+
)
34+
with patch("httpx.get") as mock_get:
35+
mock_get.return_value.is_success = False
36+
self.assertRaises(AuthException, client.mgmt.license.get)
37+
38+
def test_get_success(self):
39+
client = DescopeClient(
40+
self.dummy_project_id,
41+
self.public_key_dict,
42+
False,
43+
self.dummy_management_key,
44+
)
45+
with patch("httpx.get") as mock_get:
46+
network_resp = mock.Mock()
47+
network_resp.is_success = True
48+
network_resp.json.return_value = {"rateLimitTier": "tier4"}
49+
mock_get.return_value = network_resp
50+
51+
resp = client.mgmt.license.get()
52+
self.assertEqual(resp, {"rateLimitTier": "tier4"})
53+
54+
mock_get.assert_called_with(
55+
f"{client._mgmt_http_client.base_url}{MgmtV1.license_get_path}",
56+
headers=mock.ANY,
57+
params=None,
58+
follow_redirects=True,
59+
verify=SSLMatcher(),
60+
timeout=DEFAULT_TIMEOUT_SECONDS,
61+
)
62+
63+
def test_header_injected_after_handshake(self):
64+
client = DescopeClient(
65+
self.dummy_project_id,
66+
self.public_key_dict,
67+
False,
68+
self.dummy_management_key,
69+
)
70+
# Simulate a completed handshake by setting the cached tier directly.
71+
client._mgmt_http_client.rate_limit_tier = "tier2"
72+
headers = client._mgmt_http_client._get_default_headers()
73+
self.assertEqual(headers.get("x-descope-license"), "tier2")
74+
75+
def test_header_absent_when_tier_not_cached(self):
76+
client = DescopeClient(
77+
self.dummy_project_id,
78+
self.public_key_dict,
79+
False,
80+
self.dummy_management_key,
81+
)
82+
# Default state has no rate limit tier yet.
83+
client._mgmt_http_client.rate_limit_tier = None
84+
headers = client._mgmt_http_client._get_default_headers()
85+
self.assertNotIn("x-descope-license", headers)

0 commit comments

Comments
 (0)