diff --git a/doc/README.md b/doc/README.md index a20458774..aee354592 100644 --- a/doc/README.md +++ b/doc/README.md @@ -496,6 +496,109 @@ When using an OP that only supports statically registered clients, see the and make sure to provide the redirect URI, constructed as described in the section about Google configuration below, in the static registration. +### Logout in OIDC Backend + +The OpenID Connect backend supports Global Logout proces, which means terminating a user’s session on the IdP and all +RPs that the user currently has an active session for. At the time of authentication, SATOSA maintains the following +session information as part of its storage: + +#### Backend Session +This contains the following: + +1. SID: Retrieved from the user claims, gotten from the OP after a successful authentication flow. +2. Issuer: The Issuer URL of the OP + +Since SID and Issuer uniquely identify a Backend Session, after persisting Backend Session, a new ID (auto-incremented) +is generated, called `backend_sid` which uniquely identifies a Backend Session. + +#### Frontend Session +This contains the following: + +1. Frontend name: Name of the Frontend, which could be of any type, for example OIDC, SAML, etc. +2. Requester: Only stored in the case of OIDC Frontend, and is required at the time of logout for the creation of + Logout Token when logging out an RP. +3. Subject ID: Only stored in the case of OIDC Frontend, and is required at the time of logout for the creation of + Logout Token when logging out an RP. +4. Frontend SID: The value of SATOSA context state `session_id` which uniquely identified the Frontend Session. + +#### Session Map +Since at the time of logout, SATOSA needs to know the information about the Frontend Session associated with the +Backend Session, a Session Map is required to be maintained. This enables SATOSA to logout all the connected Frontend, +irrespective of the type, OIDC, SAML, etc. when the OIDC Backend receives a logout request by an OP. + +This contains the following: + +1. Backend Session ID +2. Frontend Session ID + +In order to enable the OIDC Backend logout functionality, the following flag is required to be set in the +`proxy.conf.yaml` file. + +````yaml +LOGOUT_ENABLED: True +```` + +The default value of `LOGOUT_ENABLED` flag is False. When the logout is not enabled, all the calls to the storage are +mocked and hence none of the session information is persisted in the storage. + +SATOSA provides two types of storage adapters out of the box: + +1. In Memory +2. PostgreSQL + +If the logout is enabled and storage configuration is not defined, by default In Memory storage is used. + +The storage configurations are defined in the `proxy_conf.yaml` file. An example configuration for the storage looks as below: + +```yaml +STORAGE: + type: satosa.storage.StoragePostgreSQL + host: 127.0.0.1 + port: 5432 + db_name: satosa + user: postgres + password: secret +``` + +The following diagram illustrates at what point each of the discussed session storages are being persisted in an +OIDC to OIDC authentication flow. + +### Session Storage in OpenID Connect Frontend <-> Idpy OIDC Backend + +![](images/session-storage.png "Session Storage") + +The OIDC backend supports the following flows of Global Logout: + +1. OP Initiated Logout with Front-Channel Communication +2. OP Initiated Logout with Back-Channel Communication + +In the OIDC Backend configuration, depending on which logout to support, the following needs to be defined for the client: + +````yaml +front_channel_logout_uri: //front-channel-logout +back_channel_logout_uri: //back-channel-logout +```` + +The same URL is required to be defined in the OP when registering this client. + +When the OIDC Backend receives the Front-Channel logout request, after validating the request, it calls the +`logout_callback_func` defined in `SATOSABase` which retrieves all the frontend sessions, and triggers the logout +for them one by one. If the Frontend logout is successful, the Frontend is responsible to delete the Frontend Session +entry. Once all the Frontend sessions are successfully logged out, the Session Map entry is deleted as well, and at +last, the Backend Session is deleted as well by the `_backend_logout_req_finish` in `SATOSABase`. + +The following diagram illustrates a high level flow of OIDC Backend Back-channel logout. + +### OIDC Backend Back-channel Logout + +![](images/oidc-be-backchannel-logout.png "OIDC Backend Back-channel Logout") + +The following diagram illustrates a high level flow of OIDC Backend Front-channel logout. + +### OIDC Backend Front-channel Logout + +![](images/oidc-be-frontchannel-logout.png "OIDC Backend Front-channel Logout") + ### Social login plugins The social login plugins can be used as backends for the proxy, allowing the diff --git a/doc/images/oidc-be-backchannel-logout.png b/doc/images/oidc-be-backchannel-logout.png new file mode 100644 index 000000000..fb1c42176 Binary files /dev/null and b/doc/images/oidc-be-backchannel-logout.png differ diff --git a/doc/images/oidc-be-frontchannel-logout.png b/doc/images/oidc-be-frontchannel-logout.png new file mode 100644 index 000000000..4392774ac Binary files /dev/null and b/doc/images/oidc-be-frontchannel-logout.png differ diff --git a/doc/images/session-storage.png b/doc/images/session-storage.png new file mode 100644 index 000000000..bffde7d44 Binary files /dev/null and b/doc/images/session-storage.png differ diff --git a/setup.py b/setup.py index 51bb389ea..19d0ba62c 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"], + "storage_postgresql": ["SQLAlchemy", "psycopg2-binary"], }, zip_safe=False, classifiers=[ diff --git a/src/satosa/backends/apple.py b/src/satosa/backends/apple.py index f7c1189ea..ef596df54 100644 --- a/src/satosa/backends/apple.py +++ b/src/satosa/backends/apple.py @@ -18,6 +18,110 @@ class AppleBackend(OpenIDConnectBackend): """Sign in with Apple backend""" + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback_func): + """ + Sign in with Apple 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 + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session + + :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 + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + """ + super().__init__(auth_callback_func, internal_attributes, base_url, name, storage, logout_callback_func) + self.auth_callback_func = auth_callback_func + self.config = config + self.client = _create_client( + config["provider_metadata"], + config["client"]["client_metadata"], + config["client"].get("verify_ssl", True), + ) + if "scope" not in config["client"]["auth_req_params"]: + config["auth_req_params"]["scope"] = "openid" + if "response_type" not in config["client"]["auth_req_params"]: + config["auth_req_params"]["response_type"] = "code" + + def start_auth(self, context, request_info): + """ + See super class method satosa.backends.base#start_auth + :type context: satosa.context.Context + :type request_info: satosa.internal.InternalData + """ + oidc_nonce = rndstr() + oidc_state = rndstr() + state_data = {NONCE_KEY: oidc_nonce, STATE_KEY: oidc_state} + context.state[self.name] = state_data + + args = { + "scope": self.config["client"]["auth_req_params"]["scope"], + "response_type": self.config["client"]["auth_req_params"]["response_type"], + "client_id": self.client.client_id, + "redirect_uri": self.client.registration_response["redirect_uris"][0], + "state": oidc_state, + "nonce": oidc_nonce, + } + args.update(self.config["client"]["auth_req_params"]) + auth_req = self.client.construct_AuthorizationRequest(request_args=args) + login_url = auth_req.request(self.client.authorization_endpoint) + return Redirect(login_url) + + 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.config["client"]["client_metadata"]["redirect_uris"][0] + ).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 _verify_nonce(self, nonce, context): + """ + Verify the received OIDC 'nonce' from the ID Token. + :param nonce: OIDC nonce + :type nonce: str + :param context: current request context + :type context: satosa.context.Context + :raise SATOSAAuthenticationError: if the nonce is incorrect + """ + backend_state = context.state[self.name] + if nonce != backend_state[NONCE_KEY]: + msg = "Missing or invalid nonce in authn response for state: {}".format( + backend_state + ) + logline = lu.LOG_FMT.format( + id=lu.get_session_id(context.state), message=msg + ) + logger.debug(logline) + raise SATOSAAuthenticationError( + context.state, "Missing or invalid nonce in authn response" + ) + def _get_tokens(self, authn_response, context): """ :param authn_response: authentication response from OP diff --git a/src/satosa/backends/base.py b/src/satosa/backends/base.py index 8d0432da8..a57e14040 100644 --- a/src/satosa/backends/base.py +++ b/src/satosa/backends/base.py @@ -10,13 +10,17 @@ class BackendModule(object): Base class for a backend module. """ - def __init__(self, auth_callback_func, internal_attributes, base_url, name): + def __init__(self, auth_callback_func, internal_attributes, base_url, name, storage, logout_callback_func): """ :type auth_callback_func: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[string, dict[str, str | list[str]]] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + :param auth_callback_func: Callback should be called by the module after the authorization in the backend is done. @@ -25,12 +29,18 @@ def __init__(self, auth_callback_func, internal_attributes, base_url, name): RP's expects namevice. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session """ self.auth_callback_func = auth_callback_func self.internal_attributes = internal_attributes self.converter = AttributeMapper(internal_attributes) self.base_url = base_url self.name = name + self.storage = storage + self.logout_callback_func = logout_callback_func def start_auth(self, context, internal_request): """ diff --git a/src/satosa/backends/bitbucket.py b/src/satosa/backends/bitbucket.py index 6932ce901..62e98f96e 100644 --- a/src/satosa/backends/bitbucket.py +++ b/src/satosa/backends/bitbucket.py @@ -19,7 +19,7 @@ class BitBucketBackend(_OAuthBackend): logprefix = "BitBucket Backend:" - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, logout_callback_func): """BitBucket backend constructor :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -29,6 +29,11 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: configuration parameters for the module. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session + :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -36,11 +41,14 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, dict[str, str] | list[str] | str] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response """ config.setdefault('response_type', 'code') config['verify_accesstoken_state'] = False - super().__init__(outgoing, internal_attributes, config, base_url, - name, 'bitbucket', 'account_id') + super().__init__(outgoing, internal_attributes, config, base_url, name, 'bitbucket', 'account_id', + storage, logout_callback_func) def get_request_args(self, get_state=stateID): request_args = super().get_request_args(get_state=get_state) diff --git a/src/satosa/backends/github.py b/src/satosa/backends/github.py index 70944e371..e33491679 100644 --- a/src/satosa/backends/github.py +++ b/src/satosa/backends/github.py @@ -21,7 +21,8 @@ class GitHubBackend(_OAuthBackend): """GitHub OAuth 2.0 backend""" - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, + logout_callback_func): """GitHub backend constructor :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -31,6 +32,11 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: configuration parameters for the module. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session + :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -38,12 +44,14 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, dict[str, str] | list[str] | str] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response """ config.setdefault('response_type', 'code') config['verify_accesstoken_state'] = False - super().__init__( - outgoing, internal_attributes, config, base_url, name, 'github', - 'id') + super().__init__(outgoing, internal_attributes, config, base_url, name, 'github', 'id', + storage, logout_callback_func) def start_auth(self, context, internal_request, get_state=stateID): """ diff --git a/src/satosa/backends/idpy_oidc.py b/src/satosa/backends/idpy_oidc.py index f3ea43f61..5b92e8fa9 100644 --- a/src/satosa/backends/idpy_oidc.py +++ b/src/satosa/backends/idpy_oidc.py @@ -5,8 +5,9 @@ import logging from urllib.parse import urlparse -from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient +from idpyoidc.client.oauth2.stand_alone_client import StandAloneClient, backchannel_logout from idpyoidc.server.user_authn.authn_context import UNSPECIFIED +from idpyoidc.message.oidc.session import BackChannelLogoutRequest, LogoutToken from satosa.backends.base import BackendModule from satosa.internal import AuthenticationInformation @@ -14,7 +15,7 @@ import satosa.logging_util as lu from ..exception import SATOSAAuthenticationError from ..exception import SATOSAError -from ..response import Redirect +from ..response import Redirect, Response UTC = datetime.timezone.utc @@ -26,7 +27,8 @@ class IdpyOIDCBackend(BackendModule): Backend module for OIDC and OAuth 2.0, can be directly used. """ - def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback_func): """ OIDC backend module. :param auth_callback_func: Callback should be called by the module after the authorization @@ -37,6 +39,10 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na :param config: Configuration parameters for the module. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session :type auth_callback_func: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -44,10 +50,11 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na :type config: dict[str, dict[str, str] | list[str]] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response """ - super().__init__(auth_callback_func, internal_attributes, base_url, name) - # self.auth_callback_func = auth_callback_func - # self.config = config + super().__init__(auth_callback_func, internal_attributes, base_url, name, storage, logout_callback_func) self.client = StandAloneClient(config=config["client"], client_type="oidc") self.client.do_provider_info() self.client.do_client_registration() @@ -57,6 +64,11 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na raise SATOSAError("Missing path in redirect uri") self.redirect_path = urlparse(_redirect_uris[0]).path + front_channel_logout_uri = config["client"].get('front_channel_logout_uri') + self.front_channel_logout_path = urlparse(front_channel_logout_uri).path if front_channel_logout_uri else None + back_channel_logout_uri = config["client"].get('back_channel_logout_uri') + self.back_channel_logout_path = urlparse(back_channel_logout_uri).path if back_channel_logout_uri else None + def start_auth(self, context, internal_request): """ See super class method satosa.backends.base#start_auth @@ -76,8 +88,11 @@ 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. """ - url_map = [] - url_map.append((f"^{self.redirect_path.lstrip('/')}$", self.response_endpoint)) + url_map = [(f"^{self.redirect_path.lstrip('/')}$", self.response_endpoint)] + if self.front_channel_logout_path: + url_map.append((f"^{self.front_channel_logout_path.lstrip('/')}$", self.front_channel_logout_endpoint)) + if self.back_channel_logout_path: + url_map.append((f"^{self.back_channel_logout_path.lstrip('/')}$", self.back_channel_logout_endpoint)) return url_map def response_endpoint(self, context, *args): @@ -108,8 +123,85 @@ def response_endpoint(self, context, *args): 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"]) + sid = all_user_claims.get("sid") + if sid: + backend_session_id = self.storage.store_backend_session(sid, _info["issuer"]) + internal_resp.backend_session_id = backend_session_id return self.auth_callback_func(context, internal_resp) + def front_channel_logout_endpoint(self, context): + """ + Handles the front channel logout request from the OP. + :type context: satosa.context.Context + :rtype: satosa.response.Response + + :param context: SATOSA context + :return: + """ + + logger.info(lu.LOG_FMT.format(id=lu.get_session_id(context.state), + message="Received front-channel logout request: {}".format(context.request))) + sid = context.request.get("sid") + issuer = context.request.get("iss") + backend_session = self.storage.get_backend_session(sid, issuer) + + if backend_session: + internal_req = InternalData( + backend_session_id=backend_session["id"], + issuer=backend_session["issuer"] + ) + return self.logout_callback_func(context, internal_req) + else: + return Response() + + def back_channel_logout_endpoint(self, context): + """ + Handles the back channel logout request from the OP. + :type context: satosa.context.Context + :rtype: satosa.response.Response + + :param context: SATOSA context + :return: + """ + logger.info(lu.LOG_FMT.format(id=lu.get_session_id(context.state), + message="Received back-channel logout request: {}".format(context.request))) + + if not context.request.get("logout_token"): + logger.warning(lu.LOG_FMT.format(id=lu.get_session_id(context.state), + message="back-channel logout request is received without logout token")) + return Response(message="Missing logout token", status="400") + else: + back_channel_logout_request = BackChannelLogoutRequest( + logout_token=context.request["logout_token"]).to_urlencoded() + + if self._verify_logout_token(context, back_channel_logout_request): + logout_token = LogoutToken().from_jwt(context.request["logout_token"], None) + sid = logout_token.get("sid") + issuer = logout_token.get("iss") + backend_session = self.storage.get_backend_session(sid, issuer) + + if backend_session: + internal_req = InternalData( + backend_session_id=backend_session["id"], + issuer=backend_session["issuer"] + ) + return self.logout_callback_func(context, internal_req) + else: + return Response(message="Invalid sid", status="400") + else: + return Response(message="Logout token verification failed", status="400") + + def _verify_logout_token(self, context, back_channel_logout_request): + try: + logger.debug(lu.LOG_FMT.format(id=lu.get_session_id(context.state), + message="Starting logout token verification")) + backchannel_logout(self.client, back_channel_logout_request) + return True + except Exception as e: + logger.warning(lu.LOG_FMT.format(id=lu.get_session_id(context.state), + message="Logout token verification failed"), e) + return False + def _translate_response(self, response, issuer): """ Translates oidc response to SATOSA internal response. diff --git a/src/satosa/backends/linkedin.py b/src/satosa/backends/linkedin.py index 8d3a85b4c..e6e45546b 100644 --- a/src/satosa/backends/linkedin.py +++ b/src/satosa/backends/linkedin.py @@ -22,7 +22,8 @@ class LinkedInBackend(_OAuthBackend): """LinkedIn OAuth 2.0 backend""" - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, + logout_callback_func): """LinkedIn backend constructor :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -32,6 +33,11 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: configuration parameters for the module. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session + :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -39,12 +45,14 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, dict[str, str] | list[str] | str] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response """ config.setdefault('response_type', 'code') config['verify_accesstoken_state'] = False - super().__init__( - outgoing, internal_attributes, config, base_url, name, 'linkedin', - 'id') + super().__init__(outgoing, internal_attributes, config, base_url, name, 'linkedin', 'id', + storage, logout_callback_func) def start_auth(self, context, internal_request, get_state=stateID): """ diff --git a/src/satosa/backends/oauth.py b/src/satosa/backends/oauth.py index 3e2bd041b..a13bfa89e 100644 --- a/src/satosa/backends/oauth.py +++ b/src/satosa/backends/oauth.py @@ -32,7 +32,8 @@ class _OAuthBackend(BackendModule): See satosa.backends.oauth.FacebookBackend. """ - 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, external_type, user_id_attr, + storage, logout_callback_func): """ :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -52,7 +53,7 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name, extern :type name: str :type external_type: str """ - super().__init__(outgoing, internal_attributes, base_url, name) + super().__init__(outgoing, internal_attributes, base_url, name, storage, logout_callback_func) self.config = config self.redirect_url = "%s/%s" % (self.config["base_url"], self.config["authz_page"]) self.external_type = external_type @@ -189,7 +190,8 @@ class FacebookBackend(_OAuthBackend): """ DEFAULT_GRAPH_ENDPOINT = "https://graph.facebook.com/v2.5/me" - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, + logout_callback_func): """ Constructor. :param outgoing: Callback should be called by the module after the authorization in the @@ -200,6 +202,10 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: Configuration parameters for the module. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -207,10 +213,14 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, dict[str, str] | list[str] | str] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response """ config.setdefault("response_type", "code") config["verify_accesstoken_state"] = False - super().__init__(outgoing, internal_attributes, config, base_url, name, "facebook", "id") + super().__init__(outgoing, internal_attributes, config, base_url, name, "facebook", "id", storage, + logout_callback_func) def get_request_args(self, get_state=stateID): request_args = super().get_request_args(get_state=get_state) diff --git a/src/satosa/backends/openid_connect.py b/src/satosa/backends/openid_connect.py index 58d47af9b..1e2cc57d2 100644 --- a/src/satosa/backends/openid_connect.py +++ b/src/satosa/backends/openid_connect.py @@ -36,7 +36,8 @@ class OpenIDConnectBackend(BackendModule): OIDC module """ - def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback_func): """ OIDC backend module. :param auth_callback_func: Callback should be called by the module after the authorization @@ -47,6 +48,10 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na :param config: Configuration parameters for the module. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session :type auth_callback_func: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -54,8 +59,11 @@ def __init__(self, auth_callback_func, internal_attributes, config, base_url, na :type config: dict[str, dict[str, str] | list[str]] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response """ - super().__init__(auth_callback_func, internal_attributes, base_url, name) + super().__init__(auth_callback_func, internal_attributes, base_url, name, storage, logout_callback_func) self.auth_callback_func = auth_callback_func self.config = config cfg_verify_ssl = config["client"].get("verify_ssl", True) diff --git a/src/satosa/backends/orcid.py b/src/satosa/backends/orcid.py index 649e72451..c33b04e27 100644 --- a/src/satosa/backends/orcid.py +++ b/src/satosa/backends/orcid.py @@ -21,7 +21,7 @@ class OrcidBackend(_OAuthBackend): """Orcid OAuth 2.0 backend""" - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, logout_callback_func): """Orcid backend constructor :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -31,6 +31,11 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: configuration parameters for the module. :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session + :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -38,12 +43,14 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, dict[str, str] | list[str] | str] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response """ config.setdefault('response_type', 'code') config['verify_accesstoken_state'] = False - super().__init__( - outgoing, internal_attributes, config, base_url, name, 'orcid', - 'orcid') + super().__init__(outgoing, internal_attributes, config, base_url, name, 'orcid', 'orcid', + storage, logout_callback_func) def get_request_args(self, get_state=stateID): oauth_state = get_state(self.config["base_url"], rndstr().encode()) diff --git a/src/satosa/backends/reflector.py b/src/satosa/backends/reflector.py index 6a9055485..83719a4f8 100644 --- a/src/satosa/backends/reflector.py +++ b/src/satosa/backends/reflector.py @@ -17,7 +17,7 @@ class ReflectorBackend(BackendModule): ENTITY_ID = ORG_NAME = AUTH_CLASS_REF = SUBJECT_ID = "reflector" - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, logout_callback_func): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -25,6 +25,9 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, Any] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -32,8 +35,12 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: The module config :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session """ - super().__init__(outgoing, internal_attributes, base_url, name) + super().__init__(outgoing, internal_attributes, base_url, name, storage, logout_callback_func) def start_auth(self, context, internal_req): """ diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 8be4572d4..fbe9269b8 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -92,7 +92,7 @@ class SAMLBackend(BackendModule, SAMLBaseModule): VALUE_ACR_COMPARISON_DEFAULT = 'exact' - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, logout_callback_func): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -100,6 +100,9 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, Any] :type base_url: str :type name: str + :type storage: satosa.storage.Storage + :type logout_callback_func: str + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -107,8 +110,12 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: The module config :param base_url: base url of the service :param name: name of the plugin + :param storage: storage to hold the backend session information + :param logout_callback_func: Callback should be called by the module after the logout + in the backend is done. This may trigger log out flow for all the frontends associated + with the backend session """ - super().__init__(outgoing, internal_attributes, base_url, name) + super().__init__(outgoing, internal_attributes, base_url, name, storage, logout_callback_func) self.config = self.init_config(config) self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV) diff --git a/src/satosa/base.py b/src/satosa/base.py index 1e17c8cbe..43a5defe6 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -8,7 +8,7 @@ from saml2.s_utils import UnknownSystemEntity from satosa import util -from satosa.response import BadRequest +from satosa.response import BadRequest, Response from satosa.response import NotFound from satosa.response import Redirect from .context import Context @@ -24,10 +24,12 @@ from .plugin_loader import load_frontends from .plugin_loader import load_request_microservices from .plugin_loader import load_response_microservices +from .plugin_loader import load_storage from .routing import ModuleRouter from .state import State from .state import cookie_to_state from .state import state_to_cookie +from .routing import STATE_KEY as ROUTER_STATE_KEY import satosa.logging_util as lu @@ -52,12 +54,15 @@ def __init__(self, config): """ self.config = config + logger.info("Loading session storage...") + self.storage = load_storage(self.config) + logger.info("Loading backend modules...") - backends = load_backends(self.config, self._auth_resp_callback_func, - self.config["INTERNAL_ATTRIBUTES"]) + backends = load_backends(self.config, self._auth_resp_callback_func, self.config["INTERNAL_ATTRIBUTES"], + self.storage, self._backend_logout_callback_func) logger.info("Loading frontend modules...") - frontends = load_frontends(self.config, self._auth_req_callback_func, - self.config["INTERNAL_ATTRIBUTES"]) + frontends = load_frontends(self.config, self._auth_req_callback_func, self.config["INTERNAL_ATTRIBUTES"], + self.storage) self.response_micro_services = [] self.request_micro_services = [] @@ -130,7 +135,14 @@ def _auth_resp_finish(self, context, internal_response): context.request = None frontend = self.module_router.frontend_routing(context) - return frontend.handle_authn_response(context, internal_response) + response = frontend.handle_authn_response(context, internal_response) + + if internal_response.frontend_sid and internal_response.backend_session_id: + self.storage.store_session_map( + internal_response["frontend_sid"], + internal_response["backend_session_id"]) + + return response def _auth_resp_callback_func(self, context, internal_response): """ @@ -163,6 +175,56 @@ def _auth_resp_callback_func(self, context, internal_response): return self._auth_resp_finish(context, internal_response) + def _backend_logout_callback_func(self, context, internal_request): + """ + This function is called by a backend module when the logout request is received by the backend. + + :type context: satosa.context.Context + :type internal_request: satosa.internal.InternalData + :rtype: satosa.response.Response + + :param context: The request context + :param internal_request: The logout request + :return: response + """ + + frontend_sessions = self.storage.get_frontend_sessions_by_backend_session_id( + internal_request.backend_session_id) + + if frontend_sessions: + msg = "Initiating frontend logout(s) for the issuer: {}".format(internal_request.issuer) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + + for frontend_session in frontend_sessions: + context.state[ROUTER_STATE_KEY] = frontend_session.get("frontend_name") + frontend = self.module_router.frontend_routing(context) + internal_request.frontend_sid = frontend_session.get("sid") + if frontend.start_logout_from_backend(context, internal_request): + self.storage.delete_session_map(frontend_session.get("sid")) + + self._backend_logout_req_finish(context, internal_request) + else: + msg = "No frontend to logout for the issuer: {}".format(internal_request.issuer) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + + return Response(message="") + + def _backend_logout_req_finish(self, context, internal_request): + frontend_sessions = self.storage.get_frontend_sessions_by_backend_session_id( + internal_request.backend_session_id) + + if frontend_sessions: + msg = "Some frontends could not logout for the backend session id : {}".format( + internal_request.backend_session_id) + else: + msg = "All the frontends logged out for the backend session id: {}.".format( + internal_request.backend_session_id) + self.storage.delete_backend_session(internal_request.backend_session_id) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + def _handle_satosa_authentication_error(self, error): """ Sends a response to the requester about the error diff --git a/src/satosa/frontends/base.py b/src/satosa/frontends/base.py index 52840a85c..35ea1862d 100644 --- a/src/satosa/frontends/base.py +++ b/src/satosa/frontends/base.py @@ -9,22 +9,25 @@ class FrontendModule(object): Base class for a frontend module. """ - def __init__(self, auth_req_callback_func, internal_attributes, base_url, name): + def __init__(self, auth_req_callback_func, internal_attributes, base_url, name, storage): """ :type auth_req_callback_func: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[str, dict[str, str | list[str]]] :type name: str + :type storage: satosa.storage.Storage :param auth_req_callback_func: Callback should be called by the module after the authorization response has been processed. :param name: name of the plugin + :param storage: storage to hold the backend session information """ self.auth_req_callback_func = auth_req_callback_func self.internal_attributes = internal_attributes self.converter = AttributeMapper(internal_attributes) self.base_url = base_url self.name = name + self.storage = storage def handle_authn_response(self, context, internal_resp): """ diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 88041b373..3f47ad020 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -4,13 +4,16 @@ import json import logging +import uuid +import requests +import time from collections import defaultdict from urllib.parse import urlencode, urlparse from jwkest.jwk import rsa_load, RSAKey from oic.oic import scope2claims -from oic.oic.message import AuthorizationRequest +from oic.oic.message import AuthorizationRequest, LogoutToken from oic.oic.message import AuthorizationErrorResponse from oic.oic.message import TokenErrorResponse from oic.oic.message import UserInfoErrorResponse @@ -56,9 +59,10 @@ class OpenIDConnectFrontend(FrontendModule): A OpenID Connect frontend module """ - def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, name): + def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, name, storage, + logout_callback): _validate_config(conf) - super().__init__(auth_req_callback_func, internal_attributes, base_url, name) + super().__init__(auth_req_callback_func, internal_attributes, base_url, name, storage) self.config = conf provider_config = self.config["provider"] @@ -88,14 +92,14 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, client_db_uri = self.config.get("client_db_uri") cdb_file = self.config.get("client_db_path") if client_db_uri: - cdb = StorageBase.from_uri( + self.cdb = StorageBase.from_uri( client_db_uri, db_name="satosa", collection="clients", ttl=None ) elif cdb_file: with open(cdb_file) as f: - cdb = json.loads(f.read()) + self.cdb = json.loads(f.read()) else: - cdb = {} + self.cdb = {} self.endpoint_baseurl = "{}/{}".format(self.base_url, self.name) self.provider = _create_provider( @@ -105,7 +109,7 @@ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, self.signing_key, authz_state, self.user_db, - cdb, + self.cdb, ) def _get_extra_id_token_claims(self, user_id, client_id): @@ -144,6 +148,15 @@ def handle_authn_response(self, context, internal_resp): del context.state[self.name] http_response = auth_resp.request(auth_req["redirect_uri"], should_fragment_encode(auth_req)) + frontend_sid = context.state.session_id + internal_resp.frontend_sid = frontend_sid + + self.storage.store_frontend_session( + self.name, + internal_resp.requester, + internal_resp.subject_id, + frontend_sid) + return SeeOther(http_response) def handle_backend_error(self, exception): @@ -394,6 +407,64 @@ def userinfo_endpoint(self, context): content="application/json") return response + def start_logout_from_backend(self, context, internal_request): + """ + Performs the back-channel logout for the RP + :param context: the current context + :param internal_request: internalData containing the frontend sid + :return: whether the back-channel logout was successful or not + + :type context: satosa.context.Context + :type internal_request: satosa.internal.InternalData + :rtype bool + """ + logout_status = True + session = self.storage.get_frontend_session(internal_request.frontend_sid) + client = self.cdb[session.get("requester")] + + if client.get("back_channel_logout_uri"): + try: + logout_token = LogoutToken(iss=self.base_url, + sub=session.get("subject_id"), + aud=session.get("requester"), + iat=int(time.time()), + jti=str(uuid.uuid4()), + events={"http://schemas.openid.net/event/backchannel-logout": {}}, + sid=internal_request.frontend_sid + ) + + logout_token_str = logout_token.to_jwt([self.signing_key], "RS256") + logger.debug('signed logout_token {} using alg={}'.format(logout_token_str, "RS256")) + resp = requests.post(client.get("back_channel_logout_uri"), json={"logout_token": logout_token_str}, verify=False) + + if resp.status_code == 200: + self.storage.delete_frontend_session(internal_request.frontend_sid) + msg = "RP with client id '{}' of the frontend name '{}' logged out successfully.".format( + session.get("requester"), self.name) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.info(logline) + else: + msg = "client {} could not log out successfully - received status code: {} and payload: {} " \ + "from the client.".format( + session.get("requester"), + resp.status_code, + resp.content) + + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warning(logline) + logout_status = False + except Exception as e: + msg = "client {} could not log out successfully - encountered error: {}".format( + session.get("requester"), e) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.warning(logline) + logout_status = False + else: + logger.info( + lu.LOG_FMT.format(id=lu.get_session_id(context.state), + message="client {} does not support logout").format(session.get("requester"))) + return logout_status + def _validate_config(config): """ diff --git a/src/satosa/frontends/ping.py b/src/satosa/frontends/ping.py index 27fec279c..c4f9cc1eb 100644 --- a/src/satosa/frontends/ping.py +++ b/src/satosa/frontends/ping.py @@ -14,8 +14,9 @@ class PingFrontend(FrontendModule): 200 OK, intended to be used as a simple heartbeat monitor. """ - def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): - super().__init__(auth_req_callback_func, internal_attributes, base_url, name) + def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback): + super().__init__(auth_req_callback_func, internal_attributes, base_url, name, storage) self.config = config diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index cecd533db..b9191b49d 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -67,10 +67,11 @@ class SAMLFrontend(FrontendModule, SAMLBaseModule): KEY_ENDPOINTS = 'endpoints' KEY_IDP_CONFIG = 'idp_config' - def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): + def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback): self._validate_config(config) - super().__init__(auth_req_callback_func, internal_attributes, base_url, name) + super().__init__(auth_req_callback_func, internal_attributes, base_url, name, storage) self.config = self.init_config(config) self.endpoints = config[self.KEY_ENDPOINTS] @@ -829,9 +830,11 @@ class SAMLVirtualCoFrontend(SAMLFrontend): KEY_ORGANIZATION = 'organization' KEY_ORGANIZATION_KEYS = ['display_name', 'name', 'url'] - def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): + def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback): self.has_multiple_backends = False - super().__init__(auth_req_callback_func, internal_attributes, config, base_url, name) + super().__init__(auth_req_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback) def handle_authn_request(self, context, binding_in): """ diff --git a/src/satosa/internal.py b/src/satosa/internal.py index a96b19b1f..b406b650e 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -124,6 +124,8 @@ def __init__( subject_id=None, subject_type=None, attributes=None, + frontend_sid=None, + backend_session_id=None, *args, **kwargs, ): @@ -157,3 +159,5 @@ def __init__( self.subject_id = subject_id self.subject_type = subject_type self.attributes = attributes if attributes is not None else {} + self.frontend_sid = frontend_sid + self.backend_session_id = backend_session_id diff --git a/src/satosa/metadata_creation/saml_metadata.py b/src/satosa/metadata_creation/saml_metadata.py index f88bbaaec..0ca824f93 100644 --- a/src/satosa/metadata_creation/saml_metadata.py +++ b/src/satosa/metadata_creation/saml_metadata.py @@ -104,8 +104,8 @@ def create_entity_descriptors(satosa_config): :type satosa_config: satosa.satosa_config.SATOSAConfig :rtype: Tuple[str, str] """ - frontend_modules = load_frontends(satosa_config, None, satosa_config["INTERNAL_ATTRIBUTES"]) - backend_modules = load_backends(satosa_config, None, satosa_config["INTERNAL_ATTRIBUTES"]) + frontend_modules = load_frontends(satosa_config, None, satosa_config["INTERNAL_ATTRIBUTES"], None) + backend_modules = load_backends(satosa_config, None, satosa_config["INTERNAL_ATTRIBUTES"], None, None) logger.info("Loaded frontend plugins: {}".format([frontend.name for frontend in frontend_modules])) logger.info("Loaded backend plugins: {}".format([backend.name for backend in backend_modules])) diff --git a/src/satosa/plugin_loader.py b/src/satosa/plugin_loader.py index b7eb4cf46..a69339a62 100644 --- a/src/satosa/plugin_loader.py +++ b/src/satosa/plugin_loader.py @@ -27,46 +27,47 @@ def prepend_to_import_path(import_paths): del sys.path[0:len(import_paths)] # restore sys.path -def load_backends(config, callback, internal_attributes): +def load_backends(config, auth_callback, internal_attributes, storage, logout_callback): """ Load all backend modules specified in the config :type config: satosa.satosa_config.SATOSAConfig - :type callback: + :type auth_callback: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[string, dict[str, str | list[str]]] :rtype: Sequence[satosa.backends.base.BackendModule] :param config: The configuration of the satosa proxy - :param callback: Function that will be called by the backend after the authentication is done. + :param auth_callback: Function that will be called by the backend after the authentication is done. :return: A list of backend modules """ backend_modules = _load_plugins( config.get("CUSTOM_PLUGIN_MODULE_PATHS"), config["BACKEND_MODULES"], backend_filter, config["BASE"], - internal_attributes, callback) + internal_attributes, auth_callback, storage, logout_callback + ) logger.info("Setup backends: {}".format([backend.name for backend in backend_modules])) return backend_modules -def load_frontends(config, callback, internal_attributes): +def load_frontends(config, auth_callback, internal_attributes, storage): """ Load all frontend modules specified in the config :type config: satosa.satosa_config.SATOSAConfig - :type callback: + :type auth_callback: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[string, dict[str, str | list[str]]] :rtype: Sequence[satosa.frontends.base.FrontendModule] :param config: The configuration of the satosa proxy - :param callback: Function that will be called by the frontend after the authentication request - has been processed. + :param auth_callback: Function that will be called by the frontend after the authentication request :return: A list of frontend modules """ frontend_modules = _load_plugins(config.get("CUSTOM_PLUGIN_MODULE_PATHS"), config["FRONTEND_MODULES"], - frontend_filter, config["BASE"], internal_attributes, callback) + frontend_filter, config["BASE"], internal_attributes, auth_callback, + storage) logger.info("Setup frontends: {}".format([frontend.name for frontend in frontend_modules])) return frontend_modules @@ -151,7 +152,8 @@ def _load_plugin_config(config): raise SATOSAConfigurationError("The configuration is corrupt.") from exc -def _load_plugins(plugin_paths, plugins, plugin_filter, base_url, internal_attributes, callback): +def _load_plugins(plugin_paths, plugins, plugin_filter, base_url, internal_attributes, auth_callback, + storage, logout_callback=None): """ Loads endpoint plugins @@ -178,8 +180,8 @@ def _load_plugins(plugin_paths, plugins, plugin_filter, base_url, internal_attri if module_class: module_config = _replace_variables_in_plugin_module_config(plugin_config["config"], base_url, plugin_config["name"]) - instance = module_class(callback, internal_attributes, module_config, base_url, - plugin_config["name"]) + instance = module_class(auth_callback, internal_attributes, module_config, base_url, + plugin_config["name"], storage, logout_callback) loaded_plugin_modules.append(instance) return loaded_plugin_modules @@ -280,3 +282,35 @@ def load_response_microservices(plugin_path, plugins, internal_attributes, base_ base_url) logger.info("Loaded response micro services:{}".format([type(k).__name__ for k in response_services])) return response_services + + +def load_storage(config): + """ + Loads the storage based on the provided config + + :type config: satosa.satosa_config.SATOSAConfig + :rtype: storage.Storage + + :param config: The configuration of the satosa proxy + :return: Storage which could either be in-memory, PostgreSQL, or any other defined storage + """ + logout_enabled = config.get("LOGOUT_ENABLED") + if logout_enabled: + storage = config.get("STORAGE") + if storage: + try: + storage_type = storage.get("type", "") + storage = locate(storage_type) + return storage(config) + except TypeError as err: + raise SATOSAConfigurationError("Unable to load storage for type : {}".format(storage_type)) from err + else: + logger.info("STORAGE is not defined") + + logger.info("Using the in-memory storage.") + from satosa.storage import StorageInMemory + return StorageInMemory(config) + else: + logger.info("Logout not enabled. Creating mock storage.") + from satosa.storage import StorageMock + return StorageMock() diff --git a/src/satosa/storage.py b/src/satosa/storage.py new file mode 100644 index 000000000..dbbc1af19 --- /dev/null +++ b/src/satosa/storage.py @@ -0,0 +1,239 @@ +import uuid + +from sqlalchemy import ForeignKey, Column, Integer, String +from sqlalchemy.orm import mapped_column +from sqlalchemy.ext.declarative import declarative_base + + +class Storage: + def __init__(self, config): + self.db_config = config.get("STORAGE") + + +class StorageInMemory(Storage): + """ + In-memory session storage + """ + + def __init__(self, config): + super().__init__(config) + self.frontend_sessions = [] + self.backend_sessions = [] + self.session_maps = [] + + def store_frontend_session(self, frontend_name, requester, subject_id, sid): + self.frontend_sessions.append({"frontend_name": frontend_name, + "requester": requester, + "subject_id": subject_id, + "sid": sid + }) + + def get_frontend_session(self, sid): + for session in self.frontend_sessions: + if session.get("sid") == sid: + return session + + def delete_frontend_session(self, sid): + for session in self.frontend_sessions: + if session.get("sid") == sid: + self.frontend_sessions.remove(session) + + def store_backend_session(self, sid, issuer): + backend_session_id = len(self.backend_sessions) + 1 + self.backend_sessions.append({"id": backend_session_id, + "sid": sid, + "issuer": issuer}) + return backend_session_id + + def get_backend_session(self, sid, issuer=None): + for session in self.backend_sessions: + if issuer: + if session.get("sid") == sid and session.get("issuer") == issuer: + return session + else: + continue + elif session.get("sid") == sid: + return session + + def delete_backend_session(self, id): + for session in self.backend_sessions: + if session.get("id") == id: + self.backend_sessions.remove(session) + return session + + def store_session_map(self, frontend_sid, backend_session_id): + self.session_maps.append({"frontend_sid": frontend_sid, + "backend_session_id": backend_session_id + }) + + def get_frontend_sessions_by_backend_session_id(self, backend_session_id): + sessions = list() + for session_map in self.session_maps: + if session_map.get("backend_session_id") == backend_session_id: + frontend_sid = session_map.get("frontend_sid") + for session in self.frontend_sessions: + if session.get("sid") == frontend_sid: + sessions.append(session) + break + return sessions + + def delete_session_map(self, frontend_sid): + for session_map in self.session_maps: + if session_map.get("frontend_sid") == frontend_sid: + self.session_maps.remove(session_map) + + +Base = declarative_base() + + +class FrontendSession(Base): + __tablename__ = 'frontend_session' + sid = Column(String, primary_key=True) + frontend_name = Column(String) + requester = Column(String) + subject_id = Column(String) + + +class BackendSession(Base): + __tablename__ = 'backend_session' + id = Column(Integer, primary_key=True, autoincrement=True) + sid = Column(String, primary_key=True) + issuer = Column(String) + + +class SessionMap(Base): + __tablename__ = 'session_map' + id = Column(Integer, primary_key=True, autoincrement=True) + frontend_sid = mapped_column(String, ForeignKey("frontend_session.sid")) + backend_session_id = mapped_column(Integer, ForeignKey("backend_session.id")) + + +class StoragePostgreSQL(Storage): + """ + PostgreSQL session storage + """ + + def __init__(self, config): + super().__init__(config) + + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + HOST = self.db_config["host"] + PORT = self.db_config["port"] + DB_NAME = self.db_config["db_name"] + USER = self.db_config["user"] + PWD = self.db_config["password"] + + engine = create_engine("postgresql://{USER}:{PWD}@{HOST}:{PORT}/{DB_NAME}".format( + USER=USER, + PWD=PWD, + HOST=HOST, + PORT=PORT, + DB_NAME=DB_NAME + )) + Base.metadata.create_all(engine) + self.Session = sessionmaker(bind=engine) + + def store_frontend_session(self, frontend_name, requester, subject_id, sid): + session = self.Session() + frontend_session = FrontendSession( + frontend_name=frontend_name, + requester=requester, + subject_id=subject_id, + sid=sid + ) + session.add(frontend_session) + session.commit() + session.close() + + def get_frontend_session(self, sid): + session = self.Session() + frontend_session = session.query(FrontendSession).filter(FrontendSession.sid == sid).first() + session.close() + if frontend_session: + return {"sid": frontend_session.sid, + "frontend_name": frontend_session.frontend_name, + "requester": frontend_session.requester, + "subject_id": frontend_session.subject_id} + return None + + def delete_frontend_session(self, sid): + session = self.Session() + session.query(FrontendSession).filter(FrontendSession.sid == sid).delete() + session.commit() + session.close() + + def store_backend_session(self, sid, issuer): + session = self.Session() + backend_session = BackendSession( + sid=sid, + issuer=issuer + ) + session.add(backend_session) + session.commit() + backend_session_id = backend_session.id + session.close() + return backend_session_id + + def get_backend_session(self, sid, issuer=None): + session = self.Session() + if issuer: + backend_session = session.query(BackendSession).filter( + BackendSession.sid == sid and BackendSession.issuer == issuer).first() + else: + backend_session = session.query(BackendSession).filter(BackendSession.sid == sid).first() + session.close() + + if backend_session: + return {"id": backend_session.id, + "sid": backend_session.sid, + "issuer": backend_session.issuer} + return None + + def delete_backend_session(self, backend_session_id): + session = self.Session() + session.query(BackendSession).filter(BackendSession.id == backend_session_id).delete() + session.commit() + session.close() + + def store_session_map(self, frontend_sid, backend_session_id): + session = self.Session() + frontend_backend_session = SessionMap( + frontend_sid=frontend_sid, + backend_session_id=backend_session_id, + ) + session.add(frontend_backend_session) + session.commit() + session.close() + + def get_frontend_sessions_by_backend_session_id(self, backend_session_id): + frontend_sessions = list() + session = self.Session() + frontend_session_rows = session.query(FrontendSession).join(SessionMap).filter(SessionMap.backend_session_id == backend_session_id).all() + session.close() + + for frontend_session in frontend_session_rows: + frontend_sessions.append({"sid": frontend_session.sid, + "frontend_name": frontend_session.frontend_name, + "requester": frontend_session.requester, + "subject_id": frontend_session.subject_id}) + + return frontend_sessions + + def delete_session_map(self, frontend_sid): + session = self.Session() + session.query(SessionMap).filter(SessionMap.frontend_sid == frontend_sid).delete() + session.commit() + session.close() + +class StorageMock: + + def __init__(*args, **kwargs): + pass + + def __call__(self, *args, **kwargs): + pass + + def __getattr__(self, *args, **kwargs): + return self diff --git a/tests/conftest.py b/tests/conftest.py index f0602a028..e466fc0b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,10 @@ from saml2.extension.idpdisc import BINDING_DISCO from saml2.saml import NAME_FORMAT_URI, NAMEID_FORMAT_TRANSIENT, NAMEID_FORMAT_PERSISTENT +from satosa.plugin_loader import load_storage from satosa.context import Context from satosa.state import State + from .util import create_metadata_from_config_dict from .util import generate_cert, write_cert @@ -353,3 +355,13 @@ def consent_module_config(signing_key_path): } } return consent_config + + +@pytest.fixture +def storage(): + storage_config = { + "STORAGE": { + "type": "satosa.storage.StorageInMemory" + } + } + return load_storage(storage_config) diff --git a/tests/flows/test_oidc-idpy_oidc.py b/tests/flows/test_oidc-idpy_oidc.py new file mode 100644 index 000000000..1f12d9893 --- /dev/null +++ b/tests/flows/test_oidc-idpy_oidc.py @@ -0,0 +1,235 @@ +from copy import copy +import json +import time +from urllib.parse import urlparse, urlencode, parse_qsl +from unittest import mock +import mongomock +import pytest +from cryptojwt.key_jar import build_keyjar +from idpyoidc.message.oidc import IdToken, ClaimsRequest, Claims +from idpyoidc.client.defaults import DEFAULT_KEY_DEFS +from requests.models import Response +from pyop.storage import StorageBase +from werkzeug.test import Client +from satosa.response import Response as satosaResp +from satosa.proxy_server import make_app +from satosa.satosa_config import SATOSAConfig +from tests.users import USERS, OIDC_USERS + + +CLIENT_ID = "client1" +CLIENT_SECRET = "secret" +CLIENT_REDIRECT_URI = "https://client.example.com/cb" +REDIRECT_URI = "https://client.example.com/cb" +DB_URI = "mongodb://localhost/satosa" +ISSUER = "https://provider.example.com" +CLIENT_BASE_URL = "https://client.test.com" +NONCE = "the nonce" + + +@pytest.fixture +def oidc_frontend_config(signing_key_path): + data = { + "module": "satosa.frontends.openid_connect.OpenIDConnectFrontend", + "name": "OIDCFrontend", + "config": { + "issuer": "https://proxy-op.example.com", + "signing_key_path": signing_key_path, + "provider": { + "response_types_supported": ["id_token"], + "claims_supported": ["email"], + }, + "client_db_uri": DB_URI, # use mongodb for integration testing + "db_uri": DB_URI, # use mongodb for integration testing + }, + } + + return data + + +@pytest.fixture +def idpy_oidc_backend_config(): + data = { + "module": "satosa.backends.idpy_oidc.IdpyOIDCBackend", + "name": "OIDCBackend", + "config": { + "client": { + "redirect_uris": ["http://example.com/OIDCBackend"], + "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"], + "front_channel_logout_uri": "https://test-proxy.com/OIDCBackend/front-channel-logout", + "scopes_supported": ["openid", "profile", "email"], + "subject_type_supported": ["public"], + "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", + "frontchannel_logout_session_required": True, + }, + } + }, + } + return data + + +@mongomock.patch(servers=(("localhost", 27017),)) +class TestOIDCToIdpyOIDC: + def _client_setup(self): + """Insert client in mongodb.""" + self._cdb = StorageBase.from_uri( + DB_URI, db_name="satosa", collection="clients", ttl=None + ) + self._cdb[CLIENT_ID] = { + "redirect_uris": [REDIRECT_URI], + "response_types": ["id_token"], + } + + @mock.patch("requests.post") + @mock.patch("idpyoidc.client.oauth2.stand_alone_client.StandAloneClient.finalize") + def test_full_flow_front_channel_logout_inmemory_storage( + self, + mock_stand_alone_client_finalize, + mock_logout_post_request, + satosa_config_dict, + oidc_frontend_config, + idpy_oidc_backend_config, + ): + self._client_setup() + subject_id = "testuser1" + + # proxy config + satosa_config_dict["FRONTEND_MODULES"] = [oidc_frontend_config] + satosa_config_dict["BACKEND_MODULES"] = [idpy_oidc_backend_config] + satosa_config_dict["INTERNAL_ATTRIBUTES"]["attributes"] = { + attr_name: {"openid": [attr_name]} for attr_name in USERS[subject_id] + } + satosa_config_dict['LOGOUT_ENABLED'] = True + + # application + test_client = Client(make_app(SATOSAConfig(satosa_config_dict)), satosaResp) + + # get frontend OP config info + provider_config = json.loads( + test_client.get("/.well-known/openid-configuration").data.decode("utf-8") + ) + + # create auth req + claims_request = ClaimsRequest( + id_token=Claims(**{k: None for k in USERS[subject_id]}) + ) + req_args = { + "scope": "openid", + "response_type": "id_token", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "nonce": "nonce", + "claims": claims_request.to_json(), + } + auth_req = ( + urlparse(provider_config["authorization_endpoint"]).path + + "?" + + urlencode(req_args) + ) + + # make auth req to proxy + proxied_auth_req = test_client.get(auth_req) + assert proxied_auth_req.status == "302 Found" + parsed_auth_req = dict( + parse_qsl(urlparse(proxied_auth_req.data.decode("utf-8")).query) + ) + + # create auth resp + self.issuer_keys = build_keyjar(DEFAULT_KEY_DEFS) + signing_key = self.issuer_keys.get_signing_key(key_type="RSA")[0] + signing_key.alg = "RS256" + + id_token_claims = {k: v for k, v in OIDC_USERS[subject_id].items()} + id_token_claims["sub"] = subject_id + id_token_claims["iat"] = time.time() + id_token_claims["exp"] = time.time() + 3600 + id_token_claims["iss"] = ISSUER + id_token_claims["aud"] = idpy_oidc_backend_config["config"]["client"][ + "client_id" + ] + id_token_claims["nonce"] = parsed_auth_req["nonce"] + id_token = IdToken(**id_token_claims).to_jwt( + [signing_key], algorithm=signing_key.alg + ) + authn_resp = {"state": parsed_auth_req["state"], "id_token": id_token} + + # mock finalize method of idpy oidc due to signing key issue and add sid manually + id_token_claims["sid"] = "30f8dae4-1da5-41bf-a801-5aad0648af8c" + mock_stand_alone_client_finalize.return_value = { + "userinfo": USERS[subject_id], + "id_token": id_token_claims, + "issuer": ISSUER, + } + + # make auth resp to proxy + redirect_uri_path = urlparse( + idpy_oidc_backend_config["config"]["client"]["redirect_uris"][0] + ).path + authn_resp_req = redirect_uri_path + "?" + urlencode(authn_resp) + authn_resp = test_client.get(authn_resp_req) + assert authn_resp.status == "303 See Other" + + # mock response from logout url of RP + resp = Response() + resp.status_code = 200 + mock_logout_post_request.return_value = resp + + # get session storage values before calling logout to verify successful logout later + ( + backend_session_before_logout, + frontend_session_before_logout, + session_maps_before_logout, + ) = get_session_storage_components_using_sid( + test_client.application.app.app.storage, + "30f8dae4-1da5-41bf-a801-5aad0648af8c", + ) + + # call front channel logout + req_args = {"sid": "30f8dae4-1da5-41bf-a801-5aad0648af8c"} + front_channel_logout_req = ( + urlparse( + idpy_oidc_backend_config["config"]["client"]["front_channel_logout_uri"] + ).path + + "?" + + urlencode(req_args) + ) + logout_resp = test_client.get(front_channel_logout_req) + assert logout_resp.status == "200 OK" + + # verify logout successful + ( + backend_session_after_logout, + frontend_session_after_logout, + session_maps_after_logout, + ) = get_session_storage_components_using_sid( + test_client.application.app.app.storage, + "30f8dae4-1da5-41bf-a801-5aad0648af8c", + ) + assert backend_session_before_logout != backend_session_after_logout + assert frontend_session_before_logout != frontend_session_after_logout + assert session_maps_before_logout != session_maps_after_logout + + +def get_session_storage_components_using_sid(storage, sid): + backend_session = storage.get_backend_session(sid) + session_maps = copy(storage.session_maps) + frontend_session = "" + for session_map in session_maps: + frontend_session = storage.get_backend_session( + session_map.get("frontend_sid") + ) + return backend_session, frontend_session, session_maps diff --git a/tests/satosa/backends/test_bitbucket.py b/tests/satosa/backends/test_bitbucket.py index d6cf25bac..a4cfe727b 100644 --- a/tests/satosa/backends/test_bitbucket.py +++ b/tests/satosa/backends/test_bitbucket.py @@ -75,7 +75,7 @@ class TestBitBucketBackend(object): @pytest.fixture(autouse=True) def create_backend(self): self.bb_backend = BitBucketBackend(Mock(), INTERNAL_ATTRIBUTES, - BB_CONFIG, "base_url", "bitbucket") + BB_CONFIG, "base_url", "bitbucket", None, None) @pytest.fixture def incoming_authn_response(self, context): diff --git a/tests/satosa/backends/test_idpy_oidc.py b/tests/satosa/backends/test_idpy_oidc.py index 95e8b427c..a4dd0af05 100644 --- a/tests/satosa/backends/test_idpy_oidc.py +++ b/tests/satosa/backends/test_idpy_oidc.py @@ -19,6 +19,7 @@ from satosa.context import Context from satosa.internal import InternalData from satosa.response import Response +from satosa.storage import StorageInMemory ISSUER = "https://provider.example.com" CLIENT_ID = "test_client" @@ -64,9 +65,41 @@ def internal_attributes(self): } } + @pytest.fixture + def storage(self): + backend_sessions = [ + { + "id": 1, + "sid": "30f8dae4-1da5-41bf-a801-5aad0648af8c", + "issuer": "https://login.microsoftonline.com/4ef96300-df5b-4978-8bb6-54ead73fd78e/v2.0" + } + ] + + session_maps = [ + { + "backend_session_id": 1, + "frontend_sid": "urn:uuid:26f0122d-4347-4e45-a6db-fceed571222a" + } + ] + + frontend_sessions = [ + { + "frontend_name": "OIDC", + "requester": "5258756a-f970-433e-8fee-0ec85b938fe6", + "subject_id": "yiPzYn2ASoQznkqSxSaYySg9qrlDejM0Lcn6mef28Mg", + "sid": "urn:uuid:26f0122d-4347-4e45-a6db-fceed571222a" + } + ] + + storage = StorageInMemory({}) + storage.backend_sessions = backend_sessions + storage.frontend_sessions = frontend_sessions + storage.session_maps = session_maps + return storage + @pytest.fixture(autouse=True) @responses.activate - def create_backend(self, internal_attributes, backend_config): + def create_backend(self, internal_attributes, backend_config, storage): base_url = backend_config['client']['base_url'] self.issuer_keys = build_keyjar(DEFAULT_KEY_DEFS) with responses.RequestsMock() as rsps: @@ -78,7 +111,7 @@ def create_backend(self, internal_attributes, backend_config): content_type="application/json") self.oidc_backend = IdpyOIDCBackend(Mock(), internal_attributes, backend_config, - base_url, "oidc") + base_url, "oidc", storage, Mock()) @pytest.fixture def userinfo(self): @@ -233,3 +266,11 @@ def test_start_auth_redirects_to_provider_authorization_endpoint(self, context): assert "state" in auth_params assert "nonce" in auth_params + def test_front_channel_logout_endpoint_with_oidc_frontend(self, context): + context.request = {"sid": "30f8dae4-1da5-41bf-a801-5aad0648af8c"} + self.oidc_backend.front_channel_logout_endpoint(context) + args = self.oidc_backend.logout_callback_func.call_args[0] + assert isinstance(args[0], Context) + assert isinstance(args[1], InternalData) + assert args[1].get("issuer") == "https://login.microsoftonline.com/4ef96300-df5b-4978-8bb6-54ead73fd78e/v2.0" + assert args[1].get("backend_session_id") == 1 diff --git a/tests/satosa/backends/test_oauth.py b/tests/satosa/backends/test_oauth.py index 22afc8ee7..3f7161b94 100644 --- a/tests/satosa/backends/test_oauth.py +++ b/tests/satosa/backends/test_oauth.py @@ -65,7 +65,7 @@ class TestFacebookBackend(object): @pytest.fixture(autouse=True) def create_backend(self): - self.fb_backend = FacebookBackend(Mock(), INTERNAL_ATTRIBUTES, FB_CONFIG, "base_url", "facebook") + self.fb_backend = FacebookBackend(Mock(), INTERNAL_ATTRIBUTES, FB_CONFIG, "base_url", "facebook", None, None) @pytest.fixture def incoming_authn_response(self, context): diff --git a/tests/satosa/backends/test_openid_connect.py b/tests/satosa/backends/test_openid_connect.py index 34bac79fe..357fd6b83 100644 --- a/tests/satosa/backends/test_openid_connect.py +++ b/tests/satosa/backends/test_openid_connect.py @@ -25,7 +25,8 @@ class TestOpenIDConnectBackend(object): @pytest.fixture(autouse=True) def create_backend(self, internal_attributes, backend_config): - self.oidc_backend = OpenIDConnectBackend(Mock(), internal_attributes, backend_config, "base_url", "oidc") + self.oidc_backend = OpenIDConnectBackend(Mock(), internal_attributes, backend_config, "base_url", "oidc", None, + None) @pytest.fixture def backend_config(self): diff --git a/tests/satosa/backends/test_orcid.py b/tests/satosa/backends/test_orcid.py index 5120d4e89..9eaa340d1 100644 --- a/tests/satosa/backends/test_orcid.py +++ b/tests/satosa/backends/test_orcid.py @@ -28,7 +28,9 @@ def create_backend(self, internal_attributes, backend_config): internal_attributes, backend_config, backend_config["base_url"], - "orcid" + "orcid", + None, + None ) @pytest.fixture diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index e1cc96466..e1ac84bce 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -91,7 +91,7 @@ def create_backend(self, sp_conf, idp_conf): self.samlbackend = SAMLBackend(Mock(), INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, "base_url", - "samlbackend") + "samlbackend", None, None) def test_register_endpoints(self, sp_conf): """ @@ -172,7 +172,8 @@ def test_start_auth_redirects_directly_to_mirrored_idp( def test_redirect_to_idp_if_only_one_idp_in_metadata(self, context, sp_conf, idp_conf): sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] # instantiate new backend, without any discovery service configured - samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend") + samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend", None, + None) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) @@ -217,6 +218,8 @@ def _make_authn_request(self, http_host, context, config, entity_id): config, "base_url", "samlbackend", + None, + None ) resp = self.samlbackend.authn_request(context, entity_id) req_params = dict(parse_qsl(urlparse(resp.message).query)) @@ -335,6 +338,8 @@ def test_authn_response_with_encrypted_assertion(self, sp_conf, context): {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, "base_url", "samlbackend", + None, + None ) response_binding = BINDING_HTTP_REDIRECT relay_state = "test relay state" @@ -370,7 +375,7 @@ def test_backend_reads_encryption_key_from_key_file(self, sp_conf): sp_conf["key_file"] = os.path.join(TEST_RESOURCE_BASE_PATH, "encryption_key.pem") samlbackend = SAMLBackend(Mock(), INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, - "base_url", "samlbackend") + "base_url", "samlbackend", None, None) assert samlbackend.encryption_keys def test_backend_reads_encryption_key_from_encryption_keypair(self, sp_conf): @@ -378,7 +383,7 @@ def test_backend_reads_encryption_key_from_encryption_keypair(self, sp_conf): sp_conf["encryption_keypairs"] = [{"key_file": os.path.join(TEST_RESOURCE_BASE_PATH, "encryption_key.pem")}] samlbackend = SAMLBackend(Mock(), INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, - "base_url", "samlbackend") + "base_url", "samlbackend", None, None) assert samlbackend.encryption_keys def test_metadata_endpoint(self, context, sp_conf): @@ -390,7 +395,8 @@ def test_metadata_endpoint(self, context, sp_conf): def test_get_metadata_desc(self, sp_conf, idp_conf): sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] # instantiate new backend, with a single backing IdP - samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend") + samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend", None, + None) entity_descriptions = samlbackend.get_metadata_desc() assert len(entity_descriptions) == 1 @@ -417,7 +423,8 @@ def test_get_metadata_desc_with_logo_without_lang(self, sp_conf, idp_conf): sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] # instantiate new backend, with a single backing IdP - samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend") + samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend", None, + None) entity_descriptions = samlbackend.get_metadata_desc() assert len(entity_descriptions) == 1 @@ -446,7 +453,7 @@ def test_default_redirect_to_discovery_service_if_using_mdq( sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] sp_conf["metadata"]["mdq"] = ["https://mdq.example.com"] samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,}, - "base_url", "saml_backend") + "base_url", "saml_backend", None, None) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) @@ -462,21 +469,21 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_not_se SAMLBackend.KEY_MEMORIZE_IDP: True, } samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) context.state[Context.KEY_MEMORIZED_IDP] = idp_conf["entityid"] samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) backend_conf[SAMLBackend.KEY_MEMORIZE_IDP] = False samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) @@ -485,7 +492,7 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_not_se context.state[Context.KEY_MEMORIZED_IDP] = idp_conf["entityid"] backend_conf[SAMLBackend.KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN] = True samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) @@ -506,14 +513,14 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_set_tr SAMLBackend.KEY_MIRROR_FORCE_AUTHN: True, } samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) backend_conf[SAMLBackend.KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN] = True samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) @@ -534,14 +541,14 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_set_1( SAMLBackend.KEY_MIRROR_FORCE_AUTHN: True, } samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) backend_conf[SAMLBackend.KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN] = True samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None, None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index f769b2c66..53bacbb36 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -86,14 +86,14 @@ def frontend_config_with_extra_id_token_claims(self, signing_key_path): return config - def create_frontend(self, frontend_config): + def create_frontend(self, frontend_config, storage): # will use in-memory storage instance = OpenIDConnectFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, - frontend_config, BASE_URL, "oidc_frontend") + frontend_config, BASE_URL, "oidc_frontend", storage, None) instance.register_endpoints(["foo_backend"]) return instance - def create_frontend_with_extra_scopes(self, frontend_config_with_extra_scopes): + def create_frontend_with_extra_scopes(self, frontend_config_with_extra_scopes, storage): # will use in-memory storage internal_attributes_with_extra_scopes = copy.deepcopy(INTERNAL_ATTRIBUTES) internal_attributes_with_extra_scopes["attributes"].update(EXTRA_CLAIMS) @@ -102,18 +102,18 @@ def create_frontend_with_extra_scopes(self, frontend_config_with_extra_scopes): internal_attributes_with_extra_scopes, frontend_config_with_extra_scopes, BASE_URL, - "oidc_frontend_with_extra_scopes", + "oidc_frontend_with_extra_scopes", storage, None ) instance.register_endpoints(["foo_backend"]) return instance @pytest.fixture - def frontend(self, frontend_config): - return self.create_frontend(frontend_config) + def frontend(self, frontend_config, storage): + return self.create_frontend(frontend_config, storage) @pytest.fixture - def frontend_with_extra_scopes(self, frontend_config_with_extra_scopes): - return self.create_frontend_with_extra_scopes(frontend_config_with_extra_scopes) + def frontend_with_extra_scopes(self, frontend_config_with_extra_scopes, storage): + return self.create_frontend_with_extra_scopes(frontend_config_with_extra_scopes, storage) @pytest.fixture def authn_req(self): @@ -377,7 +377,7 @@ def test_register_endpoints_token_and_userinfo_endpoint_is_published_if_necessar def test_register_endpoints_token_and_userinfo_endpoint_is_not_published_if_only_implicit_flow( self, frontend_config, context): frontend_config["provider"]["response_types_supported"] = ["id_token", "id_token token"] - frontend = self.create_frontend(frontend_config) + frontend = self.create_frontend(frontend_config, None) urls = frontend.register_endpoints(["test"]) assert ("^{}/{}".format("test", TokenEndpoint.url), frontend.token_endpoint) not in urls @@ -394,7 +394,7 @@ def test_register_endpoints_token_and_userinfo_endpoint_is_not_published_if_only def test_register_endpoints_dynamic_client_registration_is_configurable( self, frontend_config, client_registration_enabled): frontend_config["provider"]["client_registration_supported"] = client_registration_enabled - frontend = self.create_frontend(frontend_config) + frontend = self.create_frontend(frontend_config, None) urls = frontend.register_endpoints(["test"]) assert (("^{}/{}".format(frontend.name, RegistrationEndpoint.url), @@ -406,10 +406,10 @@ def test_register_endpoints_dynamic_client_registration_is_configurable( True, False ]) - def test_mirrored_subject(self, context, frontend_config, authn_req, sub_mirror_public): + def test_mirrored_subject(self, context, frontend_config, authn_req, sub_mirror_public, storage): frontend_config["sub_mirror_public"] = sub_mirror_public frontend_config["provider"]["subject_types_supported"] = ["public"] - frontend = self.create_frontend(frontend_config) + frontend = self.create_frontend(frontend_config, storage) self.insert_client_in_client_db(frontend, authn_req["redirect_uri"]) internal_response = self.setup_for_authn_response(context, frontend, authn_req) @@ -425,7 +425,7 @@ def test_mirrored_subject(self, context, frontend_config, authn_req, sub_mirror_ def test_token_endpoint(self, context, frontend_config, authn_req): token_lifetime = 60 * 60 * 24 frontend_config["provider"]["access_token_lifetime"] = token_lifetime - frontend = self.create_frontend(frontend_config) + frontend = self.create_frontend(frontend_config, None) user_id = "test_user" self.insert_client_in_client_db(frontend, authn_req["redirect_uri"]) @@ -445,7 +445,7 @@ def test_token_endpoint(self, context, frontend_config, authn_req): assert parsed["id_token"] def test_token_endpoint_with_extra_claims(self, context, frontend_config_with_extra_id_token_claims, authn_req): - frontend = self.create_frontend(frontend_config_with_extra_id_token_claims) + frontend = self.create_frontend(frontend_config_with_extra_id_token_claims, None) user_id = "test_user" self.insert_client_in_client_db(frontend, authn_req["redirect_uri"]) @@ -468,7 +468,7 @@ def test_token_endpoint_with_extra_claims(self, context, frontend_config_with_ex def test_token_endpoint_issues_refresh_tokens_if_configured(self, context, frontend_config, authn_req): frontend_config["provider"]["refresh_token_lifetime"] = 60 * 60 * 24 * 365 frontend = OpenIDConnectFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, - frontend_config, BASE_URL, "oidc_frontend") + frontend_config, BASE_URL, "oidc_frontend", None, None) frontend.register_endpoints(["test_backend"]) user_id = "test_user" diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index 978489429..e75e751e0 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -69,7 +69,7 @@ def setup_for_authn_req(self, context, idp_conf, sp_conf, nameid_format=None, re base_url = self.construct_base_url_from_entity_id(idp_conf["entityid"]) samlfrontend = SAMLFrontend(lambda ctx, internal_req: (ctx, internal_req), - internal_attributes, config, base_url, "saml_frontend") + internal_attributes, config, base_url, "saml_frontend", None, None) samlfrontend.register_endpoints(["saml"]) idp_metadata_str = create_metadata_from_config_dict(samlfrontend.idp_config) @@ -119,7 +119,7 @@ def get_auth_response(self, samlfrontend, context, internal_response, sp_conf, i ]) def test_config_error_handling(self, conf): with pytest.raises(ValueError): - SAMLFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend") + SAMLFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend", None, None) def test_register_endpoints(self, idp_conf): """ @@ -133,7 +133,7 @@ def get_path_from_url(url): base_url = self.construct_base_url_from_entity_id(idp_conf["entityid"]) samlfrontend = SAMLFrontend(lambda context, internal_req: (context, internal_req), - INTERNAL_ATTRIBUTES, config, base_url, "saml_frontend") + INTERNAL_ATTRIBUTES, config, base_url, "saml_frontend", None, None) providers = ["foo", "bar"] url_map = samlfrontend.register_endpoints(providers) @@ -247,7 +247,7 @@ def test_get_filter_attributes_with_sp_requested_attributes_without_friendlyname "eduPersonAffiliation", "mail", "displayName", "sn", "givenName"]}} # no op mapping for saml attribute names - samlfrontend = SAMLFrontend(None, internal_attributes, conf, base_url, "saml_frontend") + samlfrontend = SAMLFrontend(None, internal_attributes, conf, base_url, "saml_frontend", None, None) samlfrontend.register_endpoints(["testprovider"]) internal_req = InternalData( @@ -357,7 +357,8 @@ def test_sp_metadata_without_uiinfo(self, context, idp_conf, sp_conf): def test_metadata_endpoint(self, context, idp_conf): conf = {"idp_config": idp_conf, "endpoints": ENDPOINTS} - samlfrontend = SAMLFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend") + samlfrontend = SAMLFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend", None, + None) samlfrontend.register_endpoints(["todo"]) resp = samlfrontend._metadata_endpoint(context) headers = dict(resp.headers) @@ -400,7 +401,7 @@ class TestSAMLMirrorFrontend: def create_frontend(self, idp_conf): conf = {"idp_config": idp_conf, "endpoints": ENDPOINTS} self.frontend = SAMLMirrorFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, BASE_URL, - "saml_mirror_frontend") + "saml_mirror_frontend", None, None) self.frontend.register_endpoints([self.BACKEND]) def assert_dynamic_endpoints(self, sso_endpoints): @@ -493,7 +494,9 @@ def frontend(self, idp_conf, sp_conf): internal_attributes, conf, BASE_URL, - "saml_virtual_co_frontend") + "saml_virtual_co_frontend", + None, + None) frontend.register_endpoints([self.BACKEND]) return frontend diff --git a/tests/satosa/test_plugin_loader.py b/tests/satosa/test_plugin_loader.py index ef11c961b..1e9987ff0 100644 --- a/tests/satosa/test_plugin_loader.py +++ b/tests/satosa/test_plugin_loader.py @@ -1,4 +1,5 @@ import json +from unittest.mock import patch import pytest import yaml @@ -7,7 +8,15 @@ from satosa.exception import SATOSAConfigurationError from satosa.frontends.base import FrontendModule from satosa.micro_services.base import RequestMicroService, ResponseMicroService -from satosa.plugin_loader import backend_filter, frontend_filter, _request_micro_service_filter, _response_micro_service_filter, _load_plugin_config +from satosa.plugin_loader import ( + backend_filter, + frontend_filter, + _request_micro_service_filter, + _response_micro_service_filter, + _load_plugin_config, + load_storage, +) +from satosa.storage import StoragePostgreSQL, StorageInMemory class TestFilters(object): @@ -75,3 +84,34 @@ def test_handles_malformed_data(self): data = """{foo: bar""" # missing closing bracket with pytest.raises(SATOSAConfigurationError): _load_plugin_config(data) + + +class TestLoadStorage(object): + def storage_postgresql_init_mock(self, config): + pass + + @patch.object( + StoragePostgreSQL, "__init__", storage_postgresql_init_mock + ) + def test_load_postgresql_session(self): + config = { + "LOGOUT_ENABLED": True, + "STORAGE": { + "type": "satosa.storage.StoragePostgreSQL", + "host": "127.0.0.1", + "port": 5432, + "db_name": "satosa", + "user": "postgres", + "password": "secret", + } + } + postgresql_storage = load_storage(config) + assert isinstance(postgresql_storage, StoragePostgreSQL) + + def test_load_inmemory_session(self): + config = {"LOGOUT_ENABLED": True} + inmemory_storage = load_storage(config) + assert isinstance(inmemory_storage, StorageInMemory) + assert hasattr(inmemory_storage, "frontend_sessions") + assert hasattr(inmemory_storage, "backend_sessions") + assert hasattr(inmemory_storage, "session_maps") diff --git a/tests/satosa/test_routing.py b/tests/satosa/test_routing.py index be23456ad..2e17dfd10 100644 --- a/tests/satosa/test_routing.py +++ b/tests/satosa/test_routing.py @@ -13,11 +13,11 @@ class TestModuleRouter: def create_router(self): backends = [] for provider in BACKEND_NAMES: - backends.append(TestBackend(None, {"attributes": {}}, None, None, provider)) + backends.append(TestBackend(None, {"attributes": {}}, None, None, provider, None, None)) frontends = [] for receiver in FRONTEND_NAMES: - frontends.append(TestFrontend(None, {"attributes": {}}, None, None, receiver)) + frontends.append(TestFrontend(None, {"attributes": {}}, None, None, receiver, None, None)) request_micro_service_name = "RequestService" response_micro_service_name = "ResponseService" diff --git a/tests/satosa/test_storage.py b/tests/satosa/test_storage.py new file mode 100644 index 000000000..35f79b569 --- /dev/null +++ b/tests/satosa/test_storage.py @@ -0,0 +1,140 @@ +from unittest import TestCase +from unittest.mock import patch + +from satosa.plugin_loader import load_storage +from satosa.storage import FrontendSession + +CONFIG_INMEMORY = {"LOGOUT_ENABLED": True} +class TestInMemoryStorage(TestCase): + def test_inmemory_store_frontend_session(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_frontend_session( + "OIDC", "requester-Azure", "sub-id", "sid-1FE" + ) + assert storage.frontend_sessions + + def test_inmemory_get_frontend_session(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + assert not storage.get_frontend_session("sid-1FE") + storage.store_frontend_session( + "OIDC", "requester-Azure", "sub-id", "sid-1FE" + ) + assert storage.get_frontend_session("sid-1FE") + + def test_inmemory_delete_frontend_session(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_frontend_session( + "OIDC", "requester-Azure", "sub-id", "sid-1FE" + ) + storage.delete_frontend_session("sid-1FE") + assert not storage.get_frontend_session("sid-1FE") + + def test_inmemory_store_backend_session(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_backend_session("sid-1BE", "OIDC") + assert storage.backend_sessions + + def test_inmemory_get_backend_session(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_backend_session("sid-1BE", "OIDC") + backend_session = storage.get_backend_session("sid-1BE", "OIDC") + assert backend_session + + def test_inmemory_get_unique_backend_session_from_multiple_same_sid_backends(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_backend_session("sid-1BE", "OIDC") + storage.store_backend_session("sid-1BE", "OIDC2") + backend_session = storage.get_backend_session("sid-1BE", "OIDC2") + assert backend_session.get("issuer") == "OIDC2" + + def test_inmemory_get_backend_session_doesnot_exist(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + backend_session = storage.get_backend_session("sid-1BE", "OIDC2") + assert not backend_session + + def test_inmemory_delete_backend_session(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_backend_session("sid-1BE", "OIDC") + storage.delete_backend_session( + storage.get_backend_session("sid-1BE")["id"] + ) + backend_session = storage.get_backend_session("sid-1BE") + assert not backend_session + + def test_inmemory_store_session_map(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_session_map("sid-1FE", "sid-1BE") + assert storage.session_maps + + def test_inmemory_delete_session_map(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + storage.store_session_map("sid-1FE", "sid-1BE") + storage.delete_session_map("sid-1FE") + assert not storage.session_maps + + def test_inmemory_get_frontend_sessions_by_backend_session_id(self): + config = CONFIG_INMEMORY + storage = load_storage(config) + assert not storage.get_frontend_sessions_by_backend_session_id( + "sid-1BE" + ) + storage.store_frontend_session( + "OIDC", "requester-Azure", "sub-id", "sid-1FE" + ) + storage.store_frontend_session( + "OIDC", "requester-Azure", "sub-id", "sid-2FE" + ) + storage.store_session_map("sid-1FE", "sid-1BE") + storage.store_session_map("sid-2FE", "sid-1BE") + backend_sessions = storage.get_frontend_sessions_by_backend_session_id( + "sid-1BE" + ) + assert len(backend_sessions) == 2 + + +class TestPostgreSQLStorage(TestCase): + @patch("sqlalchemy.orm.session.Session.query") + @patch("sqlalchemy.orm.session.Session.commit") + @patch("satosa.storage.Base") + def test_postgresql_store_frontend_session( + self, mock_base, mock_session_commit, mock_frontend_session_query + ): + config = { + "LOGOUT_ENABLED": True, + "STORAGE": { + "type": "satosa.storage.StoragePostgreSQL", + "host": "127.0.0.1", + "port": 5432, + "db_name": "satosa", + "user": "postgres", + "password": "secret", + } + } + mock_base.return_value.metadata.return_value.create_all.return_value = None + mock_session_commit.return_value = None + storage = load_storage(config) + storage.store_frontend_session( + "OIDC", "requester-Azure", "sub-id", "sid-1FE" + ) + + frontend_session_mock = FrontendSession() + frontend_session_mock.sid = "sid" + frontend_session_mock.frontend_name = "frontend_name" + frontend_session_mock.requester = "requester" + frontend_session_mock.subject_id = "subject_id" + mock_frontend_session_query.return_value.filter.return_value.first.return_value = ( + frontend_session_mock + ) + + frontend_session = storage.get_frontend_session("sid-1FE") + assert frontend_session diff --git a/tests/util.py b/tests/util.py index c26c796fe..9b7d21d0b 100644 --- a/tests/util.py +++ b/tests/util.py @@ -458,8 +458,9 @@ def register_endpoints(self, backend_names): class TestBackend(BackendModule): __test__ = False - def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): - super().__init__(auth_callback_func, internal_attributes, base_url, name) + def __init__(self, auth_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback_func): + super().__init__(auth_callback_func, internal_attributes, base_url, name, storage, logout_callback_func) def register_endpoints(self): return [("^{}/response$".format(self.name), self.handle_response)] @@ -478,8 +479,9 @@ def handle_response(self, context): class TestFrontend(FrontendModule): __test__ = False - def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): - super().__init__(auth_req_callback_func, internal_attributes, base_url, name) + def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name, storage, + logout_callback): + super().__init__(auth_req_callback_func, internal_attributes, base_url, name, storage) def register_endpoints(self, backend_names): url_map = [("^{}/{}/request$".format(p, self.name), self.handle_request) for p in backend_names]