From 47638a79834a37ef6e2062d370f97efc1137aba3 Mon Sep 17 00:00:00 2001 From: roland Date: Thu, 8 Jun 2023 09:40:43 +0200 Subject: [PATCH 01/11] New idpyoidc based OAuth2/OIDC backend --- src/satosa/backends/idpy_oidc.py | 124 +++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/satosa/backends/idpy_oidc.py diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py new file mode 100644 index 000000000..a0aa20f72 --- /dev/null +++ b/src/satosa/backends/idpy_oidc.py @@ -0,0 +1,124 @@ +""" +OIDC backend module. +""" +import logging +from datetime import datetime + +from idpyoidc.server.user_authn.authn_context import UNSPECIFIED + +from satosa.backends.base import BackendModule +from satosa.internal import AuthenticationInformation +from satosa.internal import InternalData + +logger = logging.getLogger(__name__) + +""" +OIDC/OAuth2 backend module. +""" +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient + + +class IdpyOIDCBackend(BackendModule): + """ + Backend module for OIDC and OAuth 2.0, can be directly used. + """ + + def __init__(self, + outgoing, + internal_attributes, + config, + base_url, + name, + external_type, + user_id_attr + ): + """ + :param outgoing: 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 + :param external_type: The name for this module in the internal attributes. + + :type outgoing: + (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 + :type external_type: str + """ + super().__init__(outgoing, internal_attributes, base_url, name) + self.name = name + self.external_type = external_type + self.user_id_attr = user_id_attr + + self.client = StandAloneClient(config=config["client_config"], + client_type=config["client_config"]['client_type']) + # Deal with provider discovery and client registration + self.client.do_provider_info() + self.client.do_client_registration() + + def start_auth(self, context, internal_request): + """ + See super class method satosa.backends.base#start_auth + + :type context: satosa.context.Context + :type internal_request: satosa.internal.InternalData + :rtype satosa.response.Redirect + """ + return self.client.init_authorization() + + 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. + """ + + return self.client.context.claims.get_usage('authorization_endpoint') + + def _authn_response(self, context): + """ + Handles the authentication response from the AS. + + :type context: satosa.context.Context + :rtype: satosa.response.Response + :param context: The context in SATOSA + :return: A SATOSA response. This method is only responsible to call the callback function + which generates the Response object. + """ + + _info = self.client.finalize(context.request) + + try: + auth_info = self.auth_info(context.request) + except NotImplementedError: + auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), _info["issuer"]) + + internal_response = InternalData(auth_info=auth_info) + internal_response.attributes = self.converter.to_internal(self.external_type, + _info['userinfo']) + internal_response.subject_id = _info['userinfo'][self.user_id_attr] + del context.state[self.name] + # return self.auth_callback_func(context, internal_response) + if 'error' in _info: + return _info + else: + return _info['userinfo'] + + def auth_info(self, request): + """ + Creates the SATOSA authentication information object. + :type request: dict[str, str] + :rtype: AuthenticationInformation + + :param request: The request parameters in the authentication response sent by the AS. + :return: How, who and when the authentication took place. + """ + raise NotImplementedError("Method 'auth_info' must be implemented in the subclass!") From dba92f80141cfeb6112d05983697be55b5ae5cf5 Mon Sep 17 00:00:00 2001 From: roland Date: Thu, 8 Jun 2023 12:17:09 +0200 Subject: [PATCH 02/11] Added error message handling. --- src/satosa/backends/idpy_oidc.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index a0aa20f72..f9c18826f 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -6,7 +6,9 @@ from idpyoidc.server.user_authn.authn_context import UNSPECIFIED +import satosa.logging_util as lu from satosa.backends.base import BackendModule +from satosa.exception import SATOSAAuthenticationError from satosa.internal import AuthenticationInformation from satosa.internal import InternalData @@ -83,6 +85,23 @@ def register_endpoints(self): return self.client.context.claims.get_usage('authorization_endpoint') + def _check_error_response(self, response, context): + """ + Check if the response is an error response. + :param response: the response from finalize() + :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 _authn_response(self, context): """ Handles the authentication response from the AS. @@ -95,6 +114,7 @@ def _authn_response(self, context): """ _info = self.client.finalize(context.request) + self._check_error_response(_info, context) try: auth_info = self.auth_info(context.request) From 7ca9a801337299301965e46f3d090cb660ef4a02 Mon Sep 17 00:00:00 2001 From: roland Date: Thu, 8 Jun 2023 21:34:35 +0200 Subject: [PATCH 03/11] Updated init attributes. --- src/satosa/backends/idpy_oidc.py | 61 +++++++++++++------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index f9c18826f..cec28fe80 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -25,38 +25,23 @@ class IdpyOIDCBackend(BackendModule): Backend module for OIDC and OAuth 2.0, can be directly used. """ - def __init__(self, - outgoing, - internal_attributes, - config, - base_url, - name, - external_type, - user_id_attr - ): + def __init__(self, outgoing, internal_attributes, config, base_url, name): """ - :param outgoing: 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 - :param external_type: The name for this module in the internal attributes. - :type outgoing: (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 internal_attributes: dict[str, dict[str, list[str] | str]] + :type config: dict[str, Any] :type base_url: str :type name: str - :type external_type: str + + :param outgoing: Callback should be called by the module after + the authorization in the backend is done. + :param internal_attributes: Internal attribute map + :param config: The module config + :param base_url: base url of the service + :param name: name of the plugin """ super().__init__(outgoing, internal_attributes, base_url, name) - self.name = name - self.external_type = external_type - self.user_id_attr = user_id_attr self.client = StandAloneClient(config=config["client_config"], client_type=config["client_config"]['client_type']) @@ -119,18 +104,20 @@ def _authn_response(self, context): try: auth_info = self.auth_info(context.request) except NotImplementedError: - auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), _info["issuer"]) - - internal_response = InternalData(auth_info=auth_info) - internal_response.attributes = self.converter.to_internal(self.external_type, - _info['userinfo']) - internal_response.subject_id = _info['userinfo'][self.user_id_attr] - del context.state[self.name] - # return self.auth_callback_func(context, internal_response) - if 'error' in _info: - return _info - else: - return _info['userinfo'] + auth_info = AuthenticationInformation(auth_class_ref=UNSPECIFIED, + timestamp=str(datetime.now()), + issuer=_info["issuer"]) + + attributes = self.converter.to_internal( + self.client.client_type, _info['userinfo'], + ) + + internal_response = InternalData( + auth_info=auth_info, + attributes=attributes, + subject_id=_info['userinfo']['sub'] + ) + return internal_response def auth_info(self, request): """ From f0f38af3fd1bee7d24f055a798b6c5065bb25373 Mon Sep 17 00:00:00 2001 From: roland Date: Fri, 9 Jun 2023 16:33:12 +0200 Subject: [PATCH 04/11] Changes as a result of Ali's testing. --- src/satosa/backends/idpy_oidc.py | 141 ++++++++++++++++--------------- 1 file changed, 73 insertions(+), 68 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index cec28fe80..825ba9f72 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -1,51 +1,50 @@ """ -OIDC backend module. +OIDC/OAuth2 backend module. """ import logging from datetime import datetime +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient from idpyoidc.server.user_authn.authn_context import UNSPECIFIED import satosa.logging_util as lu from satosa.backends.base import BackendModule -from satosa.exception import SATOSAAuthenticationError from satosa.internal import AuthenticationInformation from satosa.internal import InternalData +from ..exception import SATOSAAuthenticationError +from ..response import Redirect logger = logging.getLogger(__name__) -""" -OIDC/OAuth2 backend module. -""" -from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient - class IdpyOIDCBackend(BackendModule): """ Backend module for OIDC and OAuth 2.0, can be directly used. """ - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): """ - :type outgoing: + 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[str, dict[str, list[str] | str]] - :type config: dict[str, Any] + :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 - - :param outgoing: Callback should be called by the module after - the authorization in the backend is done. - :param internal_attributes: Internal attribute map - :param config: The module config - :param base_url: base url of the service - :param name: name of the plugin """ - super().__init__(outgoing, internal_attributes, base_url, name) - - self.client = StandAloneClient(config=config["client_config"], - client_type=config["client_config"]['client_type']) - # Deal with provider discovery and client registration + super().__init__(auth_callback_func, internal_attributes, base_url, name) + # self.auth_callback_func = auth_callback_func + # self.config = config + self.client = StandAloneClient(config=config["client"], client_type="oidc") self.client.do_provider_info() self.client.do_client_registration() @@ -57,7 +56,8 @@ def start_auth(self, context, internal_request): :type internal_request: satosa.internal.InternalData :rtype satosa.response.Redirect """ - return self.client.init_authorization() + login_url = self.client.init_authorization() + return Redirect(login_url) def register_endpoints(self): """ @@ -67,8 +67,56 @@ def register_endpoints(self): :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. """ + return self.client.context.claims.get_usage('redirect_uris') + + 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 - return self.client.context.claims.get_usage('authorization_endpoint') + :param context: SATOSA context + :param args: None + :return: + """ + + _info = self.client.finalize(context.request) + self._check_error_response(_info, context) + userinfo = _info.get('userinfo') + id_token = _info.get('id_token') + + if not id_token and not userinfo: + msg = "No id_token or userinfo, nothing to do.." + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.error(logline) + raise SATOSAAuthenticationError(context.state, "No user info available.") + + all_user_claims = dict(list(userinfo.items()) + list(id_token.items())) + msg = "UserInfo: {}".format(all_user_claims) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + internal_resp = self._translate_response(all_user_claims, _info["issuer"]) + 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 _check_error_response(self, response, context): """ @@ -86,46 +134,3 @@ def _check_error_response(self, response, context): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) raise SATOSAAuthenticationError(context.state, "Access denied") - - def _authn_response(self, context): - """ - Handles the authentication response from the AS. - - :type context: satosa.context.Context - :rtype: satosa.response.Response - :param context: The context in SATOSA - :return: A SATOSA response. This method is only responsible to call the callback function - which generates the Response object. - """ - - _info = self.client.finalize(context.request) - self._check_error_response(_info, context) - - try: - auth_info = self.auth_info(context.request) - except NotImplementedError: - auth_info = AuthenticationInformation(auth_class_ref=UNSPECIFIED, - timestamp=str(datetime.now()), - issuer=_info["issuer"]) - - attributes = self.converter.to_internal( - self.client.client_type, _info['userinfo'], - ) - - internal_response = InternalData( - auth_info=auth_info, - attributes=attributes, - subject_id=_info['userinfo']['sub'] - ) - return internal_response - - def auth_info(self, request): - """ - Creates the SATOSA authentication information object. - :type request: dict[str, str] - :rtype: AuthenticationInformation - - :param request: The request parameters in the authentication response sent by the AS. - :return: How, who and when the authentication took place. - """ - raise NotImplementedError("Method 'auth_info' must be implemented in the subclass!") From b175d0ee156d2314e68aefcd4fa229973558f8b6 Mon Sep 17 00:00:00 2001 From: roland Date: Wed, 14 Jun 2023 09:10:11 +0200 Subject: [PATCH 05/11] More changes as a result of Ali Haider's testing. --- src/satosa/backends/idpy_oidc.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index 825ba9f72..06eb3c8c4 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -3,6 +3,7 @@ """ import logging from datetime import datetime +from urllib.parse import urlparse from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient from idpyoidc.server.user_authn.authn_context import UNSPECIFIED @@ -12,6 +13,7 @@ from satosa.internal import AuthenticationInformation from satosa.internal import InternalData from ..exception import SATOSAAuthenticationError +from ..exception import SATOSAError from ..response import Redirect logger = logging.getLogger(__name__) @@ -67,7 +69,13 @@ def register_endpoints(self): :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. """ - return self.client.context.claims.get_usage('redirect_uris') + url_map = [] + redirect_path = self.client.context.claims.get_usage('redirect_uris') + if not redirect_path: + raise SATOSAError("Missing path in redirect uri") + redirect_path = urlparse(redirect_path[0]).path + url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint)) + return url_map def response_endpoint(self, context, *args): """ From a56db954385ca683164f99b946ba25472c7c5a96 Mon Sep 17 00:00:00 2001 From: roland Date: Tue, 20 Jun 2023 13:19:11 +0200 Subject: [PATCH 06/11] Example backend used by Ali Haider. --- .../plugins/backends/idpyoidc_backend.yaml.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 example/plugins/backends/idpyoidc_backend.yaml.example diff --git a/example/plugins/backends/idpyoidc_backend.yaml.example b/example/plugins/backends/idpyoidc_backend.yaml.example new file mode 100644 index 000000000..45d011b21 --- /dev/null +++ b/example/plugins/backends/idpyoidc_backend.yaml.example @@ -0,0 +1,12 @@ +module: satosa.backends.idpy_oidc.IdpyOIDCBackend +name: oidc +config: + client_type: oidc + redirect_uris: [/] + client_id: !ENV SATOSA_OIDC_BACKEND_CLIENTID + client_secret: !ENV SATOSA_OIDC_BACKEND_CLIENTSECRET + response_types_supported: ["code"] + scopes_supported: ["openid", "profile", "email"] + subject_type_supported: ["public"] + provider_info: + issuer: !ENV SATOSA_OIDC_BACKEND_ISSUER \ No newline at end of file From b3860b83a26690cd39285dc3c497a1ad91bf5d38 Mon Sep 17 00:00:00 2001 From: Roland Hedberg Date: Mon, 26 Jun 2023 09:39:39 +0200 Subject: [PATCH 07/11] Added tests --- src/satosa/backends/idpy_oidc.py | 6 +- tests/satosa/backends/test_idpy_oidc.py | 207 ++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 tests/satosa/backends/test_idpy_oidc.py diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index 06eb3c8c4..0f259ea1f 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -1,17 +1,17 @@ """ OIDC/OAuth2 backend module. """ -import logging from datetime import datetime +import logging from urllib.parse import urlparse from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient from idpyoidc.server.user_authn.authn_context import UNSPECIFIED -import satosa.logging_util as lu from satosa.backends.base import BackendModule from satosa.internal import AuthenticationInformation from satosa.internal import InternalData +import satosa.logging_util as lu from ..exception import SATOSAAuthenticationError from ..exception import SATOSAError from ..response import Redirect @@ -74,7 +74,7 @@ def register_endpoints(self): if not redirect_path: raise SATOSAError("Missing path in redirect uri") redirect_path = urlparse(redirect_path[0]).path - url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint)) + url_map.append((f"^{redirect_path.lstrip('/')}$", self.response_endpoint)) return url_map def response_endpoint(self, context, *args): diff --git a/tests/satosa/backends/test_idpy_oidc.py b/tests/satosa/backends/test_idpy_oidc.py new file mode 100644 index 000000000..067118c5d --- /dev/null +++ b/tests/satosa/backends/test_idpy_oidc.py @@ -0,0 +1,207 @@ +import json +import re +import time +from unittest.mock import Mock +from urllib.parse import parse_qsl +from urllib.parse import urlparse + +from cryptojwt.key_jar import build_keyjar +from idpyoidc.client.defaults import DEFAULT_KEY_DEFS +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient +from idpyoidc.message.oidc import AuthorizationResponse +from idpyoidc.message.oidc import IdToken +from oic.oic import AuthorizationRequest +import pytest +import responses + +from satosa.backends.idpy_oidc import IdpyOIDCBackend +from satosa.context import Context +from satosa.internal import InternalData +from satosa.response import Response + +ISSUER = "https://provider.example.com" +CLIENT_ID = "test_client" +CLIENT_BASE_URL = "https://client.test.com" +NONCE = "the nonce" + + +class TestIdpyOIDCBackend(object): + @pytest.fixture + def backend_config(self): + return { + "client": { + "base_url": CLIENT_BASE_URL, + "client_id": CLIENT_ID, + "client_type": "oidc", + "client_secret": "ZJYCqe3GGRvdrudKyZS0XhGv_Z45DuKhCUk0gBR1vZk", + "application_type": "web", + "application_name": "SATOSA Test", + "contacts": ["ops@example.com"], + "response_types_supported": ["code"], + "response_type": "code id_token token", + "scope": "openid foo", + "key_conf": {"key_defs": DEFAULT_KEY_DEFS}, + "jwks_uri": f"{CLIENT_BASE_URL}/jwks.json", + "provider_info": { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authn", + "token_endpoint": f"{ISSUER}/token", + "userinfo_endpoint": f"{ISSUER}/user", + "jwks_uri": f"{ISSUER}/static/jwks" + } + } + } + + @pytest.fixture + def internal_attributes(self): + return { + "attributes": { + "givenname": {"openid": ["given_name"]}, + "mail": {"openid": ["email"]}, + "edupersontargetedid": {"openid": ["sub"]}, + "surname": {"openid": ["family_name"]} + } + } + + @pytest.fixture(autouse=True) + @responses.activate + def create_backend(self, internal_attributes, backend_config): + base_url = backend_config['client']['base_url'] + self.issuer_keys = build_keyjar(DEFAULT_KEY_DEFS) + with responses.RequestsMock() as rsps: + rsps.add( + responses.GET, + backend_config['client']['provider_info']['jwks_uri'], + body=self.issuer_keys.export_jwks_as_json(), + status=200, + content_type="application/json") + + self.oidc_backend = IdpyOIDCBackend(Mock(), internal_attributes, backend_config, + base_url, "oidc") + + @pytest.fixture + def userinfo(self): + return { + "given_name": "Test", + "family_name": "Devsson", + "email": "test_dev@example.com", + "sub": "username" + } + + def test_client(self, backend_config): + assert isinstance(self.oidc_backend.client, StandAloneClient) + # 3 signing keys. One RSA, one EC and one symmetric + assert len(self.oidc_backend.client.context.keyjar.get_signing_key()) == 3 + assert self.oidc_backend.client.context.jwks_uri == backend_config['client']['jwks_uri'] + + 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_token_endpoint(self, userinfo): + _client = self.oidc_backend.client + signing_key = self.issuer_keys.get_signing_key(key_type='RSA')[0] + signing_key.alg = "RS256" + id_token_claims = { + "iss": ISSUER, + "sub": userinfo["sub"], + "aud": CLIENT_ID, + "nonce": NONCE, + "exp": time.time() + 3600, + "iat": time.time() + } + id_token = IdToken(**id_token_claims).to_jwt([signing_key], algorithm=signing_key.alg) + token_response = { + "access_token": "SlAV32hkKG", + "token_type": "Bearer", + "refresh_token": "8xLOxBtZp8", + "expires_in": 3600, + "id_token": id_token + } + responses.add(responses.POST, + _client.context.provider_info['token_endpoint'], + body=json.dumps(token_response), + status=200, + content_type="application/json") + + def setup_userinfo_endpoint(self, userinfo): + responses.add(responses.GET, + self.oidc_backend.client.context.provider_info['userinfo_endpoint'], + body=json.dumps(userinfo), + status=200, + content_type="application/json") + + @pytest.fixture + def incoming_authn_response(self): + _context = self.oidc_backend.client.context + oidc_state = "my state" + _uri = _context.claims.get_usage("redirect_uris")[0] + _request = AuthorizationRequest( + redirect_uri=_uri, + response_type="code", + client_id=_context.get_client_id(), + scope=_context.claims.get_usage("scope"), + nonce=NONCE + ) + _context.cstate.set(oidc_state, {"iss": _context.issuer}) + _context.cstate.bind_key(NONCE, oidc_state) + _context.cstate.update(oidc_state, _request) + + response = AuthorizationResponse( + code="F+R4uWbN46U+Bq9moQPC4lEvRd2De4o=", + state=oidc_state, + iss=_context.issuer, + nonce=NONCE + ) + return response.to_dict() + + def test_register_endpoints(self): + _uri = self.oidc_backend.client.context.claims.get_usage("redirect_uris")[0] + redirect_uri_path = urlparse(_uri).path.lstrip('/') + 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, userinfo): + internal_response = self.oidc_backend._translate_response(userinfo, ISSUER) + assert internal_response.subject_id == userinfo["sub"] + self.assert_expected_attributes(self.oidc_backend.internal_attributes, userinfo, + internal_response.attributes) + + @responses.activate + def test_response_endpoint(self, context, userinfo, incoming_authn_response): + self.setup_token_endpoint(userinfo) + self.setup_userinfo_endpoint(userinfo) + + response_context = Context() + response_context.request = incoming_authn_response + response_context.state = context.state + + self.oidc_backend.response_endpoint(response_context) + + args = self.oidc_backend.auth_callback_func.call_args[0] + assert isinstance(args[0], Context) + assert isinstance(args[1], InternalData) + self.assert_expected_attributes(self.oidc_backend.internal_attributes, userinfo, + args[1].attributes) + + def test_start_auth_redirects_to_provider_authorization_endpoint(self, context): + _client = self.oidc_backend.client + 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(_client.context.provider_info["authorization_endpoint"]) + auth_params = dict(parse_qsl(parsed.query)) + assert auth_params["scope"] == " ".join(_client.context.claims.get_usage("scope")) + assert auth_params["response_type"] == _client.context.claims.get_usage("response_types")[0] + assert auth_params["client_id"] == _client.client_id + assert auth_params["redirect_uri"] == _client.context.claims.get_usage("redirect_uris")[0] + assert "state" in auth_params + assert "nonce" in auth_params + From 34d85971a79880a9a74fe594d1f9fd6588ff796c Mon Sep 17 00:00:00 2001 From: Roland Hedberg Date: Thu, 6 Jul 2023 09:49:58 +0200 Subject: [PATCH 08/11] Changes after comments from Ivan. --- src/satosa/backends/idpy_oidc.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index 0f259ea1f..fbab4c272 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -50,6 +50,11 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na self.client.do_provider_info() self.client.do_client_registration() + _redirect_uris = self.client.context.claims.get_usage('redirect_uris') + if not _redirect_uris: + raise SATOSAError("Missing path in redirect uri") + self.redirect_path = urlparse(_redirect_uris[0]).path + def start_auth(self, context, internal_request): """ See super class method satosa.backends.base#start_auth @@ -70,11 +75,7 @@ def register_endpoints(self): :return: A list that can be used to map the request to SATOSA to this endpoint. """ url_map = [] - redirect_path = self.client.context.claims.get_usage('redirect_uris') - if not redirect_path: - raise SATOSAError("Missing path in redirect uri") - redirect_path = urlparse(redirect_path[0]).path - url_map.append((f"^{redirect_path.lstrip('/')}$", self.response_endpoint)) + url_map.append((f"^{self.redirect_path.lstrip('/')}$", self.response_endpoint)) return url_map def response_endpoint(self, context, *args): @@ -120,7 +121,10 @@ def _translate_response(self, response, issuer): :param subject_type: public or pairwise according to oidc standard. :return: A SATOSA internal response. """ - auth_info = AuthenticationInformation(UNSPECIFIED, str(datetime.now()), issuer) + timestamp = response["auth_time"] + auth_class_ref = response.get("amr", response.get("acr", UNSPECIFIED)) + auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) + internal_resp = InternalData(auth_info=auth_info) internal_resp.attributes = self.converter.to_internal("openid", response) internal_resp.subject_id = response["sub"] From a8a446ad12dec0ea96c096ff7a196daa14e42de6 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 10 Jul 2023 21:59:53 +0300 Subject: [PATCH 09/11] Prepare the right datetime format --- src/satosa/backends/idpy_oidc.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index fbab4c272..f3ea43f61 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -1,7 +1,7 @@ """ OIDC/OAuth2 backend module. """ -from datetime import datetime +import datetime import logging from urllib.parse import urlparse @@ -16,6 +16,8 @@ from ..exception import SATOSAError from ..response import Redirect + +UTC = datetime.timezone.utc logger = logging.getLogger(__name__) @@ -121,9 +123,15 @@ def _translate_response(self, response, issuer): :param subject_type: public or pairwise according to oidc standard. :return: A SATOSA internal response. """ - timestamp = response["auth_time"] - auth_class_ref = response.get("amr", response.get("acr", UNSPECIFIED)) - auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer) + timestamp_epoch = ( + response.get("auth_time") + or response.get("iat") + or int(datetime.datetime.now(UTC).timestamp()) + ) + timestamp_dt = datetime.datetime.fromtimestamp(timestamp_epoch, UTC) + timestamp_iso = timestamp_dt.isoformat().replace("+00:00", "Z") + auth_class_ref = response.get("acr") or response.get("amr") or UNSPECIFIED + auth_info = AuthenticationInformation(auth_class_ref, timestamp_iso, issuer) internal_resp = InternalData(auth_info=auth_info) internal_resp.attributes = self.converter.to_internal("openid", response) From aeaea946c1679387c8223f2f0d94649433afbc8c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 10 Jul 2023 22:46:11 +0300 Subject: [PATCH 10/11] Fix tests Signed-off-by: Ivan Kanakarakis --- tests/satosa/backends/test_idpy_oidc.py | 56 ++++++++++++++++++------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/tests/satosa/backends/test_idpy_oidc.py b/tests/satosa/backends/test_idpy_oidc.py index 067118c5d..95e8b427c 100644 --- a/tests/satosa/backends/test_idpy_oidc.py +++ b/tests/satosa/backends/test_idpy_oidc.py @@ -1,6 +1,7 @@ import json import re import time +from datetime import datetime from unittest.mock import Mock from urllib.parse import parse_qsl from urllib.parse import urlparse @@ -88,6 +89,29 @@ def userinfo(self): "sub": "username" } + @pytest.fixture + def id_token(self, userinfo): + issuer_keys = build_keyjar(DEFAULT_KEY_DEFS) + signing_key = issuer_keys.get_signing_key(key_type='RSA')[0] + signing_key.alg = "RS256" + auth_time = int(datetime.utcnow().timestamp()) + id_token_claims = { + "auth_time": auth_time, + "iss": ISSUER, + "sub": userinfo["sub"], + "aud": CLIENT_ID, + "nonce": NONCE, + "exp": auth_time + 3600, + "iat": auth_time, + } + id_token = IdToken(**id_token_claims) + return id_token + + @pytest.fixture + def all_user_claims(self, userinfo, id_token): + all_user_claims = {**userinfo, **id_token} + return all_user_claims + def test_client(self, backend_config): assert isinstance(self.oidc_backend.client, StandAloneClient) # 3 signing keys. One RSA, one EC and one symmetric @@ -95,10 +119,10 @@ def test_client(self, backend_config): assert self.oidc_backend.client.context.jwks_uri == backend_config['client']['jwks_uri'] 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]]] - + expected_attributes = { + out_attr: [user_claims[in_mapping["openid"][0]]] + for out_attr, in_mapping in attr_map["attributes"].items() + } assert actual_attributes == expected_attributes def setup_token_endpoint(self, userinfo): @@ -166,16 +190,19 @@ def test_register_endpoints(self): assert re.search(regex, redirect_uri_path) assert callback == self.oidc_backend.response_endpoint - def test_translate_response_to_internal_response(self, userinfo): - internal_response = self.oidc_backend._translate_response(userinfo, ISSUER) - assert internal_response.subject_id == userinfo["sub"] - self.assert_expected_attributes(self.oidc_backend.internal_attributes, userinfo, - internal_response.attributes) + def test_translate_response_to_internal_response(self, all_user_claims): + internal_response = self.oidc_backend._translate_response(all_user_claims, ISSUER) + assert internal_response.subject_id == all_user_claims["sub"] + self.assert_expected_attributes( + self.oidc_backend.internal_attributes, + all_user_claims, + internal_response.attributes, + ) @responses.activate - def test_response_endpoint(self, context, userinfo, incoming_authn_response): - self.setup_token_endpoint(userinfo) - self.setup_userinfo_endpoint(userinfo) + def test_response_endpoint(self, context, all_user_claims, incoming_authn_response): + self.setup_token_endpoint(all_user_claims) + self.setup_userinfo_endpoint(all_user_claims) response_context = Context() response_context.request = incoming_authn_response @@ -186,8 +213,9 @@ def test_response_endpoint(self, context, userinfo, 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(self.oidc_backend.internal_attributes, userinfo, - args[1].attributes) + self.assert_expected_attributes( + self.oidc_backend.internal_attributes, all_user_claims, args[1].attributes + ) def test_start_auth_redirects_to_provider_authorization_endpoint(self, context): _client = self.oidc_backend.client From 628ee94f507d9923b1ed6b20dd831c84860d753c Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Mon, 10 Jul 2023 22:46:36 +0300 Subject: [PATCH 11/11] Add extra requirement for the new idpy-oidc based backend Signed-off-by: Ivan Kanakarakis --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 59065f6ac..51bb389ea 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "ldap": ["ldap3"], "pyop_mongo": ["pyop[mongo]"], "pyop_redis": ["pyop[redis]"], + "idpy_oidc_backend": ["idpyoidc >= 2.1.0"], }, zip_safe=False, classifiers=[