diff --git a/.gitleaksignore b/.gitleaksignore index 8ea42322f..7d30e57e8 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -85,3 +85,5 @@ e7f5ad4253ad82236a5cff5f8c06878bfb190b00:tests/test_descope_client.py:jwt:185 e7f5ad4253ad82236a5cff5f8c06878bfb190b00:tests/test_descope_client.py:jwt:197 ece761372c78a9ad8a57da5f6d13431d298a99db:tests/test_auth.py:jwt:562 f3ec873c83a7067a1226d8b712b756b1b599fb3b:tests/test_descope_client.py:jwt:519 +b6a2e217be5dceb6c85332d2e193619894d3a36e:README.md:generic-api-key:1349 +b6a2e217be5dceb6c85332d2e193619894d3a36e:README.md:generic-api-key:1372 diff --git a/README.md b/README.md index d44416e9b..f2361af84 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ These sections show how to use the SDK to perform permission and user management 13. [Manage FGA (Fine-grained Authorization)](#manage-fga-fine-grained-authorization) 14. [Manage Project](#manage-project) 15. [Manage SSO Applications](#manage-sso-applications) +16. [Manage Outbound Applications](#manage-outbound-applications) If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section. @@ -1310,6 +1311,169 @@ apps = apps_resp["apps"] # Do something ``` +### Manage Outbound Applications + +You can create, update, delete, load outbound applications and fetch tokens for them: + +```python +# Create a basic outbound application +response = descope_client.mgmt.outbound_application.create_application( + name="my new app", + description="my desc", + client_secret="secret123", # Optional + id="my-custom-id", # Optional +) +app_id = response["app"]["id"] + +# Create a full OAuth outbound application with all parameters +from descope.management.common import URLParam, AccessType, PromptType + +# Create URL parameters for authorization +auth_params = [ + URLParam("response_type", "code"), + URLParam("client_id", "my-client-id"), + URLParam("redirect_uri", "https://myapp.com/callback") +] + +# Create URL parameters for token endpoint +token_params = [ + URLParam("grant_type", "authorization_code"), + URLParam("client_id", "my-client-id") +] + +# Create prompt types +prompts = [PromptType.LOGIN, PromptType.CONSENT] + +full_app = descope_client.mgmt.outbound_application.create_application( + name="My OAuth App", + description="A full OAuth outbound application", + logo="https://example.com/logo.png", + id="my-custom-id", # Optional custom ID + client_secret="my-secret-key", + client_id="my-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + authorization_url_params=auth_params, + token_url="https://oauth2.googleapis.com/token", + token_url_params=token_params, + revocation_url="https://oauth2.googleapis.com/revoke", + default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], + default_redirect_url="https://myapp.com/callback", + callback_domain="myapp.com", + pkce=True, # Enable PKCE + access_type=AccessType.OFFLINE, # Request refresh tokens + prompt=prompts +) + +# Update an outbound application with all parameters +# Update will override all fields as is. Use carefully. +descope_client.mgmt.outbound_application.update_application( + id="my-app-id", + name="my updated app", + description="updated description", + logo="https://example.com/logo.png", + client_secret="new-secret", # Optional + client_id="new-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + authorization_url_params=auth_params, + token_url="https://oauth2.googleapis.com/token", + token_url_params=token_params, + revocation_url="https://oauth2.googleapis.com/revoke", + default_scopes=["https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"], + default_redirect_url="https://myapp.com/updated-callback", + callback_domain="myapp.com", + pkce=True, + access_type=AccessType.OFFLINE, + prompt=[PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] +) + +# Delete an outbound application by id +# Outbound application deletion cannot be undone. Use carefully. +descope_client.mgmt.outbound_application.delete_application("my-app-id") + +# Load an outbound application by id +app = descope_client.mgmt.outbound_application.load_application("my-app-id") + +# Load all outbound applications +apps_resp = descope_client.mgmt.outbound_application.load_all_applications() +apps = apps_resp["apps"] +for app in apps: + # Do something with each app + +# Fetch user token with specific scopes +user_token = descope_client.mgmt.outbound_application.fetch_token_by_scopes( + "my-app-id", + "user-id", + ["read", "write"], + {"refreshToken": True}, # Optional + "tenant-id" # Optional +) + +# Fetch latest user token +latest_user_token = descope_client.mgmt.outbound_application.fetch_token( + "my-app-id", + "user-id", + "tenant-id", # Optional + {"forceRefresh": True} # Optional +) + +# Fetch tenant token with specific scopes +tenant_token = descope_client.mgmt.outbound_application.fetch_tenant_token_by_scopes( + "my-app-id", + "tenant-id", + ["read", "write"], + {"refreshToken": True} # Optional +) + +# Fetch latest tenant token +latest_tenant_token = descope_client.mgmt.outbound_application.fetch_tenant_token( + "my-app-id", + "tenant-id", + {"forceRefresh": True} # Optional +) +``` + +Fetch outbound application tokens using an inbound application token that includes the "outbound.token.fetch" scope (no management key required) + +```python +# Fetch user token with specific scopes +user_token = descope_client.mgmt.outbound_application_by_token.fetch_token_by_scopes( + "inbound-app-token", + "my-app-id", + "user-id", + ["read", "write"], + {"refreshToken": True}, # Optional + "tenant-id" # Optional +) + +# Fetch latest user token +latest_user_token = descope_client.mgmt.outbound_application_by_token.fetch_token( + "inbound-app-token", + "my-app-id", + "user-id", + "tenant-id", # Optional + {"forceRefresh": True} # Optional +) + +# Fetch tenant token with specific scopes +tenant_token = descope_client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( + "inbound-app-token", + "my-app-id", + "tenant-id", + ["read", "write"], + {"refreshToken": True} # Optional +) + +# Fetch latest tenant token +latest_tenant_token = descope_client.mgmt.outbound_application_by_token.fetch_tenant_token( + "inbound-app-token", + "my-app-id", + "tenant-id", + {"forceRefresh": True} # Optional +) +``` + ### Utils for your end to end (e2e) tests and integration tests To ease your e2e tests, we exposed dedicated management methods, diff --git a/descope/auth.py b/descope/auth.py index 9fe26393c..ecefe3907 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -124,7 +124,7 @@ def _raise_rate_limit_exception(self, response): ) except RateLimitException: raise - except Exception as e: + except Exception: raise RateLimitException( status_code=HTTPStatus.TOO_MANY_REQUESTS, error_type=ERROR_TYPE_API_RATE_LIMIT, diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index b461aa058..9b78db860 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -118,8 +118,13 @@ def update_user_email( Auth.validate_email(email) body = EnchantedLink._compose_update_user_email_body( - login_id, email, add_to_login_ids, on_merge_use_existing, - template_options, template_id, provider_id + login_id, + email, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, ) uri = EndpointsV1.update_user_email_enchantedlink_path response = self._auth.do_post(uri, body, None, refresh_token) diff --git a/descope/authmethod/magiclink.py b/descope/authmethod/magiclink.py index 432c3356c..4182e5484 100644 --- a/descope/authmethod/magiclink.py +++ b/descope/authmethod/magiclink.py @@ -117,8 +117,13 @@ def update_user_email( Auth.validate_email(email) body = MagicLink._compose_update_user_email_body( - login_id, email, add_to_login_ids, on_merge_use_existing, - template_options, template_id, provider_id + login_id, + email, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, ) uri = EndpointsV1.update_user_email_magiclink_path response = self._auth.do_post(uri, body, None, refresh_token) @@ -144,8 +149,13 @@ def update_user_phone( Auth.validate_phone(method, phone) body = MagicLink._compose_update_user_phone_body( - login_id, phone, add_to_login_ids, on_merge_use_existing, - template_options, template_id, provider_id + login_id, + phone, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, ) uri = EndpointsV1.update_user_phone_magiclink_path response = self._auth.do_post(uri, body, None, refresh_token) diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py index 5a9a38cf0..86cc3c10d 100644 --- a/descope/authmethod/otp.py +++ b/descope/authmethod/otp.py @@ -198,8 +198,13 @@ def update_user_email( uri = EndpointsV1.update_user_email_otp_path body = OTP._compose_update_user_email_body( - login_id, email, add_to_login_ids, on_merge_use_existing, - template_options, template_id, provider_id + login_id, + email, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, ) response = self._auth.do_post(uri, body, None, refresh_token) return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) @@ -241,8 +246,13 @@ def update_user_phone( uri = OTP._compose_update_phone_url(method) body = OTP._compose_update_user_phone_body( - login_id, phone, add_to_login_ids, on_merge_use_existing, - template_options, template_id, provider_id + login_id, + phone, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, ) response = self._auth.do_post(uri, body, None, refresh_token) return Auth.extract_masked_address(response.json(), method) diff --git a/descope/descope_client.py b/descope/descope_client.py index 24c2260e7..fb8969bf4 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -55,10 +55,6 @@ def __init__( @property def mgmt(self): - if not self._auth.management_key: - raise AuthException( - 400, ERROR_TYPE_INVALID_ARGUMENT, "management_key cannot be empty" - ) return self._mgmt @property diff --git a/descope/flask/__init__.py b/descope/flask/__init__.py index ad96ab635..aab97141b 100644 --- a/descope/flask/__init__.py +++ b/descope/flask/__init__.py @@ -4,7 +4,7 @@ import uuid from functools import wraps -from flask import Response, redirect, request, g +from flask import Response, g, redirect, request from .. import ( COOKIE_DATA_NAME, diff --git a/descope/management/common.py b/descope/management/common.py index 57181794f..f7e594890 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -1,6 +1,34 @@ +from enum import Enum from typing import List, Optional +class AccessType(Enum): + OFFLINE = "offline" + ONLINE = "online" + + +class PromptType(Enum): + NONE = "none" + LOGIN = "login" + CONSENT = "consent" + SELECT_ACCOUNT = "select_account" + + +class URLParam: + def __init__(self, name: str, value: str): + self.name = name + self.value = value + + def to_dict(self) -> dict: + return {"name": self.name, "value": self.value} + + +def url_params_to_dict(url_params: Optional[List[URLParam]] = None) -> list: + if url_params is None: + return [] + return [param.to_dict() for param in url_params] + + class MgmtV1: # tenant tenant_create_path = "/v1/mgmt/tenant/create" @@ -19,6 +47,21 @@ class MgmtV1: sso_application_load_path = "/v1/mgmt/sso/idp/app/load" sso_application_load_all_path = "/v1/mgmt/sso/idp/apps/load" + # outbound application + outbound_application_create_path = "/v1/mgmt/outbound/app/create" + outbound_application_update_path = "/v1/mgmt/outbound/app/update" + outbound_application_delete_path = "/v1/mgmt/outbound/app/delete" + outbound_application_load_path = "/v1/mgmt/outbound/app" + outbound_application_load_all_path = "/v1/mgmt/outbound/apps" + outbound_application_fetch_token_by_scopes_path = "/v1/mgmt/outbound/app/user/token" + outbound_application_fetch_token_path = "/v1/mgmt/outbound/app/user/token/latest" + outbound_application_fetch_tenant_token_by_scopes_path = ( + "/v1/mgmt/outbound/app/tenant/token" + ) + outbound_application_fetch_tenant_token_path = ( + "/v1/mgmt/outbound/app/tenant/token/latest" + ) + # user user_create_path = "/v1/mgmt/user/create" test_user_create_path = "/v1/mgmt/user/create/test" @@ -365,7 +408,8 @@ def sort_to_dict(sort: List[Sort]) -> list: ) return sort_list + def map_to_values_object(input_map: dict): if not input_map: return {} - return {k: {"values": v} for k, v in input_map.items()} \ No newline at end of file + return {k: {"values": v} for k, v in input_map.items()} diff --git a/descope/management/fga.py b/descope/management/fga.py index cce698cf0..91dbb71b5 100644 --- a/descope/management/fga.py +++ b/descope/management/fga.py @@ -1,5 +1,4 @@ -from datetime import datetime, timezone -from typing import Any, List, Optional +from typing import List from descope._auth_base import AuthBase from descope.management.common import MgmtV1 diff --git a/descope/management/jwt.py b/descope/management/jwt.py index 42181a98b..9adaa9338 100644 --- a/descope/management/jwt.py +++ b/descope/management/jwt.py @@ -87,7 +87,7 @@ def impersonate( pswd=self._auth.management_key, ) return response.json().get("jwt", "") - + def stop_impersonation( self, jwt: str, @@ -109,9 +109,7 @@ def stop_impersonation( AuthException: raised if update failed """ if not jwt or jwt == "": - raise AuthException( - 400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty" - ) + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") response = self._auth.do_post( MgmtV1.stop_impersonation_path, diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py new file mode 100644 index 000000000..2b9bc2deb --- /dev/null +++ b/descope/management/outbound_application.py @@ -0,0 +1,648 @@ +from typing import Any, List, Optional + +from descope._auth_base import AuthBase +from descope.auth import Auth +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 +from descope.management.common import ( + AccessType, + MgmtV1, + PromptType, + URLParam, + url_params_to_dict, +) + + +class _OutboundApplicationTokenFetcher: + """Internal helper class for shared token fetching logic.""" + + @staticmethod + def fetch_token_by_scopes( + auth_instance: Auth, + token: str, + app_id: str, + user_id: str, + scopes: List[str], + options: Optional[dict] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """Internal implementation for fetching token by scopes.""" + uri = MgmtV1.outbound_application_fetch_token_by_scopes_path + response = auth_instance.do_post( + uri, + { + "appId": app_id, + "userId": user_id, + "scopes": scopes, + "options": options, + "tenantId": tenant_id, + }, + pswd=token, + ) + return response.json() + + @staticmethod + def fetch_token( + auth_instance: Auth, + token: str, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + options: Optional[dict] = None, + ) -> dict: + """Internal implementation for fetching token.""" + uri = MgmtV1.outbound_application_fetch_token_path + response = auth_instance.do_post( + uri, + { + "appId": app_id, + "userId": user_id, + "tenantId": tenant_id, + "options": options, + }, + pswd=token, + ) + return response.json() + + @staticmethod + def fetch_tenant_token_by_scopes( + auth_instance: Auth, + token: str, + app_id: str, + tenant_id: str, + scopes: List[str], + options: Optional[dict] = None, + ) -> dict: + """Internal implementation for fetching tenant token by scopes.""" + uri = MgmtV1.outbound_application_fetch_tenant_token_by_scopes_path + response = auth_instance.do_post( + uri, + { + "appId": app_id, + "tenantId": tenant_id, + "scopes": scopes, + "options": options, + }, + pswd=token, + ) + return response.json() + + @staticmethod + def fetch_tenant_token( + auth_instance: Auth, + token: str, + 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( + uri, + { + "appId": app_id, + "tenantId": tenant_id, + "options": options, + }, + pswd=token, + ) + return response.json() + + +class OutboundApplication(AuthBase): + def create_application( + self, + name: str, + description: Optional[str] = None, + logo: Optional[str] = None, + id: Optional[str] = None, + client_secret: Optional[str] = None, + client_id: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[URLParam]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[URLParam]] = None, + revocation_url: Optional[str] = None, + default_scopes: Optional[List[str]] = None, + default_redirect_url: Optional[str] = None, + callback_domain: Optional[str] = None, + pkce: Optional[bool] = None, + access_type: Optional[AccessType] = None, + prompt: Optional[List[PromptType]] = None, + ) -> dict: + """ + Create a new outbound application with the given name. Outbound application IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + name (str): The outbound application's name. + description (str): Optional outbound application description. + logo (str): Optional outbound application logo. + id (str): Optional outbound application ID. + client_secret (str): Optional client secret for the application. + client_id (str): Optional client ID for the application. + discovery_url (str): Optional OAuth discovery URL. + authorization_url (str): Optional OAuth authorization URL. + authorization_url_params (List[URLParam]): Optional authorization URL parameters. + token_url (str): Optional OAuth token URL. + token_url_params (List[URLParam]): Optional token URL parameters. + revocation_url (str): Optional OAuth token revocation URL. + default_scopes (List[str]): Optional default OAuth scopes. + default_redirect_url (str): Optional default redirect URL. + callback_domain (str): Optional callback domain. + pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. + access_type (AccessType): Optional OAuth access type. + prompt (List[PromptType]): Optional OAuth prompt parameters. + + Return value (dict): + Return dict in the format + {"app": {"id": , "name": , "description": , "logo": }} + + Raise: + AuthException: raised if create operation fails + """ + uri = MgmtV1.outbound_application_create_path + response = self._auth.do_post( + uri, + OutboundApplication._compose_create_update_body( + name, + description, + logo, + id, + client_secret, + client_id, + discovery_url, + authorization_url, + authorization_url_params, + token_url, + token_url_params, + revocation_url, + default_scopes, + default_redirect_url, + callback_domain, + pkce, + access_type, + prompt, + ), + pswd=self._auth.management_key, + ) + return response.json() + + def update_application( + self, + id: str, + name: str, + description: Optional[str] = None, + logo: Optional[str] = None, + client_secret: Optional[str] = None, + client_id: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[URLParam]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[URLParam]] = None, + revocation_url: Optional[str] = None, + default_scopes: Optional[List[str]] = None, + default_redirect_url: Optional[str] = None, + callback_domain: Optional[str] = None, + pkce: Optional[bool] = None, + access_type: Optional[AccessType] = None, + prompt: Optional[List[PromptType]] = None, + ) -> dict: + """ + Update an existing outbound application with the given parameters. IMPORTANT: All parameters are used as overrides + to the existing outbound application. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the outbound application to update. + name (str): Updated outbound application name. + description (str): Optional outbound application description. + logo (str): Optional outbound application logo. + client_secret (str): Optional client secret for the application. + client_id (str): Optional client ID for the application. + discovery_url (str): Optional OAuth discovery URL. + authorization_url (str): Optional OAuth authorization URL. + authorization_url_params (List[URLParam]): Optional authorization URL parameters. + token_url (str): Optional OAuth token URL. + token_url_params (List[URLParam]): Optional token URL parameters. + revocation_url (str): Optional OAuth token revocation URL. + default_scopes (List[str]): Optional default OAuth scopes. + default_redirect_url (str): Optional default redirect URL. + callback_domain (str): Optional callback domain. + pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. + access_type (AccessType): Optional OAuth access type. + prompt (List[PromptType]): Optional OAuth prompt parameters. + + Return value (dict): + Return dict in the format + {"app": {"id": , "name": , "description": , "logo": }} + + Raise: + AuthException: raised if update operation fails + """ + uri = MgmtV1.outbound_application_update_path + response = self._auth.do_post( + uri, + { + "app": OutboundApplication._compose_create_update_body( + name, + description, + logo, + id, + client_secret, + client_id, + discovery_url, + authorization_url, + authorization_url_params, + token_url, + token_url_params, + revocation_url, + default_scopes, + default_redirect_url, + callback_domain, + pkce, + access_type, + prompt, + ) + }, + pswd=self._auth.management_key, + ) + return response.json() + + def delete_application( + self, + id: str, + ): + """ + Delete an existing outbound application. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the outbound application that's to be deleted. + + Raise: + AuthException: raised if deletion operation fails + """ + uri = MgmtV1.outbound_application_delete_path + self._auth.do_post(uri, {"id": id}, pswd=self._auth.management_key) + + def load_application( + self, + id: str, + ) -> dict: + """ + Load outbound application by id. + + Args: + id (str): The ID of the outbound application to load. + + Return value (dict): + Return dict in the format + {"app": {"id": , "name": , "description": , "logo": }} + Containing the loaded outbound application information. + + 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, + ) + return response.json() + + def load_all_applications( + self, + ) -> dict: + """ + Load all outbound applications. + + Return value (dict): + Return dict in the format + {"apps": [{"id": , "name": , "description": , "logo": }, ...]} + Containing the loaded outbound applications information. + + Raise: + AuthException: raised if load operation fails + """ + response = self._auth.do_get( + uri=MgmtV1.outbound_application_load_all_path, + pswd=self._auth.management_key, + ) + return response.json() + + def fetch_token_by_scopes( + self, + app_id: str, + user_id: str, + scopes: List[str], + options: Optional[dict] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Fetch an outbound application token for a user with specific scopes. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + tenant_id (str): Optional tenant ID. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + 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, + ) + + def fetch_token( + self, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a user. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + 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, + ) + + def fetch_tenant_token_by_scopes( + self, + app_id: str, + tenant_id: str, + scopes: List[str], + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a tenant with specific scopes. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + 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, + ) + + def fetch_tenant_token( + self, + app_id: str, + tenant_id: str, + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a tenant. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + 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, + ) + + @staticmethod + def _compose_create_update_body( + name: str, + description: Optional[str] = None, + logo: Optional[str] = None, + id: Optional[str] = None, + client_secret: Optional[str] = None, + client_id: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[URLParam]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[URLParam]] = None, + revocation_url: Optional[str] = None, + default_scopes: Optional[List[str]] = None, + default_redirect_url: Optional[str] = None, + callback_domain: Optional[str] = None, + pkce: Optional[bool] = None, + access_type: Optional[AccessType] = None, + prompt: Optional[List[PromptType]] = None, + ) -> dict: + body: dict[str, Any] = { + "name": name, + "id": id, + "description": description, + "logo": logo, + } + if client_secret: + body["clientSecret"] = client_secret + if client_id: + body["clientId"] = client_id + if discovery_url: + body["discoveryUrl"] = discovery_url + if authorization_url: + body["authorizationUrl"] = authorization_url + if authorization_url_params is not None: + body["authorizationUrlParams"] = url_params_to_dict( + authorization_url_params + ) + if token_url: + body["tokenUrl"] = token_url + if token_url_params is not None: + body["tokenUrlParams"] = url_params_to_dict(token_url_params) + if revocation_url: + body["revocationUrl"] = revocation_url + if default_scopes is not None: + body["defaultScopes"] = default_scopes + if default_redirect_url: + body["defaultRedirectUrl"] = default_redirect_url + if callback_domain: + body["callbackDomain"] = callback_domain + if pkce is not None: + body["pkce"] = pkce + if access_type: + body["accessType"] = access_type.value + if prompt is not None: + body["prompt"] = [p.value for p in prompt] + return body + + +class OutboundApplicationByToken(AuthBase): + + # Methods for fetching outbound application tokens using an inbound application token + # that includes the "outbound.token.fetch" scope (no management key required) + + def _check_inbound_app_token(self, token: str): + """Check if inbound app token is available for the given property.""" + if not token: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "Inbound app token is required for perform this functionality", + ) + + def fetch_token_by_scopes( + self, + token: str, + app_id: str, + user_id: str, + scopes: List[str], + options: Optional[dict] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Fetch an outbound application token for a user with specific scopes. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + tenant_id (str): Optional tenant ID. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return _OutboundApplicationTokenFetcher.fetch_token_by_scopes( + self._auth, token, app_id, user_id, scopes, options, tenant_id + ) + + def fetch_token( + self, + token: str, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a user. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return _OutboundApplicationTokenFetcher.fetch_token( + self._auth, token, app_id, user_id, tenant_id, options + ) + + def fetch_tenant_token_by_scopes( + self, + token: str, + app_id: str, + tenant_id: str, + scopes: List[str], + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a tenant with specific scopes. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return _OutboundApplicationTokenFetcher.fetch_tenant_token_by_scopes( + self._auth, token, app_id, tenant_id, scopes, options + ) + + def fetch_tenant_token( + self, token: str, app_id: str, tenant_id: str, options: Optional[dict] = None + ) -> dict: + """ + Fetch an outbound application token for a tenant. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return _OutboundApplicationTokenFetcher.fetch_tenant_token( + self._auth, token, app_id, tenant_id, options + ) diff --git a/descope/management/tenant.py b/descope/management/tenant.py index aa2bbb5ec..ccfa1a578 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -25,7 +25,7 @@ def create( Users authenticating from these domains will be associated with this tenant. custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - disabled (bool): Optional, login to the tenant will be disabled + disabled (bool): Optional, login to the tenant will be disabled Return value (dict): Return dict in the format @@ -42,7 +42,12 @@ def create( response = self._auth.do_post( uri, Tenant._compose_create_update_body( - name, id, self_provisioning_domains, custom_attributes, enforce_sso, disabled + name, + id, + self_provisioning_domains, + custom_attributes, + enforce_sso, + disabled, ), pswd=self._auth.management_key, ) @@ -68,7 +73,7 @@ def update( Users authenticating from these domains will be associated with this tenant. custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - disabled (bool): Optional, login to the tenant will be disabled + disabled (bool): Optional, login to the tenant will be disabled Raise: AuthException: raised if creation operation fails @@ -81,7 +86,12 @@ def update( self._auth.do_post( uri, Tenant._compose_create_update_body( - name, id, self_provisioning_domains, custom_attributes, enforce_sso, disabled + name, + id, + self_provisioning_domains, + custom_attributes, + enforce_sso, + disabled, ), pswd=self._auth.management_key, ) @@ -200,7 +210,7 @@ def _compose_create_update_body( "id": id, "selfProvisioningDomains": self_provisioning_domains, "enforceSSO": enforce_sso, - "disabled": disabled + "disabled": disabled, } if custom_attributes is not None: body["customAttributes"] = custom_attributes diff --git a/descope/management/user.py b/descope/management/user.py index f0cbb9162..65aee89b2 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -9,8 +9,8 @@ MgmtV1, Sort, associated_tenants_to_dict, - sort_to_dict, map_to_values_object, + sort_to_dict, ) from descope.management.user_pwd import UserPassword @@ -731,12 +731,12 @@ def search_all( body["fromModifiedTime"] = from_modified_time if to_modified_time is not None: body["toModifiedTime"] = to_modified_time - + if tenant_role_ids is not None: body["tenantRoleIds"] = map_to_values_object(tenant_role_ids) if tenant_role_names is not None: body["tenantRoleNames"] = map_to_values_object(tenant_role_names) - + response = self._auth.do_post( MgmtV1.users_search_path, body=body, @@ -848,7 +848,7 @@ def search_all_test_users( body["fromModifiedTime"] = from_modified_time if to_modified_time is not None: body["toModifiedTime"] = to_modified_time - + if tenant_role_ids is not None: body["tenantRoleIds"] = map_to_values_object(tenant_role_ids) if tenant_role_names is not None: @@ -1722,9 +1722,13 @@ def generate_embedded_link( return response.json()["token"] def generate_sign_up_embedded_link( - self, login_id: str, user: Optional[CreateUserObj] = None, - email_verified: bool = False, phone_verified: bool = False, - login_options: Optional[LoginOptions] = None, timeout: int = 0 + self, + login_id: str, + user: Optional[CreateUserObj] = None, + email_verified: bool = False, + phone_verified: bool = False, + login_options: Optional[LoginOptions] = None, + timeout: int = 0, ) -> str: """ Generate sign up Embedded Link for the given user login ID. @@ -1752,7 +1756,7 @@ def generate_sign_up_embedded_link( "loginOptions": login_options.__dict__ if login_options else {}, "emailVerified": email_verified, "phoneVerified": phone_verified, - "timeout": timeout + "timeout": timeout, }, pswd=self._auth.management_key, ) diff --git a/descope/mgmt.py b/descope/mgmt.py index 52901ac22..75a83cebe 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -1,4 +1,5 @@ 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 @@ -6,13 +7,17 @@ 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 + 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.user import User # noqa: F401 +from descope.management.user import User class MGMT: @@ -34,59 +39,94 @@ def __init__(self, auth: 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): + """Check if management key is available for the given property.""" + if not self._auth.management_key: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Management key is required to access '{property_name}' functionality", + ) @property def tenant(self): + self._check_management_key("tenant") return self._tenant @property def sso_application(self): + self._check_management_key("sso_application") return self._sso_application @property def user(self): + self._check_management_key("user") return self._user @property def access_key(self): + self._check_management_key("access_key") return self._access_key @property def sso(self): + self._check_management_key("sso") return self._sso @property def jwt(self): + self._check_management_key("jwt") return self._jwt @property def permission(self): + self._check_management_key("permission") return self._permission @property def role(self): + self._check_management_key("role") return self._role @property def group(self): + self._check_management_key("group") return self._group @property def flow(self): + self._check_management_key("flow") return self._flow @property def audit(self): + self._check_management_key("audit") return self._audit @property def authz(self): + self._check_management_key("authz") return self._authz @property def fga(self): + self._check_management_key("fga") return self._fga @property def project(self): + self._check_management_key("project") return self._project + + @property + def outbound_application(self): + self._check_management_key("outbound_application") + return self._outbound_application + + @property + def outbound_application_by_token(self): + # No management key check for outbound_app_token (as authentication for those methods is done by inbound app token) + return self._outbound_application_by_token diff --git a/samples/management/outbound_application_sample_app.py b/samples/management/outbound_application_sample_app.py new file mode 100644 index 000000000..0d6a1ea1c --- /dev/null +++ b/samples/management/outbound_application_sample_app.py @@ -0,0 +1,154 @@ +import logging + +from descope import AuthException, DescopeClient + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + management_key = "" + + try: + descope_client = DescopeClient( + project_id=project_id, management_key=management_key + ) + outbound_app_id = "" + + # CREATE OUTBOUND APPLICATION + + try: + logging.info("Going to create a new outbound application") + resp = descope_client.mgmt.outbound_application.create_application( + "My first outbound application", + description="This is a test outbound application", + client_secret="shhh..", + ) + outbound_app_id = resp["app"]["id"] + logging.info(f"Outbound application creation response: {resp}") + + except AuthException as e: + logging.info(f"Outbound application creation failed {e}") + + # LOAD OUTBOUND APPLICATION + + try: + logging.info("Loading outbound application by id") + outbound_app_resp = ( + descope_client.mgmt.outbound_application.load_application( + outbound_app_id + ) + ) + logging.info(f"Found outbound application {outbound_app_resp}") + + except AuthException as e: + logging.info(f"Outbound application load failed {e}") + + # LOAD ALL OUTBOUND APPLICATIONS + + try: + logging.info("Loading all outbound applications") + outbound_app_resp = ( + descope_client.mgmt.outbound_application.load_all_applications() + ) + apps = outbound_app_resp["apps"] + for app in apps: + logging.info(f"LoadAll Found outbound application: {app}") + + except AuthException as e: + logging.info(f"Outbound application load all failed {e}") + + # UPDATE OUTBOUND APPLICATION + + try: + logging.info("Going to update the outbound application") + descope_client.mgmt.outbound_application.update_application( + outbound_app_id, + "Updated outbound application name", + description="Updated description", + logo="https://example.com/logo.png", + client_secret="new-secret", + ) + logging.info("Outbound application updated successfully") + + except AuthException as e: + logging.info(f"Outbound application update failed {e}") + + # FETCH TOKEN BY SCOPES + + try: + logging.info("Going to fetch token by scopes") + token_resp = descope_client.mgmt.outbound_application.fetch_token_by_scopes( + outbound_app_id, + "user123", + ["read", "write"], + options={"refreshToken": True}, + tenant_id="tenant456", + ) + logging.info(f"Token fetch response: {token_resp}") + + except AuthException as e: + logging.info(f"Token fetch by scopes failed {e}") + + # FETCH TOKEN + + try: + logging.info("Going to fetch token") + token_resp = descope_client.mgmt.outbound_application.fetch_token( + outbound_app_id, + "user123", + tenant_id="tenant456", + options={"forceRefresh": True}, + ) + logging.info(f"Token fetch response: {token_resp}") + + except AuthException as e: + logging.info(f"Token fetch failed {e}") + + # FETCH TENANT TOKEN BY SCOPES + + try: + logging.info("Going to fetch tenant token by scopes") + token_resp = ( + descope_client.mgmt.outbound_application.fetch_tenant_token_by_scopes( + outbound_app_id, + "tenant456", + ["read", "write"], + options={"refreshToken": True}, + ) + ) + logging.info(f"Tenant token fetch response: {token_resp}") + + except AuthException as e: + logging.info(f"Tenant token fetch by scopes failed {e}") + + # FETCH TENANT TOKEN + + try: + logging.info("Going to fetch tenant token") + token_resp = descope_client.mgmt.outbound_application.fetch_tenant_token( + outbound_app_id, + "tenant456", + options={"forceRefresh": True}, + ) + logging.info(f"Tenant token fetch response: {token_resp}") + + except AuthException as e: + logging.info(f"Tenant token fetch failed {e}") + + # DELETE OUTBOUND APPLICATION + + try: + logging.info("Going to delete the outbound application") + descope_client.mgmt.outbound_application.delete_application(outbound_app_id) + logging.info("Outbound application deleted successfully") + + except AuthException as e: + logging.info(f"Outbound application deletion failed {e}") + + except AuthException as e: + logging.info(f"Failed to initialize client: {e}") + + +if __name__ == "__main__": + main() diff --git a/tests/management/test_jwt.py b/tests/management/test_jwt.py index 4deb7e837..2cab70de8 100644 --- a/tests/management/test_jwt.py +++ b/tests/management/test_jwt.py @@ -156,7 +156,9 @@ def test_stop_impersonation(self): with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( - AuthException, client.mgmt.jwt.stop_impersonation, "", + AuthException, + client.mgmt.jwt.stop_impersonation, + "", ) # Test success flow @@ -187,7 +189,6 @@ def test_stop_impersonation(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_sign_in(self): client = DescopeClient( self.dummy_project_id, @@ -364,7 +365,8 @@ def test_anonymous(self): json={ "customClaims": {"k1": "v1"}, "selectedTenant": "id", - "refreshDuration": None}, + "refreshDuration": None, + }, allow_redirects=False, verify=True, params=None, diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py new file mode 100644 index 000000000..05a3874f1 --- /dev/null +++ b/tests/management/test_outbound_application.py @@ -0,0 +1,1044 @@ +from unittest import mock +from unittest.mock import patch + +from descope import AuthException, DescopeClient +from descope.management.common import AccessType, PromptType, URLParam +from descope.management.outbound_application import OutboundApplication + +from .. import common + + +class TestOutboundApplication(common.DescopeTest): + def setUp(self) -> None: + super().setUp() + self.dummy_project_id = "dummy" + self.dummy_management_key = "key" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", + "kty": "EC", + "use": "sig", + "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", + "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", + } + + def test_create_application_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "app": { + "id": "app123", + "name": "Test App", + "description": "Test Description", + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.create_application( + "Test App", description="Test Description", client_secret="secret" + ) + + assert response == { + "app": { + "id": "app123", + "name": "Test App", + "description": "Test Description", + } + } + + def test_create_application_with_all_parameters_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Create test data for all new parameters + auth_params = [ + URLParam("response_type", "code"), + URLParam("client_id", "test-client"), + ] + token_params = [URLParam("grant_type", "authorization_code")] + prompts = [PromptType.LOGIN, PromptType.CONSENT] + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "app": { + "id": "app123", + "name": "Test OAuth App", + "description": "Test Description", + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.create_application( + name="Test OAuth App", + description="Test Description", + logo="https://example.com/logo.png", + id="app123", + client_secret="secret", + client_id="test-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + authorization_url_params=auth_params, + token_url="https://oauth2.googleapis.com/token", + token_url_params=token_params, + revocation_url="https://oauth2.googleapis.com/revoke", + default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], + default_redirect_url="https://myapp.com/callback", + callback_domain="myapp.com", + pkce=True, + access_type=AccessType.OFFLINE, + prompt=prompts, + ) + + assert response == { + "app": { + "id": "app123", + "name": "Test OAuth App", + "description": "Test Description", + } + } + + def test_create_application_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.create_application, + "Test App", + ) + + def test_update_application_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "app": { + "id": "app123", + "name": "Updated App", + "description": "Updated Description", + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.update_application( + "app123", + "Updated App", + description="Updated Description", + client_secret="new-secret", + ) + + assert response == { + "app": { + "id": "app123", + "name": "Updated App", + "description": "Updated Description", + } + } + + def test_update_application_with_all_parameters_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Create test data for all new parameters + auth_params = [ + URLParam("response_type", "code"), + URLParam("client_id", "test-client"), + ] + token_params = [URLParam("grant_type", "authorization_code")] + prompts = [PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "app": { + "id": "app123", + "name": "Updated OAuth App", + "description": "Updated Description", + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.update_application( + id="app123", + name="Updated OAuth App", + description="Updated Description", + logo="https://example.com/new-logo.png", + client_secret="new-secret", + client_id="new-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + authorization_url_params=auth_params, + token_url="https://oauth2.googleapis.com/token", + token_url_params=token_params, + revocation_url="https://oauth2.googleapis.com/revoke", + default_scopes=[ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + ], + default_redirect_url="https://myapp.com/updated-callback", + callback_domain="myapp.com", + pkce=True, + access_type=AccessType.OFFLINE, + prompt=prompts, + ) + + assert response == { + "app": { + "id": "app123", + "name": "Updated OAuth App", + "description": "Updated Description", + } + } + + def test_update_application_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.update_application, + "app123", + "Updated App", + ) + + def test_delete_application_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + mock_post.return_value = network_resp + client.mgmt.outbound_application.delete_application("app123") + + def test_delete_application_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.delete_application, + "app123", + ) + + def test_load_application_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.get") as mock_get: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "app": { + "id": "app123", + "name": "Test App", + "description": "Test Description", + } + } + mock_get.return_value = network_resp + response = client.mgmt.outbound_application.load_application("app123") + + assert response == { + "app": { + "id": "app123", + "name": "Test App", + "description": "Test Description", + } + } + + def test_load_application_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.load_application, + "app123", + ) + + def test_load_all_applications_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.get") as mock_get: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "apps": [ + {"id": "app1", "name": "App 1", "description": "Description 1"}, + {"id": "app2", "name": "App 2", "description": "Description 2"}, + ] + } + mock_get.return_value = network_resp + response = client.mgmt.outbound_application.load_all_applications() + + assert response == { + "apps": [ + {"id": "app1", "name": "App 1", "description": "Description 1"}, + {"id": "app2", "name": "App 2", "description": "Description 2"}, + ] + } + + def test_load_all_applications_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.load_all_applications, + ) + + def test_fetch_token_by_scopes_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.fetch_token_by_scopes( + "app123", + "user456", + ["read", "write"], + {"refreshToken": True}, + "tenant789", + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_token_by_scopes_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.fetch_token_by_scopes, + "app123", + "user456", + ["read"], + ) + + def test_fetch_token_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.fetch_token( + "app123", "user456", "tenant789", {"forceRefresh": True} + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_token_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.fetch_token, + "app123", + "user456", + ) + + def test_fetch_tenant_token_by_scopes_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.fetch_tenant_token_by_scopes( + "app123", "tenant789", ["read", "write"], {"refreshToken": True} + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_tenant_token_by_scopes_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.fetch_tenant_token_by_scopes, + "app123", + "tenant789", + ["read"], + ) + + def test_fetch_tenant_token_success(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application.fetch_tenant_token( + "app123", "tenant789", {"forceRefresh": True} + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_tenant_token_failure(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.fetch_tenant_token, + "app123", + "tenant789", + ) + + def test_compose_create_update_body(self): + body = OutboundApplication._compose_create_update_body( + "Test App", + "Test Description", + "https://example.com/logo.png", + "app123", + "secret", + ) + + expected_body = { + "name": "Test App", + "id": "app123", + "description": "Test Description", + "logo": "https://example.com/logo.png", + "clientSecret": "secret", + } + + assert body == expected_body + + def test_compose_create_update_body_without_client_secret(self): + body = OutboundApplication._compose_create_update_body( + "Test App", "Test Description", "https://example.com/logo.png", "app123" + ) + + expected_body = { + "name": "Test App", + "id": "app123", + "description": "Test Description", + "logo": "https://example.com/logo.png", + } + + assert body == expected_body + + def test_compose_create_update_body_with_all_new_parameters(self): + # Create test data for all new parameters + auth_params = [ + URLParam("response_type", "code"), + URLParam("client_id", "test-client"), + ] + token_params = [URLParam("grant_type", "authorization_code")] + prompts = [PromptType.LOGIN, PromptType.CONSENT] + + body = OutboundApplication._compose_create_update_body( + name="Test OAuth App", + description="Test Description", + logo="https://example.com/logo.png", + id="app123", + client_secret="secret", + client_id="test-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + authorization_url_params=auth_params, + token_url="https://oauth2.googleapis.com/token", + token_url_params=token_params, + revocation_url="https://oauth2.googleapis.com/revoke", + default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], + default_redirect_url="https://myapp.com/callback", + callback_domain="myapp.com", + pkce=True, + access_type=AccessType.OFFLINE, + prompt=prompts, + ) + + expected_body = { + "name": "Test OAuth App", + "id": "app123", + "description": "Test Description", + "logo": "https://example.com/logo.png", + "clientSecret": "secret", + "clientId": "test-client-id", + "discoveryUrl": "https://accounts.google.com/.well-known/openid_configuration", + "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth", + "authorizationUrlParams": [ + {"name": "response_type", "value": "code"}, + {"name": "client_id", "value": "test-client"}, + ], + "tokenUrl": "https://oauth2.googleapis.com/token", + "tokenUrlParams": [{"name": "grant_type", "value": "authorization_code"}], + "revocationUrl": "https://oauth2.googleapis.com/revoke", + "defaultScopes": ["https://www.googleapis.com/auth/userinfo.profile"], + "defaultRedirectUrl": "https://myapp.com/callback", + "callbackDomain": "myapp.com", + "pkce": True, + "accessType": "offline", + "prompt": ["login", "consent"], + } + + assert body == expected_body + + def test_compose_create_update_body_with_partial_new_parameters(self): + # Test with only some of the new parameters + body = OutboundApplication._compose_create_update_body( + name="Test App", + description="Test Description", + logo="https://example.com/logo.png", + id="app123", + client_secret="secret", + client_id="test-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + pkce=False, + access_type=AccessType.ONLINE, + ) + + expected_body = { + "name": "Test App", + "id": "app123", + "description": "Test Description", + "logo": "https://example.com/logo.png", + "clientSecret": "secret", + "clientId": "test-client-id", + "discoveryUrl": "https://accounts.google.com/.well-known/openid_configuration", + "pkce": False, + "accessType": "online", + } + + assert body == expected_body + + def test_compose_create_update_body_with_url_params_only(self): + # Test with only URL parameters + auth_params = [URLParam("response_type", "code")] + token_params = [URLParam("grant_type", "authorization_code")] + + body = OutboundApplication._compose_create_update_body( + name="Test App", + description="Test Description", + authorization_url_params=auth_params, + token_url_params=token_params, + ) + + expected_body = { + "name": "Test App", + "id": None, + "description": "Test Description", + "logo": None, + "authorizationUrlParams": [{"name": "response_type", "value": "code"}], + "tokenUrlParams": [{"name": "grant_type", "value": "authorization_code"}], + } + + assert body == expected_body + + def test_compose_create_update_body_with_prompt_types(self): + # Test with different prompt type combinations + prompts = [PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] + + body = OutboundApplication._compose_create_update_body( + name="Test App", description="Test Description", prompt=prompts + ) + + expected_body = { + "name": "Test App", + "id": None, + "description": "Test Description", + "logo": None, + "prompt": ["login", "consent", "select_account"], + } + + assert body == expected_body + + def test_compose_create_update_body_with_none_values(self): + # Test that None values are handled correctly + body = OutboundApplication._compose_create_update_body( + name="Test App", + description="Test Description", + pkce=None, # Should not be included in body + access_type=None, # Should not be included in body + prompt=None, # Should not be included in body + ) + + expected_body = { + "name": "Test App", + "id": None, + "description": "Test Description", + "logo": None, + } + + assert body == expected_body + + def test_compose_create_update_body_with_empty_lists(self): + # Test with empty lists for URL parameters and prompts + body = OutboundApplication._compose_create_update_body( + name="Test App", + description="Test Description", + authorization_url_params=[], + token_url_params=[], + default_scopes=[], + prompt=[], + ) + + expected_body = { + "name": "Test App", + "id": None, + "description": "Test Description", + "logo": None, + "authorizationUrlParams": [], + "tokenUrlParams": [], + "defaultScopes": [], + "prompt": [], + } + + assert body == expected_body + + def test_url_param_to_dict(self): + # Test URLParam to_dict method + param = URLParam("test_name", "test_value") + param_dict = param.to_dict() + + expected_dict = {"name": "test_name", "value": "test_value"} + assert param_dict == expected_dict + + def test_access_type_enum_values(self): + # Test AccessType enum values + assert AccessType.OFFLINE.value == "offline" + assert AccessType.ONLINE.value == "online" + + def test_prompt_type_enum_values(self): + # Test PromptType enum values + assert PromptType.NONE.value == "none" + assert PromptType.LOGIN.value == "login" + assert PromptType.CONSENT.value == "consent" + assert PromptType.SELECT_ACCOUNT.value == "select_account" + + +class TestOutboundApplicationByToken(common.DescopeTest): + def setUp(self) -> None: + super().setUp() + self.dummy_project_id = "dummy" + self.dummy_token = "inbound-app-token" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", + "kty": "EC", + "use": "sig", + "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", + "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", + } + + def test_fetch_token_by_scopes_success(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application_by_token.fetch_token_by_scopes( + self.dummy_token, + "app123", + "user456", + ["read", "write"], + {"refreshToken": True}, + "tenant789", + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_token_by_scopes_failure(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + # Test failure of empty token + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_token_by_scopes, + "", # empty token + "app123", + "user456", + ["read"], + ) + + # Test invalid response failure + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_token_by_scopes, + self.dummy_token, + "app123", + "user456", + ["read"], + ) + + def test_fetch_token_success(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application_by_token.fetch_token( + self.dummy_token, + "app123", + "user456", + "tenant789", + {"forceRefresh": True}, + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_token_failure(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + # Test failure of empty token + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_token, + "", # empty token + "app123", + "user456", + ) + + # Test invalid response failure + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_token, + self.dummy_token, + "app123", + "user456", + ) + + def test_fetch_tenant_token_by_scopes_success(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = ( + client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( + self.dummy_token, + "app123", + "tenant789", + ["read", "write"], + {"refreshToken": True}, + ) + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_tenant_token_by_scopes_failure(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + # Test failure of empty token + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes, + "", # empty token + "app123", + "tenant789", + ["read"], + ) + + # Test invalid response failure + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes, + self.dummy_token, + "app123", + "tenant789", + ["read"], + ) + + def test_fetch_tenant_token_success(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + mock_post.return_value = network_resp + response = client.mgmt.outbound_application_by_token.fetch_tenant_token( + self.dummy_token, "app123", "tenant789", {"forceRefresh": True} + ) + + assert response == { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } + } + + def test_fetch_tenant_token_failure(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) + + # Test failure of empty token + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_tenant_token, + "", # empty token + "app123", + "tenant789", + ) + + # Test invalid response failure + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application_by_token.fetch_tenant_token, + self.dummy_token, + "app123", + "tenant789", + ) diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 51cf4f5b4..8bc15e176 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -44,7 +44,9 @@ def test_create(self): # Test success flow with patch("requests.post") as mock_post: mock_post.return_value.ok = True - self.assertIsNone(client.mgmt.role.create("R1", "Something", ["P1"], "t1", True)) + self.assertIsNone( + client.mgmt.role.create("R1", "Something", ["P1"], "t1", True) + ) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_create_path}", headers={ diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index 19141d7e7..6a705cadd 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -75,7 +75,14 @@ def test_create(self): network_resp.ok = True network_resp.json.return_value = json.loads("""{"id": "t1"}""") mock_post.return_value = network_resp - resp = client.mgmt.tenant.create("name", "t1", ["domain.com"], {"k1": "v1"}, enforce_sso=True, disabled=True) + resp = client.mgmt.tenant.create( + "name", + "t1", + ["domain.com"], + {"k1": "v1"}, + enforce_sso=True, + disabled=True, + ) self.assertEqual(resp["id"], "t1") mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_create_path}", @@ -120,7 +127,9 @@ def test_update(self): with patch("requests.post") as mock_post: mock_post.return_value.ok = True self.assertIsNone( - client.mgmt.tenant.update("t1", "new-name", ["domain.com"], enforce_sso=True, disabled=True) + client.mgmt.tenant.update( + "t1", "new-name", ["domain.com"], enforce_sso=True, disabled=True + ) ) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_update_path}", @@ -147,7 +156,12 @@ def test_update(self): mock_post.return_value.ok = True self.assertIsNone( client.mgmt.tenant.update( - "t1", "new-name", ["domain.com"], {"k1": "v1"}, enforce_sso=True, disabled=True + "t1", + "new-name", + ["domain.com"], + {"k1": "v1"}, + enforce_sso=True, + disabled=True, ) ) mock_post.assert_called_with( @@ -163,7 +177,7 @@ def test_update(self): "id": "t1", "selfProvisioningDomains": ["domain.com"], "customAttributes": {"k1": "v1"}, - "enforceSSO": True, + "enforceSSO": True, "disabled": True, }, allow_redirects=False, diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index e95d147a0..3c7c47692 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -71,7 +71,31 @@ def test_descope_client(self): def test_mgmt(self): client = DescopeClient(self.dummy_project_id, self.public_key_dict) - self.assertRaises(AuthException, lambda: client.mgmt) + + # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set + self.assertRaises(AuthException, lambda: client.mgmt.tenant) + self.assertRaises(AuthException, lambda: client.mgmt.sso_application) + self.assertRaises(AuthException, lambda: client.mgmt.user) + self.assertRaises(AuthException, lambda: client.mgmt.access_key) + self.assertRaises(AuthException, lambda: client.mgmt.sso) + self.assertRaises(AuthException, lambda: client.mgmt.jwt) + self.assertRaises(AuthException, lambda: client.mgmt.permission) + self.assertRaises(AuthException, lambda: client.mgmt.role) + self.assertRaises(AuthException, lambda: client.mgmt.group) + self.assertRaises(AuthException, lambda: client.mgmt.flow) + self.assertRaises(AuthException, lambda: client.mgmt.audit) + self.assertRaises(AuthException, lambda: client.mgmt.authz) + self.assertRaises(AuthException, lambda: client.mgmt.fga) + self.assertRaises(AuthException, lambda: client.mgmt.project) + self.assertRaises(AuthException, lambda: client.mgmt.outbound_application) + + # Validate that outbound_application_by_token doesnt require mgmt key + try: + client.mgmt.outbound_application_by_token + except AuthException: + self.fail( + "failed to initiate outbound_application_by_token without management key" + ) def test_logout(self): dummy_refresh_token = ""