From 48eff99c6f4401c1dd6e74e3efb965f52b8a4283 Mon Sep 17 00:00:00 2001 From: Dave Lafferty Date: Mon, 8 Apr 2024 11:13:21 -0400 Subject: [PATCH] Added an EntraID OIDC backend based on the included OpenIDConnectBackend. --- .../entraid_oidc_backend.yaml.example | 41 +++ setup.py | 1 + src/satosa/backends/entraid_oidc.py | 194 ++++++++++ tests/satosa/backends/test_entraid_oidc.py | 340 ++++++++++++++++++ tox.ini | 2 + 5 files changed, 578 insertions(+) create mode 100644 example/plugins/backends/entraid_oidc_backend.yaml.example create mode 100644 src/satosa/backends/entraid_oidc.py create mode 100644 tests/satosa/backends/test_entraid_oidc.py diff --git a/example/plugins/backends/entraid_oidc_backend.yaml.example b/example/plugins/backends/entraid_oidc_backend.yaml.example new file mode 100644 index 000000000..7491d37aa --- /dev/null +++ b/example/plugins/backends/entraid_oidc_backend.yaml.example @@ -0,0 +1,41 @@ +module: satosa.backends.entraid_oidc.EntraIDOIDCBackend +name: entraid_oidc +config: + # https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#find-your-apps-openid-configuration-document-uri + # The issuer will usually be https://login.microsoftonline.com/{tenant}/v2.0 unless + # targeting a specific population. + issuer: "https://login.microsoftonline.com/{tenantid}/v2.0" + redirect_uri: "/" + # Scopes are added by default by the msal library, so there's no need to + # provide common scopes. Default scopes: offline_access openid profile. + # To get information from default scopes, you have to enable API + # access under API permissions -> Graph -> OpenId + # Add optional claims family_name and given_name if necessary to the app. + scopes: + - User.Read + client: + # https://learn.microsoft.com/en-us/python/api/msal/msal.application.confidentialclientapplication?view=msal-py-latest + # Arguments to initialize ConfidentialClientApplication + init: + client_id: "CLIENT_ID_HERE" + client_credential: "CLIENT_CREDENTIAL_HERE" + # Token authority, by default will be https://login.microsoftonline.com/common but common + # can be replaced by your target tenant + authority: "https://login.microsoftonline.com/common" + app_name: "SATOSA" + # https://learn.microsoft.com/en-us/python/api/msal/msal.application.clientapplication?view=msal-py-latest#msal-application-clientapplication-initiate-auth-code-flow + # Additional arguments to ConfidentialClientApplication.initiate_auth_code_flow + initiate_auth_code_flow_args: {} + entity_info: + organization: + display_name: + - ["Microsoft", "en"] + name: + - ["Microsoft", "en"] + url: + - ["https://www.microsoft.com/about/", "en"] + ui_info: + description: + - ["Microsoft OP", "en"] + display_name: + - ["Microsoft", "en"] diff --git a/setup.py b/setup.py index 51bb389ea..1be5522c7 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ "pyop_mongo": ["pyop[mongo]"], "pyop_redis": ["pyop[redis]"], "idpy_oidc_backend": ["idpyoidc >= 2.1.0"], + "entraid_oidc_backend": ["msal==1.28.0"], }, zip_safe=False, classifiers=[ diff --git a/src/satosa/backends/entraid_oidc.py b/src/satosa/backends/entraid_oidc.py new file mode 100644 index 000000000..b062040d3 --- /dev/null +++ b/src/satosa/backends/entraid_oidc.py @@ -0,0 +1,194 @@ +""" +Microsoft backend module for Entra ID OIDC. +""" + +import logging +from datetime import datetime +from urllib.parse import urlparse + +import satosa.logging_util as lu +from satosa.internal import AuthenticationInformation +from satosa.internal import InternalData +from satosa.backends.base import BackendModule +from satosa.backends.oauth import get_metadata_desc_for_oauth_backend +from satosa.exception import SATOSAAuthenticationError, SATOSAError +from satosa.response import Redirect + +from oic.utils.authn.authn_context import UNSPECIFIED +from secrets import token_urlsafe + +import msal + +logger = logging.getLogger(__name__) + +AUTH_CODE_FLOW_STATE_KEY = "auth_code_flow" + + +class EntraIDOIDCBackend(BackendModule): + """ + Microsoft module for Entra ID OIDC + """ + + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): + """ + EntraID OIDC backend module. + :param auth_callback_func: Callback should be called by the module after the authorization + in the backend is done. + :param internal_attributes: Mapping dictionary between SATOSA internal attribute names and + the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and + RP's expects namevice. + :param config: Configuration parameters for the module. + :param base_url: base url of the service + :param name: name of the plugin + + :type auth_callback_func: + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + :type internal_attributes: dict[string, dict[str, str | list[str]]] + :type config: dict[str, dict[str, str] | list[str]] + :type base_url: str + :type name: str + """ + super().__init__(auth_callback_func, internal_attributes, base_url, name) + self.auth_callback_func = auth_callback_func + self.config = config + self.client = _create_client(config) + try: + self.redirect_uri = self.config["redirect_uri"] + except KeyError: + raise SATOSAError("Missing redirect_uri") + + def start_auth(self, context, *args, **kwargs): + """ + See super class method satosa.backends.base#start_auth + :type context: satosa.context.Context + :type request_info: satosa.internal.InternalData + """ + scopes = self.config.get("scopes", ["User.Read"]) + + csrf_protection = token_urlsafe(32) + + auth_code_flow = _initiate_auth_code_flow( + self.client, + scopes=scopes, + redirect_uri=self.redirect_uri, + state=csrf_protection, + **self.config["client"].get("initiate_auth_code_flow_args", {}), + ) + + context.state[self.name] = {AUTH_CODE_FLOW_STATE_KEY: auth_code_flow} + + return Redirect(auth_code_flow.get("auth_uri")) + + def register_endpoints(self): + """ + Creates a list of all the endpoints this backend module needs to listen to. In this case + it's the authentication response from the underlying OP that is redirected from the OP to + the proxy. + :rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]] + :return: A list that can be used to map the request to SATOSA to this endpoint. + """ + url_map = [] + + redirect_path = urlparse(self.redirect_uri).path + if not redirect_path: + raise SATOSAError("Missing path in redirect uri") + + url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint)) + return url_map + + def _check_error_response(self, response, context): + """ + Check if the response is an OAuth error response. + :param response: the OIDC response + :type response: oic.oic.message + :raise SATOSAAuthenticationError: if the response is an OAuth error response + """ + if "error" in response: + msg = "{name} error: {error} {description}".format( + name=type(response).__name__, + error=response["error"], + description=response.get("error_description", ""), + ) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) + logger.debug(logline) + raise SATOSAAuthenticationError(context.state, "Access denied") + + def response_endpoint(self, context, *args): + """ + Handles the authentication response from the OP. + :type context: satosa.context.Context + :type args: Any + :rtype: satosa.response.Response + + :param context: SATOSA context + :param args: None + :return: + """ + backend_state = context.state[self.name] + auth_code_flow = backend_state.get(AUTH_CODE_FLOW_STATE_KEY, {}) + + if not auth_code_flow: + raise SATOSAAuthenticationError(context.state, "No auth_code_flow found.") + + token = self.client.acquire_token_by_auth_code_flow( + auth_code_flow, context.request + ) + + self._check_error_response(token, context) + id_token_claims = token.get("id_token_claims") + + if not id_token_claims: + raise SATOSAAuthenticationError(context.state, "No user info available.") + + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), + message=f"Claims returned: {id_token_claims}", + ) + logger.debug(logline) + + internal_resp = self._translate_response( + id_token_claims, id_token_claims["iss"] + ) + + del context.state[self.name] + return self.auth_callback_func(context, internal_resp) + + def _translate_response(self, response, issuer): + """ + Translates oidc response to SATOSA internal response. + :type response: dict[str, str] + :type issuer: str + :type subject_type: str + :rtype: InternalData + + :param response: Dictioary with attribute name as key. + :param issuer: The oidc op that gave the repsonse. + :param subject_type: public or pairwise according to oidc standard. + :return: A SATOSA internal response. + """ + auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer) + internal_resp = InternalData(auth_info=auth_info) + internal_resp.attributes = self.converter.to_internal("openid", response) + internal_resp.subject_id = response["sub"] + return internal_resp + + def get_metadata_desc(self): + """ + See satosa.backends.oauth.get_metadata_desc + :rtype: satosa.metadata_creation.description.MetadataDescription + """ + return get_metadata_desc_for_oauth_backend(self.config["issuer"], self.config) + + +def _create_client(config): + return msal.ConfidentialClientApplication(**config["client"]["init"]) + + +def _initiate_auth_code_flow(client, scopes, redirect_uri, state, **kwargs): + auth_code_flow = client.initiate_auth_code_flow( + scopes=scopes, redirect_uri=redirect_uri, state=state, **kwargs + ) + + return auth_code_flow diff --git a/tests/satosa/backends/test_entraid_oidc.py b/tests/satosa/backends/test_entraid_oidc.py new file mode 100644 index 000000000..e9d2ccad0 --- /dev/null +++ b/tests/satosa/backends/test_entraid_oidc.py @@ -0,0 +1,340 @@ +import json +import re +import time +from unittest.mock import Mock +from urllib.parse import urlparse, parse_qsl + +import pytest +import responses +from Cryptodome.PublicKey import RSA +from jwkest.jwk import RSAKey +from oic.oic.message import IdToken +from msal.oauth2cli.oidc import _nonce_hash + +from satosa.backends.entraid_oidc import ( + EntraIDOIDCBackend, + _create_client, + _initiate_auth_code_flow, + AUTH_CODE_FLOW_STATE_KEY, +) +from satosa.context import Context +from satosa.exception import SATOSAAuthenticationError +from satosa.internal import InternalData +from satosa.response import Response + +VARIABLE_TENANT_BASE = "https://login.microsoftonline.com/{tenantid}" +TENANT_BOUND_BASE = "https://login.microsoftonline.com/common" + +ISSUER = f"{VARIABLE_TENANT_BASE}/v2.0" + +CLIENT_ID = "test_client" +NONCE = "HvEWnLsQNRGOkUxm" + +CONFIGURATION_ENDPOINT = f"{TENANT_BOUND_BASE}/v2.0/.well-known/openid-configuration" +AUTHORIZATION_ENDPOINT = f"{TENANT_BOUND_BASE}/oauth2/v2.0/authorize" +TOKEN_ENDPOINT = f"{TENANT_BOUND_BASE}/oauth2/v2.0/token" +JWKS_ENDPOINT = f"{TENANT_BOUND_BASE}/discovery/v2.0/keys" + + +class TestEntraIDOIDCBackend: + + @pytest.fixture(autouse=True) + def mock_responses(self): + self.r_mock = responses.RequestsMock(assert_all_requests_are_fired=False) + self.r_mock.start() + yield + self.r_mock.stop() + self.r_mock.reset() + + @pytest.fixture(autouse=True) + def create_backend(self, mock_responses, internal_attributes, backend_config): + self.setup_configuration_endpoint() + self.oidc_backend = EntraIDOIDCBackend( + Mock(), internal_attributes, backend_config, "base_url", "microsoft" + ) + + @pytest.fixture + def backend_config(self): + return { + "issuer": ISSUER, + "redirect_uri": "https://client.test.com/entraid_oidc", + "scopes": ["User.Read"], + "client": { + "init": { + "client_id": CLIENT_ID, + "client_credential": "satosa.credentials", + "authority": "https://login.microsoftonline.com/common", + "app_name": "SATOSA", + }, + "auth_req_params": { + "scope": "User.Read offline_access openid profile", + "response_type": "code", + }, + }, + } + + @pytest.fixture + def internal_attributes(self): + return { + "attributes": { + "givenname": {"openid": ["given_name"]}, + "mail": {"openid": ["email"]}, + "edupersontargetedid": {"openid": ["sub"]}, + "surname": {"openid": ["family_name"]}, + } + } + + @pytest.fixture + def userinfo(self): + return { + "given_name": "Test", + "family_name": "Devsson", + "email": "test_dev@example.com", + "sub": "username", + } + + @pytest.fixture(scope="session") + def signing_key(self): + return RSAKey(key=RSA.generate(2048), alg="RS256") + + def assert_expected_attributes(self, attr_map, user_claims, actual_attributes): + expected_attributes = {} + for out_attr, in_mapping in attr_map["attributes"].items(): + expected_attributes[out_attr] = [user_claims[in_mapping["openid"][0]]] + + assert actual_attributes == expected_attributes + + def setup_jwks_uri(self, key, body=None): + self.r_mock.add( + responses.GET, + JWKS_ENDPOINT, + body=json.dumps(body) if body else json.dumps({"keys": [key.serialize()]}), + status=200, + content_type="application/json", + ) + + def setup_configuration_endpoint(self): + self.r_mock.add( + responses.GET, + CONFIGURATION_ENDPOINT, + body="""{ + "token_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "private_key_jwt", + "client_secret_basic" + ], + "jwks_uri": "https://login.microsoftonline.com/common/discovery/v2.0/keys", + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "subject_types_supported": [ + "pairwise" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "response_types_supported": [ + "code", + "id_token", + "code id_token", + "id_token token" + ], + "scopes_supported": [ + "openid", + "profile", + "email", + "offline_access" + ], + "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0", + "request_uri_parameter_supported": false, + "userinfo_endpoint": "https://graph.microsoft.com/oidc/userinfo", + "authorization_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "device_authorization_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode", + "http_logout_supported": true, + "frontchannel_logout_supported": true, + "end_session_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/logout", + "claims_supported": [ + "sub", + "iss", + "cloud_instance_name", + "cloud_instance_host_name", + "cloud_graph_host_name", + "msgraph_host", + "aud", + "exp", + "iat", + "auth_time", + "acr", + "nonce", + "preferred_username", + "name", + "tid", + "ver", + "at_hash", + "c_hash", + "email" + ], + "kerberos_endpoint": "https://login.microsoftonline.com/common/kerberos", + "tenant_region_scope": null, + "cloud_instance_name": "microsoftonline.com", + "cloud_graph_host_name": "graph.windows.net", + "msgraph_host": "graph.microsoft.com", + "rbac_url": "https://pas.windows.net" + }""", + status=200, + content_type="application/json", + ) + + def setup_token_endpoint(self, userinfo, signing_key, nonce=NONCE, body=None): + id_token_claims = { + "iss": ISSUER, + "sub": userinfo["sub"], + "given_name": userinfo["given_name"], + "family_name": userinfo["family_name"], + "email": userinfo["email"], + "aud": CLIENT_ID, + "nonce": _nonce_hash(nonce), + "exp": time.time() + 3600, + "iat": time.time(), + } + id_token = IdToken(**id_token_claims).to_jwt([signing_key], signing_key.alg) + token_response = { + "access_token": "SlAV32hkKG", + "token_type": "Bearer", + "refresh_token": "8xLOxBtZp8", + "expires_in": 3600, + "id_token": id_token, + } + self.r_mock.add( + responses.POST, + TOKEN_ENDPOINT, + body=json.dumps(body) if body else json.dumps(token_response), + status=200, + content_type="application/json", + ) + + def get_redirect_uri_path(self, backend_config): + return urlparse(backend_config["redirect_uri"]).path.lstrip("/") + + @pytest.fixture + def incoming_authn_response(self, context, backend_config): + csrf_protection = "some-random-value" + client = _create_client(backend_config) + auth_code_flow = _initiate_auth_code_flow( + client, + scopes=backend_config["scopes"], + redirect_uri=backend_config["redirect_uri"], + state=csrf_protection, + ) + + context.path = self.get_redirect_uri_path(backend_config) + context.request = { + "code": "F+R4uWbN46U+Bq9moQPC4lEvRd2De4o=", + "state": csrf_protection, + } + + state_data = {AUTH_CODE_FLOW_STATE_KEY: auth_code_flow} + context.state[self.oidc_backend.name] = state_data + return context + + def test_register_endpoints(self, backend_config): + redirect_uri_path = self.get_redirect_uri_path(backend_config) + url_map = self.oidc_backend.register_endpoints() + regex, callback = url_map[0] + assert re.search(regex, redirect_uri_path) + assert callback == self.oidc_backend.response_endpoint + + def test_translate_response_to_internal_response( + self, internal_attributes, userinfo + ): + internal_response = self.oidc_backend._translate_response(userinfo, ISSUER) + assert internal_response.subject_id == userinfo["sub"] + self.assert_expected_attributes( + internal_attributes, userinfo, internal_response.attributes + ) + + def test_response_endpoint( + self, internal_attributes, userinfo, signing_key, incoming_authn_response + ): + self.setup_configuration_endpoint() + self.setup_jwks_uri(signing_key) + # We can't easily control what nonce is created, but we can re-use the one they create in our test + nonce = incoming_authn_response.state[self.oidc_backend.name][ + AUTH_CODE_FLOW_STATE_KEY + ]["nonce"] + self.setup_token_endpoint(userinfo, signing_key, nonce=nonce) + + self.oidc_backend.response_endpoint(incoming_authn_response) + + args = self.oidc_backend.auth_callback_func.call_args[0] + assert isinstance(args[0], Context) + assert isinstance(args[1], InternalData) + self.assert_expected_attributes( + internal_attributes, userinfo, args[1].attributes + ) + + def test_token_error( + self, internal_attributes, userinfo, signing_key, incoming_authn_response + ): + self.setup_configuration_endpoint() + self.setup_jwks_uri( + signing_key, + ) + # We can't easily control what nonce is created, but we can re-use the one they create in our test + nonce = incoming_authn_response.state[self.oidc_backend.name][ + AUTH_CODE_FLOW_STATE_KEY + ]["nonce"] + self.setup_token_endpoint(userinfo, signing_key, nonce=nonce, body={ + "error_description": "Unrecognised token type", + "error": "server_error", + },) + + with pytest.raises(SATOSAAuthenticationError): + self.oidc_backend.response_endpoint(incoming_authn_response) + + + def test_start_auth_redirects_to_provider_authorization_endpoint( + self, context, backend_config + ): + auth_response = self.oidc_backend.start_auth(context, None) + assert isinstance(auth_response, Response) + + login_url = auth_response.message + parsed = urlparse(login_url) + + assert login_url.startswith(AUTHORIZATION_ENDPOINT) + auth_params = dict(parse_qsl(parsed.query)) + assert ( + auth_params["scope"] == backend_config["client"]["auth_req_params"]["scope"] + ) + assert ( + auth_params["response_type"] + == backend_config["client"]["auth_req_params"]["response_type"] + ) + assert auth_params["client_id"] == backend_config["client"]["init"]["client_id"] + assert auth_params["redirect_uri"] == backend_config["redirect_uri"] + assert "state" in auth_params + assert "nonce" in auth_params + assert "code_challenge" in auth_params + + def test_entire_flow(self, context, signing_key, internal_attributes, userinfo): + auth_response = self.oidc_backend.start_auth(context, None) + nonce = context.state[self.oidc_backend.name][AUTH_CODE_FLOW_STATE_KEY]["nonce"] + self.setup_token_endpoint(userinfo, signing_key, nonce=nonce) + auth_params = dict(parse_qsl(urlparse(auth_response.message).query)) + + access_token = 12345 + context.request = { + "state": auth_params["state"], + "access_token": access_token, + "token_type": "Bearer", + "code": "F+R4uWbN46U+Bq9moQPC4lEvRd2De4o=", + } + self.oidc_backend.response_endpoint(context) + args = self.oidc_backend.auth_callback_func.call_args[0] + self.assert_expected_attributes( + internal_attributes, userinfo, args[1].attributes + ) diff --git a/tox.ini b/tox.ini index 95cbdc864..13cb040b2 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,8 @@ allowlist_externals = commands = pip install -U pip wheel setuptools pip install -U .[pyop_mongo] + pip install -U .[idpy_oidc_backend] + pip install -U .[entraid_oidc_backend] xmlsec1 --version python --version pytest --version