From 4c6fda28142f40e2b7fd70c1bcc0e3f9c0060b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn?= Date: Wed, 25 Feb 2026 14:31:26 +0100 Subject: [PATCH 1/5] Retry auth requests on missing scope --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/cradl/credentials.py | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dddfe2..b635e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Version 0.6.2 - 2026-02-25 + +- Add retry when auth endpoint does not provide scopes + ## Version 0.6.1 - 2026-02-23 - Add exponential backoff to calls toward the auth endpoint diff --git a/pyproject.toml b/pyproject.toml index 84ffeb9..56aa349 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cradl" -version = "0.6.1" +version = "0.6.2" description = "Python SDK for Cradl" authors = [{ name = "Cradl", email = "hello@cradl.ai" }] readme = "README.md" diff --git a/src/cradl/credentials.py b/src/cradl/credentials.py index 3313161..5bf2f70 100644 --- a/src/cradl/credentials.py +++ b/src/cradl/credentials.py @@ -21,6 +21,10 @@ class MissingCredentials(Exception): pass +class MissingScope(Exception): + pass + + class Credentials: """Used to fetch and store credentials and to generate/cache an access token. @@ -86,7 +90,7 @@ def access_token(self) -> str: # Backoff on BadRequest since Kinde seems to sometimes give bogus 400 responses @exponential_backoff(exceptions=(TooManyRequestsException, BadRequest), max_tries=4) # type: ignore - @exponential_backoff(RequestException, max_tries=3, giveup=fatal_code) + @exponential_backoff(exceptions=(RequestException, MissingScope), max_tries=3, giveup=fatal_code) # type: ignore def _get_client_credentials(self) -> Tuple[str, int]: if any(endpoint in self.auth_endpoint for endpoint in ['auth.lucidtech.io', 'auth.cradl.ai', 'kinde.com']): data = { @@ -105,7 +109,13 @@ def _get_client_credentials(self) -> Tuple[str, int]: response.raise_for_status() response_data = response.json() - return response_data['access_token'], time.time() + response_data['expires_in'] + token = response_data['access_token'] + + _, payload, _ = token.split('.') + if not json.loads(b64decode(payload)).get('scope'): + raise MissingScope + + return token, time.time() + response_data['expires_in'] def read_token_from_cache(cached_profile: str, cache_path: Path): From fdb82e3f5dc31be68c477612e5829003e347824c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn?= Date: Wed, 25 Feb 2026 15:07:28 +0100 Subject: [PATCH 2/5] Retry on other missing claims and fix tests --- src/cradl/credentials.py | 9 +++++---- tests/conftest.py | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/cradl/credentials.py b/src/cradl/credentials.py index 5bf2f70..7cef873 100644 --- a/src/cradl/credentials.py +++ b/src/cradl/credentials.py @@ -21,7 +21,7 @@ class MissingCredentials(Exception): pass -class MissingScope(Exception): +class MissingClaims(Exception): pass @@ -90,7 +90,7 @@ def access_token(self) -> str: # Backoff on BadRequest since Kinde seems to sometimes give bogus 400 responses @exponential_backoff(exceptions=(TooManyRequestsException, BadRequest), max_tries=4) # type: ignore - @exponential_backoff(exceptions=(RequestException, MissingScope), max_tries=3, giveup=fatal_code) # type: ignore + @exponential_backoff(exceptions=(RequestException, MissingClaims), max_tries=3, giveup=fatal_code) # type: ignore def _get_client_credentials(self) -> Tuple[str, int]: if any(endpoint in self.auth_endpoint for endpoint in ['auth.lucidtech.io', 'auth.cradl.ai', 'kinde.com']): data = { @@ -112,8 +112,9 @@ def _get_client_credentials(self) -> Tuple[str, int]: token = response_data['access_token'] _, payload, _ = token.split('.') - if not json.loads(b64decode(payload)).get('scope'): - raise MissingScope + claims = json.loads(b64decode(payload)) + if not all([claims.get(key) for key in ['external_app_client_id', 'external_organization_id', 'scope']]): + raise MissingClaims return token, time.time() + response_data['expires_in'] diff --git a/tests/conftest.py b/tests/conftest.py index 436827a..ae85aea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ +import json import string +from base64 import b64encode from os import urandom from random import choice, randint @@ -9,8 +11,21 @@ @pytest.fixture(scope='session') def token(): + header = b64encode(json.dumps({ + 'alg': 'RS256', + 'kid': 'ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff', + 'typ': 'JWT' + }).encode()).decode() + + claims = b64encode(json.dumps({ + 'external_app_client_id': 'cradl:app-client:00000000000000000000000000000000', + 'external_organization_id': 'cradl:organization:00000000000000000000000000000000', + 'scope': 'actions: read actions:write', + }).encode()).decode() + + signature = ''.join(choice(string.ascii_uppercase) for _ in range(randint(50, 50))) #invalid return { - 'access_token': ''.join(choice(string.ascii_uppercase) for _ in range(randint(50, 50))), + 'access_token': '.'.join([header, claims, signature]), 'expires_in': 123456789, } From 9b79d55f479dd3a7d0dfe371687ed2e25fc1c4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn?= Date: Wed, 25 Feb 2026 15:08:54 +0100 Subject: [PATCH 3/5] Lint --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index ae85aea..ae2b35b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ def token(): 'scope': 'actions: read actions:write', }).encode()).decode() - signature = ''.join(choice(string.ascii_uppercase) for _ in range(randint(50, 50))) #invalid + signature = ''.join(choice(string.ascii_uppercase) for _ in range(randint(50, 50))) # invalid return { 'access_token': '.'.join([header, claims, signature]), 'expires_in': 123456789, From 56e5e4dcf42c3a18716b259b00f65c42203266bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn?= Date: Thu, 26 Feb 2026 09:19:19 +0100 Subject: [PATCH 4/5] Only perform check if Kinde is IDP --- src/cradl/credentials.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/cradl/credentials.py b/src/cradl/credentials.py index 7cef873..98d9200 100644 --- a/src/cradl/credentials.py +++ b/src/cradl/credentials.py @@ -92,7 +92,8 @@ def access_token(self) -> str: @exponential_backoff(exceptions=(TooManyRequestsException, BadRequest), max_tries=4) # type: ignore @exponential_backoff(exceptions=(RequestException, MissingClaims), max_tries=3, giveup=fatal_code) # type: ignore def _get_client_credentials(self) -> Tuple[str, int]: - if any(endpoint in self.auth_endpoint for endpoint in ['auth.lucidtech.io', 'auth.cradl.ai', 'kinde.com']): + get_credentials_from_kinde = any(endpoint in self.auth_endpoint for endpoint in ['auth.lucidtech.io', 'auth.cradl.ai', 'kinde.com']) + if get_credentials_from_kinde: data = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -111,10 +112,12 @@ def _get_client_credentials(self) -> Tuple[str, int]: response_data = response.json() token = response_data['access_token'] - _, payload, _ = token.split('.') - claims = json.loads(b64decode(payload)) - if not all([claims.get(key) for key in ['external_app_client_id', 'external_organization_id', 'scope']]): - raise MissingClaims + if get_credentials_from_kinde: + # Confirm that Kinde has provided necessary claims + _, payload, _ = token.split('.') + claims = json.loads(b64decode(payload)) + if not all([claims.get(key) for key in ['external_app_client_id', 'external_organization_id', 'scope']]): + raise MissingClaims return token, time.time() + response_data['expires_in'] From 18baef7047af152eea78530a9c05c2fd4583628f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn?= Date: Thu, 26 Feb 2026 09:21:43 +0100 Subject: [PATCH 5/5] Lint --- src/cradl/credentials.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cradl/credentials.py b/src/cradl/credentials.py index 98d9200..0db0415 100644 --- a/src/cradl/credentials.py +++ b/src/cradl/credentials.py @@ -92,7 +92,9 @@ def access_token(self) -> str: @exponential_backoff(exceptions=(TooManyRequestsException, BadRequest), max_tries=4) # type: ignore @exponential_backoff(exceptions=(RequestException, MissingClaims), max_tries=3, giveup=fatal_code) # type: ignore def _get_client_credentials(self) -> Tuple[str, int]: - get_credentials_from_kinde = any(endpoint in self.auth_endpoint for endpoint in ['auth.lucidtech.io', 'auth.cradl.ai', 'kinde.com']) + get_credentials_from_kinde = any( + endpoint in self.auth_endpoint for endpoint in ['auth.lucidtech.io', 'auth.cradl.ai', 'kinde.com'] + ) if get_credentials_from_kinde: data = { 'client_id': self.client_id,