diff --git a/doc/README.md b/doc/README.md index fe87e1f97..ab6a78669 100644 --- a/doc/README.md +++ b/doc/README.md @@ -405,6 +405,30 @@ config: [...] ``` +##### Dynamic requested attributes + +The `dynamic_requested_attributes` option can be used to enable the requested +attributes eIDAS extension for requesting attributes from the IdP. These +attributes are populated dynamically using the attributes which were +requested from the frontend. + +In order for this to work the frontend must populate the internal request's +`attributes` field. + +To enable this feature we need to provide a list of the friendly names of the +attributes which we want to be able to request and whether they are required or +not. E.g.: + +```yaml +config: + dynamic_requested_attributes: + - friendly_name: attr1 + required: True + - friendly_name: attr2 + required: False + [...] +``` + ### OpenID Connect plugins #### Backend diff --git a/src/satosa/attribute_mapping.py b/src/satosa/attribute_mapping.py index ebb008bc0..d776ffaad 100644 --- a/src/satosa/attribute_mapping.py +++ b/src/satosa/attribute_mapping.py @@ -219,3 +219,38 @@ def from_internal(self, attribute_profile, internal_dict): external_dict[external_attribute_name] = internal_dict[internal_attribute_name] return external_dict + + def from_internal_filter( + self, attribute_profile, internal_attribute_names + ): + """ + Converts attribute names from internal to external "type" + + :type attribute_profile: str + :type internal_attribute_names: list[str] + :rtype: list[str] + + :param attribute_profile: To which external type to convert to + (ex: oidc, saml, ...) + :param internal_attribute_names: A list of attribute names + :return: A list of attribute names in the external format + """ + external_attribute_names = set() + for internal_attribute_name in internal_attribute_names: + try: + external_attribute_name = self.from_internal_attributes[ + internal_attribute_name + ] + # Take the first value always + external_attribute_names.add( + external_attribute_name[attribute_profile][0] + ) + except KeyError: + logger.warn( + f"No attribute mapping found for the attribute " + f"{internal_attribute_name} to the profile " + f"{attribute_profile}" + ) + pass + + return list(external_attribute_names) diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index 2c37e6a2b..e14f6b67a 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -82,6 +82,7 @@ class SAMLBackend(BackendModule, SAMLBaseModule): KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn' KEY_MEMORIZE_IDP = 'memorize_idp' KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN = 'use_memorized_idp_when_force_authn' + KEY_DYNAMIC_REQUESTED_ATTRIBUTES = 'dynamic_requested_attributes' VALUE_ACR_COMPARISON_DEFAULT = 'exact' @@ -113,6 +114,9 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): self.encryption_keys = [] self.outstanding_queries = {} self.idp_blacklist_file = config.get('idp_blacklist_file', None) + self.requested_attributes = self.config.get( + SAMLBackend.KEY_DYNAMIC_REQUESTED_ATTRIBUTES + ) sp_keypairs = sp_config.getattr('encryption_keypairs', '') sp_key_file = sp_config.getattr('key_file', '') @@ -170,15 +174,22 @@ def start_auth(self, context, internal_req): """ entity_id = self.get_idp_entity_id(context) + requested_attributes = internal_req.get("attributes") if entity_id is None: # since context is not passed to disco_query # keep the information in the state cookie context.state[Context.KEY_FORCE_AUTHN] = get_force_authn( context, self.config, self.sp.config ) + if self.requested_attributes: + # We need the requested attributes, so store them in the cookie + context.state[Context.KEY_REQUESTED_ATTRIBUTES] = \ + requested_attributes return self.disco_query(context) - return self.authn_request(context, entity_id) + return self.authn_request( + context, entity_id, requested_attributes=requested_attributes + ) def disco_query(self, context): """ @@ -232,13 +243,59 @@ def construct_requested_authn_context(self, entity_id): return authn_context - def authn_request(self, context, entity_id): + def _get_requested_attributes(self, requested_attributes): + if not requested_attributes: + return + + attrs = self.converter.from_internal_filter( + self.attribute_profile, requested_attributes + ) + requested_attrs = [] + for attr in attrs: + # Internal attributes map to the attribute's friendly_name + for req_attr in self.requested_attributes: + if req_attr['friendly_name'] == attr: + requested_attrs.append( + dict( + friendly_name=attr, + required=req_attr['required'] + ) + ) + + return requested_attrs + + def _get_authn_request_args( + self, context, entity_id, requested_attributes=None + ): + kwargs = {} + authn_context = self.construct_requested_authn_context(entity_id) + _, response_binding = self.sp.config.getattr( + "endpoints", "sp" + )["assertion_consumer_service"][0] + kwargs["binding"] = response_binding + + if authn_context: + kwargs["requested_authn_context"] = authn_context + if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN): + kwargs["force_authn"] = get_force_authn( + context, self.config, self.sp.config + ) + if self.requested_attributes: + requested_attributes = self._get_requested_attributes( + requested_attributes + ) + if requested_attributes: + kwargs["requested_attributes"] = requested_attributes + return kwargs + + def authn_request(self, context, entity_id, requested_attributes=None): """ Do an authorization request on idp with given entity id. This is the start of the authorization. :type context: satosa.context.Context :type entity_id: str + :type requested_attributes: list :rtype: satosa.response.Response :param context: The current context @@ -257,15 +314,6 @@ def authn_request(self, context, entity_id): logger.debug(logline, exc_info=False) raise SATOSAAuthenticationError(context.state, "Selected IdP is blacklisted for this backend") - kwargs = {} - authn_context = self.construct_requested_authn_context(entity_id) - if authn_context: - kwargs["requested_authn_context"] = authn_context - if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN): - kwargs["force_authn"] = get_force_authn( - context, self.config, self.sp.config - ) - try: binding, destination = self.sp.pick_binding( "single_sign_on_service", None, "idpsso", entity_id=entity_id @@ -274,10 +322,10 @@ def authn_request(self, context, entity_id): logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) - acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0] - req_id, req = self.sp.create_authn_request( - destination, binding=response_binding, **kwargs + kwargs = self._get_authn_request_args( + context, entity_id, requested_attributes=requested_attributes ) + req_id, req = self.sp.create_authn_request(destination, **kwargs) relay_state = util.rndstr() ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state) msg = "ht_args: {}".format(ht_args) @@ -363,6 +411,9 @@ def disco_response(self, context): """ info = context.request state = context.state + requested_attributes = state.pop( + Context.KEY_REQUESTED_ATTRIBUTES, None + ) try: entity_id = info["entityID"] @@ -372,7 +423,11 @@ def disco_response(self, context): logger.debug(logline, exc_info=True) raise SATOSAAuthenticationError(state, "No IDP chosen") from err - return self.authn_request(context, entity_id) + return self.authn_request( + context, + entity_id, + requested_attributes=requested_attributes + ) def _translate_response(self, response, state): """ diff --git a/src/satosa/context.py b/src/satosa/context.py index a30f67c3d..c40743399 100644 --- a/src/satosa/context.py +++ b/src/satosa/context.py @@ -18,6 +18,7 @@ class Context(object): KEY_TARGET_ENTITYID = 'target_entity_id' KEY_FORCE_AUTHN = 'force_authn' KEY_MEMORIZED_IDP = 'memorized_idp' + KEY_REQUESTED_ATTRIBUTES = 'requested_attributes' KEY_AUTHN_CONTEXT_CLASS_REF = 'authn_context_class_ref' def __init__(self):