diff --git a/README.md b/README.md index 4a8e8af78..1e949373c 100644 --- a/README.md +++ b/README.md @@ -987,6 +987,14 @@ descope_client.mgmt.sso.configure_oidc_settings( domains=["tenant-users.com"] # Users authentication with these domains will be logged in to this tenant ) +# You can disable an SSO configuration without deleting it, and enable it again later. +# Its settings, mappings and domains are kept, so re-enabling needs no payload. +descope_client.mgmt.sso.configure_auth_type( + tenant_id, # Which tenant the configuration belongs to + "none", # "none" disables it; "saml" or "oidc" enable it on that protocol + sso_id="my-sso-id" # Optional, omit for the tenant's default SSO configuration +) + # DEPRECATED (use load_settings(..) function instead) # You can get SSO settings for a tenant sso_settings_res = descope_client.mgmt.sso.get_settings("tenant-id") diff --git a/descope/management/_sso_settings_base.py b/descope/management/_sso_settings_base.py index 4ec0c9da6..43b40a9f3 100644 --- a/descope/management/_sso_settings_base.py +++ b/descope/management/_sso_settings_base.py @@ -206,6 +206,20 @@ def _jwt_bearer_settings_to_dict( "jwtBearerGrantTypeCustomClaimsToUse": settings.jwt_bearer_grant_type_custom_claims_to_use, } + @staticmethod + def _compose_configure_auth_type_body( + tenant_id: str, + auth_type: str, + sso_id: Optional[str], + ) -> dict: + body: dict = { + "tenantId": tenant_id, + "authType": auth_type, + } + if sso_id: + body["ssoId"] = sso_id + return body + @staticmethod def _compose_configure_xaa_settings_body( tenant_id: str, diff --git a/descope/management/common.py b/descope/management/common.py index 6857b3ff6..bc7ba154f 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -405,6 +405,7 @@ class MgmtV1: sso_redirect_path = "/v1/mgmt/sso/redirect" sso_load_all_settings_path = "/v2/mgmt/sso/settings/all" sso_new_settings_path = "/v1/mgmt/sso/settings/new" + sso_configure_auth_type_path = "/v1/mgmt/sso/settings/authtype" # tenant revoke sso config link tenant_revoke_sso_configuration_link_path = "/v1/mgmt/tenant/adminlinks/sso/revoke" diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 0fe6c8b35..1600854ec 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -427,19 +427,49 @@ def new_settings( def delete_settings( self, tenant_id: str, + sso_id: Optional[str] = None, ): """ Delete SSO setting for the provided tenant_id. Args: tenant_id (str): The tenant ID of the desired SSO Settings to delete + sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. Raise: AuthException: raised if delete operation fails """ + params = {"tenantId": tenant_id} + if sso_id: + params["ssoId"] = sso_id + self._http.delete( MgmtV1.sso_settings_path, - params={"tenantId": tenant_id}, + params=params, + ) + + def configure_auth_type( + self, + tenant_id: str, + auth_type: str, + sso_id: Optional[str] = None, + ): + """ + Set the authentication type of a single SSO configuration, leaving its stored SAML/OIDC + settings, mappings and domains untouched. + + Args: + tenant_id (str): The tenant ID the configuration belongs to + auth_type (str): "none" disables the configuration without deleting it, "saml" or "oidc" + enable it on that protocol with its stored settings, so re-enabling needs no payload. + sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. + + Raise: + AuthException: raised if configuration operation fails + """ + self._http.post( + MgmtV1.sso_configure_auth_type_path, + body=SSOSettings._compose_configure_auth_type_body(tenant_id, auth_type, sso_id), ) def configure_oidc_settings( diff --git a/descope/management/sso_settings_async.py b/descope/management/sso_settings_async.py index 12bcdf501..37219da9e 100644 --- a/descope/management/sso_settings_async.py +++ b/descope/management/sso_settings_async.py @@ -160,19 +160,25 @@ async def new_settings( async def delete_settings( self, tenant_id: str, + sso_id: Optional[str] = None, ): """ Delete SSO setting for the provided tenant_id. Args: tenant_id (str): The tenant ID of the desired SSO Settings to delete + sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. Raise: AuthException: raised if delete operation fails """ + params = {"tenantId": tenant_id} + if sso_id: + params["ssoId"] = sso_id + await self._http.delete( MgmtV1.sso_settings_path, - params={"tenantId": tenant_id}, + params=params, ) async def configure_oidc_settings( @@ -250,6 +256,30 @@ async def configure_saml_settings_by_metadata( ), ) + async def configure_auth_type( + self, + tenant_id: str, + auth_type: str, + sso_id: Optional[str] = None, + ): + """ + Set the authentication type of a single SSO configuration, leaving its stored SAML/OIDC + settings, mappings and domains untouched. + + Args: + tenant_id (str): The tenant ID the configuration belongs to + auth_type (str): "none" disables the configuration without deleting it, "saml" or "oidc" + enable it on that protocol with its stored settings, so re-enabling needs no payload. + sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. + + Raise: + AuthException: raised if configuration operation fails + """ + await self._http.post( + MgmtV1.sso_configure_auth_type_path, + body=SSOSettingsAsync._compose_configure_auth_type_body(tenant_id, auth_type, sso_id), + ) + async def configure_xaa_settings( self, tenant_id: str, diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py index 2a9698a1f..9fdf5b7d6 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -47,6 +47,23 @@ async def test_delete_settings(self, client_factory): follow_redirects=False, ) + # Test success flow (a specific SSO configuration of a multi-SSO tenant) + with client.mock_mgmt_delete(make_response()) as mock_delete: + await client.invoke(client.mgmt.sso.delete_settings("tenant-id", sso_id="conf1")) + + assert_http_called( + mock_delete, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", + params={"tenantId": "tenant-id", "ssoId": "conf1"}, + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + follow_redirects=False, + ) + async def test_load_settings(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") @@ -1343,6 +1360,56 @@ async def test_load_all_settings(self, client_factory): follow_redirects=True, ) + async def test_configure_auth_type(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso.configure_auth_type("tenant-id", "none")) + + # Test success flow (disable a specific configuration) + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.sso.configure_auth_type("tenant-id", "none", sso_id="sso-1")) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_auth_type_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "tenantId": "tenant-id", + "authType": "none", + "ssoId": "sso-1", + }, + follow_redirects=False, + ) + + # Test success flow (the default configuration carries no ssoId) + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke(client.mgmt.sso.configure_auth_type("tenant-id", "saml")) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_auth_type_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "tenantId": "tenant-id", + "authType": "saml", + }, + follow_redirects=False, + ) + async def test_new_settings(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key")