diff --git a/descope/management/_outbound_scim_base.py b/descope/management/_outbound_scim_base.py index 4a36d692e..46124ba2d 100644 --- a/descope/management/_outbound_scim_base.py +++ b/descope/management/_outbound_scim_base.py @@ -2,12 +2,14 @@ from typing import Any, Optional +from descope.management.outbound_scim_types import OutboundSCIMConfigurationData + class OutboundSCIMBase: @staticmethod def _compose_create_body( app_id: str, - configuration: Optional[dict] = None, + configuration: Optional[OutboundSCIMConfigurationData] = None, ) -> dict: body: dict[str, Any] = { "appId": app_id, @@ -20,7 +22,7 @@ def _compose_create_body( def _compose_update_body( app_id: str, version: int, - configuration: Optional[dict] = None, + configuration: Optional[OutboundSCIMConfigurationData] = None, ) -> dict: body: dict[str, Any] = { "appId": app_id, diff --git a/descope/management/outbound_scim.py b/descope/management/outbound_scim.py index 62841c4ba..e8b49328f 100644 --- a/descope/management/outbound_scim.py +++ b/descope/management/outbound_scim.py @@ -5,13 +5,14 @@ from descope._http_base import HTTPBase from descope.management._outbound_scim_base import OutboundSCIMBase from descope.management.common import MgmtV1 +from descope.management.outbound_scim_types import OutboundSCIMConfigurationData class OutboundSCIM(OutboundSCIMBase, HTTPBase): def create_configuration( self, app_id: str, - configuration: Optional[dict] = None, + configuration: Optional[OutboundSCIMConfigurationData] = None, ) -> dict: """ Create a new outbound SCIM configuration on the federated SSO application @@ -41,7 +42,7 @@ def update_configuration( self, app_id: str, version: int, - configuration: Optional[dict] = None, + configuration: Optional[OutboundSCIMConfigurationData] = None, ) -> dict: """ Update the outbound SCIM configuration attached to the given federated SSO app. diff --git a/descope/management/outbound_scim_async.py b/descope/management/outbound_scim_async.py index 211bf19ba..c22322711 100644 --- a/descope/management/outbound_scim_async.py +++ b/descope/management/outbound_scim_async.py @@ -5,6 +5,7 @@ from descope._http_base import AsyncHTTPBase from descope.management._outbound_scim_base import OutboundSCIMBase from descope.management.common import MgmtV1 +from descope.management.outbound_scim_types import OutboundSCIMConfigurationData class OutboundSCIMAsync(OutboundSCIMBase, AsyncHTTPBase): @@ -13,7 +14,7 @@ class OutboundSCIMAsync(OutboundSCIMBase, AsyncHTTPBase): async def create_configuration( self, app_id: str, - configuration: Optional[dict] = None, + configuration: Optional[OutboundSCIMConfigurationData] = None, ) -> dict: """ Create a new outbound SCIM configuration on the federated SSO application @@ -43,7 +44,7 @@ async def update_configuration( self, app_id: str, version: int, - configuration: Optional[dict] = None, + configuration: Optional[OutboundSCIMConfigurationData] = None, ) -> dict: """ Update the outbound SCIM configuration attached to the given federated SSO app. diff --git a/descope/management/outbound_scim_types.py b/descope/management/outbound_scim_types.py new file mode 100644 index 000000000..1e612cab3 --- /dev/null +++ b/descope/management/outbound_scim_types.py @@ -0,0 +1,140 @@ +"""Typed shapes for outbound-SCIM configuration. + +Mirror the SCIM connector template +(``content/connectors/templates/scim/metadata.json``) so callers get IDE/mypy +completion when constructing an ``OutboundSCIMConfigurationData`` dict. +Optional-field TypedDicts are declared with ``total=False``; the backend +requires ``baseUrl`` on Create but that's enforced at the call site, not in +the type. Secret-typed fields (``hmacSecret``, ``awsAccessKeyId``, +``awsSecretAccessKey``, ``rfc9421PrivateKey``) are stored encrypted +server-side and returned masked on Load — never plaintext. +""" + +from __future__ import annotations + +# TypedDict, Literal, and List are all stdlib since Python 3.8; the project's +# declared floor is 3.9 so no version guard is needed. All TypedDicts here +# declare fields via ``total=False`` rather than ``NotRequired``, so no +# ``typing_extensions`` import is needed either. +from typing import List, Literal, TypedDict + +OutboundSCIMHTTPAuthMethod = Literal[ + "none", + "bearerToken", + "apiKey", + "basicAuth", + "oauth2ClientCredentials", +] + + +class OutboundSCIMUserMapping(TypedDict): + """One entry in the user attribute mapping. ``srcKey`` may be a dot-path + (e.g. ``"customAttributes.foo"``).""" + + srcKey: str + namespace: str + destKey: str + + +class OutboundSCIMHeader(TypedDict, total=False): + """One extra HTTP header sent with every SCIM request. ``secret``-flagged + headers are stored encrypted server-side.""" + + key: str + value: str + secret: bool + + +class OutboundSCIMAPIKeyAuth(TypedDict): + """API key credential. ``key`` is the header name, ``token`` is the value.""" + + key: str + token: str + + +class OutboundSCIMBasicAuth(TypedDict): + """HTTP basic-auth credentials.""" + + username: str + password: str + + +class OutboundSCIMOAuth2RequestHeader(TypedDict): + """One extra header sent to the OAuth2 token endpoint.""" + + name: str + value: str + + +class OutboundSCIMOAuth2ClientCredentials(TypedDict, total=False): + """OAuth2 client-credentials grant configuration. ``scopes`` is + space-separated. ``authStyle`` is one of ``"header"`` (default) or + ``"body"``.""" + + clientId: str + clientSecret: str + authUrl: str + scopes: str + authStyle: Literal["header", "body"] + tokenRequestHeaders: List[OutboundSCIMOAuth2RequestHeader] + + +class OutboundSCIMHTTPAuth(TypedDict, total=False): + """Flat HTTP auth config: ``method`` is the discriminator; the sub-field + matching ``method`` carries the credentials. Only the sub-field named by + ``method`` is used at request time — others are ignored server-side.""" + + method: OutboundSCIMHTTPAuthMethod + bearerToken: str + apiKey: OutboundSCIMAPIKeyAuth + basicAuth: OutboundSCIMBasicAuth + oauth2ClientCredentials: OutboundSCIMOAuth2ClientCredentials + + +class OutboundSCIMConfigurationData(TypedDict, total=False): + """Provider-specific configuration blob for an outbound SCIM connector. + + Field names mirror the SCIM connector template + (``content/connectors/templates/scim/metadata.json``). ``baseUrl`` is + required by the backend on Create. Secret-typed fields (``hmacSecret``, + ``awsAccessKeyId``, ``awsSecretAccessKey``, ``rfc9421PrivateKey``) are + stored encrypted server-side and returned masked on Load — never plaintext. + """ + + # SCIM SP root URL, e.g. "https://scim.example.com". Required on Create. + baseUrl: str + # Drop unverified phone numbers from outgoing SCIM payloads. + ignoreUnverifiedPhones: bool + # Drop unverified emails from outgoing SCIM payloads. + ignoreUnverifiedEmails: bool + # Maps Descope user attributes to SCIM attributes. + userMapping: List[OutboundSCIMUserMapping] + # HTTP auth used for every SCIM request. + authentication: OutboundSCIMHTTPAuth + # Extra HTTP headers sent with every SCIM request. Values may be secret-typed. + headers: List[OutboundSCIMHeader] + # HMAC secret; signs the base64-encoded payload — the signature is + # delivered in the "x-descope-webhook-s256" header. Secret-typed. + hmacSecret: str + # AWS Signature V4 signing mode. One of "none" (default) or "credentials". + awsAuthType: Literal["none", "credentials"] + # Required when awsAuthType == "credentials". Secret-typed. + awsAccessKeyId: str + # Required when awsAuthType == "credentials". Secret-typed. + awsSecretAccessKey: str + # AWS service to target (e.g. "lambda", "execute-api"). Required when + # awsAuthType == "credentials". + awsService: str + # Turn on RFC 9421 HTTP Message Signatures. + rfc9421SigningEnabled: bool + # PEM private key (ECDSA / Ed25519 / RSA) or HMAC secret. Secret-typed. + rfc9421PrivateKey: str + # Key id included in the signature metadata. + rfc9421KeyId: str + # HTTP message components covered by the signature (comma-separated, e.g. + # "@method,@target-uri,@authority"). Empty means defaults. + rfc9421Components: str + # How long the signature is valid, in seconds. Default 300. + rfc9421SignatureTTL: int + # Disable TLS certificate verification. Do not use in production. + insecure: bool diff --git a/tests/management/test_outbound_scim.py b/tests/management/test_outbound_scim.py index d914d6173..622b2a9b3 100644 --- a/tests/management/test_outbound_scim.py +++ b/tests/management/test_outbound_scim.py @@ -34,7 +34,10 @@ async def test_create_configuration_success(self, client_factory): response = await client.invoke( client.mgmt.outbound_scim.create_configuration( "app1", - {"baseUrl": "https://scim.example.com"}, + { + "baseUrl": "https://scim.example.com", + "authentication": {"method": "bearerToken", "bearerToken": "shh"}, + }, ) ) assert response == CONFIG_RESPONSE @@ -46,7 +49,10 @@ async def test_create_configuration_success(self, client_factory): params=None, json={ "appId": "app1", - "configuration": {"baseUrl": "https://scim.example.com"}, + "configuration": { + "baseUrl": "https://scim.example.com", + "authentication": {"method": "bearerToken", "bearerToken": "shh"}, + }, }, follow_redirects=False, ) @@ -82,7 +88,10 @@ async def test_update_configuration_success(self, client_factory): client.mgmt.outbound_scim.update_configuration( "app1", 3, - {"baseUrl": "https://scim.example.com"}, + { + "baseUrl": "https://scim.example.com", + "authentication": {"method": "bearerToken", "bearerToken": "shh"}, + }, ) ) assert response == CONFIG_RESPONSE @@ -95,7 +104,10 @@ async def test_update_configuration_success(self, client_factory): json={ "appId": "app1", "version": 3, - "configuration": {"baseUrl": "https://scim.example.com"}, + "configuration": { + "baseUrl": "https://scim.example.com", + "authentication": {"method": "bearerToken", "bearerToken": "shh"}, + }, }, follow_redirects=False, )