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..0db0415 100644 --- a/src/cradl/credentials.py +++ b/src/cradl/credentials.py @@ -21,6 +21,10 @@ class MissingCredentials(Exception): pass +class MissingClaims(Exception): + pass + + class Credentials: """Used to fetch and store credentials and to generate/cache an access token. @@ -86,9 +90,12 @@ 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, 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, @@ -105,7 +112,16 @@ 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'] + + 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'] def read_token_from_cache(cached_profile: str, cache_path: Path): diff --git a/tests/conftest.py b/tests/conftest.py index 436827a..ae2b35b 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, }