From cdfb08070b77257302354f81ee9234c13e22ae42 Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Thu, 14 Aug 2025 19:59:31 +0300 Subject: [PATCH 1/6] Introduce HTTPClient to encapsulate the different mgmt keys needs --- descope/_auth_base.py | 2 + descope/_http_base.py | 10 + descope/auth.py | 267 +++------------------ descope/authmethod/enchantedlink.py | 20 +- descope/authmethod/magiclink.py | 25 +- descope/authmethod/oauth.py | 7 +- descope/authmethod/otp.py | 12 +- descope/authmethod/password.py | 23 +- descope/authmethod/saml.py | 7 +- descope/authmethod/sso.py | 7 +- descope/authmethod/totp.py | 6 +- descope/authmethod/webauthn.py | 21 +- descope/common.py | 17 ++ descope/descope_client.py | 65 +++-- descope/http_client.py | 209 ++++++++++++++++ descope/jwt_common.py | 143 +++++++++++ descope/management/access_key.py | 37 ++- descope/management/audit.py | 16 +- descope/management/authz.py | 95 +++----- descope/management/fga.py | 40 ++- descope/management/flow.py | 35 +-- descope/management/group.py | 19 +- descope/management/jwt.py | 47 ++-- descope/management/outbound_application.py | 152 +++++++----- descope/management/permission.py | 24 +- descope/management/project.py | 34 ++- descope/management/role.py | 29 +-- descope/management/sso_application.py | 38 ++- descope/management/sso_settings.py | 45 ++-- descope/management/tenant.py | 41 ++-- descope/management/user.py | 240 ++++++++---------- descope/mgmt.py | 120 ++++----- tests/common.py | 26 ++ tests/management/test_jwt.py | 2 +- tests/test_auth.py | 123 ++++++---- tests/test_common.py | 34 +++ tests/test_enchantedlink.py | 56 ++++- tests/test_magiclink.py | 50 +++- tests/test_oauth.py | 24 +- tests/test_password.py | 48 +++- tests/test_saml.py | 24 +- tests/test_sso.py | 24 +- tests/test_totp.py | 24 +- tests/test_webauthn.py | 64 ++++- 44 files changed, 1380 insertions(+), 972 deletions(-) create mode 100644 descope/_http_base.py create mode 100644 descope/http_client.py create mode 100644 descope/jwt_common.py create mode 100644 tests/test_common.py diff --git a/descope/_auth_base.py b/descope/_auth_base.py index 37821b98..dac29d90 100644 --- a/descope/_auth_base.py +++ b/descope/_auth_base.py @@ -2,8 +2,10 @@ from descope.auth import Auth +# XXX in the future we can remove this class entirely and have auth methods be base HTTPBase instead class AuthBase: """Base class for classes having auth""" def __init__(self, auth: Auth): self._auth = auth + self._http = auth.http_client diff --git a/descope/_http_base.py b/descope/_http_base.py new file mode 100644 index 00000000..564dd8ab --- /dev/null +++ b/descope/_http_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from descope.http_client import HTTPClient + + +class HTTPBase: + """Base class for classes that only need HTTP access.""" + + def __init__(self, http_client: HTTPClient): + self._http = http_client diff --git a/descope/auth.py b/descope/auth.py index ecefe390..33cee738 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -3,20 +3,12 @@ import copy import json import os -import platform import re from http import HTTPStatus from threading import Lock from typing import Iterable import jwt - -try: - from importlib.metadata import version -except ImportError: - from pkg_resources import get_distribution - -import requests from email_validator import EmailNotValidError, validate_email from jwt import ExpiredSignatureError, ImmatureSignatureError @@ -24,7 +16,6 @@ COOKIE_DATA_NAME, DEFAULT_BASE_URL, DEFAULT_DOMAIN, - DEFAULT_TIMEOUT_SECONDS, DEFAULT_URL_PREFIX, PHONE_REGEX, REFRESH_SESSION_COOKIE_NAME, @@ -45,21 +36,10 @@ AuthException, RateLimitException, ) - - -def sdk_version(): - try: - return version("descope") - except NameError: - return get_distribution("descope").version - - -_default_headers = { - "Content-Type": "application/json", - "x-descope-sdk-name": "python", - "x-descope-sdk-python-version": platform.python_version(), - "x-descope-sdk-version": sdk_version(), -} +from descope.http_client import HTTPClient +from descope.jwt_common import adjust_properties as jwt_adjust_properties +from descope.jwt_common import generate_auth_info as jwt_generate_auth_info +from descope.jwt_common import generate_jwt_response as jwt_generate_jwt_response class Auth: @@ -69,11 +49,9 @@ def __init__( self, project_id: str | None = None, public_key: dict | str | None = None, - skip_verify: bool = False, - management_key: str | None = None, - timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, jwt_validation_leeway: int = 5, - auth_management_key: str | None = None, + *, + http_client: HTTPClient, ): self.lock_public_keys = Lock() # validate project id @@ -89,16 +67,9 @@ def __init__( ) self.project_id = project_id self.jwt_validation_leeway = jwt_validation_leeway - self.secure = not skip_verify - - self.base_url = os.getenv("DESCOPE_BASE_URI") - if not self.base_url: - self.base_url = self.base_url_for_project_id(self.project_id) - self.timeout_seconds = timeout_seconds - self.management_key = management_key or os.getenv("DESCOPE_MANAGEMENT_KEY") - self.auth_management_key = auth_management_key or os.getenv( - "DESCOPE_AUTH_MANAGEMENT_KEY" - ) + + # Internal HTTP client for all network traffic (must be injected) + self._http = http_client public_key = public_key or os.getenv("DESCOPE_PUBLIC_KEY") with self.lock_public_keys: @@ -138,75 +109,9 @@ def _parse_retry_after(self, headers): except (ValueError, TypeError): return 0 - def do_get( - self, - uri: str, - params=None, - allow_redirects=None, - pswd: str | None = None, - ) -> requests.Response: - response = requests.get( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - params=params, - allow_redirects=allow_redirects, - verify=self.secure, - timeout=self.timeout_seconds, - ) - self._raise_from_response(response) - return response - - def do_post( - self, - uri: str, - body: dict | list[dict] | list[str] | None, - params=None, - pswd: str | None = None, - ) -> requests.Response: - response = requests.post( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - allow_redirects=False, - verify=self.secure, - params=params, - timeout=self.timeout_seconds, - ) - self._raise_from_response(response) - return response - - def do_patch( - self, - uri: str, - body: dict | list[dict] | list[str] | None, - params=None, - pswd: str | None = None, - ) -> requests.Response: - response = requests.patch( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - allow_redirects=False, - verify=self.secure, - params=params, - timeout=self.timeout_seconds, - ) - self._raise_from_response(response) - return response - - def do_delete( - self, uri: str, params=None, pswd: str | None = None - ) -> requests.Response: - response = requests.delete( - f"{self.base_url}{uri}", - params=params, - headers=self._get_default_headers(pswd), - allow_redirects=False, - verify=self.secure, - timeout=self.timeout_seconds, - ) - self._raise_from_response(response) - return response + @property + def http_client(self) -> HTTPClient: + return self._http def exchange_token( self, uri, code: str, audience: str | None | Iterable[str] = None @@ -219,7 +124,7 @@ def exchange_token( ) body = Auth._compose_exchange_body(code) - response = self.do_post(uri=uri, body=body, params=None) + response = self._http.post(uri, body=body) resp = response.json() jwt_response = self.generate_jwt_response( resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME), audience @@ -303,23 +208,6 @@ def get_login_id_by_method(method: DeliveryMethod, user: dict) -> tuple[str, str return login_id - @staticmethod - def get_method_string(method: DeliveryMethod) -> str: - name = { - DeliveryMethod.EMAIL: "email", - DeliveryMethod.SMS: "sms", - DeliveryMethod.VOICE: "voice", - DeliveryMethod.WHATSAPP: "whatsapp", - DeliveryMethod.EMBEDDED: "Embedded", - }.get(method) - - if not name: - raise AuthException( - 400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}" - ) - - return name - @staticmethod def validate_email(email: str): if email == "": @@ -369,10 +257,13 @@ def exchange_access_key( body = { "loginOptions": login_options.__dict__ if login_options else {}, } - server_response = self.do_post(uri=uri, body=body, params=None, pswd=access_key) - json = server_response.json() + server_response = self._http.post(uri, body=body, pswd=access_key) + json_body = server_response.json() return self._generate_auth_info( - response_body=json, refresh_token=None, user_jwt=False, audience=audience + response_body=json_body, + refresh_token=None, + user_jwt=False, + audience=audience, ) @staticmethod @@ -439,13 +330,9 @@ def _raise_from_response(self, response): def _fetch_public_keys(self) -> None: # This function called under mutex protection so no need to acquire it once again - response = requests.get( - f"{self.base_url}{EndpointsV2.public_key_path}/{self.project_id}", - headers=self._get_default_headers(), - verify=self.secure, - timeout=self.timeout_seconds, + response = self._http.get( + f"{EndpointsV2.public_key_path}/{self.project_id}", ) - self._raise_from_response(response) jwks_data = response.text try: @@ -467,55 +354,8 @@ def _fetch_public_keys(self) -> None: pass def adjust_properties(self, jwt_response: dict, user_jwt: bool): - # Save permissions, roles and tenants info from Session token or from refresh token on the json top level - if SESSION_TOKEN_NAME in jwt_response: - jwt_response["permissions"] = jwt_response[SESSION_TOKEN_NAME].get( - "permissions", [] - ) - jwt_response["roles"] = jwt_response[SESSION_TOKEN_NAME].get("roles", []) - jwt_response["tenants"] = jwt_response[SESSION_TOKEN_NAME].get( - "tenants", {} - ) - elif REFRESH_SESSION_TOKEN_NAME in jwt_response: - jwt_response["permissions"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get( - "permissions", [] - ) - jwt_response["roles"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get( - "roles", [] - ) - jwt_response["tenants"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get( - "tenants", {} - ) - else: - jwt_response["permissions"] = jwt_response.get("permissions", []) - jwt_response["roles"] = jwt_response.get("roles", []) - jwt_response["tenants"] = jwt_response.get("tenants", {}) - - # Save the projectID also in the dict top level - issuer = ( - jwt_response.get(SESSION_TOKEN_NAME, {}).get("iss", None) - or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("iss", None) - or jwt_response.get("iss", "") - ) - jwt_response["projectId"] = issuer.rsplit("/")[ - -1 - ] # support both url issuer and project ID issuer - - sub = ( - jwt_response.get(SESSION_TOKEN_NAME, {}).get("dsub", None) - or jwt_response.get(SESSION_TOKEN_NAME, {}).get("sub", None) - or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("dsub", None) - or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("sub", None) - or jwt_response.get("sub", "") - ) - if user_jwt: - # Save the userID also in the dict top level - jwt_response["userId"] = sub - else: - # Save the AccessKeyID also in the dict top level - jwt_response["keyId"] = sub - - return jwt_response + # Delegate to shared JWT utilities for normalization + return jwt_adjust_properties(jwt_response, user_jwt) def _generate_auth_info( self, @@ -524,31 +364,14 @@ def _generate_auth_info( user_jwt: bool, audience: str | None | Iterable[str] = None, ) -> dict: - jwt_response = {} - st_jwt = response_body.get("sessionJwt", "") - if st_jwt: - jwt_response[SESSION_TOKEN_NAME] = self._validate_token(st_jwt, audience) - rt_jwt = response_body.get("refreshJwt", "") - if rt_jwt: - jwt_response[REFRESH_SESSION_TOKEN_NAME] = self._validate_token( - rt_jwt, audience - ) - elif refresh_token: - jwt_response[REFRESH_SESSION_TOKEN_NAME] = self._validate_token( - refresh_token, audience - ) - - jwt_response = self.adjust_properties(jwt_response, user_jwt) - - if user_jwt: - jwt_response[COOKIE_DATA_NAME] = { - "exp": response_body.get("cookieExpiration", 0), - "maxAge": response_body.get("cookieMaxAge", 0), - "domain": response_body.get("cookieDomain", ""), - "path": response_body.get("cookiePath", "/"), - } - - return jwt_response + # Use shared generator with class validator to preserve signature checks + return jwt_generate_auth_info( + response_body, + refresh_token, + user_jwt, + audience, + token_validator=self._validate_token, + ) def generate_jwt_response( self, @@ -556,24 +379,16 @@ def generate_jwt_response( refresh_cookie: str | None, audience: str | None | Iterable[str] = None, ) -> dict: - jwt_response = self._generate_auth_info( - response_body, refresh_cookie, True, audience + # Delegate to shared implementation (keeps same output shape) + return jwt_generate_jwt_response( + response_body, + refresh_cookie, + audience, + token_validator=self._validate_token, ) - jwt_response["user"] = response_body.get("user", {}) - jwt_response["firstSeen"] = response_body.get("firstSeen", True) - return jwt_response - def _get_default_headers(self, pswd: str | None = None): - headers = _default_headers.copy() - headers["x-descope-project-id"] = self.project_id - bearer = self.project_id - if pswd: - bearer = f"{self.project_id}:{pswd}" - if self.auth_management_key: - bearer = f"{bearer}:{self.auth_management_key}" - headers["Authorization"] = f"Bearer {bearer}" - return headers + return self._http.get_default_headers(pswd) # Validate a token and load the public key if needed def _validate_token( @@ -676,7 +491,7 @@ def refresh_session( self._validate_token(refresh_token, audience) uri = EndpointsV1.refresh_token_path - response = self.do_post(uri=uri, body={}, params=None, pswd=refresh_token) + response = self._http.post(uri, body={}, pswd=refresh_token) resp = response.json() refresh_token = ( @@ -723,9 +538,7 @@ def select_tenant( ) uri = EndpointsV1.select_tenant_path - response = self.do_post( - uri=uri, body={"tenant": tenant_id}, params=None, pswd=refresh_token - ) + response = self._http.post(uri, body={"tenant": tenant_id}, pswd=refresh_token) resp = response.json() jwt_response = self.generate_jwt_response( diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index 9b78db86..c8b037f7 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -34,9 +34,8 @@ def sign_in( validate_refresh_token_provided(login_options, refresh_token) body = EnchantedLink._compose_signin_body(login_id, uri, login_options) - uri = EnchantedLink._compose_signin_url() - - response = self._auth.do_post(uri, body, None, refresh_token) + url = EnchantedLink._compose_signin_url() + response = self._http.post(url, body=body, pswd=refresh_token) return EnchantedLink._get_pending_ref_from_response(response) def sign_up( @@ -59,8 +58,8 @@ def sign_up( ) body = EnchantedLink._compose_signup_body(login_id, uri, user, signup_options) - uri = EnchantedLink._compose_signup_url() - response = self._auth.do_post(uri, body, None) + url = EnchantedLink._compose_signup_url() + response = self._http.post(url, body=body) return EnchantedLink._get_pending_ref_from_response(response) def sign_up_or_in( @@ -79,15 +78,14 @@ def sign_up_or_in( uri, login_options, ) - uri = EnchantedLink._compose_sign_up_or_in_url() - response = self._auth.do_post(uri, body, None) + url = EnchantedLink._compose_sign_up_or_in_url() + response = self._http.post(url, body=body) return EnchantedLink._get_pending_ref_from_response(response) def get_session(self, pending_ref: str) -> dict: uri = EndpointsV1.get_session_enchantedlink_auth_path body = EnchantedLink._compose_get_session_body(pending_ref) - response = self._auth.do_post(uri, body, None) - + response = self._http.post(uri, body=body) resp = response.json() jwt_response = self._auth.generate_jwt_response( resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None @@ -97,7 +95,7 @@ def get_session(self, pending_ref: str) -> dict: def verify(self, token: str): uri = EndpointsV1.verify_enchantedlink_auth_path body = EnchantedLink._compose_verify_body(token) - self._auth.do_post(uri, body, None) + self._http.post(uri, body=body) def update_user_email( self, @@ -127,7 +125,7 @@ def update_user_email( provider_id, ) uri = EndpointsV1.update_user_email_enchantedlink_path - response = self._auth.do_post(uri, body, None, refresh_token) + response = self._http.post(uri, body=body, pswd=refresh_token) return EnchantedLink._get_pending_ref_from_response(response) @staticmethod diff --git a/descope/authmethod/magiclink.py b/descope/authmethod/magiclink.py index 4182e548..f143ce86 100644 --- a/descope/authmethod/magiclink.py +++ b/descope/authmethod/magiclink.py @@ -35,9 +35,8 @@ def sign_in( validate_refresh_token_provided(login_options, refresh_token) body = MagicLink._compose_signin_body(login_id, uri, login_options) - uri = MagicLink._compose_signin_url(method) - - response = self._auth.do_post(uri, body, None, refresh_token) + url = MagicLink._compose_signin_url(method) + response = self._http.post(url, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), method) def sign_up( @@ -61,8 +60,8 @@ def sign_up( body = MagicLink._compose_signup_body( method, login_id, uri, user, signup_options ) - uri = MagicLink._compose_signup_url(method) - response = self._auth.do_post(uri, body, None) + url = MagicLink._compose_signup_url(method) + response = self._http.post(url, body=body) return Auth.extract_masked_address(response.json(), method) def sign_up_or_in( @@ -84,14 +83,14 @@ def sign_up_or_in( uri, login_options, ) - uri = MagicLink._compose_sign_up_or_in_url(method) - response = self._auth.do_post(uri, body, None) + url = MagicLink._compose_sign_up_or_in_url(method) + response = self._http.post(url, body=body) return Auth.extract_masked_address(response.json(), method) def verify(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - uri = EndpointsV1.verify_magiclink_auth_path + url = EndpointsV1.verify_magiclink_auth_path body = MagicLink._compose_verify_body(token) - response = self._auth.do_post(uri, body, None) + response = self._http.post(url, body=body) resp = response.json() jwt_response = self._auth.generate_jwt_response( resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience @@ -125,8 +124,8 @@ def update_user_email( template_id, provider_id, ) - uri = EndpointsV1.update_user_email_magiclink_path - response = self._auth.do_post(uri, body, None, refresh_token) + url = EndpointsV1.update_user_email_magiclink_path + response = self._http.post(url, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) def update_user_phone( @@ -157,8 +156,8 @@ def update_user_phone( template_id, provider_id, ) - uri = EndpointsV1.update_user_phone_magiclink_path - response = self._auth.do_post(uri, body, None, refresh_token) + url = EndpointsV1.update_user_phone_magiclink_path + response = self._http.post(url, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS) @staticmethod diff --git a/descope/authmethod/oauth.py b/descope/authmethod/oauth.py index 55278bdf..c9f0a83d 100644 --- a/descope/authmethod/oauth.py +++ b/descope/authmethod/oauth.py @@ -25,8 +25,11 @@ def start( uri = EndpointsV1.oauth_start_path params = OAuth._compose_start_params(provider, return_url) - response = self._auth.do_post( - uri, login_options.__dict__ if login_options else {}, params, refresh_token + response = self._http.post( + uri, + body=login_options.__dict__ if login_options else {}, + params=params, + pswd=refresh_token, ) return response.json() diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py index 86cc3c10..5fae817e 100644 --- a/descope/authmethod/otp.py +++ b/descope/authmethod/otp.py @@ -48,7 +48,7 @@ def sign_in( uri = OTP._compose_signin_url(method) body = OTP._compose_signin_body(login_id, login_options) - response = self._auth.do_post(uri, body, None, refresh_token) + response = self._http.post(uri, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), method) def sign_up( @@ -85,7 +85,7 @@ def sign_up( uri = OTP._compose_signup_url(method) body = OTP._compose_signup_body(method, login_id, user, signup_options) - response = self._auth.do_post(uri, body) + response = self._http.post(uri, body=body) return Auth.extract_masked_address(response.json(), method) def sign_up_or_in( @@ -124,7 +124,7 @@ def sign_up_or_in( login_id, login_options, ) - response = self._auth.do_post(uri, body) + response = self._http.post(uri, body=body) return Auth.extract_masked_address(response.json(), method) def verify_code( @@ -158,7 +158,7 @@ def verify_code( uri = OTP._compose_verify_code_url(method) body = OTP._compose_verify_code_body(login_id, code) - response = self._auth.do_post(uri, body, None) + response = self._http.post(uri, body=body) resp = response.json() jwt_response = self._auth.generate_jwt_response( @@ -206,7 +206,7 @@ def update_user_email( template_id, provider_id, ) - response = self._auth.do_post(uri, body, None, refresh_token) + response = self._http.post(uri, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) def update_user_phone( @@ -254,7 +254,7 @@ def update_user_phone( template_id, provider_id, ) - response = self._auth.do_post(uri, body, None, refresh_token) + response = self._http.post(uri, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), method) @staticmethod diff --git a/descope/authmethod/password.py b/descope/authmethod/password.py index 3d192ef3..cd73569c 100644 --- a/descope/authmethod/password.py +++ b/descope/authmethod/password.py @@ -46,7 +46,7 @@ def sign_up( uri = EndpointsV1.sign_up_password_path body = Password._compose_signup_body(login_id, password, user) - response = self._auth.do_post(uri, body) + response = self._http.post(uri, body=body) resp = response.json() jwt_response = self._auth.generate_jwt_response( @@ -87,7 +87,9 @@ def sign_in( ) uri = EndpointsV1.sign_in_password_path - response = self._auth.do_post(uri, {"loginId": login_id, "password": password}) + response = self._http.post( + uri, body={"loginId": login_id, "password": password} + ) resp = response.json() jwt_response = self._auth.generate_jwt_response( @@ -135,10 +137,7 @@ def send_reset( if template_options is not None: body["templateOptions"] = template_options - response = self._auth.do_post( - uri, - body, - ) + response = self._http.post(uri, body=body) return response.json() @@ -171,8 +170,10 @@ def update(self, login_id: str, new_password: str, refresh_token: str) -> None: ) uri = EndpointsV1.update_password_path - self._auth.do_post( - uri, {"loginId": login_id, "newPassword": new_password}, None, refresh_token + self._http.post( + uri, + body={"loginId": login_id, "newPassword": new_password}, + pswd=refresh_token, ) def replace( @@ -217,9 +218,9 @@ def replace( ) uri = EndpointsV1.replace_password_path - response = self._auth.do_post( + response = self._http.post( uri, - { + body={ "loginId": login_id, "oldPassword": old_password, "newPassword": new_password, @@ -250,7 +251,7 @@ def get_policy(self) -> dict: AuthException: raised if get policy operation fails """ - response = self._auth.do_get(uri=EndpointsV1.password_policy_path) + response = self._http.get(uri=EndpointsV1.password_policy_path) return response.json() @staticmethod diff --git a/descope/authmethod/saml.py b/descope/authmethod/saml.py index b9b8e448..b2d240a6 100644 --- a/descope/authmethod/saml.py +++ b/descope/authmethod/saml.py @@ -31,8 +31,11 @@ def start( uri = EndpointsV1.auth_saml_start_path params = SAML._compose_start_params(tenant, return_url) - response = self._auth.do_post( - uri, login_options.__dict__ if login_options else {}, params, refresh_token + response = self._http.post( + uri, + body=login_options.__dict__ if login_options else {}, + params=params, + pswd=refresh_token, ) return response.json() diff --git a/descope/authmethod/sso.py b/descope/authmethod/sso.py index 7d21f23d..58b0f7e1 100644 --- a/descope/authmethod/sso.py +++ b/descope/authmethod/sso.py @@ -36,8 +36,11 @@ def start( prompt if prompt else "", sso_id if sso_id else "", ) - response = self._auth.do_post( - uri, login_options.__dict__ if login_options else {}, params, refresh_token + response = self._http.post( + uri, + body=login_options.__dict__ if login_options else {}, + params=params, + pswd=refresh_token, ) return response.json() diff --git a/descope/authmethod/totp.py b/descope/authmethod/totp.py index 11a490bd..0d47ed2b 100644 --- a/descope/authmethod/totp.py +++ b/descope/authmethod/totp.py @@ -39,7 +39,7 @@ def sign_up(self, login_id: str, user: Optional[dict] = None) -> dict: uri = EndpointsV1.sign_up_auth_totp_path body = TOTP._compose_signup_body(login_id, user) - response = self._auth.do_post(uri, body) + response = self._http.post(uri, body=body) return response.json() @@ -83,7 +83,7 @@ def sign_in_code( uri = EndpointsV1.verify_totp_path body = TOTP._compose_signin_body(login_id, code, login_options) - response = self._auth.do_post(uri, body, None, refresh_token) + response = self._http.post(uri, body=body, pswd=refresh_token) resp = response.json() jwt_response = self._auth.generate_jwt_response( @@ -122,7 +122,7 @@ def update_user(self, login_id: str, refresh_token: str) -> None: uri = EndpointsV1.update_totp_path body = TOTP._compose_update_user_body(login_id) - response = self._auth.do_post(uri, body, None, refresh_token) + response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py index 9f3f89b2..07590993 100644 --- a/descope/authmethod/webauthn.py +++ b/descope/authmethod/webauthn.py @@ -37,8 +37,7 @@ def sign_up_start( uri = EndpointsV1.sign_up_auth_webauthn_start_path body = WebAuthn._compose_sign_up_start_body(login_id, user, origin) - response = self._auth.do_post(uri, body) - + response = self._http.post(uri, body=body) return response.json() def sign_up_finish( @@ -59,11 +58,9 @@ def sign_up_finish( raise AuthException( 400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty" ) - uri = EndpointsV1.sign_up_auth_webauthn_finish_path body = WebAuthn._compose_sign_up_in_finish_body(transaction_id, response) - response = self._auth.do_post(uri, body, None, "") - + response = self._http.post(uri, body=body) resp = response.json() jwt_response = self._auth.generate_jwt_response( resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience @@ -94,8 +91,7 @@ def sign_in_start( uri = EndpointsV1.sign_in_auth_webauthn_start_path body = WebAuthn._compose_sign_in_start_body(login_id, origin, login_options) - response = self._auth.do_post(uri, body, pswd=refresh_token) - + response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() def sign_in_finish( @@ -119,8 +115,7 @@ def sign_in_finish( uri = EndpointsV1.sign_in_auth_webauthn_finish_path body = WebAuthn._compose_sign_up_in_finish_body(transaction_id, response) - response = self._auth.do_post(uri, body, None) - + response = self._http.post(uri, body=body) resp = response.json() jwt_response = self._auth.generate_jwt_response( resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience @@ -147,8 +142,7 @@ def sign_up_or_in_start( uri = EndpointsV1.sign_up_or_in_auth_webauthn_start_path body = WebAuthn._compose_sign_up_or_in_start_body(login_id, origin) - response = self._auth.do_post(uri, body) - + response = self._http.post(uri, body=body) return response.json() def update_start(self, login_id: str, refresh_token: str, origin: str): @@ -167,8 +161,7 @@ def update_start(self, login_id: str, refresh_token: str, origin: str): uri = EndpointsV1.update_auth_webauthn_start_path body = WebAuthn._compose_update_start_body(login_id, origin) - response = self._auth.do_post(uri, body, None, refresh_token) - + response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() def update_finish(self, transaction_id: str, response: str) -> None: @@ -187,7 +180,7 @@ def update_finish(self, transaction_id: str, response: str) -> None: uri = EndpointsV1.update_auth_webauthn_finish_path body = WebAuthn._compose_update_finish_body(transaction_id, response) - self._auth.do_post(uri, body) + self._http.post(uri, body=body) @staticmethod def _compose_sign_up_start_body(login_id: str, user: dict, origin: str) -> dict: diff --git a/descope/common.py b/descope/common.py index 0926894c..e560b6d2 100644 --- a/descope/common.py +++ b/descope/common.py @@ -184,3 +184,20 @@ def signup_options_to_dict(signup_options: Optional[SignUpOptions] = None) -> di if signup_options.revokeOtherSessions is not None: res["revokeOtherSessions"] = signup_options.revokeOtherSessions return res + + +def get_method_string(method: DeliveryMethod) -> str: + name = { + DeliveryMethod.EMAIL: "email", + DeliveryMethod.SMS: "sms", + DeliveryMethod.VOICE: "voice", + DeliveryMethod.WHATSAPP: "whatsapp", + DeliveryMethod.EMBEDDED: "Embedded", + }.get(method) + + if not name: + raise AuthException( + 400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}" + ) + + return name diff --git a/descope/descope_client.py b/descope/descope_client.py index fb8969bf..54a866e2 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Iterable import requests @@ -16,6 +17,7 @@ from descope.authmethod.webauthn import WebAuthn # noqa: F401 from descope.common import DEFAULT_TIMEOUT_SECONDS, AccessKeyLoginOptions, EndpointsV1 from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.http_client import HTTPClient from descope.mgmt import MGMT # noqa: F401 @@ -28,30 +30,45 @@ def __init__( public_key: dict | None = None, skip_verify: bool = False, management_key: str | None = None, + auth_management_key: str | None = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, jwt_validation_leeway: int = 5, - auth_management_key: str | None = None, ): - auth = Auth( + # Auth Initialization + auth_http_client = HTTPClient( + project_id=project_id, + timeout_seconds=timeout_seconds, + secure=not skip_verify, + management_key=auth_management_key + or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), + ) + self._auth = Auth( project_id, public_key, - skip_verify, - management_key, - timeout_seconds, jwt_validation_leeway, - auth_management_key, + http_client=auth_http_client, + ) + self._magiclink = MagicLink(self._auth) + self._enchantedlink = EnchantedLink(self._auth) + self._oauth = OAuth(self._auth) + self._saml = SAML(self._auth) # deprecated + self._sso = SSO(self._auth) + self._otp = OTP(self._auth) + self._totp = TOTP(self._auth) + self._webauthn = WebAuthn(self._auth) + self._password = Password(self._auth) + + # Management Initialization + mgmt_http_client = HTTPClient( + project_id=project_id, + base_url=auth_http_client.base_url, + timeout_seconds=auth_http_client.timeout_seconds, + secure=auth_http_client.secure, + management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), + ) + self._mgmt = MGMT( + http_client=mgmt_http_client, ) - self._auth = auth - self._mgmt = MGMT(auth) - self._magiclink = MagicLink(auth) - self._enchantedlink = EnchantedLink(auth) - self._oauth = OAuth(auth) - self._saml = SAML(auth) # deprecated - self._sso = SSO(auth) - self._otp = OTP(auth) - self._totp = TOTP(auth) - self._webauthn = WebAuthn(auth) - self._password = Password(auth) @property def mgmt(self): @@ -381,7 +398,7 @@ def logout(self, refresh_token: str) -> requests.Response: ) uri = EndpointsV1.logout_path - return self._auth.do_post(uri, {}, None, refresh_token) + return self._auth.http_client.post(uri, body={}, pswd=refresh_token) def logout_all(self, refresh_token: str) -> requests.Response: """ @@ -404,7 +421,7 @@ def logout_all(self, refresh_token: str) -> requests.Response: ) uri = EndpointsV1.logout_all_path - return self._auth.do_post(uri, {}, None, refresh_token) + return self._auth.http_client.post(uri, body={}, pswd=refresh_token) def me(self, refresh_token: str) -> dict: """ @@ -428,8 +445,8 @@ def me(self, refresh_token: str) -> dict: ) uri = EndpointsV1.me_path - response = self._auth.do_get( - uri=uri, params=None, allow_redirects=None, pswd=refresh_token + response = self._auth.http_client.get( + uri=uri, allow_redirects=None, pswd=refresh_token ) return response.json() @@ -477,7 +494,7 @@ def my_tenants( body["ids"] = ids uri = EndpointsV1.my_tenants_path - response = self._auth.do_post(uri, body, None, refresh_token) + response = self._auth.http_client.post(uri, body=body, pswd=refresh_token) return response.json() def history(self, refresh_token: str) -> list[dict]: @@ -510,8 +527,8 @@ def history(self, refresh_token: str) -> list[dict]: ) uri = EndpointsV1.history_path - response = self._auth.do_get( - uri=uri, params=None, allow_redirects=None, pswd=refresh_token + response = self._auth.http_client.get( + uri=uri, allow_redirects=None, pswd=refresh_token ) return response.json() diff --git a/descope/http_client.py b/descope/http_client.py new file mode 100644 index 00000000..e8eb9ed3 --- /dev/null +++ b/descope/http_client.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import os +import platform +from http import HTTPStatus + +try: + from importlib.metadata import version +except ImportError: + from pkg_resources import get_distribution + +import requests + +from descope.common import ( + DEFAULT_BASE_URL, + DEFAULT_DOMAIN, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_URL_PREFIX, +) +from descope.exceptions import ( + API_RATE_LIMIT_RETRY_AFTER_HEADER, + ERROR_TYPE_API_RATE_LIMIT, + ERROR_TYPE_SERVER_ERROR, + AuthException, + RateLimitException, +) + + +def sdk_version(): + try: + return version("descope") # type: ignore + except NameError: # pragma: no cover + return get_distribution("descope").version # type: ignore + + +_default_headers = { + "Content-Type": "application/json", + "x-descope-sdk-name": "python", + "x-descope-sdk-python-version": platform.python_version(), + "x-descope-sdk-version": sdk_version(), +} + + +class HTTPClient: + def __init__( + self, + project_id: str, + base_url: str | None = None, + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + secure: bool = True, + management_key: str | None = None, + ) -> None: + if not project_id: + raise AuthException( + 400, + ERROR_TYPE_SERVER_ERROR, + "Project ID is required to initialize HTTP client", + ) + + # Prefer explicitly provided base_url, then env var, then computed default + env_base = os.getenv("DESCOPE_BASE_URI") + self.base_url = base_url or env_base or self.base_url_for_project_id(project_id) + + self.project_id = project_id + self.timeout_seconds = timeout_seconds + self.secure = secure + self.management_key = management_key + + # ------------- public API ------------- + def get( + self, + uri: str, + *, + params=None, + allow_redirects: bool | None = None, + pswd: str | None = None, + ) -> requests.Response: + response = requests.get( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + params=params, + allow_redirects=allow_redirects, + verify=self.secure, + timeout=self.timeout_seconds, + ) + self._raise_from_response(response) + return response + + def post( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = None, + params=None, + pswd: str | None = None, + ) -> requests.Response: + response = requests.post( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + allow_redirects=False, + verify=self.secure, + params=params, + timeout=self.timeout_seconds, + ) + self._raise_from_response(response) + return response + + def patch( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None, + params=None, + pswd: str | None = None, + ) -> requests.Response: + response = requests.patch( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + allow_redirects=False, + verify=self.secure, + params=params, + timeout=self.timeout_seconds, + ) + self._raise_from_response(response) + return response + + def delete( + self, + uri: str, + *, + params=None, + pswd: str | None = None, + ) -> requests.Response: + response = requests.delete( + f"{self.base_url}{uri}", + params=params, + headers=self._get_default_headers(pswd), + allow_redirects=False, + verify=self.secure, + timeout=self.timeout_seconds, + ) + self._raise_from_response(response) + return response + + def get_default_headers(self, pswd: str | None = None) -> dict: + return self._get_default_headers(pswd) + + # ------------- helpers ------------- + @staticmethod + def base_url_for_project_id(project_id: str) -> str: + if len(project_id) >= 32: + region = project_id[1:5] + return ".".join([DEFAULT_URL_PREFIX, region, DEFAULT_DOMAIN]) + return DEFAULT_BASE_URL + + def _parse_retry_after(self, headers) -> int: + try: + return int(headers.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, 0)) + except (ValueError, TypeError): + return 0 + + def _raise_rate_limit_exception(self, response): + try: + resp = response.json() + raise RateLimitException( + resp.get("errorCode", HTTPStatus.TOO_MANY_REQUESTS), + ERROR_TYPE_API_RATE_LIMIT, + resp.get("errorDescription", ""), + resp.get("errorMessage", ""), + rate_limit_parameters={ + API_RATE_LIMIT_RETRY_AFTER_HEADER: self._parse_retry_after( + response.headers + ) + }, + ) + except RateLimitException: + raise + except Exception: + raise RateLimitException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + error_type=ERROR_TYPE_API_RATE_LIMIT, + error_message=ERROR_TYPE_API_RATE_LIMIT, + error_description=ERROR_TYPE_API_RATE_LIMIT, + ) + + def _raise_from_response(self, response): + if response.ok: + return + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + self._raise_rate_limit_exception(response) + raise AuthException( + response.status_code, + ERROR_TYPE_SERVER_ERROR, + response.text, + ) + + def _get_default_headers(self, pswd: str | None = None): + headers = _default_headers.copy() + headers["x-descope-project-id"] = self.project_id + bearer = self.project_id + if pswd: + bearer = f"{self.project_id}:{pswd}" + if self.management_key: + bearer = f"{bearer}:{self.management_key}" + headers["Authorization"] = f"Bearer {bearer}" + return headers diff --git a/descope/jwt_common.py b/descope/jwt_common.py new file mode 100644 index 00000000..2183df5e --- /dev/null +++ b/descope/jwt_common.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import Callable, Iterable, Optional + +import jwt + +from descope.common import ( + COOKIE_DATA_NAME, + REFRESH_SESSION_TOKEN_NAME, + SESSION_TOKEN_NAME, +) + + +def adjust_properties(jwt_response: dict, user_jwt: bool) -> dict: + """Normalize top-level fields on a JWT response. + + Copies permissions/roles/tenants from present token claims (session or refresh) + to the top-level and sets projectId and userId/keyId for convenience. + """ + # Save permissions, roles and tenants info from Session token or from refresh token on the json top level + if SESSION_TOKEN_NAME in jwt_response: + jwt_response["permissions"] = jwt_response[SESSION_TOKEN_NAME].get( + "permissions", [] + ) + jwt_response["roles"] = jwt_response[SESSION_TOKEN_NAME].get("roles", []) + jwt_response["tenants"] = jwt_response[SESSION_TOKEN_NAME].get("tenants", {}) + elif REFRESH_SESSION_TOKEN_NAME in jwt_response: + jwt_response["permissions"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get( + "permissions", [] + ) + jwt_response["roles"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get( + "roles", [] + ) + jwt_response["tenants"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get( + "tenants", {} + ) + else: + jwt_response["permissions"] = jwt_response.get("permissions", []) + jwt_response["roles"] = jwt_response.get("roles", []) + jwt_response["tenants"] = jwt_response.get("tenants", {}) + + # Save the projectID also in the dict top level + issuer = ( + jwt_response.get(SESSION_TOKEN_NAME, {}).get("iss", None) + or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("iss", None) + or jwt_response.get("iss", "") + ) + jwt_response["projectId"] = issuer.rsplit("/")[ + -1 + ] # support both url issuer and project ID issuer + + sub = ( + jwt_response.get(SESSION_TOKEN_NAME, {}).get("dsub", None) + or jwt_response.get(SESSION_TOKEN_NAME, {}).get("sub", None) + or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("dsub", None) + or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("sub", None) + or jwt_response.get("sub", "") + ) + if user_jwt: + # Save the userID also in the dict top level + jwt_response["userId"] = sub + else: + # Save the AccessKeyID also in the dict top level + jwt_response["keyId"] = sub + + return jwt_response + + +def decode_token_unverified( + token: str, audience: Optional[str | Iterable[str]] = None +) -> dict: + """Decode a JWT without verifying signature (used when no validator is provided). + + Audience verification is disabled by default since no key is provided. + Returns an empty dict if decoding fails. + """ + try: + return jwt.decode( + token, options={"verify_signature": False, "verify_aud": False} + ) + except Exception: + return {} + + +def generate_auth_info( + response_body: dict, + refresh_token: Optional[str], + user_jwt: bool, + audience: Optional[str | Iterable[str]] = None, + token_validator: Optional[ + Callable[[str, Optional[str | Iterable[str]]], dict] + ] = None, +) -> dict: + """Build the normalized JWT info dict using a provided token validator. + + token_validator should accept (token, audience) and return decoded claims dict. + If not provided, tokens will be decoded without signature verification. + """ + if token_validator is None: + token_validator = decode_token_unverified + + jwt_response: dict = {} + + st_jwt = response_body.get("sessionJwt", "") + if st_jwt: + jwt_response[SESSION_TOKEN_NAME] = token_validator(st_jwt, audience) + + rt_jwt = response_body.get("refreshJwt", "") + if rt_jwt: + jwt_response[REFRESH_SESSION_TOKEN_NAME] = token_validator(rt_jwt, audience) + elif refresh_token: + jwt_response[REFRESH_SESSION_TOKEN_NAME] = token_validator( + refresh_token, audience + ) + + jwt_response = adjust_properties(jwt_response, user_jwt) + + if user_jwt: + jwt_response[COOKIE_DATA_NAME] = { + "exp": response_body.get("cookieExpiration", 0), + "maxAge": response_body.get("cookieMaxAge", 0), + "domain": response_body.get("cookieDomain", ""), + "path": response_body.get("cookiePath", "/"), + } + + return jwt_response + + +def generate_jwt_response( + response_body: dict, + refresh_cookie: Optional[str], + audience: Optional[str | Iterable[str]] = None, + token_validator: Optional[ + Callable[[str, Optional[str | Iterable[str]]], dict] + ] = None, +) -> dict: + """Compose the final JWT response body using the provided token validator.""" + jwt_response = generate_auth_info( + response_body, refresh_cookie, True, audience, token_validator + ) + jwt_response["user"] = response_body.get("user", {}) + jwt_response["firstSeen"] = response_body.get("firstSeen", True) + return jwt_response diff --git a/descope/management/access_key.py b/descope/management/access_key.py index e51c6546..d0b551fd 100644 --- a/descope/management/access_key.py +++ b/descope/management/access_key.py @@ -1,6 +1,6 @@ from typing import List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import ( AssociatedTenant, MgmtV1, @@ -8,7 +8,7 @@ ) -class AccessKey(AuthBase): +class AccessKey(HTTPBase): def create( self, name: str, @@ -51,9 +51,9 @@ def create( role_names = [] if role_names is None else role_names key_tenants = [] if key_tenants is None else key_tenants - response = self._auth.do_post( + response = self._http.post( MgmtV1.access_key_create_path, - AccessKey._compose_create_body( + body=AccessKey._compose_create_body( name, expire_time, role_names, @@ -63,7 +63,6 @@ def create( description, permitted_ips, ), - pswd=self._auth.management_key, ) return response.json() @@ -85,10 +84,9 @@ def load( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( + response = self._http.get( uri=MgmtV1.access_key_load_path, params={"id": id}, - pswd=self._auth.management_key, ) return response.json() @@ -112,10 +110,9 @@ def search_all_access_keys( """ tenant_ids = [] if tenant_ids is None else tenant_ids - response = self._auth.do_post( + response = self._http.post( MgmtV1.access_keys_search_path, - {"tenantIds": tenant_ids}, - pswd=self._auth.management_key, + body={"tenantIds": tenant_ids}, ) return response.json() @@ -136,10 +133,9 @@ def update( Raise: AuthException: raised if update operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.access_key_update_path, - {"id": id, "name": name, "description": description}, - pswd=self._auth.management_key, + body={"id": id, "name": name, "description": description}, ) def deactivate( @@ -156,10 +152,9 @@ def deactivate( Raise: AuthException: raised if deactivation operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.access_key_deactivate_path, - {"id": id}, - pswd=self._auth.management_key, + body={"id": id}, ) def activate( @@ -176,10 +171,9 @@ def activate( Raise: AuthException: raised if activation operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.access_key_activate_path, - {"id": id}, - pswd=self._auth.management_key, + body={"id": id}, ) def delete( @@ -195,10 +189,9 @@ def delete( Raise: AuthException: raised if creation operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.access_key_delete_path, - {"id": id}, - pswd=self._auth.management_key, + body={"id": id}, ) @staticmethod diff --git a/descope/management/audit.py b/descope/management/audit.py index 324ff16b..40ed721a 100644 --- a/descope/management/audit.py +++ b/descope/management/audit.py @@ -1,11 +1,11 @@ from datetime import datetime from typing import Any, List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Audit(AuthBase): +class Audit(HTTPBase): def search( self, user_ids: Optional[List[str]] = None, @@ -91,11 +91,7 @@ def search( if to_ts is not None: body["to"] = int(to_ts.timestamp() * 1000) - response = self._auth.do_post( - MgmtV1.audit_search, - body=body, - pswd=self._auth.management_key, - ) + response = self._http.post(MgmtV1.audit_search, body=body) return { "audits": list(map(Audit._convert_audit_record, response.json()["audits"])) } @@ -134,11 +130,7 @@ def create_event( if data is not None: body["data"] = data - self._auth.do_post( - MgmtV1.audit_create_event, - body=body, - pswd=self._auth.management_key, - ) + self._http.post(MgmtV1.audit_create_event, body=body) @staticmethod def _convert_audit_record(a: dict) -> dict: diff --git a/descope/management/authz.py b/descope/management/authz.py index f2d6816a..7e3fa698 100644 --- a/descope/management/authz.py +++ b/descope/management/authz.py @@ -1,11 +1,11 @@ from datetime import datetime, timezone from typing import Any, List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Authz(AuthBase): +class Authz(HTTPBase): def save_schema(self, schema: dict, upgrade: bool = False): """ Create or update the ReBAC schema. @@ -40,10 +40,9 @@ def save_schema(self, schema: dict, upgrade: bool = False): Raise: AuthException: raised if saving fails """ - self._auth.do_post( + self._http.post( MgmtV1.authz_schema_save, - {"schema": schema, "upgrade": upgrade}, - pswd=self._auth.management_key, + body={"schema": schema, "upgrade": upgrade}, ) def delete_schema(self): @@ -52,10 +51,8 @@ def delete_schema(self): Raise: AuthException: raised if delete schema fails """ - self._auth.do_post( + self._http.post( MgmtV1.authz_schema_delete, - None, - pswd=self._auth.management_key, ) def load_schema(self) -> dict: @@ -66,10 +63,8 @@ def load_schema(self) -> dict: Raise: AuthException: raised if load schema fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_schema_load, - None, - pswd=self._auth.management_key, ) return response.json()["schema"] @@ -91,10 +86,9 @@ def save_namespace( body["oldName"] = old_name if schema_name != "": body["schemaName"] = schema_name - self._auth.do_post( + self._http.post( MgmtV1.authz_ns_save, - body, - pswd=self._auth.management_key, + body=body, ) def delete_namespace(self, name: str, schema_name: str = ""): @@ -109,10 +103,9 @@ def delete_namespace(self, name: str, schema_name: str = ""): body: dict[str, Any] = {"name": name} if schema_name != "": body["schemaName"] = schema_name - self._auth.do_post( + self._http.post( MgmtV1.authz_ns_delete, - body, - pswd=self._auth.management_key, + body=body, ) def save_relation_definition( @@ -141,10 +134,9 @@ def save_relation_definition( body["oldName"] = old_name if schema_name != "": body["schemaName"] = schema_name - self._auth.do_post( + self._http.post( MgmtV1.authz_rd_save, - body, - pswd=self._auth.management_key, + body=body, ) def delete_relation_definition( @@ -162,10 +154,9 @@ def delete_relation_definition( body: dict[str, Any] = {"name": name, "namespace": namespace} if schema_name != "": body["schemaName"] = schema_name - self._auth.do_post( + self._http.post( MgmtV1.authz_rd_delete, - body, - pswd=self._auth.management_key, + body=body, ) def create_relations( @@ -202,12 +193,9 @@ def create_relations( Raise: AuthException: raised if create relations fails """ - self._auth.do_post( + self._http.post( MgmtV1.authz_re_create, - { - "relations": relations, - }, - pswd=self._auth.management_key, + body={"relations": relations}, ) def delete_relations( @@ -221,12 +209,9 @@ def delete_relations( Raise: AuthException: raised if delete relations fails """ - self._auth.do_post( + self._http.post( MgmtV1.authz_re_delete, - { - "relations": relations, - }, - pswd=self._auth.management_key, + body={"relations": relations}, ) def delete_relations_for_resources( @@ -240,12 +225,9 @@ def delete_relations_for_resources( Raise: AuthException: raised if delete relations for resources fails """ - self._auth.do_post( + self._http.post( MgmtV1.authz_re_delete_resources, - { - "resources": resources, - }, - pswd=self._auth.management_key, + body={"resources": resources}, ) def has_relations( @@ -277,12 +259,9 @@ def has_relations( Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_re_has_relations, - { - "relationQueries": relation_queries, - }, - pswd=self._auth.management_key, + body={"relationQueries": relation_queries}, ) return response.json()["relationQueries"] @@ -300,14 +279,13 @@ def who_can_access( Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_re_who, - { + body={ "resource": resource, "relationDefinition": relation_definition, "namespace": namespace, }, - pswd=self._auth.management_key, ) return response.json()["targets"] @@ -322,10 +300,9 @@ def resource_relations(self, resource: str) -> List[dict]: Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_re_resource, - {"resource": resource}, - pswd=self._auth.management_key, + body={"resource": resource}, ) return response.json()["relations"] @@ -340,10 +317,9 @@ def targets_relations(self, targets: List[str]) -> List[dict]: Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_re_targets, - {"targets": targets}, - pswd=self._auth.management_key, + body={"targets": targets}, ) return response.json()["relations"] @@ -358,10 +334,9 @@ def what_can_target_access(self, target: str) -> List[dict]: Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_re_target_all, - {"target": target}, - pswd=self._auth.management_key, + body={"target": target}, ) return response.json()["relations"] @@ -380,14 +355,13 @@ def what_can_target_access_with_relation( Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_re_target_with_relation, - { + body={ "target": target, "relationDefinition": relation_definition, "namespace": namespace, }, - pswd=self._auth.management_key, ) return response.json()["relations"] @@ -402,15 +376,14 @@ def get_modified(self, since: Optional[datetime] = None) -> dict: Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.authz_get_modified, - { + body={ "since": ( int(since.replace(tzinfo=timezone.utc).timestamp() * 1000) if since else 0 ) }, - pswd=self._auth.management_key, ) return response.json()["relations"] diff --git a/descope/management/fga.py b/descope/management/fga.py index 91dbb71b..53d75ea6 100644 --- a/descope/management/fga.py +++ b/descope/management/fga.py @@ -1,10 +1,10 @@ from typing import List -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class FGA(AuthBase): +class FGA(HTTPBase): def save_schema(self, schema: str): """ Create or update an FGA schema. @@ -40,10 +40,9 @@ def save_schema(self, schema: str): Raise: AuthException: raised if saving fails """ - self._auth.do_post( + self._http.post( MgmtV1.fga_save_schema, - {"dsl": schema}, - pswd=self._auth.management_key, + body={"dsl": schema}, ) def create_relations( @@ -64,12 +63,9 @@ def create_relations( Raise: AuthException: raised if create relations fails """ - self._auth.do_post( + self._http.post( MgmtV1.fga_create_relations, - { - "tuples": relations, - }, - pswd=self._auth.management_key, + body={"tuples": relations}, ) def delete_relations( @@ -83,12 +79,9 @@ def delete_relations( Raise: AuthException: raised if delete relations fails """ - self._auth.do_post( + self._http.post( MgmtV1.fga_delete_relations, - { - "tuples": relations, - }, - pswd=self._auth.management_key, + body={"tuples": relations}, ) def check( @@ -124,12 +117,9 @@ def check( Raise: AuthException: raised if query fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.fga_check, - { - "tuples": relations, - }, - pswd=self._auth.management_key, + body={"tuples": relations}, ) return list( map( @@ -146,10 +136,9 @@ def load_resources_details(self, resource_identifiers: List[dict]) -> List[dict] Returns: List[dict]: list of resources details as returned by the server. """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.fga_resources_load, - {"resourceIdentifiers": resource_identifiers}, - pswd=self._auth.management_key, + body={"resourceIdentifiers": resource_identifiers}, ) return response.json().get("resourcesDetails", []) @@ -159,8 +148,7 @@ def save_resources_details(self, resources_details: List[dict]) -> None: Args: resources_details (List[dict]): list of dicts each containing 'resourceId' and 'resourceType' plus optionally containing metadata fields such as 'displayName'. """ - self._auth.do_post( + self._http.post( MgmtV1.fga_resources_save, - {"resourcesDetails": resources_details}, - pswd=self._auth.management_key, + body={"resourcesDetails": resources_details}, ) diff --git a/descope/management/flow.py b/descope/management/flow.py index 80dad519..d7e2a5fe 100644 --- a/descope/management/flow.py +++ b/descope/management/flow.py @@ -1,10 +1,10 @@ from typing import List -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Flow(AuthBase): +class Flow(HTTPBase): def list_flows( self, ) -> dict: @@ -18,11 +18,7 @@ def list_flows( Raise: AuthException: raised if list operation fails """ - response = self._auth.do_post( - MgmtV1.flow_list_path, - None, - pswd=self._auth.management_key, - ) + response = self._http.post(MgmtV1.flow_list_path) return response.json() def delete_flows( @@ -38,12 +34,11 @@ def delete_flows( Raise: AuthException: raised if delete operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.flow_delete_path, - { + body={ "ids": flow_ids, }, - pswd=self._auth.management_key, ) return response.json() @@ -64,12 +59,11 @@ def export_flow( Raise: AuthException: raised if export operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.flow_export_path, - { + body={ "flowId": flow_id, }, - pswd=self._auth.management_key, ) return response.json() @@ -97,14 +91,13 @@ def import_flow( Raise: AuthException: raised if import operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.flow_import_path, - { + body={ "flowId": flow_id, "flow": flow, "screens": screens, }, - pswd=self._auth.management_key, ) return response.json() @@ -121,10 +114,9 @@ def export_theme( Raise: AuthException: raised if export operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.theme_export_path, - {}, - pswd=self._auth.management_key, + body={}, ) return response.json() @@ -147,11 +139,10 @@ def import_theme( Raise: AuthException: raised if import operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.theme_import_path, - { + body={ "theme": theme, }, - pswd=self._auth.management_key, ) return response.json() diff --git a/descope/management/group.py b/descope/management/group.py index c4dba3b5..d03ac1a8 100644 --- a/descope/management/group.py +++ b/descope/management/group.py @@ -1,10 +1,10 @@ from typing import List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Group(AuthBase): +class Group(HTTPBase): def load_all_groups( self, tenant_id: str, @@ -35,12 +35,11 @@ def load_all_groups( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.group_load_all_path, - { + body={ "tenantId": tenant_id, }, - pswd=self._auth.management_key, ) return response.json() @@ -81,14 +80,13 @@ def load_all_groups_for_members( user_ids = [] if user_ids is None else user_ids login_ids = [] if login_ids is None else login_ids - response = self._auth.do_post( + response = self._http.post( MgmtV1.group_load_all_for_member_path, - { + body={ "tenantId": tenant_id, "loginIds": login_ids, "userIds": user_ids, }, - pswd=self._auth.management_key, ) return response.json() @@ -124,12 +122,11 @@ def load_all_group_members( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.group_load_all_group_members_path, - { + body={ "tenantId": tenant_id, "groupId": group_id, }, - pswd=self._auth.management_key, ) return response.json() diff --git a/descope/management/jwt.py b/descope/management/jwt.py index 9adaa933..0a573b85 100644 --- a/descope/management/jwt.py +++ b/descope/management/jwt.py @@ -1,7 +1,8 @@ from typing import Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.jwt_common import generate_jwt_response from descope.management.common import ( MgmtLoginOptions, MgmtSignUpOptions, @@ -11,7 +12,7 @@ ) -class JWT(AuthBase): +class JWT(HTTPBase): def update_jwt( self, jwt: str, custom_claims: dict, refresh_duration: int = 0 ) -> str: @@ -30,14 +31,14 @@ def update_jwt( """ if not jwt: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") - response = self._auth.do_post( + response = self._http.post( MgmtV1.update_jwt_path, - { + body={ "jwt": jwt, "customClaims": custom_claims, "refreshDuration": refresh_duration, }, - pswd=self._auth.management_key, + params=None, ) return response.json().get("jwt", "") @@ -74,9 +75,9 @@ def impersonate( raise AuthException( 400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty" ) - response = self._auth.do_post( + response = self._http.post( MgmtV1.impersonate_path, - { + body={ "loginId": login_id, "impersonatorId": impersonator_id, "validateConsent": validate_consent, @@ -84,7 +85,7 @@ def impersonate( "selectedTenant": tenant_id, "refreshDuration": refresh_duration, }, - pswd=self._auth.management_key, + params=None, ) return response.json().get("jwt", "") @@ -111,15 +112,15 @@ def stop_impersonation( if not jwt or jwt == "": raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") - response = self._auth.do_post( + response = self._http.post( MgmtV1.stop_impersonation_path, - { + body={ "jwt": jwt, "customClaims": custom_claims, "selectedTenant": tenant_id, "refreshDuration": refresh_duration, }, - pswd=self._auth.management_key, + params=None, ) return response.json().get("jwt", "") @@ -145,9 +146,9 @@ def sign_in( if is_jwt_required(login_options) and not login_options.jwt: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "JWT is required") - response = self._auth.do_post( + response = self._http.post( MgmtV1.mgmt_sign_in_path, - { + body={ "loginId": login_id, "stepup": login_options.stepup, "mfa": login_options.mfa, @@ -156,10 +157,10 @@ def sign_in( "jwt": login_options.jwt, "refreshDuration": login_options.refresh_duration, }, - pswd=self._auth.management_key, + params=None, ) resp = response.json() - jwt_response = self._auth.generate_jwt_response(resp, None, None) + jwt_response = generate_jwt_response(resp, None, None) return jwt_response def sign_up( @@ -217,9 +218,9 @@ def _sign_up_internal( if signup_options is None: signup_options = MgmtSignUpOptions() - response = self._auth.do_post( + response = self._http.post( endpoint, - { + body={ "loginId": login_id, "user": user.to_dict(), "emailVerified": user.email_verified, @@ -228,10 +229,10 @@ def _sign_up_internal( "customClaims": signup_options.custom_claims, "refreshDuration": signup_options.refresh_duration, }, - pswd=self._auth.management_key, + params=None, ) resp = response.json() - jwt_response = self._auth.generate_jwt_response(resp, None, None) + jwt_response = generate_jwt_response(resp, None, None) return jwt_response def anonymous( @@ -248,17 +249,17 @@ def anonymous( tenant_id (str): tenant id to set on DCT claim. """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.anonymous_path, - { + body={ "customClaims": custom_claims, "selectedTenant": tenant_id, "refreshDuration": refresh_duration, }, - pswd=self._auth.management_key, + params=None, ) resp = response.json() - jwt_response = self._auth.generate_jwt_response(resp, None, None) + jwt_response = generate_jwt_response(resp, None, None) del jwt_response["firstSeen"] del jwt_response["user"] return jwt_response diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py index 2b9bc2de..337ca2ba 100644 --- a/descope/management/outbound_application.py +++ b/descope/management/outbound_application.py @@ -1,8 +1,8 @@ from typing import Any, List, Optional -from descope._auth_base import AuthBase -from descope.auth import Auth +from descope._http_base import HTTPBase from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 +from descope.http_client import HTTPClient from descope.management.common import ( AccessType, MgmtV1, @@ -17,8 +17,9 @@ class _OutboundApplicationTokenFetcher: @staticmethod def fetch_token_by_scopes( - auth_instance: Auth, - token: str, + *, + http: HTTPClient, + token: str | None = None, app_id: str, user_id: str, scopes: List[str], @@ -27,9 +28,9 @@ def fetch_token_by_scopes( ) -> dict: """Internal implementation for fetching token by scopes.""" uri = MgmtV1.outbound_application_fetch_token_by_scopes_path - response = auth_instance.do_post( + response = http.post( uri, - { + body={ "appId": app_id, "userId": user_id, "scopes": scopes, @@ -42,8 +43,9 @@ def fetch_token_by_scopes( @staticmethod def fetch_token( - auth_instance: Auth, - token: str, + *, + http: HTTPClient, + token: str | None = None, app_id: str, user_id: str, tenant_id: Optional[str] = None, @@ -51,9 +53,9 @@ def fetch_token( ) -> dict: """Internal implementation for fetching token.""" uri = MgmtV1.outbound_application_fetch_token_path - response = auth_instance.do_post( + response = http.post( uri, - { + body={ "appId": app_id, "userId": user_id, "tenantId": tenant_id, @@ -65,8 +67,9 @@ def fetch_token( @staticmethod def fetch_tenant_token_by_scopes( - auth_instance: Auth, - token: str, + *, + http: HTTPClient, + token: str | None = None, app_id: str, tenant_id: str, scopes: List[str], @@ -74,9 +77,9 @@ def fetch_tenant_token_by_scopes( ) -> dict: """Internal implementation for fetching tenant token by scopes.""" uri = MgmtV1.outbound_application_fetch_tenant_token_by_scopes_path - response = auth_instance.do_post( + response = http.post( uri, - { + body={ "appId": app_id, "tenantId": tenant_id, "scopes": scopes, @@ -88,17 +91,18 @@ def fetch_tenant_token_by_scopes( @staticmethod def fetch_tenant_token( - auth_instance: Auth, - token: str, + *, + http: HTTPClient, + token: str | None = None, app_id: str, tenant_id: str, options: Optional[dict] = None, ) -> dict: """Internal implementation for fetching tenant token.""" uri = MgmtV1.outbound_application_fetch_tenant_token_path - response = auth_instance.do_post( + response = http.post( uri, - { + body={ "appId": app_id, "tenantId": tenant_id, "options": options, @@ -108,7 +112,7 @@ def fetch_tenant_token( return response.json() -class OutboundApplication(AuthBase): +class OutboundApplication(HTTPBase): def create_application( self, name: str, @@ -162,9 +166,9 @@ def create_application( AuthException: raised if create operation fails """ uri = MgmtV1.outbound_application_create_path - response = self._auth.do_post( + response = self._http.post( uri, - OutboundApplication._compose_create_update_body( + body=OutboundApplication._compose_create_update_body( name, description, logo, @@ -184,7 +188,6 @@ def create_application( access_type, prompt, ), - pswd=self._auth.management_key, ) return response.json() @@ -241,9 +244,9 @@ def update_application( AuthException: raised if update operation fails """ uri = MgmtV1.outbound_application_update_path - response = self._auth.do_post( + response = self._http.post( uri, - { + body={ "app": OutboundApplication._compose_create_update_body( name, description, @@ -265,7 +268,6 @@ def update_application( prompt, ) }, - pswd=self._auth.management_key, ) return response.json() @@ -283,7 +285,7 @@ def delete_application( AuthException: raised if deletion operation fails """ uri = MgmtV1.outbound_application_delete_path - self._auth.do_post(uri, {"id": id}, pswd=self._auth.management_key) + self._http.post(uri, body={"id": id}) def load_application( self, @@ -303,15 +305,10 @@ def load_application( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=f"{MgmtV1.outbound_application_load_path}/{id}", - pswd=self._auth.management_key, - ) + response = self._http.get(f"{MgmtV1.outbound_application_load_path}/{id}") return response.json() - def load_all_applications( - self, - ) -> dict: + def load_all_applications(self) -> dict: """ Load all outbound applications. @@ -323,10 +320,7 @@ def load_all_applications( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=MgmtV1.outbound_application_load_all_path, - pswd=self._auth.management_key, - ) + response = self._http.get(MgmtV1.outbound_application_load_all_path) return response.json() def fetch_token_by_scopes( @@ -355,13 +349,12 @@ def fetch_token_by_scopes( AuthException: raised if fetch operation fails """ return _OutboundApplicationTokenFetcher.fetch_token_by_scopes( - self._auth, - self._auth.management_key, # type: ignore[arg-type] # will never get here with None value - app_id, - user_id, - scopes, - options, - tenant_id, + http=self._http, + app_id=app_id, + user_id=user_id, + scopes=scopes, + options=options, + tenant_id=tenant_id, ) def fetch_token( @@ -388,12 +381,11 @@ def fetch_token( AuthException: raised if fetch operation fails """ return _OutboundApplicationTokenFetcher.fetch_token( - self._auth, - self._auth.management_key, # type: ignore[arg-type] # will never get here with None value - app_id, - user_id, - tenant_id, - options, + http=self._http, + app_id=app_id, + user_id=user_id, + tenant_id=tenant_id, + options=options, ) def fetch_tenant_token_by_scopes( @@ -420,12 +412,11 @@ def fetch_tenant_token_by_scopes( AuthException: raised if fetch operation fails """ return _OutboundApplicationTokenFetcher.fetch_tenant_token_by_scopes( - self._auth, - self._auth.management_key, # type: ignore[arg-type] # will never get here with None value - app_id, - tenant_id, - scopes, - options, + http=self._http, + app_id=app_id, + tenant_id=tenant_id, + scopes=scopes, + options=options, ) def fetch_tenant_token( @@ -450,11 +441,10 @@ def fetch_tenant_token( AuthException: raised if fetch operation fails """ return _OutboundApplicationTokenFetcher.fetch_tenant_token( - self._auth, - self._auth.management_key, # type: ignore[arg-type] # will never get here with None value - app_id, - tenant_id, - options, + http=self._http, + app_id=app_id, + tenant_id=tenant_id, + options=options, ) @staticmethod @@ -517,7 +507,17 @@ def _compose_create_update_body( return body -class OutboundApplicationByToken(AuthBase): +class OutboundApplicationByToken(HTTPBase): + def __init__(self, http_client: HTTPClient): + # This class expects the token to be passed for each call + no_key_client = HTTPClient( + project_id=http_client.project_id, + base_url=http_client.base_url, + timeout_seconds=http_client.timeout_seconds, + secure=http_client.secure, + management_key=None, # Override the management key for this client + ) + super().__init__(no_key_client) # Methods for fetching outbound application tokens using an inbound application token # that includes the "outbound.token.fetch" scope (no management key required) @@ -560,7 +560,13 @@ def fetch_token_by_scopes( """ self._check_inbound_app_token(token) return _OutboundApplicationTokenFetcher.fetch_token_by_scopes( - self._auth, token, app_id, user_id, scopes, options, tenant_id + http=self._http, + token=token, + app_id=app_id, + user_id=user_id, + scopes=scopes, + options=options, + tenant_id=tenant_id, ) def fetch_token( @@ -590,7 +596,12 @@ def fetch_token( """ self._check_inbound_app_token(token) return _OutboundApplicationTokenFetcher.fetch_token( - self._auth, token, app_id, user_id, tenant_id, options + http=self._http, + token=token, + app_id=app_id, + user_id=user_id, + tenant_id=tenant_id, + options=options, ) def fetch_tenant_token_by_scopes( @@ -620,7 +631,12 @@ def fetch_tenant_token_by_scopes( """ self._check_inbound_app_token(token) return _OutboundApplicationTokenFetcher.fetch_tenant_token_by_scopes( - self._auth, token, app_id, tenant_id, scopes, options + http=self._http, + token=token, + app_id=app_id, + tenant_id=tenant_id, + scopes=scopes, + options=options, ) def fetch_tenant_token( @@ -644,5 +660,9 @@ def fetch_tenant_token( """ self._check_inbound_app_token(token) return _OutboundApplicationTokenFetcher.fetch_tenant_token( - self._auth, token, app_id, tenant_id, options + http=self._http, + token=token, + app_id=app_id, + tenant_id=tenant_id, + options=options, ) diff --git a/descope/management/permission.py b/descope/management/permission.py index 273d96ed..75727373 100644 --- a/descope/management/permission.py +++ b/descope/management/permission.py @@ -1,10 +1,10 @@ from typing import Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Permission(AuthBase): +class Permission(HTTPBase): def create( self, name: str, @@ -20,10 +20,9 @@ def create( Raise: AuthException: raised if creation operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.permission_create_path, - {"name": name, "description": description}, - pswd=self._auth.management_key, + body={"name": name, "description": description}, ) def update( @@ -44,10 +43,9 @@ def update( Raise: AuthException: raised if update operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.permission_update_path, - {"name": name, "newName": new_name, "description": description}, - pswd=self._auth.management_key, + body={"name": name, "newName": new_name, "description": description}, ) def delete( @@ -63,10 +61,9 @@ def delete( Raise: AuthException: raised if creation operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.permission_delete_path, - {"name": name}, - pswd=self._auth.management_key, + body={"name": name}, ) def load_all( @@ -83,8 +80,7 @@ def load_all( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=MgmtV1.permission_load_all_path, - pswd=self._auth.management_key, + response = self._http.get( + MgmtV1.permission_load_all_path, ) return response.json() diff --git a/descope/management/project.py b/descope/management/project.py index ba9fa2b7..cf660aba 100644 --- a/descope/management/project.py +++ b/descope/management/project.py @@ -1,10 +1,10 @@ from typing import List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Project(AuthBase): +class Project(HTTPBase): def update_name( self, name: str, @@ -17,12 +17,11 @@ def update_name( Raise: AuthException: raised if operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.project_update_name, - { + body={ "name": name, }, - pswd=self._auth.management_key, ) def update_tags( @@ -37,12 +36,11 @@ def update_tags( Raise: AuthException: raised if operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.project_update_tags, - { + body={ "tags": tags, }, - pswd=self._auth.management_key, ) def list_projects( @@ -59,10 +57,9 @@ def list_projects( Raise: AuthException: raised if operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.project_list_projects, - {}, - pswd=self._auth.management_key, + body={}, ) resp = response.json() @@ -96,14 +93,13 @@ def clone( Raise: AuthException: raised if clone operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.project_clone, - { + body={ "name": name, "environment": environment, "tags": tags, }, - pswd=self._auth.management_key, ) return response.json() @@ -123,10 +119,9 @@ def export_project( Raise: AuthException: raised if export operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.project_export, - {}, - pswd=self._auth.management_key, + body={}, ) return response.json()["files"] @@ -147,12 +142,11 @@ def import_project( Raise: AuthException: raised if import operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.project_import, - { + body={ "files": files, }, - pswd=self._auth.management_key, ) return diff --git a/descope/management/role.py b/descope/management/role.py index 2c11f286..2dffbe6f 100644 --- a/descope/management/role.py +++ b/descope/management/role.py @@ -1,10 +1,10 @@ from typing import List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Role(AuthBase): +class Role(HTTPBase): def create( self, name: str, @@ -28,16 +28,15 @@ def create( """ permission_names = [] if permission_names is None else permission_names - self._auth.do_post( + self._http.post( MgmtV1.role_create_path, - { + body={ "name": name, "description": description, "permissionNames": permission_names, "tenantId": tenant_id, "default": default, }, - pswd=self._auth.management_key, ) def update( @@ -65,9 +64,9 @@ def update( AuthException: raised if update operation fails """ permission_names = [] if permission_names is None else permission_names - self._auth.do_post( + self._http.post( MgmtV1.role_update_path, - { + body={ "name": name, "newName": new_name, "description": description, @@ -75,7 +74,6 @@ def update( "tenantId": tenant_id, "default": default, }, - pswd=self._auth.management_key, ) def delete( @@ -92,10 +90,9 @@ def delete( Raise: AuthException: raised if creation operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.role_delete_path, - {"name": name, "tenantId": tenant_id}, - pswd=self._auth.management_key, + body={"name": name, "tenantId": tenant_id}, ) def load_all( @@ -112,9 +109,8 @@ def load_all( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=MgmtV1.role_load_all_path, - pswd=self._auth.management_key, + response = self._http.get( + MgmtV1.role_load_all_path, ) return response.json() @@ -155,9 +151,8 @@ def search( if include_project_roles is not None: body["includeProjectRoles"] = include_project_roles - response = self._auth.do_post( + response = self._http.post( MgmtV1.role_search_path, - body, - pswd=self._auth.management_key, + body=body, ) return response.json() diff --git a/descope/management/sso_application.py b/descope/management/sso_application.py index c3173ee2..3cdd4c7e 100644 --- a/descope/management/sso_application.py +++ b/descope/management/sso_application.py @@ -1,6 +1,6 @@ from typing import Any, List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import ( MgmtV1, SAMLIDPAttributeMappingInfo, @@ -10,7 +10,7 @@ ) -class SSOApplication(AuthBase): +class SSOApplication(HTTPBase): def create_oidc_application( self, name: str, @@ -42,9 +42,9 @@ def create_oidc_application( AuthException: raised if create operation fails """ uri = MgmtV1.sso_application_oidc_create_path - response = self._auth.do_post( + response = self._http.post( uri, - SSOApplication._compose_create_update_oidc_body( + body=SSOApplication._compose_create_update_oidc_body( name, login_page_url, id, @@ -53,7 +53,6 @@ def create_oidc_application( enabled, force_authentication, ), - pswd=self._auth.management_key, ) return response.json() @@ -128,9 +127,9 @@ def create_saml_application( ) uri = MgmtV1.sso_application_saml_create_path - response = self._auth.do_post( + response = self._http.post( uri, - SSOApplication._compose_create_update_saml_body( + body=SSOApplication._compose_create_update_saml_body( name, login_page_url, id, @@ -151,7 +150,6 @@ def create_saml_application( force_authentication, logout_redirect_url, ), - pswd=self._auth.management_key, ) return response.json() @@ -183,9 +181,9 @@ def update_oidc_application( """ uri = MgmtV1.sso_application_oidc_update_path - self._auth.do_post( + self._http.post( uri, - SSOApplication._compose_create_update_oidc_body( + body=SSOApplication._compose_create_update_oidc_body( name, login_page_url, id, @@ -194,7 +192,6 @@ def update_oidc_application( enabled, force_authentication, ), - pswd=self._auth.management_key, ) def update_saml_application( @@ -264,9 +261,9 @@ def update_saml_application( ) uri = MgmtV1.sso_application_saml_update_path - self._auth.do_post( + self._http.post( uri, - SSOApplication._compose_create_update_saml_body( + body=SSOApplication._compose_create_update_saml_body( name, login_page_url, id, @@ -287,7 +284,6 @@ def update_saml_application( force_authentication, logout_redirect_url, ), - pswd=self._auth.management_key, ) def delete( @@ -304,7 +300,8 @@ def delete( AuthException: raised if deletion operation fails """ uri = MgmtV1.sso_application_delete_path - self._auth.do_post(uri, {"id": id}, pswd=self._auth.management_key) + # Using adapter's do_post which already includes management key in Authorization header + self._http.post(uri, body={"id": id}) def load( self, @@ -324,11 +321,7 @@ def load( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=MgmtV1.sso_application_load_path, - params={"id": id}, - pswd=self._auth.management_key, - ) + response = self._http.get(MgmtV1.sso_application_load_path, params={"id": id}) return response.json() def load_all( @@ -350,10 +343,7 @@ def load_all( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=MgmtV1.sso_application_load_all_path, - pswd=self._auth.management_key, - ) + response = self._http.get(MgmtV1.sso_application_load_all_path) return response.json() @staticmethod diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 01d0fc46..977d9915 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -1,6 +1,6 @@ from typing import List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 @@ -160,7 +160,7 @@ def __init__( self.sp_entity_id = sp_entity_id -class SSOSettings(AuthBase): +class SSOSettings(HTTPBase): def load_settings( self, tenant_id: str, @@ -179,10 +179,9 @@ def load_settings( Raise: AuthException: raised if load configuration operation fails """ - response = self._auth.do_get( + response = self._http.get( uri=MgmtV1.sso_load_settings_path, params={"tenantId": tenant_id}, - pswd=self._auth.management_key, ) return response.json() @@ -199,10 +198,9 @@ def delete_settings( Raise: AuthException: raised if delete operation fails """ - self._auth.do_delete( + self._http.delete( MgmtV1.sso_settings_path, - {"tenantId": tenant_id}, - pswd=self._auth.management_key, + params={"tenantId": tenant_id}, ) def configure_oidc_settings( @@ -223,12 +221,11 @@ def configure_oidc_settings( AuthException: raised if configuration operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.sso_configure_oidc_settings, - SSOSettings._compose_configure_oidc_settings_body( + body=SSOSettings._compose_configure_oidc_settings_body( tenant_id, settings, domains ), - pswd=self._auth.management_key, ) def configure_saml_settings( @@ -251,12 +248,11 @@ def configure_saml_settings( AuthException: raised if configuration operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.sso_configure_saml_settings, - SSOSettings._compose_configure_saml_settings_body( + body=SSOSettings._compose_configure_saml_settings_body( tenant_id, settings, redirect_url, domains ), - pswd=self._auth.management_key, ) def configure_saml_settings_by_metadata( @@ -279,12 +275,11 @@ def configure_saml_settings_by_metadata( AuthException: raised if configuration operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.sso_configure_saml_by_metadata_settings, - SSOSettings._compose_configure_saml_settings_by_metadata_body( + body=SSOSettings._compose_configure_saml_settings_by_metadata_body( tenant_id, settings, redirect_url, domains ), - pswd=self._auth.management_key, ) # DEPRECATED @@ -306,10 +301,9 @@ def get_settings( Raise: AuthException: raised if configuration operation fails """ - response = self._auth.do_get( + response = self._http.get( uri=MgmtV1.sso_settings_path, params={"tenantId": tenant_id}, - pswd=self._auth.management_key, ) return response.json() @@ -339,12 +333,11 @@ def configure( Raise: AuthException: raised if configuration operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.sso_settings_path, - SSOSettings._compose_configure_body( + body=SSOSettings._compose_configure_body( tenant_id, idp_url, entity_id, idp_cert, redirect_url, domains ), - pswd=self._auth.management_key, ) # DEPRECATED @@ -369,12 +362,11 @@ def configure_via_metadata( Raise: AuthException: raised if configuration operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.sso_metadata_path, - SSOSettings._compose_metadata_body( + body=SSOSettings._compose_metadata_body( tenant_id, idp_metadata_url, redirect_url, domains ), - pswd=self._auth.management_key, ) # DEPRECATED @@ -397,12 +389,11 @@ def mapping( Raise: AuthException: raised if configuration operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.sso_mapping_path, - SSOSettings._compose_mapping_body( + body=SSOSettings._compose_mapping_body( tenant_id, role_mappings, attribute_mapping ), - pswd=self._auth.management_key, ) @staticmethod diff --git a/descope/management/tenant.py b/descope/management/tenant.py index ccfa1a57..41130141 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -1,10 +1,10 @@ from typing import Any, List, Optional -from descope._auth_base import AuthBase +from descope._http_base import HTTPBase from descope.management.common import MgmtV1 -class Tenant(AuthBase): +class Tenant(HTTPBase): def create( self, name: str, @@ -38,10 +38,9 @@ def create( [] if self_provisioning_domains is None else self_provisioning_domains ) - uri = MgmtV1.tenant_create_path - response = self._auth.do_post( - uri, - Tenant._compose_create_update_body( + response = self._http.post( + MgmtV1.tenant_create_path, + body=Tenant._compose_create_update_body( name, id, self_provisioning_domains, @@ -49,7 +48,6 @@ def create( enforce_sso, disabled, ), - pswd=self._auth.management_key, ) return response.json() @@ -82,10 +80,9 @@ def update( [] if self_provisioning_domains is None else self_provisioning_domains ) - uri = MgmtV1.tenant_update_path - self._auth.do_post( - uri, - Tenant._compose_create_update_body( + self._http.post( + MgmtV1.tenant_update_path, + body=Tenant._compose_create_update_body( name, id, self_provisioning_domains, @@ -93,7 +90,6 @@ def update( enforce_sso, disabled, ), - pswd=self._auth.management_key, ) def delete( @@ -110,9 +106,9 @@ def delete( Raise: AuthException: raised if creation operation fails """ - uri = MgmtV1.tenant_delete_path - self._auth.do_post( - uri, {"id": id, "cascade": cascade}, pswd=self._auth.management_key + self._http.post( + MgmtV1.tenant_delete_path, + body={"id": id, "cascade": cascade}, ) def load( @@ -133,10 +129,9 @@ def load( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=MgmtV1.tenant_load_path, + response = self._http.get( + MgmtV1.tenant_load_path, params={"id": id}, - pswd=self._auth.management_key, ) return response.json() @@ -154,9 +149,8 @@ def load_all( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( - uri=MgmtV1.tenant_load_all_path, - pswd=self._auth.management_key, + response = self._http.get( + MgmtV1.tenant_load_all_path, ) return response.json() @@ -184,15 +178,14 @@ def search_all( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_post( - uri=MgmtV1.tenant_search_all_path, + response = self._http.post( + MgmtV1.tenant_search_all_path, body={ "tenantIds": ids, "tenantNames": names, "tenantSelfProvisioningDomains": self_provisioning_domains, "customAttributes": custom_attributes, }, - pswd=self._auth.management_key, ) return response.json() diff --git a/descope/management/user.py b/descope/management/user.py index 65aee89b..a752fefd 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -1,8 +1,7 @@ from typing import Any, List, Optional, Union -from descope._auth_base import AuthBase -from descope.auth import Auth -from descope.common import DeliveryMethod, LoginOptions +from descope._http_base import HTTPBase +from descope.common import DeliveryMethod, LoginOptions, get_method_string from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.management.common import ( AssociatedTenant, @@ -75,7 +74,7 @@ def __init__( self.family_name = family_name -class User(AuthBase): +class User(HTTPBase): def create( self, login_id: str, @@ -122,9 +121,9 @@ def create( role_names = [] if role_names is None else role_names user_tenants = [] if user_tenants is None else user_tenants - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_create_path, - User._compose_create_body( + body=User._compose_create_body( login_id, email, phone, @@ -146,7 +145,6 @@ def create( additional_login_ids, sso_app_ids, ), - pswd=self._auth.management_key, ) return response.json() @@ -198,9 +196,9 @@ def create_test_user( role_names = [] if role_names is None else role_names user_tenants = [] if user_tenants is None else user_tenants - response = self._auth.do_post( + response = self._http.post( MgmtV1.test_user_create_path, - User._compose_create_body( + body=User._compose_create_body( login_id, email, phone, @@ -222,7 +220,6 @@ def create_test_user( additional_login_ids, sso_app_ids, ), - pswd=self._auth.management_key, ) return response.json() @@ -267,9 +264,9 @@ def invite( role_names = [] if role_names is None else role_names user_tenants = [] if user_tenants is None else user_tenants - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_create_path, - User._compose_create_body( + body=User._compose_create_body( login_id, email, phone, @@ -292,7 +289,6 @@ def invite( sso_app_ids, template_id, ), - pswd=self._auth.management_key, ) return response.json() @@ -319,15 +315,14 @@ def invite_batch( calling the method. """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_create_batch_path, - User._compose_create_batch_body( + body=User._compose_create_batch_body( users, invite_url, send_mail, send_sms, ), - pswd=self._auth.management_key, ) return response.json() @@ -383,9 +378,9 @@ def update( role_names = [] if role_names is None else role_names user_tenants = [] if user_tenants is None else user_tenants - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_path, - User._compose_update_body( + body=User._compose_update_body( login_id, email, phone, @@ -404,7 +399,6 @@ def update( sso_app_ids, None, ), - pswd=self._auth.management_key, ) return response.json() @@ -454,9 +448,9 @@ def patch( Raise: AuthException: raised if patch operation fails """ - response = self._auth.do_patch( + response = self._http.patch( MgmtV1.user_patch_path, - User._compose_patch_body( + body=User._compose_patch_body( login_id, email, phone, @@ -473,7 +467,6 @@ def patch( sso_app_ids, test, ), - pswd=self._auth.management_key, ) return response.json() @@ -490,10 +483,9 @@ def delete( Raise: AuthException: raised if delete operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_delete_path, - {"loginId": login_id}, - pswd=self._auth.management_key, + body={"loginId": login_id}, ) def delete_by_user_id( @@ -509,10 +501,9 @@ def delete_by_user_id( Raise: AuthException: raised if delete operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_delete_path, - {"userId": user_id}, - pswd=self._auth.management_key, + body={"userId": user_id}, ) def delete_all_test_users( @@ -524,9 +515,8 @@ def delete_all_test_users( Raise: AuthException: raised if delete operation fails """ - self._auth.do_delete( + self._http.delete( MgmtV1.user_delete_all_test_users_path, - pswd=self._auth.management_key, ) def load( @@ -547,10 +537,9 @@ def load( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( + response = self._http.get( uri=MgmtV1.user_load_path, params={"loginId": login_id}, - pswd=self._auth.management_key, ) return response.json() @@ -573,10 +562,9 @@ def load_by_user_id( Raise: AuthException: raised if load operation fails """ - response = self._auth.do_get( + response = self._http.get( uri=MgmtV1.user_load_path, params={"userId": user_id}, - pswd=self._auth.management_key, ) return response.json() @@ -593,10 +581,9 @@ def logout_user( Raise: AuthException: raised if logout operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_logout_path, - {"loginId": login_id}, - pswd=self._auth.management_key, + body={"loginId": login_id}, ) def logout_user_by_user_id( @@ -612,10 +599,9 @@ def logout_user_by_user_id( Raise: AuthException: raised if logout operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_logout_path, - {"userId": user_id}, - pswd=self._auth.management_key, + body={"userId": user_id}, ) def search_all( @@ -737,10 +723,9 @@ def search_all( if tenant_role_names is not None: body["tenantRoleNames"] = map_to_values_object(tenant_role_names) - response = self._auth.do_post( + response = self._http.post( MgmtV1.users_search_path, body=body, - pswd=self._auth.management_key, ) return response.json() @@ -854,10 +839,9 @@ def search_all_test_users( if tenant_role_names is not None: body["tenantRoleNames"] = map_to_values_object(tenant_role_names) - response = self._auth.do_post( + response = self._http.post( MgmtV1.test_users_search_path, body=body, - pswd=self._auth.management_key, ) return response.json() @@ -887,15 +871,14 @@ def get_provider_token( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_get( + response = self._http.get( MgmtV1.user_get_provider_token, - { + params={ "loginId": login_id, "provider": provider, "withRefreshToken": withRefreshToken, "forceRefresh": forceRefresh, }, - pswd=self._auth.management_key, ) return response.json() @@ -917,10 +900,9 @@ def activate( Raise: AuthException: raised if activate operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_status_path, - {"loginId": login_id, "status": "enabled"}, - pswd=self._auth.management_key, + body={"loginId": login_id, "status": "enabled"}, ) return response.json() @@ -942,10 +924,9 @@ def deactivate( Raise: AuthException: raised if deactivate operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_status_path, - {"loginId": login_id, "status": "disabled"}, - pswd=self._auth.management_key, + body={"loginId": login_id, "status": "disabled"}, ) return response.json() @@ -970,10 +951,9 @@ def update_login_id( Raise: AuthException: raised if the update operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_login_id_path, - {"loginId": login_id, "newLoginId": new_login_id}, - pswd=self._auth.management_key, + body={"loginId": login_id, "newLoginId": new_login_id}, ) return response.json() @@ -999,10 +979,9 @@ def update_email( Raise: AuthException: raised if the update operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_email_path, - {"loginId": login_id, "email": email, "verified": verified}, - pswd=self._auth.management_key, + body={"loginId": login_id, "email": email, "verified": verified}, ) return response.json() @@ -1028,10 +1007,9 @@ def update_phone( Raise: AuthException: raised if the update operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_phone_path, - {"loginId": login_id, "phone": phone, "verified": verified}, - pswd=self._auth.management_key, + body={"loginId": login_id, "phone": phone, "verified": verified}, ) return response.json() @@ -1067,10 +1045,9 @@ def update_display_name( bdy["middleName"] = middle_name if family_name is not None: bdy["familyName"] = family_name - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_name_path, - bdy, - pswd=self._auth.management_key, + body=bdy, ) return response.json() @@ -1094,10 +1071,9 @@ def update_picture( Raise: AuthException: raised if the update operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_picture_path, - {"loginId": login_id, "picture": picture}, - pswd=self._auth.management_key, + body={"loginId": login_id, "picture": picture}, ) return response.json() @@ -1120,14 +1096,13 @@ def update_custom_attribute( Raise: AuthException: raised if the update operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_update_custom_attribute_path, - { + body={ "loginId": login_id, "attributeKey": attribute_key, "attributeValue": attribute_val, }, - pswd=self._auth.management_key, ) return response.json() @@ -1152,10 +1127,9 @@ def set_roles( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_set_role_path, - {"loginId": login_id, "roleNames": role_names}, - pswd=self._auth.management_key, + body={"loginId": login_id, "roleNames": role_names}, ) return response.json() @@ -1180,10 +1154,9 @@ def add_roles( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_add_role_path, - {"loginId": login_id, "roleNames": role_names}, - pswd=self._auth.management_key, + body={"loginId": login_id, "roleNames": role_names}, ) return response.json() @@ -1208,10 +1181,9 @@ def remove_roles( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_remove_role_path, - {"loginId": login_id, "roleNames": role_names}, - pswd=self._auth.management_key, + body={"loginId": login_id, "roleNames": role_names}, ) return response.json() @@ -1235,10 +1207,9 @@ def set_sso_apps( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_set_sso_apps, - {"loginId": login_id, "ssoAppIds": sso_app_ids}, - pswd=self._auth.management_key, + body={"loginId": login_id, "ssoAppIds": sso_app_ids}, ) return response.json() @@ -1262,10 +1233,9 @@ def add_sso_apps( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_add_sso_apps, - {"loginId": login_id, "ssoAppIds": sso_app_ids}, - pswd=self._auth.management_key, + body={"loginId": login_id, "ssoAppIds": sso_app_ids}, ) return response.json() @@ -1289,10 +1259,9 @@ def remove_sso_apps( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_remove_sso_apps, - {"loginId": login_id, "ssoAppIds": sso_app_ids}, - pswd=self._auth.management_key, + body={"loginId": login_id, "ssoAppIds": sso_app_ids}, ) return response.json() @@ -1316,10 +1285,9 @@ def add_tenant( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_add_tenant_path, - {"loginId": login_id, "tenantId": tenant_id}, - pswd=self._auth.management_key, + body={"loginId": login_id, "tenantId": tenant_id}, ) return response.json() @@ -1343,10 +1311,9 @@ def remove_tenant( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_remove_tenant_path, - {"loginId": login_id, "tenantId": tenant_id}, - pswd=self._auth.management_key, + body={"loginId": login_id, "tenantId": tenant_id}, ) return response.json() @@ -1372,10 +1339,9 @@ def set_tenant_roles( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_set_role_path, - {"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - pswd=self._auth.management_key, + body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, ) return response.json() @@ -1401,10 +1367,9 @@ def add_tenant_roles( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_add_role_path, - {"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - pswd=self._auth.management_key, + body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, ) return response.json() @@ -1430,10 +1395,9 @@ def remove_tenant_roles( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_remove_role_path, - {"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - pswd=self._auth.management_key, + body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, ) return response.json() @@ -1455,14 +1419,13 @@ def set_temporary_password( Raise: AuthException: raised if the operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_set_temporary_password_path, - { + body={ "loginId": login_id, "password": password, "setActive": False, }, - pswd=self._auth.management_key, ) return @@ -1481,14 +1444,13 @@ def set_active_password( Raise: AuthException: raised if the operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_set_active_password_path, - { + body={ "loginId": login_id, "password": password, "setActive": True, }, - pswd=self._auth.management_key, ) return @@ -1513,14 +1475,13 @@ def set_password( Raise: AuthException: raised if the operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_set_password_path, - { + body={ "loginId": login_id, "password": password, "setActive": set_active, }, - pswd=self._auth.management_key, ) return @@ -1539,10 +1500,9 @@ def expire_password( Raise: AuthException: raised if the operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_expire_password_path, - {"loginId": login_id}, - pswd=self._auth.management_key, + body={"loginId": login_id}, ) return @@ -1561,10 +1521,9 @@ def remove_all_passkeys( Raise: AuthException: raised if the operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_remove_all_passkeys_path, - {"loginId": login_id}, - pswd=self._auth.management_key, + body={"loginId": login_id}, ) return @@ -1583,10 +1542,9 @@ def remove_totp_seed( Raise: AuthException: raised if the operation fails """ - self._auth.do_post( + self._http.post( MgmtV1.user_remove_totp_seed_path, - {"loginId": login_id}, - pswd=self._auth.management_key, + body={"loginId": login_id}, ) return @@ -1614,14 +1572,13 @@ def generate_otp_for_test_user( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_generate_otp_for_test_path, - { + body={ "loginId": login_id, - "deliveryMethod": Auth.get_method_string(method), + "deliveryMethod": get_method_string(method), "loginOptions": login_options.__dict__ if login_options else {}, }, - pswd=self._auth.management_key, ) return response.json() @@ -1651,15 +1608,14 @@ def generate_magic_link_for_test_user( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_generate_magic_link_for_test_path, - { + body={ "loginId": login_id, - "deliveryMethod": Auth.get_method_string(method), + "deliveryMethod": get_method_string(method), "URI": uri, "loginOptions": login_options.__dict__ if login_options else {}, }, - pswd=self._auth.management_key, ) return response.json() @@ -1686,14 +1642,13 @@ def generate_enchanted_link_for_test_user( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_generate_enchanted_link_for_test_path, - { + body={ "loginId": login_id, "URI": uri, "loginOptions": login_options.__dict__ if login_options else {}, }, - pswd=self._auth.management_key, ) return response.json() @@ -1714,10 +1669,13 @@ def generate_embedded_link( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_generate_embedded_link_path, - {"loginId": login_id, "customClaims": custom_claims, "timeout": timeout}, - pswd=self._auth.management_key, + body={ + "loginId": login_id, + "customClaims": custom_claims, + "timeout": timeout, + }, ) return response.json()["token"] @@ -1748,9 +1706,9 @@ def generate_sign_up_embedded_link( Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_generate_sign_up_embedded_link_path, - { + body={ "loginId": login_id, "user": user.__dict__ if user else {}, "loginOptions": login_options.__dict__ if login_options else {}, @@ -1758,7 +1716,6 @@ def generate_sign_up_embedded_link( "phoneVerified": phone_verified, "timeout": timeout, }, - pswd=self._auth.management_key, ) return response.json()["token"] @@ -1784,10 +1741,9 @@ def history(self, user_ids: List[str]) -> List[dict]: Raise: AuthException: raised if the operation fails """ - response = self._auth.do_post( + response = self._http.post( MgmtV1.user_history_path, - user_ids, - pswd=self._auth.management_key, + body=user_ids, ) return response.json() diff --git a/descope/mgmt.py b/descope/mgmt.py index 75a83ceb..68b4821c 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -1,129 +1,135 @@ -from descope.auth import Auth -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 -from descope.management.access_key import AccessKey # noqa: F401 -from descope.management.audit import Audit # noqa: F401 -from descope.management.authz import Authz # noqa: F401 -from descope.management.fga import FGA # noqa: F401 -from descope.management.flow import Flow # noqa: F401 -from descope.management.group import Group # noqa: F401 -from descope.management.jwt import JWT # noqa: F401 -from descope.management.outbound_application import ( # noqa: F401 # noqa: F401 +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.http_client import HTTPClient +from descope.management.access_key import AccessKey +from descope.management.audit import Audit +from descope.management.authz import Authz +from descope.management.fga import FGA +from descope.management.flow import Flow +from descope.management.group import Group +from descope.management.jwt import JWT +from descope.management.outbound_application import ( OutboundApplication, OutboundApplicationByToken, ) -from descope.management.permission import Permission # noqa: F401 -from descope.management.project import Project # noqa: F401 -from descope.management.role import Role # noqa: F401 -from descope.management.sso_application import SSOApplication # noqa: F401 -from descope.management.sso_settings import SSOSettings # noqa: F401 -from descope.management.tenant import Tenant # noqa: F401 +from descope.management.permission import Permission +from descope.management.project import Project +from descope.management.role import Role +from descope.management.sso_application import SSOApplication +from descope.management.sso_settings import SSOSettings + +# Import management modules after adapter to avoid circularities +from descope.management.tenant import Tenant from descope.management.user import User class MGMT: - _auth: Auth - - def __init__(self, auth: Auth): - self._auth = auth - self._tenant = Tenant(auth) - self._sso_application = SSOApplication(auth) - self._user = User(auth) - self._access_key = AccessKey(auth) - self._sso = SSOSettings(auth) - self._jwt = JWT(auth) - self._permission = Permission(auth) - self._role = Role(auth) - self._group = Group(auth) - self._flow = Flow(auth) - self._audit = Audit(auth) - self._authz = Authz(auth) - self._fga = FGA(auth) - self._project = Project(auth) - self._outbound_application = OutboundApplication(auth) - self._outbound_application_by_token = OutboundApplicationByToken(auth) - - def _check_management_key(self, property_name: str): + _http: HTTPClient + + def __init__(self, http_client: HTTPClient): + """Create a management API facade. + + Args: + http_client: HTTP client to use for all management HTTP calls. + """ + self._http = http_client + self._access_key = AccessKey(http_client) + self._audit = Audit(http_client) + self._authz = Authz(http_client) + self._fga = FGA(http_client) + self._flow = Flow(http_client) + self._group = Group(http_client) + self._jwt = JWT(http_client) + self._outbound_application = OutboundApplication(http_client) + self._outbound_application_by_token = OutboundApplicationByToken(http_client) + self._permission = Permission(http_client) + self._project = Project(http_client) + self._role = Role(http_client) + self._sso = SSOSettings(http_client) + self._sso_application = SSOApplication(http_client) + self._tenant = Tenant(http_client) + self._user = User(http_client) + + def _ensure_management_key(self, property_name: str): """Check if management key is available for the given property.""" - if not self._auth.management_key: + if not self._http.management_key: raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Management key is required to access '{property_name}' functionality", + error_type=ERROR_TYPE_INVALID_ARGUMENT, + error_message=f"Management key is required to access '{property_name}' functionality", ) @property def tenant(self): - self._check_management_key("tenant") + self._ensure_management_key("tenant") return self._tenant @property def sso_application(self): - self._check_management_key("sso_application") + self._ensure_management_key("sso_application") return self._sso_application @property def user(self): - self._check_management_key("user") + self._ensure_management_key("user") return self._user @property def access_key(self): - self._check_management_key("access_key") + self._ensure_management_key("access_key") return self._access_key @property def sso(self): - self._check_management_key("sso") + self._ensure_management_key("sso") return self._sso @property def jwt(self): - self._check_management_key("jwt") + self._ensure_management_key("jwt") return self._jwt @property def permission(self): - self._check_management_key("permission") + self._ensure_management_key("permission") return self._permission @property def role(self): - self._check_management_key("role") + self._ensure_management_key("role") return self._role @property def group(self): - self._check_management_key("group") + self._ensure_management_key("group") return self._group @property def flow(self): - self._check_management_key("flow") + self._ensure_management_key("flow") return self._flow @property def audit(self): - self._check_management_key("audit") + self._ensure_management_key("audit") return self._audit @property def authz(self): - self._check_management_key("authz") + self._ensure_management_key("authz") return self._authz @property def fga(self): - self._check_management_key("fga") + self._ensure_management_key("fga") return self._fga @property def project(self): - self._check_management_key("project") + self._ensure_management_key("project") return self._project @property def outbound_application(self): - self._check_management_key("outbound_application") + self._ensure_management_key("outbound_application") return self._outbound_application @property diff --git a/tests/common.py b/tests/common.py index 4c9f4345..307f0c8b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -30,3 +30,29 @@ def setUp(self) -> None: os.environ["DESCOPE_BASE_URI"] = ( DEFAULT_BASE_URL # Make sure tests always running against localhost ) + # Some tests instantiate Auth directly; provide defaults they can use + self.dummy_project_id = getattr(self, "dummy_project_id", "dummy") + self.public_key_dict = getattr( + self, + "public_key_dict", + { + "alg": "ES384", + "crv": "P-384", + "kid": "testkid", + "kty": "EC", + "use": "sig", + "x": "x", + "y": "y", + }, + ) + + # Test helper to build a default HTTP client + def make_http_client(self, management_key: str | None = None): + from descope.http_client import HTTPClient + + return HTTPClient( + project_id=self.dummy_project_id, + timeout_seconds=60, + secure=True, + management_key=management_key, + ) diff --git a/tests/management/test_jwt.py b/tests/management/test_jwt.py index 2cab70de..75fd66d5 100644 --- a/tests/management/test_jwt.py +++ b/tests/management/test_jwt.py @@ -9,7 +9,7 @@ from .. import common -class TestUser(common.DescopeTest): +class TestJWT(common.DescopeTest): def setUp(self) -> None: super().setUp() self.dummy_project_id = "dummy" diff --git a/tests/test_auth.py b/tests/test_auth.py index 7dba9635..358e2943 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -97,7 +97,11 @@ def test_validate_and_load_public_key(self): ) def test_fetch_public_key(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) valid_keys_response = """{"keys":[ { "alg": "ES384", @@ -129,11 +133,11 @@ def test_fetch_public_key(self): def test_project_id_from_env(self): os.environ["DESCOPE_PROJECT_ID"] = self.dummy_project_id - Auth() + Auth(http_client=self.make_http_client()) def test_project_id_from_env_without_env(self): os.environ["DESCOPE_PROJECT_ID"] = "" - self.assertRaises(AuthException, Auth) + self.assertRaises(AuthException, Auth, http_client=self.make_http_client()) def test_base_url_for_project_id(self): self.assertEqual("https://api.descope.com", Auth.base_url_for_project_id("")) @@ -322,36 +326,13 @@ class AAA(Enum): self.assertRaises(AuthException, Auth.get_login_id_by_method, AAA.DUMMY, user) - def test_get_method_string(self): - self.assertEqual( - Auth.get_method_string(DeliveryMethod.EMAIL), - "email", - ) - self.assertEqual( - Auth.get_method_string(DeliveryMethod.SMS), - "sms", - ) - self.assertEqual( - Auth.get_method_string(DeliveryMethod.VOICE), - "voice", - ) - self.assertEqual( - Auth.get_method_string(DeliveryMethod.WHATSAPP), - "whatsapp", - ) - self.assertEqual( - Auth.get_method_string(DeliveryMethod.EMBEDDED), - "Embedded", - ) - - class AAA(Enum): - DUMMY = 4 - - self.assertRaises(AuthException, Auth.get_method_string, AAA.DUMMY) - def test_refresh_session(self): dummy_refresh_token = "dummy refresh token" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Test fail flow with patch("requests.post") as mock_request: @@ -363,7 +344,11 @@ def test_refresh_session(self): ) def test_validate_session_and_refresh_input(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Bad input for session with self.assertRaises(AuthException): @@ -425,7 +410,11 @@ def test_validate_session_and_refresh_input(self): def test_exchange_access_key(self): dummy_access_key = "dummy access key" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Test fail flow with patch("requests.post") as mock_request: @@ -554,7 +543,11 @@ def test_adjust_properties(self): ) def test_api_rate_limit_exception(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Test do_post with patch("requests.post") as mock_request: @@ -569,7 +562,9 @@ def test_api_rate_limit_exception(self): API_RATE_LIMIT_RETRY_AFTER_HEADER: "10" } with self.assertRaises(RateLimitException) as cm: - auth.do_post("http://test.com", {}, None, None) + auth.http_client.post( + "http://test.com", body={}, params=None, pswd=None + ) the_exception = cm.exception self.assertEqual(the_exception.status_code, "E130429") self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) @@ -595,7 +590,9 @@ def test_api_rate_limit_exception(self): API_RATE_LIMIT_RETRY_AFTER_HEADER: "10" } with self.assertRaises(RateLimitException) as cm: - auth.do_get(uri="http://test.com", params=False, allow_redirects=None) + auth.http_client.get( + uri="http://test.com", params=False, allow_redirects=None + ) the_exception = cm.exception self.assertEqual(the_exception.status_code, "E130429") self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) @@ -621,7 +618,7 @@ def test_api_rate_limit_exception(self): API_RATE_LIMIT_RETRY_AFTER_HEADER: "10" } with self.assertRaises(RateLimitException) as cm: - auth.do_delete("http://test.com") + auth.http_client.delete("http://test.com") the_exception = cm.exception self.assertEqual(the_exception.status_code, "E130429") self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) @@ -640,7 +637,7 @@ def test_api_rate_limit_exception(self): network_resp.ok = True mock_delete.return_value = network_resp - auth.do_delete("/a/b", params={"key": "value"}, pswd="pswd") + auth.http_client.delete("/a/b", params={"key": "value"}, pswd="pswd") mock_delete.assert_called_with( "http://127.0.0.1/a/b", @@ -682,7 +679,11 @@ def test_api_rate_limit_exception(self): ) def test_api_rate_limit_invalid_header(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Test do_post empty body with patch("requests.post") as mock_request: @@ -697,7 +698,9 @@ def test_api_rate_limit_invalid_header(self): API_RATE_LIMIT_RETRY_AFTER_HEADER: "hello" } with self.assertRaises(RateLimitException) as cm: - auth.do_post("http://test.com", {}, None, None) + auth.http_client.post( + "http://test.com", body={}, params=None, pswd=None + ) the_exception = cm.exception self.assertEqual(the_exception.status_code, "E130429") self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) @@ -711,7 +714,11 @@ def test_api_rate_limit_invalid_header(self): ) def test_api_rate_limit_invalid_response_body(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Test do_post empty body with patch("requests.post") as mock_request: @@ -719,7 +726,9 @@ def test_api_rate_limit_invalid_response_body(self): mock_request.return_value.status_code = 429 mock_request.return_value.json.return_value = "aaa" with self.assertRaises(RateLimitException) as cm: - auth.do_post("http://test.com", {}, None, None) + auth.http_client.post( + "http://test.com", body={}, params=None, pswd=None + ) the_exception = cm.exception self.assertEqual(the_exception.status_code, HTTPStatus.TOO_MANY_REQUESTS) self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) @@ -728,7 +737,11 @@ def test_api_rate_limit_invalid_response_body(self): self.assertEqual(the_exception.rate_limit_parameters, {}) def test_api_rate_limit_empty_response_body(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Test do_post empty body with patch("requests.post") as mock_request: @@ -736,7 +749,9 @@ def test_api_rate_limit_empty_response_body(self): mock_request.return_value.status_code = 429 mock_request.return_value.json.return_value = "" with self.assertRaises(RateLimitException) as cm: - auth.do_post("http://test.com", {}, None, None) + auth.http_client.post( + "http://test.com", body={}, params=None, pswd=None + ) the_exception = cm.exception self.assertEqual(the_exception.status_code, HTTPStatus.TOO_MANY_REQUESTS) self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) @@ -745,7 +760,11 @@ def test_api_rate_limit_empty_response_body(self): self.assertEqual(the_exception.rate_limit_parameters, {}) def test_api_rate_limit_none_response_body(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) # Test do_post empty body with patch("requests.post") as mock_request: @@ -753,7 +772,9 @@ def test_api_rate_limit_none_response_body(self): mock_request.return_value.status_code = 429 mock_request.return_value.json.return_value = None with self.assertRaises(RateLimitException) as cm: - auth.do_post("http://test.com", {}, None, None) + auth.http_client.post( + "http://test.com", body={}, params=None, pswd=None + ) the_exception = cm.exception self.assertEqual(the_exception.status_code, HTTPStatus.TOO_MANY_REQUESTS) self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) @@ -762,14 +783,20 @@ def test_api_rate_limit_none_response_body(self): self.assertEqual(the_exception.rate_limit_parameters, {}) def test_raise_from_response(self): - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) with patch("requests.get") as mock_request: mock_request.return_value.ok = False mock_request.return_value.status_code = 400 mock_request.return_value.error_type = ERROR_TYPE_SERVER_ERROR mock_request.return_value.text = """{"errorCode":"E062108","errorDescription":"User not found","errorMessage":"Cannot find user"}""" with self.assertRaises(AuthException) as cm: - auth.do_get(uri="http://test.com", params=False, allow_redirects=None) + auth.http_client.get( + uri="http://test.com", params=False, allow_redirects=None + ) the_exception = cm.exception self.assertEqual(the_exception.status_code, 400) self.assertEqual(the_exception.error_type, ERROR_TYPE_SERVER_ERROR) diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 00000000..7fe2f831 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,34 @@ +from enum import Enum + +from descope.common import DeliveryMethod, get_method_string +from descope.exceptions import AuthException +from tests import common + + +class TestCommon(common.DescopeTest): + def test_get_method_string(self): + self.assertEqual( + get_method_string(DeliveryMethod.EMAIL), + "email", + ) + self.assertEqual( + get_method_string(DeliveryMethod.SMS), + "sms", + ) + self.assertEqual( + get_method_string(DeliveryMethod.VOICE), + "voice", + ) + self.assertEqual( + get_method_string(DeliveryMethod.WHATSAPP), + "whatsapp", + ) + self.assertEqual( + get_method_string(DeliveryMethod.EMBEDDED), + "Embedded", + ) + + class AAA(Enum): + DUMMY = 4 + + self.assertRaises(AuthException, get_method_string, AAA.DUMMY) diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py index d34a81c3..67d3ffae 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -96,7 +96,13 @@ def test_compose_body(self): ) def test_sign_in(self): - enchantedlink = EnchantedLink(Auth(self.dummy_project_id, self.public_key_dict)) + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.post") as mock_post: my_mock_response = mock.Mock() my_mock_response.ok = True @@ -198,7 +204,13 @@ def test_sign_in(self): ) def test_sign_in_with_login_options(self): - enchantedlink = EnchantedLink(Auth(self.dummy_project_id, self.public_key_dict)) + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.post") as mock_post: my_mock_response = mock.Mock() my_mock_response.ok = True @@ -230,7 +242,13 @@ def test_sign_in_with_login_options(self): ) def test_sign_up(self): - enchantedlink = EnchantedLink(Auth(self.dummy_project_id, self.public_key_dict)) + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.post") as mock_post: my_mock_response = mock.Mock() my_mock_response.ok = True @@ -343,7 +361,13 @@ def test_sign_up(self): self.assertEqual(res["pendingRef"], "aaaa") def test_sign_up_or_in(self): - enchantedlink = EnchantedLink(Auth(self.dummy_project_id, self.public_key_dict)) + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.post") as mock_post: my_mock_response = mock.Mock() my_mock_response.ok = True @@ -410,7 +434,13 @@ def test_sign_up_or_in(self): def test_verify(self): token = "1234" - enchantedlink = EnchantedLink(Auth(self.dummy_project_id, self.public_key_dict)) + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.post") as mock_post: mock_post.return_value.ok = False @@ -434,7 +464,13 @@ def test_verify(self): self.assertIsNone(enchantedlink.verify(token)) def test_get_session(self): - enchantedlink = EnchantedLink(Auth(self.dummy_project_id, self.public_key_dict)) + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL" with patch("requests.post") as mock_post: @@ -449,7 +485,13 @@ def test_get_session(self): self.assertIsNotNone(enchantedlink.get_session("aaaaaa")) def test_update_user_email(self): - enchantedlink = EnchantedLink(Auth(self.dummy_project_id, self.public_key_dict)) + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.post") as mock_post: my_mock_response = mock.Mock() my_mock_response.ok = True diff --git a/tests/test_magiclink.py b/tests/test_magiclink.py index c851b48c..b6dfbc49 100644 --- a/tests/test_magiclink.py +++ b/tests/test_magiclink.py @@ -112,7 +112,13 @@ def test_compose_body(self): ) def test_sign_in(self): - magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + magiclink = MagicLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -231,7 +237,13 @@ def test_sign_up(self): "email": "dummy@dummy.com", } - magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + magiclink = MagicLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -392,7 +404,13 @@ def test_sign_up(self): ) def test_sign_up_or_in(self): - magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + magiclink = MagicLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows @@ -461,7 +479,13 @@ def test_sign_up_or_in(self): def test_verify(self): token = "1234" - magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + magiclink = MagicLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.post") as mock_post: mock_post.return_value.ok = False @@ -487,7 +511,7 @@ def test_verify(self): def test_verify_with_get_keys_mock(self): token = "1234" magiclink = MagicLink( - Auth(self.dummy_project_id, None) + Auth(self.dummy_project_id, None, http_client=self.make_http_client()) ) # public key will be "fetched" by Get mock # Test success flow @@ -508,7 +532,13 @@ def test_verify_with_get_keys_mock(self): self.assertIsNotNone(magiclink.verify(token)) def test_update_user_email(self): - magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + magiclink = MagicLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) self.assertRaises( AuthException, @@ -593,7 +623,13 @@ def test_update_user_email(self): ) def test_update_user_phone(self): - magiclink = MagicLink(Auth(self.dummy_project_id, self.public_key_dict)) + magiclink = MagicLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) self.assertRaises( AuthException, diff --git a/tests/test_oauth.py b/tests/test_oauth.py index eed6bc46..4a1b31f5 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -43,7 +43,13 @@ def test_verify_oauth_providers(self): ) def test_oauth_start(self): - oauth = OAuth(Auth(self.dummy_project_id, self.public_key_dict)) + oauth = OAuth( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, oauth.start, "") @@ -84,7 +90,13 @@ def test_oauth_start(self): ) def test_oauth_start_with_login_options(self): - oauth = OAuth(Auth(self.dummy_project_id, self.public_key_dict)) + oauth = OAuth( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, oauth.start, "") @@ -121,7 +133,13 @@ def test_compose_exchange_params(self): self.assertEqual(Auth._compose_exchange_body("c1"), {"code": "c1"}) def test_exchange_token(self): - oauth = OAuth(Auth(self.dummy_project_id, self.public_key_dict)) + oauth = OAuth( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, oauth.exchange_token, "") diff --git a/tests/test_password.py b/tests/test_password.py index 51a97862..a9201165 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -32,7 +32,13 @@ def test_sign_up(self): "email": "dummy@dummy.com", } - password = Password(Auth(self.dummy_project_id, self.public_key_dict)) + password = Password( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -117,7 +123,13 @@ def test_sign_up(self): ) def test_sign_in(self): - password = Password(Auth(self.dummy_project_id, self.public_key_dict)) + password = Password( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -189,7 +201,13 @@ def test_sign_in(self): ) def test_send_reset(self): - password = Password(Auth(self.dummy_project_id, self.public_key_dict)) + password = Password( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -284,7 +302,13 @@ def test_send_reset(self): ) def test_update(self): - password = Password(Auth(self.dummy_project_id, self.public_key_dict)) + password = Password( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -370,7 +394,13 @@ def test_update(self): ) def test_replace(self): - password = Password(Auth(self.dummy_project_id, self.public_key_dict)) + password = Password( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -466,7 +496,13 @@ def test_replace(self): ) def test_policy(self): - password = Password(Auth(self.dummy_project_id, self.public_key_dict)) + password = Password( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) with patch("requests.get") as mock_get: mock_get.return_value.ok = False diff --git a/tests/test_saml.py b/tests/test_saml.py index 804fb9a0..ed75b53e 100644 --- a/tests/test_saml.py +++ b/tests/test_saml.py @@ -32,7 +32,13 @@ def test_compose_start_params(self): ) def test_saml_start(self): - saml = SAML(Auth(self.dummy_project_id, self.public_key_dict)) + saml = SAML( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, saml.start, "", "http://dummy.com") @@ -77,7 +83,13 @@ def test_saml_start(self): ) def test_saml_start_with_login_options(self): - saml = SAML(Auth(self.dummy_project_id, self.public_key_dict)) + saml = SAML( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, saml.start, "", "http://dummy.com") @@ -119,7 +131,13 @@ def test_compose_exchange_params(self): self.assertEqual(Auth._compose_exchange_body("c1"), {"code": "c1"}) def test_exchange_token(self): - saml = SAML(Auth(self.dummy_project_id, self.public_key_dict)) + saml = SAML( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, saml.exchange_token, "") diff --git a/tests/test_sso.py b/tests/test_sso.py index eb555fc3..e5e03054 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -42,7 +42,13 @@ def test_compose_start_params(self): ) def test_sso_start(self): - sso = SSO(Auth(self.dummy_project_id, self.public_key_dict)) + sso = SSO( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, sso.start, "", "http://dummy.com") @@ -87,7 +93,13 @@ def test_sso_start(self): ) def test_sso_start_with_login_options(self): - sso = SSO(Auth(self.dummy_project_id, self.public_key_dict)) + sso = SSO( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, sso.start, "", "http://dummy.com") @@ -125,7 +137,13 @@ def test_compose_exchange_params(self): self.assertEqual(Auth._compose_exchange_body("c1"), {"code": "c1"}) def test_exchange_token(self): - sso = SSO(Auth(self.dummy_project_id, self.public_key_dict)) + sso = SSO( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, sso.exchange_token, "") diff --git a/tests/test_totp.py b/tests/test_totp.py index 19f37296..b57ab085 100644 --- a/tests/test_totp.py +++ b/tests/test_totp.py @@ -32,7 +32,13 @@ def test_sign_up(self): "email": "dummy@dummy.com", } - totp = TOTP(Auth(self.dummy_project_id, self.public_key_dict)) + totp = TOTP( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -64,7 +70,13 @@ def test_sign_up(self): self.assertIsNotNone(totp.sign_up("dummy@dummy.com", signup_user_details)) def test_sign_in(self): - totp = TOTP(Auth(self.dummy_project_id, self.public_key_dict)) + totp = TOTP( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, totp.sign_in_code, None, "1234") @@ -137,7 +149,13 @@ def test_sign_in(self): ) def test_update_user(self): - totp = TOTP(Auth(self.dummy_project_id, self.public_key_dict)) + totp = TOTP( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, totp.update_user, None, "") diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 3a3a0bf0..769f7601 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -80,7 +80,13 @@ def test_compose_update_finish_body(self): ) def test_sign_up_start(self): - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -126,7 +132,13 @@ def test_sign_up_start(self): self.assertEqual(res, valid_response) def test_sign_up_finish(self): - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, webauthn.sign_up_finish, "", "response01") @@ -176,7 +188,13 @@ def test_sign_up_finish(self): self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) def test_sign_in_start(self): - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -237,7 +255,13 @@ def test_sign_in_start(self): self.assertEqual(res, valid_response) def test_sign_in_start_with_login_options(self): - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -296,7 +320,13 @@ def test_sign_in_start_with_login_options(self): self.assertEqual(res, valid_response) def test_sign_in_finish(self): - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, webauthn.sign_in_finish, "", "response01") @@ -340,7 +370,13 @@ def test_sign_in_finish(self): self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) def test_sign_up_or_in_start(self): - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -394,7 +430,13 @@ def test_sign_up_or_in_start(self): def test_update_start(self): valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises( @@ -463,7 +505,13 @@ def test_update_start(self): self.assertEqual(res, valid_response) def test_update_finish(self): - webauthn = WebAuthn(Auth(self.dummy_project_id, self.public_key_dict)) + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + ) # Test failed flows self.assertRaises(AuthException, webauthn.update_finish, "", "response01") From 0a4541758ace3339e77f3f8336eafc3805223bf8 Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Sun, 17 Aug 2025 14:06:55 +0300 Subject: [PATCH 2/6] Trying to fix python version compatibility --- descope/auth.py | 10 ++++----- descope/descope_client.py | 20 ++++++++--------- descope/http_client.py | 25 +++++++++++----------- descope/management/audit.py | 2 ++ descope/management/authz.py | 2 ++ descope/management/outbound_application.py | 10 +++++---- descope/management/project.py | 2 ++ descope/management/role.py | 2 ++ descope/management/sso_application.py | 2 ++ tests/common.py | 4 +++- tests/management/test_access_key.py | 2 +- tests/management/test_permission.py | 2 +- tests/management/test_role.py | 2 +- tests/management/test_sso_application.py | 4 ++-- tests/management/test_sso_settings.py | 4 ++-- tests/management/test_tenant.py | 4 ++-- tests/management/test_user.py | 6 +++--- tests/test_auth.py | 4 ++-- tests/test_password.py | 2 +- 19 files changed, 61 insertions(+), 48 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index 33cee738..fe8fbf9c 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -6,20 +6,18 @@ import re from http import HTTPStatus from threading import Lock -from typing import Iterable +from typing import Iterable, Optional import jwt from email_validator import EmailNotValidError, validate_email from jwt import ExpiredSignatureError, ImmatureSignatureError from descope.common import ( - COOKIE_DATA_NAME, DEFAULT_BASE_URL, DEFAULT_DOMAIN, DEFAULT_URL_PREFIX, PHONE_REGEX, REFRESH_SESSION_COOKIE_NAME, - REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME, AccessKeyLoginOptions, DeliveryMethod, @@ -47,8 +45,8 @@ class Auth: def __init__( self, - project_id: str | None = None, - public_key: dict | str | None = None, + project_id: Optional[str] = None, + public_key: Optional[dict | str] = None, jwt_validation_leeway: int = 5, *, http_client: HTTPClient, @@ -114,7 +112,7 @@ def http_client(self) -> HTTPClient: return self._http def exchange_token( - self, uri, code: str, audience: str | None | Iterable[str] = None + self, uri, code: str, audience: Optional[Iterable[str] | str] = None ) -> dict: if not code: raise AuthException( diff --git a/descope/descope_client.py b/descope/descope_client.py index 54a866e2..d4d4ea9f 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import Iterable +from typing import Iterable, Optional import requests @@ -27,10 +27,10 @@ class DescopeClient: def __init__( self, project_id: str, - public_key: dict | None = None, + public_key: Optional[dict] = None, skip_verify: bool = False, - management_key: str | None = None, - auth_management_key: str | None = None, + management_key: Optional[str] = None, + auth_management_key: Optional[str] = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, jwt_validation_leeway: int = 5, ): @@ -310,7 +310,7 @@ def get_matched_tenant_roles( return matched def validate_session( - self, session_token: str, audience: str | Iterable[str] | None = None + self, session_token: str, audience: Optional[Iterable[str] | str] = None ) -> dict: """ Validate a session token. Call this function for every incoming request to your @@ -333,7 +333,7 @@ def validate_session( return self._auth.validate_session(session_token, audience) def refresh_session( - self, refresh_token: str, audience: str | Iterable[str] | None = None + self, refresh_token: str, audience: Optional[Iterable[str] | str] = None ) -> dict: """ Refresh a session. Call this function when a session expires and needs to be refreshed. @@ -354,7 +354,7 @@ def validate_and_refresh_session( self, session_token: str, refresh_token: str, - audience: str | Iterable[str] | None = None, + audience: Optional[Iterable[str] | str] = None, ) -> dict: """ Validate the session token and refresh it if it has expired, the session token will automatically be refreshed. @@ -454,7 +454,7 @@ def my_tenants( self, refresh_token: str, dct: bool = False, - ids: list[str] | None = None, + ids: Optional[list[str]] = None, ) -> dict: """ Retrieve tenant attributes that user belongs to, one of dct/ids must be populated . @@ -535,8 +535,8 @@ def history(self, refresh_token: str) -> list[dict]: def exchange_access_key( self, access_key: str, - audience: str | Iterable[str] | None = None, - login_options: AccessKeyLoginOptions | None = None, + audience: Optional[Iterable[str] | str] = None, + login_options: Optional[AccessKeyLoginOptions] = None, ) -> dict: """ Return a new session token for the given access key diff --git a/descope/http_client.py b/descope/http_client.py index e8eb9ed3..8877c9c9 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -3,6 +3,7 @@ import os import platform from http import HTTPStatus +from typing import Optional, Union, cast try: from importlib.metadata import version @@ -45,11 +46,11 @@ class HTTPClient: def __init__( self, project_id: str, - base_url: str | None = None, + base_url: Optional[str] = None, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, secure: bool = True, - management_key: str | None = None, + management_key: Optional[str] = None, ) -> None: if not project_id: raise AuthException( @@ -73,14 +74,14 @@ def get( uri: str, *, params=None, - allow_redirects: bool | None = None, - pswd: str | None = None, + allow_redirects: Optional[bool] = True, + pswd: Optional[str] = None, ) -> requests.Response: response = requests.get( f"{self.base_url}{uri}", headers=self._get_default_headers(pswd), params=params, - allow_redirects=allow_redirects, + allow_redirects=cast(bool, allow_redirects), verify=self.secure, timeout=self.timeout_seconds, ) @@ -91,9 +92,9 @@ def post( self, uri: str, *, - body: dict | list[dict] | list[str] | None = None, + body: Optional[Union[dict, list[dict], list[str]]] = None, params=None, - pswd: str | None = None, + pswd: Optional[str] = None, ) -> requests.Response: response = requests.post( f"{self.base_url}{uri}", @@ -111,9 +112,9 @@ def patch( self, uri: str, *, - body: dict | list[dict] | list[str] | None, + body: Optional[Union[dict, list[dict], list[str]]], params=None, - pswd: str | None = None, + pswd: Optional[str] = None, ) -> requests.Response: response = requests.patch( f"{self.base_url}{uri}", @@ -132,7 +133,7 @@ def delete( uri: str, *, params=None, - pswd: str | None = None, + pswd: Optional[str] = None, ) -> requests.Response: response = requests.delete( f"{self.base_url}{uri}", @@ -145,7 +146,7 @@ def delete( self._raise_from_response(response) return response - def get_default_headers(self, pswd: str | None = None) -> dict: + def get_default_headers(self, pswd: Optional[str] = None) -> dict: return self._get_default_headers(pswd) # ------------- helpers ------------- @@ -197,7 +198,7 @@ def _raise_from_response(self, response): response.text, ) - def _get_default_headers(self, pswd: str | None = None): + def _get_default_headers(self, pswd: Optional[str] = None): headers = _default_headers.copy() headers["x-descope-project-id"] = self.project_id bearer = self.project_id diff --git a/descope/management/audit.py b/descope/management/audit.py index 40ed721a..e1219621 100644 --- a/descope/management/audit.py +++ b/descope/management/audit.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime from typing import Any, List, Optional diff --git a/descope/management/authz.py b/descope/management/authz.py index 7e3fa698..6f45d267 100644 --- a/descope/management/authz.py +++ b/descope/management/authz.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime, timezone from typing import Any, List, Optional diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py index 337ca2ba..56b9f497 100644 --- a/descope/management/outbound_application.py +++ b/descope/management/outbound_application.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any, List, Optional from descope._http_base import HTTPBase @@ -19,7 +21,7 @@ class _OutboundApplicationTokenFetcher: def fetch_token_by_scopes( *, http: HTTPClient, - token: str | None = None, + token: Optional[str] = None, app_id: str, user_id: str, scopes: List[str], @@ -45,7 +47,7 @@ def fetch_token_by_scopes( def fetch_token( *, http: HTTPClient, - token: str | None = None, + token: Optional[str] = None, app_id: str, user_id: str, tenant_id: Optional[str] = None, @@ -69,7 +71,7 @@ def fetch_token( def fetch_tenant_token_by_scopes( *, http: HTTPClient, - token: str | None = None, + token: Optional[str] = None, app_id: str, tenant_id: str, scopes: List[str], @@ -93,7 +95,7 @@ def fetch_tenant_token_by_scopes( def fetch_tenant_token( *, http: HTTPClient, - token: str | None = None, + token: Optional[str] = None, app_id: str, tenant_id: str, options: Optional[dict] = None, diff --git a/descope/management/project.py b/descope/management/project.py index cf660aba..d34e9dba 100644 --- a/descope/management/project.py +++ b/descope/management/project.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import List, Optional from descope._http_base import HTTPBase diff --git a/descope/management/role.py b/descope/management/role.py index 2dffbe6f..fd61558d 100644 --- a/descope/management/role.py +++ b/descope/management/role.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import List, Optional from descope._http_base import HTTPBase diff --git a/descope/management/sso_application.py b/descope/management/sso_application.py index 3cdd4c7e..6d13a55d 100644 --- a/descope/management/sso_application.py +++ b/descope/management/sso_application.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any, List, Optional from descope._http_base import HTTPBase diff --git a/tests/common.py b/tests/common.py index 307f0c8b..67fd8bd5 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import platform import unittest @@ -47,7 +49,7 @@ def setUp(self) -> None: ) # Test helper to build a default HTTP client - def make_http_client(self, management_key: str | None = None): + def make_http_client(self, management_key: "str | None" = None): from descope.http_client import HTTPClient return HTTPClient( diff --git a/tests/management/test_access_key.py b/tests/management/test_access_key.py index 947a7560..8587ab72 100644 --- a/tests/management/test_access_key.py +++ b/tests/management/test_access_key.py @@ -123,7 +123,7 @@ def test_load(self): "x-descope-project-id": self.dummy_project_id, }, params={"id": "key-id"}, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index b6a9caca..9d7b1f0c 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -179,7 +179,7 @@ def test_load_all(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 8bc15e17..63e2eb63 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -199,7 +199,7 @@ def test_load_all(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_sso_application.py b/tests/management/test_sso_application.py index 8da82ba3..09702ffa 100644 --- a/tests/management/test_sso_application.py +++ b/tests/management/test_sso_application.py @@ -453,7 +453,7 @@ def test_load(self): "x-descope-project-id": self.dummy_project_id, }, params={"id": "app1"}, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) @@ -534,7 +534,7 @@ def test_load_all(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py index 482e236b..05eaae08 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -114,7 +114,7 @@ def test_load_settings(self): "x-descope-project-id": self.dummy_project_id, }, params={"tenantId": "T2AAAA"}, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) @@ -436,7 +436,7 @@ def test_get_settings(self): "x-descope-project-id": self.dummy_project_id, }, params={"tenantId": "tenant-id"}, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index 6a705cad..0ad89cf1 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -258,7 +258,7 @@ def test_load(self): "x-descope-project-id": self.dummy_project_id, }, params={"id": "t1"}, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) @@ -305,7 +305,7 @@ def test_load_all(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_user.py b/tests/management/test_user.py index 6ce2601d..fa25578d 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -807,7 +807,7 @@ def test_load(self): "x-descope-project-id": self.dummy_project_id, }, params={"loginId": "valid-id"}, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) @@ -839,7 +839,7 @@ def test_load_by_user_id(self): "x-descope-project-id": self.dummy_project_id, }, params={"userId": "user-id"}, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) @@ -1372,7 +1372,7 @@ def test_get_provider_token(self): "withRefreshToken": True, "forceRefresh": True, }, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 358e2943..7cdeb1a6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -591,7 +591,7 @@ def test_api_rate_limit_exception(self): } with self.assertRaises(RateLimitException) as cm: auth.http_client.get( - uri="http://test.com", params=False, allow_redirects=None + uri="http://test.com", params=False, allow_redirects=True ) the_exception = cm.exception self.assertEqual(the_exception.status_code, "E130429") @@ -795,7 +795,7 @@ def test_raise_from_response(self): mock_request.return_value.text = """{"errorCode":"E062108","errorDescription":"User not found","errorMessage":"Cannot find user"}""" with self.assertRaises(AuthException) as cm: auth.http_client.get( - uri="http://test.com", params=False, allow_redirects=None + uri="http://test.com", params=False, allow_redirects=True ) the_exception = cm.exception self.assertEqual(the_exception.status_code, 400) diff --git a/tests/test_password.py b/tests/test_password.py index a9201165..55dab52a 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -529,7 +529,7 @@ def test_policy(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - allow_redirects=None, + allow_redirects=True, verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) From 115d03096b7ef65f7920d821f3817c98bd59901f Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Sun, 17 Aug 2025 15:24:32 +0300 Subject: [PATCH 3/6] trying to fix coverage action --- .github/workflows/ci.yml | 8 +++----- pyproject.toml | 12 +++++++++++- tox.ini | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d980e17..be7531d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,12 +45,13 @@ jobs: - name: Run test suite run: tox --skip-pkg-install env: - COVERAGE_FILE: "coverage.${{ matrix.os }}.${{ matrix.py }}" + COVERAGE_FILE: ".coverage.${{ matrix.os }}.${{ matrix.py }}" - name: Store coverage file uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage.${{ matrix.os }}.${{ matrix.py }} - path: coverage.${{ matrix.os }}.${{ matrix.py }} + path: .coverage.${{ matrix.os }}.${{ matrix.py }} + include-hidden-files: true if-no-files-found: error coverage: @@ -61,8 +62,6 @@ jobs: permissions: pull-requests: write contents: write - env: - COVERAGE_FILE: "coverage" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -79,7 +78,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MERGE_COVERAGE_FILES: true ANNOTATE_MISSING_LINES: true - VERBOSE: true - name: Store Pull Request comment to be posted uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/pyproject.toml b/pyproject.toml index b422d1e1..07cb429f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,9 +80,19 @@ relative_files = true source = ["descope"] omit = ["descope/flask/*"] +[tool.coverage.paths] +# Normalize paths across OSes so combined reports resolve to the same files +source = [ + "descope", + "*/descope", + "*\\descope", +] + [tool.coverage.report] -fail_under = 98 +# Don't enforce threshold during JSON export used by the GitHub Action. +# Enforce thresholds explicitly in CI if desired. +fail_under = 0 skip_covered = true skip_empty = true diff --git a/tox.ini b/tox.ini index f44b54be..625e252d 100644 --- a/tox.ini +++ b/tox.ini @@ -32,7 +32,7 @@ commands = [testenv:report] commands = - poetry run coverage report + poetry run coverage report --fail-under=98 depends = py3{12, 11, 10, 9, 8} From d9cb8057570f4ce426d2f1bac186b1923c9f778a Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Sun, 17 Aug 2025 16:24:36 +0300 Subject: [PATCH 4/6] Increase coverage --- descope/http_client.py | 2 +- tests/test_auth.py | 330 ++++++++++++++++++++++++++++++++++++++ tests/test_http_client.py | 60 +++++++ tests/test_jwt_common.py | 66 ++++++++ 4 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 tests/test_http_client.py create mode 100644 tests/test_jwt_common.py diff --git a/descope/http_client.py b/descope/http_client.py index 8877c9c9..8f5db20a 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -7,7 +7,7 @@ try: from importlib.metadata import version -except ImportError: +except ImportError: # pragma: no cover from pkg_resources import get_distribution import requests diff --git a/tests/test_auth.py b/tests/test_auth.py index 7cdeb1a6..16c37dbd 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,6 +3,7 @@ import unittest from enum import Enum from http import HTTPStatus +from types import SimpleNamespace from unittest import mock from unittest.mock import patch @@ -454,6 +455,148 @@ def test_exchange_access_key(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + def test_exchange_token_success_and_empty_code(self): + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + + # Empty code -> error + with self.assertRaises(AuthException): + auth.exchange_token("/oauth/exchange", "") + + # Success path + with patch("requests.post") as mock_post: + net_resp = mock.Mock() + net_resp.ok = True + net_resp.cookies = {"DSR": "cookie_token"} + # Make validator return claims + auth._validate_token = lambda token, audience=None: { + "iss": "https://issuer/PX", + "sub": "user-x", + } + net_resp.json.return_value = { + "sessionJwt": "s1", + "refreshJwt": "r1", + "user": {"id": "user-x"}, + "firstSeen": True, + } + mock_post.return_value = net_resp + out = auth.exchange_token("/oauth/exchange", code="abc") + self.assertEqual(out["projectId"], "PX") + self.assertEqual(out["userId"], "user-x") + + def test_validate_session_success(self): + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + # Stub validator to bypass network + auth._validate_token = lambda token, audience=None: { + "iss": "P123", + "sub": "u123", + "permissions": ["p1"], + "roles": ["r1"], + "tenants": {"t1": {}}, + } + res = auth.validate_session("token-session") + self.assertEqual(res["projectId"], "P123") + self.assertEqual(res["userId"], "u123") + self.assertEqual(res["permissions"], ["p1"]) + self.assertIn(SESSION_TOKEN_NAME, res) + + def test_select_tenant_success(self): + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + # Missing refresh token + with self.assertRaises(AuthException): + auth.select_tenant("tenant1", "") + + # Success network path + with patch("requests.post") as mock_post: + net_resp = mock.Mock() + net_resp.ok = True + net_resp.cookies = {"DSR": "cookie_r"} + # validator stub + auth._validate_token = lambda token, audience=None: { + "iss": "P77", + "sub": "u77", + } + net_resp.json.return_value = { + "sessionJwt": "s77", + "refreshJwt": "r77", + } + mock_post.return_value = net_resp + out = auth.select_tenant("tenant1", refresh_token="r0") + self.assertEqual(out["projectId"], "P77") + self.assertIn(SESSION_TOKEN_NAME, out) + + def test_compose_url_invalid_method(self): + class Dummy(Enum): + X = 1 + + with self.assertRaises(AuthException): + Auth.compose_url("/base", Dummy.X) + + def test_validate_token_header_errors(self): + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + # Empty token + with self.assertRaises(AuthException): + auth._validate_token("") + + # Garbage token -> header parse error + with self.assertRaises(AuthException): + auth._validate_token("not-a-jwt") + + # Missing alg -> mock header dict without alg + with patch("descope.auth.jwt.get_unverified_header") as mock_hdr: + mock_hdr.return_value = {"kid": "kid1"} + with self.assertRaises(AuthException) as cm: + auth._validate_token("any.token.value") + self.assertIn("missing property: alg", str(cm.exception).lower()) + + # Missing kid -> mock header dict without kid + with patch("descope.auth.jwt.get_unverified_header") as mock_hdr: + mock_hdr.return_value = {"alg": "ES384"} + with self.assertRaises(AuthException) as cm2: + auth._validate_token("any.token.value") + self.assertIn("missing property: kid", str(cm2.exception).lower()) + + # Algorithm mismatch after fetching keys (kid found but alg different) + with patch("descope.auth.jwt.get_unverified_header") as mock_hdr: + mock_hdr.return_value = { + "alg": "RS256", + "kid": self.public_key_dict["kid"], + } + with self.assertRaises(AuthException) as cm3: + auth._validate_token("any.token.value") + self.assertIn("does not match", str(cm3.exception)) + + def test_extract_masked_address_default(self): + # Unknown method should return empty string + class DummyMethod(Enum): + OTHER = 999 + + self.assertEqual(Auth.extract_masked_address({}, DummyMethod.OTHER), "") + + def test_extract_masked_address_known_methods(self): + resp = {"maskedPhone": "+1-***-***-1234", "maskedEmail": "a***@b.com"} + self.assertEqual( + Auth.extract_masked_address(resp, DeliveryMethod.SMS), "+1-***-***-1234" + ) + self.assertEqual( + Auth.extract_masked_address(resp, DeliveryMethod.EMAIL), "a***@b.com" + ) + def test_adjust_properties(self): self.assertEqual( Auth.adjust_properties(self, jwt_response={}, user_jwt={}), @@ -805,6 +948,193 @@ def test_raise_from_response(self): """{"errorCode":"E062108","errorDescription":"User not found","errorMessage":"Cannot find user"}""", ) + def test_http_client_authorization_header_variants(self): + # Base client without management key + client = self.make_http_client() + headers = client.get_default_headers() + self.assertEqual(headers["Authorization"], f"Bearer {self.dummy_project_id}") + + # With password/pswd only + headers = client.get_default_headers(pswd="sekret") + self.assertEqual( + headers["Authorization"], f"Bearer {self.dummy_project_id}:sekret" + ) + + # With management key only + client2 = self.make_http_client(management_key="mkey") + headers2 = client2.get_default_headers() + self.assertEqual( + headers2["Authorization"], f"Bearer {self.dummy_project_id}:mkey" + ) + + # With both pswd and management key + headers3 = client2.get_default_headers(pswd="sekret") + self.assertEqual( + headers3["Authorization"], + f"Bearer {self.dummy_project_id}:sekret:mkey", + ) + + def test_compose_url_success(self): + base = "/otp/send" + self.assertEqual(Auth.compose_url(base, DeliveryMethod.EMAIL), f"{base}/email") + self.assertEqual(Auth.compose_url(base, DeliveryMethod.SMS), f"{base}/sms") + self.assertEqual(Auth.compose_url(base, DeliveryMethod.VOICE), f"{base}/voice") + self.assertEqual( + Auth.compose_url(base, DeliveryMethod.WHATSAPP), f"{base}/whatsapp" + ) + + def test_internal_rate_limit_helpers(self): + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + + class Resp: + def __init__(self, ok, status_code, body, headers): + self.ok = ok + self.status_code = status_code + self._body = body + self.headers = headers + self.text = "txt" + + def json(self): + return self._body + + # _parse_retry_after + self.assertEqual( + auth._parse_retry_after({API_RATE_LIMIT_RETRY_AFTER_HEADER: "7"}), 7 + ) + self.assertEqual( + auth._parse_retry_after({API_RATE_LIMIT_RETRY_AFTER_HEADER: "x"}), 0 + ) + + # _raise_rate_limit_exception with valid JSON + r1 = Resp( + ok=False, + status_code=429, + body={ + "errorCode": "E130429", + "errorDescription": "https://docs", + "errorMessage": "rate", + }, + headers={API_RATE_LIMIT_RETRY_AFTER_HEADER: "3"}, + ) + with self.assertRaises(RateLimitException) as cm: + auth._raise_rate_limit_exception(r1) + ex = cm.exception + self.assertEqual(ex.status_code, "E130429") + self.assertEqual(ex.error_type, ERROR_TYPE_API_RATE_LIMIT) + self.assertEqual(ex.error_description, "https://docs") + self.assertEqual(ex.error_message, "rate") + self.assertEqual( + ex.rate_limit_parameters, {API_RATE_LIMIT_RETRY_AFTER_HEADER: 3} + ) + + # _raise_rate_limit_exception with invalid JSON + r2 = Resp(False, 429, "not-a-dict", {API_RATE_LIMIT_RETRY_AFTER_HEADER: "x"}) + with self.assertRaises(RateLimitException) as cm2: + auth._raise_rate_limit_exception(r2) + ex2 = cm2.exception + self.assertEqual(ex2.status_code, HTTPStatus.TOO_MANY_REQUESTS) + self.assertEqual(ex2.error_type, ERROR_TYPE_API_RATE_LIMIT) + self.assertEqual(ex2.error_description, ERROR_TYPE_API_RATE_LIMIT) + self.assertEqual(ex2.error_message, ERROR_TYPE_API_RATE_LIMIT) + + # _raise_from_response with non-429 + r3 = Resp(False, 400, {}, {}) + with self.assertRaises(AuthException): + auth._raise_from_response(r3) + + # _raise_from_response with 429 invokes rate-limit handler + r4 = Resp( + False, + 429, + {"errorCode": "E130", "errorDescription": "d", "errorMessage": "m"}, + {API_RATE_LIMIT_RETRY_AFTER_HEADER: "2"}, + ) + with self.assertRaises(RateLimitException): + auth._raise_from_response(r4) + + def test_validate_and_refresh_session_refresh_path(self): + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=self.make_http_client(), + ) + # Force validate_session to fail + with patch.object( + Auth, + "validate_session", + side_effect=AuthException(400, ERROR_TYPE_SERVER_ERROR, "e"), + ): + # Stub refresh network + with patch("requests.post") as mock_post: + net_resp = mock.Mock() + net_resp.ok = True + net_resp.cookies = {"DSR": "cookie"} + auth._validate_token = lambda token, audience=None: { + "iss": "P1", + "sub": "u1", + } + net_resp.json.return_value = {"sessionJwt": "s", "refreshJwt": "r"} + mock_post.return_value = net_resp + out = auth.validate_and_refresh_session("bad", refresh_token="r0") + self.assertEqual(out["projectId"], "P1") + + def test_validate_token_public_key_not_found(self): + auth = Auth( + self.dummy_project_id, + None, + http_client=self.make_http_client(), + ) + # ensure public keys empty and fetching sets nothing + auth.public_keys = {} + with patch.object( + Auth, + "_fetch_public_keys", + side_effect=lambda self=auth: setattr(auth, "public_keys", {}), + ): + with patch("descope.auth.jwt.get_unverified_header") as mock_hdr: + mock_hdr.return_value = {"alg": "ES384", "kid": "unknown"} + with self.assertRaises(AuthException) as cm: + auth._validate_token("any") + self.assertIn("public key not found", str(cm.exception).lower()) + + def test_validate_token_decode_time_errors(self): + auth = Auth( + self.dummy_project_id, + None, + http_client=self.make_http_client(), + ) + # Prepare a fake key entry and matching header + auth.public_keys = {"kid": (SimpleNamespace(key="k"), "ES384")} + with patch("descope.auth.jwt.get_unverified_header") as mock_hdr, patch( + "descope.auth.jwt.decode" + ) as mock_dec: + mock_hdr.return_value = {"alg": "ES384", "kid": "kid"} + from jwt import ImmatureSignatureError + + mock_dec.side_effect = ImmatureSignatureError("early") + with self.assertRaises(AuthException) as cm: + auth._validate_token("tok") + self.assertEqual(cm.exception.status_code, 400) + + def test_validate_token_success(self): + auth = Auth( + self.dummy_project_id, + None, + http_client=self.make_http_client(), + ) + auth.public_keys = {"kid": (SimpleNamespace(key="k"), "ES384")} + with patch("descope.auth.jwt.get_unverified_header") as mock_hdr, patch( + "descope.auth.jwt.decode" + ) as mock_dec: + mock_hdr.return_value = {"alg": "ES384", "kid": "kid"} + mock_dec.return_value = {"sub": "u"} + out = auth._validate_token("tok") + self.assertEqual(out["jwt"], "tok") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 00000000..bf610cbb --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,60 @@ +import importlib +import importlib.util +import sys +import types +import unittest + +from descope.http_client import HTTPClient + + +class TestHTTPClient(unittest.TestCase): + def test_base_url_for_project_id(self): + # short project id -> default base + assert HTTPClient.base_url_for_project_id("short") == "https://api.descope.com" + # long project id -> computed region + pid = "Puse12aAc4T2V93bddihGEx2Ryhc8e5Z" + assert HTTPClient.base_url_for_project_id(pid) == "https://api.use1.descope.com" + + @unittest.skipIf( + importlib.util.find_spec("importlib.metadata") is not None, + "Stdlib metadata available; skip fallback path test", + ) + def test_sdk_version_import_fallback(self): + # Simulate absence of importlib.metadata to take fallback path + import builtins + + import descope.http_client as http_client_mod + + original_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "importlib.metadata": + raise ImportError("simulated") + return original_import(name, globals, locals, fromlist, level) + + # Prepare a fake pkg_resources for fallback path + class FakeDist: + def __init__(self, version="0.0.0"): + self.version = version + + fake_pkg = types.ModuleType("pkg_resources") + fake_pkg.get_distribution = lambda name: FakeDist("9.9.9") + + saved_pkg = sys.modules.get("pkg_resources") + sys.modules["pkg_resources"] = fake_pkg + + try: + builtins.__import__ = fake_import + reloaded = importlib.reload(http_client_mod) + v = reloaded.sdk_version() + assert isinstance(v, str) + finally: + builtins.__import__ = original_import + if saved_pkg is not None: + sys.modules["pkg_resources"] = saved_pkg + else: + sys.modules.pop("pkg_resources", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jwt_common.py b/tests/test_jwt_common.py new file mode 100644 index 00000000..dcfd1217 --- /dev/null +++ b/tests/test_jwt_common.py @@ -0,0 +1,66 @@ +import unittest + +from descope.jwt_common import ( + COOKIE_DATA_NAME, + REFRESH_SESSION_TOKEN_NAME, + SESSION_TOKEN_NAME, + decode_token_unverified, + generate_auth_info, + generate_jwt_response, +) + + +class TestJwtCommon(unittest.TestCase): + def test_generate_jwt_response_sets_user_and_first_seen_and_cookie(self): + # Arrange: a response body with cookie and user fields + response_body = { + "sessionJwt": "token1", + # do not provide refreshJwt to exercise refresh_cookie fallback + "cookieExpiration": 123, + "cookieMaxAge": 456, + "cookieDomain": "example.com", + "cookiePath": "/test", + "user": {"name": "Ada"}, + "firstSeen": False, + } + + def validator(token: str, audience=None): + # Return different iss/sub based on which token is being validated + if token == "token1": + return {"iss": "https://issuer.example/P123", "sub": "user-1"} + # refresh cookie fallback + return {"iss": "https://issuer.example/P999", "sub": "user-2"} + + # Act + jwt_response = generate_jwt_response( + response_body, + refresh_cookie="token2", + audience=None, + token_validator=validator, + ) + + # Assert top-level fields + assert jwt_response["user"] == {"name": "Ada"} + assert jwt_response["firstSeen"] is False + # Project ID should be parsed from issuer (last path segment) + assert jwt_response["projectId"] == "P123" + # userId copied from session token sub + assert jwt_response["userId"] == "user-1" + # cookie data present + assert jwt_response[COOKIE_DATA_NAME] == { + "exp": 123, + "maxAge": 456, + "domain": "example.com", + "path": "/test", + } + # both tokens should be decoded (session from body, refresh from cookie) + assert SESSION_TOKEN_NAME in jwt_response + assert REFRESH_SESSION_TOKEN_NAME in jwt_response + + def test_decode_token_unverified_handles_garbage(self): + # Invalid token strings should not raise and should return empty dict + assert decode_token_unverified("not-a-jwt") == {} + + +if __name__ == "__main__": + unittest.main() From 57919812cff9e111c89e0b1490de396ab482861f Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Sun, 17 Aug 2025 18:50:20 +0300 Subject: [PATCH 5/6] Revert "trying to fix coverage action" This reverts commit 115d03096b7ef65f7920d821f3817c98bd59901f. --- .github/workflows/ci.yml | 8 +++++--- pyproject.toml | 12 +----------- tox.ini | 2 +- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be7531d3..2d980e17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,13 +45,12 @@ jobs: - name: Run test suite run: tox --skip-pkg-install env: - COVERAGE_FILE: ".coverage.${{ matrix.os }}.${{ matrix.py }}" + COVERAGE_FILE: "coverage.${{ matrix.os }}.${{ matrix.py }}" - name: Store coverage file uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage.${{ matrix.os }}.${{ matrix.py }} - path: .coverage.${{ matrix.os }}.${{ matrix.py }} - include-hidden-files: true + path: coverage.${{ matrix.os }}.${{ matrix.py }} if-no-files-found: error coverage: @@ -62,6 +61,8 @@ jobs: permissions: pull-requests: write contents: write + env: + COVERAGE_FILE: "coverage" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -78,6 +79,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MERGE_COVERAGE_FILES: true ANNOTATE_MISSING_LINES: true + VERBOSE: true - name: Store Pull Request comment to be posted uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/pyproject.toml b/pyproject.toml index 07cb429f..b422d1e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,19 +80,9 @@ relative_files = true source = ["descope"] omit = ["descope/flask/*"] -[tool.coverage.paths] -# Normalize paths across OSes so combined reports resolve to the same files -source = [ - "descope", - "*/descope", - "*\\descope", -] - [tool.coverage.report] -# Don't enforce threshold during JSON export used by the GitHub Action. -# Enforce thresholds explicitly in CI if desired. -fail_under = 0 +fail_under = 98 skip_covered = true skip_empty = true diff --git a/tox.ini b/tox.ini index 625e252d..f44b54be 100644 --- a/tox.ini +++ b/tox.ini @@ -32,7 +32,7 @@ commands = [testenv:report] commands = - poetry run coverage report --fail-under=98 + poetry run coverage report depends = py3{12, 11, 10, 9, 8} From fcb13247bf881e90e24c377638544e7d4a8ba161 Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Sun, 17 Aug 2025 18:56:00 +0300 Subject: [PATCH 6/6] Remove orphaned code --- descope/auth.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/descope/auth.py b/descope/auth.py index fe8fbf9c..c7047457 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -385,9 +385,6 @@ def generate_jwt_response( token_validator=self._validate_token, ) - def _get_default_headers(self, pswd: str | None = None): - return self._http.get_default_headers(pswd) - # Validate a token and load the public key if needed def _validate_token( self, token: str, audience: str | None | Iterable[str] = None