diff --git a/README.md b/README.md index 5399ef49c..27cb287e0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,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. @@ -1301,6 +1302,61 @@ apps = apps_resp["apps"] # Do something ``` +### Manage Outbound Applications + +You can create, update, delete or load outbound applications, as well as manage user tokens: + +```python +# Create OIDC outbound application +descope_client.mgmt.outbound_application.create_application( + name="My Google App", + client_id="google-client-id", + client_secret="google-client-secret", + template_id="google", # Use pre-configured Google template + default_scopes=["openid", "profile", "email"], + pkce=True, + access_type="offline", +) + +# Update outbound application +# Update will override all fields as is. Use carefully. +descope_client.mgmt.outbound_application.update_application( + id="app-id", + name="My Updated Google App", + client_id="updated-client-id", + description="Updated description", + default_scopes=["openid", "profile"], +) + +# Outbound application deletion cannot be undone. Use carefully. +descope_client.mgmt.outbound_application.delete_application("app-id") + +# Load outbound application by id +app_resp = descope_client.mgmt.outbound_application.load_application("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 + +# Fetch user token for outbound application +token_resp = descope_client.mgmt.outbound_application.fetch_outbound_app_user_token( + user_id="user-id", + app_id="app-id", + scopes=["openid", "profile"], +) + +# Delete specific token by ID +descope_client.mgmt.outbound_application.delete_outbound_app_token_by_id("token-id") + +# Delete all user tokens for a specific app +descope_client.mgmt.outbound_application.delete_outbound_app_user_tokens( + user_id="user-id", + app_id="app-id", +) +``` + ### 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/management/common.py b/descope/management/common.py index 57181794f..cd44fd3b4 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -154,6 +154,16 @@ class MgmtV1: project_import = "/v1/mgmt/project/import" project_list_projects = "/v1/mgmt/projects/list" + # outbound application + outbound_application_create_path = "/v1/mgmt/outbound/application/create" + outbound_application_update_path = "/v1/mgmt/outbound/application/update" + outbound_application_delete_path = "/v1/mgmt/outbound/application/delete" + outbound_application_load_path = "/v1/mgmt/outbound/application/load" + outbound_application_load_all_path = "/v1/mgmt/outbound/application/all" + outbound_application_fetch_user_token_path = "/v1/mgmt/outbound/application/token/fetch" + outbound_application_delete_token_by_id_path = "/v1/mgmt/outbound/application/token/delete" + outbound_application_delete_user_tokens_path = "/v1/mgmt/outbound/application/user/tokens/delete" + class MgmtSignUpOptions: def __init__( diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py new file mode 100644 index 000000000..513ab63fc --- /dev/null +++ b/descope/management/outbound_application.py @@ -0,0 +1,353 @@ +from typing import Any, Dict, List, Optional + +from descope._auth_base import AuthBase +from descope.management.common import MgmtV1 + + +class OutboundApplication(AuthBase): + def create_application( + self, + name: str, + client_id: str, + client_secret: str, + id: Optional[str] = None, + description: Optional[str] = None, + template_id: Optional[str] = None, + logo: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[Dict[str, str]]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[Dict[str, str]]] = 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[str] = None, + prompt: Optional[List[str]] = None, + ) -> dict: + """ + Create a new outbound application with the given parameters. + + Args: + name (str): The application's name. + client_id (str): The OAuth client ID for the external provider. + client_secret (str): The OAuth client secret for the external provider. + id (str): Optional application ID. + description (str): Optional application description. + template_id (str): Optional template ID for pre-configured providers. + logo (str): Optional application logo URL. + discovery_url (str): Optional OIDC discovery URL. + authorization_url (str): Optional OAuth authorization URL. + authorization_url_params (List[Dict[str, str]]): Optional authorization URL parameters. + token_url (str): Optional OAuth token URL. + token_url_params (List[Dict[str, str]]): Optional token URL parameters. + revocation_url (str): Optional OAuth 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 support flag. + access_type (str): Optional access type ("online" or "offline"). + prompt (List[str]): Optional prompt parameters. + + Return value (dict): + Return dict in the format + {"app": } + + 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=name, + client_id=client_id, + client_secret=client_secret, + id=id, + description=description, + template_id=template_id, + logo=logo, + discovery_url=discovery_url, + authorization_url=authorization_url, + authorization_url_params=authorization_url_params, + token_url=token_url, + token_url_params=token_url_params, + revocation_url=revocation_url, + default_scopes=default_scopes, + default_redirect_url=default_redirect_url, + callback_domain=callback_domain, + pkce=pkce, + access_type=access_type, + prompt=prompt, + ), + pswd=self._auth.management_key, + ) + return response.json() + + def update_application( + self, + id: str, + name: str, + client_id: str, + client_secret: Optional[str] = None, + description: Optional[str] = None, + template_id: Optional[str] = None, + logo: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[Dict[str, str]]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[Dict[str, str]]] = 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[str] = None, + prompt: Optional[List[str]] = None, + ) -> dict: + """ + Update an existing outbound application with the given parameters. IMPORTANT: All parameters are used as overrides + to the existing application. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the application to update. + name (str): Updated application name. + client_id (str): The OAuth client ID for the external provider. + client_secret (str): Optional OAuth client secret for the external provider. + description (str): Optional application description. + template_id (str): Optional template ID for pre-configured providers. + logo (str): Optional application logo URL. + discovery_url (str): Optional OIDC discovery URL. + authorization_url (str): Optional OAuth authorization URL. + authorization_url_params (List[Dict[str, str]]): Optional authorization URL parameters. + token_url (str): Optional OAuth token URL. + token_url_params (List[Dict[str, str]]): Optional token URL parameters. + revocation_url (str): Optional OAuth 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 support flag. + access_type (str): Optional access type ("online" or "offline"). + prompt (List[str]): Optional prompt parameters. + + Return value (dict): + Return dict in the format + {"app": } + + 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=name, + client_id=client_id, + client_secret=client_secret, + id=id, + description=description, + template_id=template_id, + logo=logo, + discovery_url=discovery_url, + authorization_url=authorization_url, + authorization_url_params=authorization_url_params, + token_url=token_url, + token_url_params=token_url_params, + revocation_url=revocation_url, + default_scopes=default_scopes, + default_redirect_url=default_redirect_url, + callback_domain=callback_domain, + pkce=pkce, + access_type=access_type, + prompt=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 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 application to load. + + Return value (dict): + Return dict in the format + {"app": } + 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": []} + 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_outbound_app_user_token( + self, + user_id: str, + app_id: str, + scopes: Optional[List[str]] = None, + ) -> dict: + """ + Fetch the requested token (if exists) for the given user and outbound application. + + Args: + user_id (str): The user ID to fetch the token for. + app_id (str): The outbound application ID. + scopes (List[str]): Optional requested scopes. + + Return value (dict): + Return dict containing the token information. + + Raise: + AuthException: raised if fetch operation fails + """ + uri = MgmtV1.outbound_application_fetch_user_token_path + response = self._auth.do_post( + uri, + { + "userId": user_id, + "appId": app_id, + "scopes": scopes or [], + }, + pswd=self._auth.management_key, + ) + return response.json() + + def delete_outbound_app_token_by_id( + self, + token_id: str, + ): + """ + Delete the outbound application token for the given ID. + + Args: + token_id (str): Required token ID to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + uri = MgmtV1.outbound_application_delete_token_by_id_path + self._auth.do_post(uri, {"tokenId": token_id}, pswd=self._auth.management_key) + + def delete_outbound_app_user_tokens( + self, + user_id: str, + app_id: str, + ): + """ + Delete all outbound application tokens for the given user. + + Args: + user_id (str): Required user ID. + app_id (str): Required application ID. + + Raise: + AuthException: raised if deletion operation fails + """ + uri = MgmtV1.outbound_application_delete_user_tokens_path + self._auth.do_post( + uri, + { + "userId": user_id, + "appId": app_id, + }, + pswd=self._auth.management_key, + ) + + @staticmethod + def _compose_create_update_body( + name: str, + client_id: str, + client_secret: Optional[str] = None, + id: Optional[str] = None, + description: Optional[str] = None, + template_id: Optional[str] = None, + logo: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[Dict[str, str]]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[Dict[str, str]]] = 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[str] = None, + prompt: Optional[List[str]] = None, + ) -> dict: + body: Dict[str, Any] = { + "id": id, + "name": name, + "description": description, + "templateId": template_id, + "clientId": client_id, + "logo": logo, + "discoveryUrl": discovery_url, + "authorizationUrl": authorization_url, + "authorizationUrlParams": authorization_url_params or [], + "tokenUrl": token_url, + "tokenUrlParams": token_url_params or [], + "revocationUrl": revocation_url, + "defaultScopes": default_scopes or [], + "defaultRedirectUrl": default_redirect_url, + "callbackDomain": callback_domain, + "pkce": pkce, + "accessType": access_type, + "prompt": prompt or [], + } + + # Only include clientSecret if provided (for security) + if client_secret is not None: + body["clientSecret"] = client_secret + + return body diff --git a/descope/mgmt.py b/descope/mgmt.py index 52901ac22..cf049c54d 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -6,6 +6,7 @@ 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 OutboundApplication # noqa: F401 from descope.management.permission import Permission # noqa: F401 from descope.management.project import Project # noqa: F401 from descope.management.role import Role # noqa: F401 @@ -22,6 +23,7 @@ def __init__(self, auth: Auth): self._auth = auth self._tenant = Tenant(auth) self._sso_application = SSOApplication(auth) + self._outbound_application = OutboundApplication(auth) self._user = User(auth) self._access_key = AccessKey(auth) self._sso = SSOSettings(auth) @@ -43,6 +45,10 @@ def tenant(self): def sso_application(self): return self._sso_application + @property + def outbound_application(self): + return self._outbound_application + @property def user(self): return self._user diff --git a/samples/management_outbound_application.py b/samples/management_outbound_application.py new file mode 100644 index 000000000..d2768527f --- /dev/null +++ b/samples/management_outbound_application.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Sample script demonstrating outbound application management with Descope Python SDK. + +This script shows how to: +1. Create outbound applications (OAuth/OIDC providers) +2. Manage application settings +3. Handle user tokens for outbound applications + +Outbound applications allow Descope to connect to external OAuth/OIDC providers +like Google, Microsoft, Facebook, etc. as a client. +""" + +import os +from descope import DescopeClient + +def main(): + # Initialize Descope client + # You'll need to set DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY environment variables + project_id = os.getenv("DESCOPE_PROJECT_ID") + management_key = os.getenv("DESCOPE_MANAGEMENT_KEY") + + if not project_id or not management_key: + print("Please set DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY environment variables") + return + + client = DescopeClient(project_id=project_id, management_key=management_key) + + try: + # 1. Create a new outbound application (Google OAuth) + print("Creating Google OAuth outbound application...") + create_resp = client.mgmt.outbound_application.create_application( + name="My Google Integration", + client_id="your-google-client-id", + client_secret="your-google-client-secret", + template_id="google", # Use pre-configured Google template + description="Integration with Google for user authentication", + default_scopes=["openid", "profile", "email"], + pkce=True, + access_type="offline", # Request refresh tokens + ) + app_id = create_resp["app"]["id"] + print(f"✓ Created application with ID: {app_id}") + + # 2. Load the created application + print(f"\nLoading application {app_id}...") + app_resp = client.mgmt.outbound_application.load_application(app_id) + print(f"✓ Loaded application: {app_resp['app']['name']}") + + # 3. Update the application + print(f"\nUpdating application {app_id}...") + client.mgmt.outbound_application.update_application( + id=app_id, + name="My Updated Google Integration", + client_id="your-google-client-id", + description="Updated description for Google integration", + default_scopes=["openid", "profile", "email", "https://www.googleapis.com/auth/calendar.readonly"], + ) + print("✓ Application updated successfully") + + # 4. List all outbound applications + print("\nListing all outbound applications...") + all_apps_resp = client.mgmt.outbound_application.load_all_applications() + apps = all_apps_resp["apps"] + print(f"✓ Found {len(apps)} outbound applications:") + for app in apps: + print(f" - {app['name']} (ID: {app['id']})") + + # 5. Token management examples (these would typically be called after users authenticate) + print(f"\nToken management examples for application {app_id}...") + + # Note: These operations would typically be performed after a user has authenticated + # and you have actual user IDs and tokens to work with + + # Example: Fetch user token for outbound application + # This would retrieve stored OAuth tokens for a user from the external provider + print("Example: Fetching user token...") + try: + token_resp = client.mgmt.outbound_application.fetch_outbound_app_user_token( + user_id="example-user-id", + app_id=app_id, + scopes=["openid", "profile"], + ) + print("✓ Token fetch would work for authenticated users") + except Exception as e: + print(f"ℹ Token fetch example (would work with real user): {str(e)[:100]}...") + + # Example: Delete user tokens for an application + print("Example: Deleting user tokens...") + try: + client.mgmt.outbound_application.delete_outbound_app_user_tokens( + user_id="example-user-id", + app_id=app_id, + ) + print("✓ Token deletion would work for authenticated users") + except Exception as e: + print(f"ℹ Token deletion example (would work with real user): {str(e)[:100]}...") + + # 6. Clean up - delete the application + print(f"\nCleaning up - deleting application {app_id}...") + client.mgmt.outbound_application.delete_application(app_id) + print("✓ Application deleted successfully") + + print("\n🎉 Outbound application management demo completed successfully!") + + except Exception as e: + print(f"❌ Error: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py new file mode 100644 index 000000000..5cdf161ee --- /dev/null +++ b/tests/management/test_outbound_application.py @@ -0,0 +1,431 @@ +import json +from unittest import mock +from unittest.mock import patch + +from descope import ( + AuthException, + DescopeClient, +) +from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope.management.common import MgmtV1 + +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(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.create_application, + "valid-name", + "client-id", + "client-secret", + ) + + # Test success flow + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads("""{"app": {"id": "app1", "name": "Test App"}}""") + mock_post.return_value = network_resp + resp = client.mgmt.outbound_application.create_application( + name="Test App", + client_id="test-client-id", + client_secret="test-client-secret", + description="Test description", + template_id="google", + default_scopes=["openid", "profile"], + pkce=True, + access_type="offline", + ) + self.assertEqual(resp["app"]["id"], "app1") + self.assertEqual(resp["app"]["name"], "Test App") + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_create_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "id": None, + "name": "Test App", + "description": "Test description", + "templateId": "google", + "clientId": "test-client-id", + "clientSecret": "test-client-secret", + "logo": None, + "discoveryUrl": None, + "authorizationUrl": None, + "authorizationUrlParams": [], + "tokenUrl": None, + "tokenUrlParams": [], + "revocationUrl": None, + "defaultScopes": ["openid", "profile"], + "defaultRedirectUrl": None, + "callbackDomain": None, + "pkce": True, + "accessType": "offline", + "prompt": [], + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_update_application(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.update_application, + "app1", + "valid-name", + "client-id", + ) + + # Test success flow + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads("""{"app": {"id": "app1", "name": "Updated App"}}""") + mock_post.return_value = network_resp + resp = client.mgmt.outbound_application.update_application( + id="app1", + name="Updated App", + client_id="updated-client-id", + description="Updated description", + ) + self.assertEqual(resp["app"]["id"], "app1") + self.assertEqual(resp["app"]["name"], "Updated App") + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_update_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "app": { + "id": "app1", + "name": "Updated App", + "description": "Updated description", + "templateId": None, + "clientId": "updated-client-id", + "logo": None, + "discoveryUrl": None, + "authorizationUrl": None, + "authorizationUrlParams": [], + "tokenUrl": None, + "tokenUrlParams": [], + "revocationUrl": None, + "defaultScopes": [], + "defaultRedirectUrl": None, + "callbackDomain": None, + "pkce": None, + "accessType": None, + "prompt": [], + } + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_delete_application(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.delete_application, + "valid-id", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone(client.mgmt.outbound_application.delete_application("app1")) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "id": "app1", + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_load_application(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.load_application, + "valid-id", + ) + + # Test success flow + with patch("requests.get") as mock_get: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """ + {"app": {"id":"app1","name":"Test App","description":"Test description","templateId":"google","clientId":"test-client-id","defaultScopes":["openid","profile"],"pkce":true,"accessType":"offline"}} + """ + ) + mock_get.return_value = network_resp + resp = client.mgmt.outbound_application.load_application("app1") + self.assertEqual(resp["app"]["name"], "Test App") + self.assertEqual(resp["app"]["templateId"], "google") + self.assertEqual(resp["app"]["clientId"], "test-client-id") + self.assertEqual(resp["app"]["defaultScopes"], ["openid", "profile"]) + self.assertTrue(resp["app"]["pkce"]) + self.assertEqual(resp["app"]["accessType"], "offline") + mock_get.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_load_path}/app1", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + allow_redirects=None, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_load_all_applications(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises(AuthException, client.mgmt.outbound_application.load_all_applications) + + # Test success flow + with patch("requests.get") as mock_get: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """ + { + "apps": [ + {"id":"app1","name":"Test App 1","templateId":"google","clientId":"client1"}, + {"id":"app2","name":"Test App 2","templateId":"microsoft","clientId":"client2"} + ] + } + """ + ) + mock_get.return_value = network_resp + resp = client.mgmt.outbound_application.load_all_applications() + apps = resp["apps"] + self.assertEqual(len(apps), 2) + self.assertEqual(apps[0]["name"], "Test App 1") + self.assertEqual(apps[0]["templateId"], "google") + self.assertEqual(apps[1]["name"], "Test App 2") + self.assertEqual(apps[1]["templateId"], "microsoft") + mock_get.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_load_all_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + allow_redirects=None, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_fetch_outbound_app_user_token(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.fetch_outbound_app_user_token, + "user123", + "app1", + ) + + # Test success flow + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """{"token": {"accessToken": "access123", "refreshToken": "refresh456", "scopes": ["openid", "profile"]}}""" + ) + mock_post.return_value = network_resp + resp = client.mgmt.outbound_application.fetch_outbound_app_user_token( + user_id="user123", + app_id="app1", + scopes=["openid", "profile"], + ) + self.assertEqual(resp["token"]["accessToken"], "access123") + self.assertEqual(resp["token"]["refreshToken"], "refresh456") + self.assertEqual(resp["token"]["scopes"], ["openid", "profile"]) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_fetch_user_token_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "userId": "user123", + "appId": "app1", + "scopes": ["openid", "profile"], + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_delete_outbound_app_token_by_id(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.delete_outbound_app_token_by_id, + "token123", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.outbound_application.delete_outbound_app_token_by_id("token123") + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_token_by_id_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "tokenId": "token123", + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_delete_outbound_app_user_tokens(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.outbound_application.delete_outbound_app_user_tokens, + "user123", + "app1", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.outbound_application.delete_outbound_app_user_tokens("user123", "app1") + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_user_tokens_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "userId": "user123", + "appId": "app1", + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + )