Skip to content

Commit 0c96266

Browse files
committed
Merge branch 'feature-microservice-primary-identifier'
2 parents 5ecf9d6 + ee8c8f1 commit 0c96266

File tree

1 file changed

+208
-0
lines changed

1 file changed

+208
-0
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""
2+
SATOSA microservice that uses a configured ordered list of
3+
attributes that may be asserted by a SAML IdP to construct
4+
a primary identifier or key for the user and assert it as
5+
the value for a configured attribute, for example uid.
6+
"""
7+
8+
import satosa.micro_services.base
9+
from satosa.logging_util import satosa_logging
10+
from satosa.response import Redirect
11+
12+
import copy
13+
import logging
14+
import urllib.parse
15+
16+
logger = logging.getLogger(__name__)
17+
18+
class PrimaryIdentifier(satosa.micro_services.base.ResponseMicroService):
19+
"""
20+
Use a configured ordered list of attributes to construct a primary
21+
identifier for the user and assert it as a particular configured
22+
attribute. If a primary identifier cannot be found or constructed
23+
handle the error in a configured way that may be to ignore
24+
the error or redirect to an external error handling service.
25+
"""
26+
logprefix = "PRIMARY_IDENTIFIER:"
27+
28+
def __init__(self, config, *args, **kwargs):
29+
super().__init__(*args, **kwargs)
30+
self.config = config
31+
32+
def constructPrimaryIdentifier(self, data, ordered_identifier_candidates):
33+
"""
34+
Construct and return a primary identifier value from the
35+
data asserted by the IdP using the ordered list of candidates
36+
from the configuration.
37+
"""
38+
logprefix = PrimaryIdentifier.logprefix
39+
context = self.context
40+
41+
attributes = data.attributes
42+
satosa_logging(logger, logging.DEBUG, "{} Input attributes {}".format(logprefix, attributes), context.state)
43+
44+
value = None
45+
46+
for candidate in ordered_identifier_candidates:
47+
satosa_logging(logger, logging.DEBUG, "{} Considering candidate {}".format(logprefix, candidate), context.state)
48+
49+
# Get the values asserted by the IdP for the configured list of attribute names for this candidate
50+
# and substitute None if the IdP did not assert any value for a configured attribute.
51+
values = [ attributes.get(attribute_name, [None])[0] for attribute_name in candidate['attribute_names'] ]
52+
satosa_logging(logger, logging.DEBUG, "{} Found candidate values {}".format(logprefix, values), context.state)
53+
54+
# If one of the configured attribute names is name_id then if there is also a configured
55+
# name_id_format add the value for the NameID of that format if it was asserted by the IdP
56+
# or else add the value None.
57+
if 'name_id' in candidate['attribute_names']:
58+
candidate_nameid_value = None
59+
candidate_nameid_value = None
60+
candidate_name_id_format = candidate.get('name_id_format')
61+
name_id_value = data.subject_id
62+
name_id_format = data.subject_type
63+
if (
64+
name_id_value
65+
and candidate_name_id_format
66+
and candidate_name_id_format == name_id_format
67+
):
68+
satosa_logging(logger, logging.DEBUG, "{} IdP asserted NameID {}".format(logprefix, name_id_value), context.state)
69+
candidate_nameid_value = name_id_value
70+
71+
# Only add the NameID value asserted by the IdP if it is not already
72+
# in the list of values. This is necessary because some non-compliant IdPs
73+
# have been known, for example, to assert the value of eduPersonPrincipalName
74+
# in the value for SAML2 persistent NameID as well as asserting
75+
# eduPersonPrincipalName.
76+
if candidate_nameid_value not in values:
77+
satosa_logging(logger, logging.DEBUG, "{} Added NameID {} to candidate values".format(logprefix, candidate_nameid_value), context.state)
78+
values.append(candidate_nameid_value)
79+
else:
80+
satosa_logging(logger, logging.WARN, "{} NameID {} value also asserted as attribute value".format(logprefix, candidate_nameid_value), context.state)
81+
82+
# If no value was asserted by the IdP for one of the configured list of attribute names
83+
# for this candidate then go onto the next candidate.
84+
if None in values:
85+
satosa_logging(logger, logging.DEBUG, "{} Candidate is missing value so skipping".format(logprefix), context.state)
86+
continue
87+
88+
# All values for the configured list of attribute names are present
89+
# so we can create a primary identifer. Add a scope if configured
90+
# to do so.
91+
if 'add_scope' in candidate:
92+
if candidate['add_scope'] == 'issuer_entityid':
93+
scope = data.auth_info.issuer
94+
else:
95+
scope = candidate['add_scope']
96+
satosa_logging(logger, logging.DEBUG, "{} Added scope {} to values".format(logprefix, scope), context.state)
97+
values.append(scope)
98+
99+
# Concatenate all values to create the primary identifier.
100+
value = ''.join(values)
101+
break
102+
103+
return value
104+
105+
def process(self, context, data):
106+
logprefix = PrimaryIdentifier.logprefix
107+
self.context = context
108+
109+
# Initialize the configuration to use as the default configuration
110+
# that is passed during initialization.
111+
config = self.config
112+
113+
satosa_logging(logger, logging.DEBUG, "{} Using default configuration {}".format(logprefix, config), context.state)
114+
115+
# Find the entityID for the SP that initiated the flow
116+
try:
117+
spEntityID = context.state.state_dict['SATOSA_BASE']['requester']
118+
except KeyError as err:
119+
satosa_logging(logger, logging.ERROR, "{} Unable to determine the entityID for the SP requester".format(logprefix), context.state)
120+
return super().process(context, data)
121+
122+
satosa_logging(logger, logging.DEBUG, "{} entityID for the SP requester is {}".format(logprefix, spEntityID), context.state)
123+
124+
# Find the entityID for the IdP that issued the assertion
125+
try:
126+
idpEntityID = data.auth_info.issuer
127+
except KeyError as err:
128+
satosa_logging(logger, logging.ERROR, "{} Unable to determine the entityID for the IdP issuer".format(logprefix), context.state)
129+
return super().process(context, data)
130+
131+
# Examine our configuration to determine if there is a per-IdP configuration
132+
if idpEntityID in self.config:
133+
config = self.config[idpEntityID]
134+
satosa_logging(logger, logging.DEBUG, "{} For IdP {} using configuration {}".format(logprefix, idpEntityID, config), context.state)
135+
136+
# Examine our configuration to determine if there is a per-SP configuration.
137+
# An SP configuration overrides an IdP configuration when there is a conflict.
138+
if spEntityID in self.config:
139+
config = self.config[spEntityID]
140+
satosa_logging(logger, logging.DEBUG, "{} For SP {} using configuration {}".format(logprefix, spEntityID, config), context.state)
141+
142+
# Obtain configuration details from the per-SP configuration or the default configuration
143+
try:
144+
if 'ordered_identifier_candidates' in config:
145+
ordered_identifier_candidates = config['ordered_identifier_candidates']
146+
else:
147+
ordered_identifier_candidates = self.config['ordered_identifier_candidates']
148+
if 'primary_identifier' in config:
149+
primary_identifier = config['primary_identifier']
150+
elif 'primary_identifier' in self.config:
151+
primary_identifier = self.config['primary_identifier']
152+
else:
153+
primary_identifier = 'uid'
154+
if 'clear_input_attributes' in config:
155+
clear_input_attributes = config['clear_input_attributes']
156+
elif 'clear_input_attributes' in self.config:
157+
clear_input_attributes = self.config['clear_input_attributes']
158+
else:
159+
clear_input_attributes = False
160+
if 'ignore' in config:
161+
ignore = True
162+
else:
163+
ignore = False
164+
if 'on_error' in config:
165+
on_error = config['on_error']
166+
elif 'on_error' in self.config:
167+
on_error = self.config['on_error']
168+
else:
169+
on_error = None
170+
171+
except KeyError as err:
172+
satosa_logging(logger, logging.ERROR, "{} Configuration '{}' is missing".format(logprefix, err), context.state)
173+
return super().process(context, data)
174+
175+
# Ignore this SP entirely if so configured.
176+
if ignore:
177+
satosa_logging(logger, logging.INFO, "{} Ignoring SP {}".format(logprefix, spEntityID), context.state)
178+
return super().process(context, data)
179+
180+
# Construct the primary identifier.
181+
satosa_logging(logger, logging.DEBUG, "{} Constructing primary identifier".format(logprefix), context.state)
182+
primary_identifier_val = self.constructPrimaryIdentifier(data, ordered_identifier_candidates)
183+
184+
if not primary_identifier_val:
185+
satosa_logging(logger, logging.WARN, "{} No primary identifier found".format(logprefix), context.state)
186+
if on_error:
187+
# Redirect to the configured error handling service with
188+
# the entityIDs for the target SP and IdP used by the user
189+
# as query string parameters (URL encoded).
190+
encodedSpEntityID = urllib.parse.quote_plus(spEntityID)
191+
encodedIdpEntityID = urllib.parse.quote_plus(data.auth_info.issuer)
192+
url = "{}?sp={}&idp={}".format(on_error, encodedSpEntityID, encodedIdpEntityID)
193+
satosa_logging(logger, logging.INFO, "{} Redirecting to {}".format(logprefix, url), context.state)
194+
return Redirect(url)
195+
196+
satosa_logging(logger, logging.INFO, "{} Found primary identifier: {}".format(logprefix, primary_identifier_val), context.state)
197+
198+
# Clear input attributes if so configured.
199+
if clear_input_attributes:
200+
satosa_logging(logger, logging.DEBUG, "{} Clearing values for these input attributes: {}".format(logprefix, data.attributes), context.state)
201+
data.attributes = {}
202+
203+
# Set the primary identifier attribute to the value found.
204+
data.attributes[primary_identifier] = primary_identifier_val
205+
satosa_logging(logger, logging.DEBUG, "{} Setting attribute {} to value {}".format(logprefix, primary_identifier, primary_identifier_val), context.state)
206+
207+
satosa_logging(logger, logging.DEBUG, "{} returning data.attributes {}".format(logprefix, str(data.attributes)), context.state)
208+
return super().process(context, data)

0 commit comments

Comments
 (0)