Skip to content

Commit 2815bfb

Browse files
committed
Add dynamic requested attributes
1 parent 74fc79a commit 2815bfb

File tree

4 files changed

+130
-15
lines changed

4 files changed

+130
-15
lines changed

doc/README.md

+24
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,30 @@ config:
405405
[...]
406406
```
407407

408+
##### Dynamic requested attributes
409+
410+
The `dynamic_requested_attributes` option can be used to enable the requested
411+
attributes eIDAS extension for requesting attributes from the IdP. These
412+
attributes are populated dynamically using the attributes which were
413+
requested from the frontend.
414+
415+
In order for this to work the frontend must populate the internal request's
416+
`attributes` field.
417+
418+
To enable this feature we need to provide a list of the friendly names of the
419+
attributes which we want to be able to request and whether they are required or
420+
not. E.g.:
421+
422+
```yaml
423+
config:
424+
dynamic_requested_attributes:
425+
- friendly_name: attr1
426+
required: True
427+
- friendly_name: attr2
428+
required: False
429+
[...]
430+
```
431+
408432
### <a name="openid_plugin" style="color:#000000">OpenID Connect plugins</a>
409433

410434
#### Backend

src/satosa/attribute_mapping.py

+35
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,38 @@ def from_internal(self, attribute_profile, internal_dict):
219219
external_dict[external_attribute_name] = internal_dict[internal_attribute_name]
220220

221221
return external_dict
222+
223+
def from_internal_filter(
224+
self, attribute_profile, internal_attribute_names
225+
):
226+
"""
227+
Converts attribute names from internal to external "type"
228+
229+
:type attribute_profile: str
230+
:type internal_attribute_names: list[str]
231+
:rtype: list[str]
232+
233+
:param attribute_profile: To which external type to convert to
234+
(ex: oidc, saml, ...)
235+
:param internal_attribute_names: A list of attribute names
236+
:return: A list of attribute names in the external format
237+
"""
238+
external_attribute_names = set()
239+
for internal_attribute_name in internal_attribute_names:
240+
try:
241+
external_attribute_name = self.from_internal_attributes[
242+
internal_attribute_name
243+
]
244+
# Take the first value always
245+
external_attribute_names.add(
246+
external_attribute_name[attribute_profile][0]
247+
)
248+
except KeyError:
249+
logger.warn(
250+
f"No attribute mapping found for the attribute "
251+
f"{internal_attribute_name} to the profile "
252+
f"{attribute_profile}"
253+
)
254+
pass
255+
256+
return list(external_attribute_names)

src/satosa/backends/saml2.py

+70-15
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class SAMLBackend(BackendModule, SAMLBaseModule):
8282
KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn'
8383
KEY_MEMORIZE_IDP = 'memorize_idp'
8484
KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN = 'use_memorized_idp_when_force_authn'
85+
KEY_DYNAMIC_REQUESTED_ATTRIBUTES = 'dynamic_requested_attributes'
8586

8687
VALUE_ACR_COMPARISON_DEFAULT = 'exact'
8788

@@ -113,6 +114,9 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
113114
self.encryption_keys = []
114115
self.outstanding_queries = {}
115116
self.idp_blacklist_file = config.get('idp_blacklist_file', None)
117+
self.requested_attributes = self.config.get(
118+
SAMLBackend.KEY_DYNAMIC_REQUESTED_ATTRIBUTES
119+
)
116120

117121
sp_keypairs = sp_config.getattr('encryption_keypairs', '')
118122
sp_key_file = sp_config.getattr('key_file', '')
@@ -170,15 +174,22 @@ def start_auth(self, context, internal_req):
170174
"""
171175

172176
entity_id = self.get_idp_entity_id(context)
177+
requested_attributes = internal_req.get("attributes")
173178
if entity_id is None:
174179
# since context is not passed to disco_query
175180
# keep the information in the state cookie
176181
context.state[Context.KEY_FORCE_AUTHN] = get_force_authn(
177182
context, self.config, self.sp.config
178183
)
184+
if self.requested_attributes:
185+
# We need the requested attributes, so store them in the cookie
186+
context.state[Context.KEY_REQUESTED_ATTRIBUTES] = \
187+
requested_attributes
179188
return self.disco_query(context)
180189

181-
return self.authn_request(context, entity_id)
190+
return self.authn_request(
191+
context, entity_id, requested_attributes=requested_attributes
192+
)
182193

183194
def disco_query(self, context):
184195
"""
@@ -232,13 +243,59 @@ def construct_requested_authn_context(self, entity_id):
232243

233244
return authn_context
234245

235-
def authn_request(self, context, entity_id):
246+
def _get_requested_attributes(self, requested_attributes):
247+
if not requested_attributes:
248+
return
249+
250+
attrs = self.converter.from_internal_filter(
251+
self.attribute_profile, requested_attributes
252+
)
253+
requested_attrs = []
254+
for attr in attrs:
255+
# Internal attributes map to the attribute's friendly_name
256+
for req_attr in self.requested_attributes:
257+
if req_attr['friendly_name'] == attr:
258+
requested_attrs.append(
259+
dict(
260+
friendly_name=attr,
261+
required=req_attr['required']
262+
)
263+
)
264+
265+
return requested_attrs
266+
267+
def _get_authn_request_args(
268+
self, context, entity_id, requested_attributes=None
269+
):
270+
kwargs = {}
271+
authn_context = self.construct_requested_authn_context(entity_id)
272+
_, response_binding = self.sp.config.getattr(
273+
"endpoints", "sp"
274+
)["assertion_consumer_service"][0]
275+
kwargs["binding"] = response_binding
276+
277+
if authn_context:
278+
kwargs["requested_authn_context"] = authn_context
279+
if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN):
280+
kwargs["force_authn"] = get_force_authn(
281+
context, self.config, self.sp.config
282+
)
283+
if self.requested_attributes:
284+
requested_attributes = self._get_requested_attributes(
285+
requested_attributes
286+
)
287+
if requested_attributes:
288+
kwargs["requested_attributes"] = requested_attributes
289+
return kwargs
290+
291+
def authn_request(self, context, entity_id, requested_attributes=None):
236292
"""
237293
Do an authorization request on idp with given entity id.
238294
This is the start of the authorization.
239295
240296
:type context: satosa.context.Context
241297
:type entity_id: str
298+
:type requested_attributes: list
242299
:rtype: satosa.response.Response
243300
244301
:param context: The current context
@@ -257,15 +314,6 @@ def authn_request(self, context, entity_id):
257314
logger.debug(logline, exc_info=False)
258315
raise SATOSAAuthenticationError(context.state, "Selected IdP is blacklisted for this backend")
259316

260-
kwargs = {}
261-
authn_context = self.construct_requested_authn_context(entity_id)
262-
if authn_context:
263-
kwargs["requested_authn_context"] = authn_context
264-
if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN):
265-
kwargs["force_authn"] = get_force_authn(
266-
context, self.config, self.sp.config
267-
)
268-
269317
try:
270318
binding, destination = self.sp.pick_binding(
271319
"single_sign_on_service", None, "idpsso", entity_id=entity_id
@@ -274,10 +322,10 @@ def authn_request(self, context, entity_id):
274322
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
275323
logger.debug(logline)
276324

277-
acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0]
278-
req_id, req = self.sp.create_authn_request(
279-
destination, binding=response_binding, **kwargs
325+
kwargs = self._get_authn_request_args(
326+
context, entity_id, requested_attributes=requested_attributes
280327
)
328+
req_id, req = self.sp.create_authn_request(destination, **kwargs)
281329
relay_state = util.rndstr()
282330
ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state)
283331
msg = "ht_args: {}".format(ht_args)
@@ -363,6 +411,9 @@ def disco_response(self, context):
363411
"""
364412
info = context.request
365413
state = context.state
414+
requested_attributes = state.pop(
415+
Context.KEY_REQUESTED_ATTRIBUTES, None
416+
)
366417

367418
try:
368419
entity_id = info["entityID"]
@@ -372,7 +423,11 @@ def disco_response(self, context):
372423
logger.debug(logline, exc_info=True)
373424
raise SATOSAAuthenticationError(state, "No IDP chosen") from err
374425

375-
return self.authn_request(context, entity_id)
426+
return self.authn_request(
427+
context,
428+
entity_id,
429+
requested_attributes=requested_attributes
430+
)
376431

377432
def _translate_response(self, response, state):
378433
"""

src/satosa/context.py

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class Context(object):
1818
KEY_TARGET_ENTITYID = 'target_entity_id'
1919
KEY_FORCE_AUTHN = 'force_authn'
2020
KEY_MEMORIZED_IDP = 'memorized_idp'
21+
KEY_REQUESTED_ATTRIBUTES = 'requested_attributes'
2122
KEY_AUTHN_CONTEXT_CLASS_REF = 'authn_context_class_ref'
2223

2324
def __init__(self):

0 commit comments

Comments
 (0)