Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dynamic requested attributes #332

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions doc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
[...]
```

### <a name="openid_plugin" style="color:#000000">OpenID Connect plugins</a>

#### Backend
Expand Down
35 changes: 35 additions & 0 deletions src/satosa/attribute_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
81 changes: 66 additions & 15 deletions src/satosa/backends/saml2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
import copy
import functools
from itertools import product
import json
import logging
import warnings as _warnings
Expand Down Expand Up @@ -82,6 +83,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'

Expand Down Expand Up @@ -113,6 +115,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', '')
Expand Down Expand Up @@ -170,15 +175,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):
"""
Expand Down Expand Up @@ -232,13 +244,54 @@ 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
)
attrs_req_attrs_product = product(attrs, self.requested_attributes)

requested_attrs = [
dict(friendly_name=attr, required=req_attr['required'])
for (attr, req_attr) in attrs_req_attrs_product
if req_attr['friendly_name'] == attr
]
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
Expand All @@ -257,15 +310,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
Expand All @@ -274,10 +318,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)
Expand Down Expand Up @@ -363,6 +407,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"]
Expand All @@ -372,7 +419,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):
"""
Expand Down
1 change: 1 addition & 0 deletions src/satosa/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading