Skip to content

Commit 3f8a39e

Browse files
committed
Accept authn response with no NameID element
The SAML backend SP should accept an authentication response from an IdP that does not contain a SAML NameID element since it is not required by the SAML 2.0 specification. This change enables the _translate_response method for the SAMLBackend class to continue gracefully if there is no NameID, and for the _auth_resp_callback_func method of the SATOSABase class to not insist on grabbing a user_id generated from a NameID and hashing it.
1 parent 19451a3 commit 3f8a39e

File tree

4 files changed

+177
-29
lines changed

4 files changed

+177
-29
lines changed

src/satosa/backends/saml2.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,8 @@ def _translate_response(self, response, state):
265265
:return: A translated internal response
266266
"""
267267

268-
# The response may have been encrypted by the IdP so if we have an encryption key, try it
268+
# The response may have been encrypted by the IdP so if we have an
269+
# encryption key, try it.
269270
if self.encryption_keys:
270271
response.parse_assertion(self.encryption_keys)
271272

@@ -274,19 +275,25 @@ def _translate_response(self, response, state):
274275
timestamp = response.assertion.authn_statement[0].authn_instant
275276
issuer = response.response.issuer.text
276277

277-
auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer)
278+
auth_info = AuthenticationInformation(auth_class_ref,
279+
timestamp, issuer)
278280
internal_resp = SAMLInternalResponse(auth_info=auth_info)
279281

280-
internal_resp.user_id = response.get_subject().text
281-
internal_resp.attributes = self.converter.to_internal(self.attribute_profile, response.ava)
282-
283-
# The SAML response may not include a NameID
282+
# The SAML response may not include a NameID.
284283
try:
284+
internal_resp.user_id = response.get_subject().text
285285
internal_resp.name_id = response.assertion.subject.name_id
286286
except AttributeError:
287287
pass
288288

289-
satosa_logging(logger, logging.DEBUG, "backend received attributes:\n%s" % json.dumps(response.ava, indent=4), state)
289+
internal_resp.attributes = self.converter.to_internal(
290+
self.attribute_profile,
291+
response.ava
292+
)
293+
294+
satosa_logging(logger, logging.DEBUG,
295+
"backend received attributes:\n%s" %
296+
json.dumps(response.ava, indent=4), state)
290297
return internal_resp
291298

292299
def _metadata_endpoint(self, context):

src/satosa/base.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ def _auth_resp_finish(self, context, internal_response):
160160

161161
def _auth_resp_callback_func(self, context, internal_response):
162162
"""
163-
This function is called by a backend module when the authorization is complete.
163+
This function is called by a backend module when the authorization is
164+
complete.
164165
165166
:type context: satosa.context.Context
166167
:type internal_response: satosa.internal_data.InternalResponse
@@ -173,16 +174,27 @@ def _auth_resp_callback_func(self, context, internal_response):
173174

174175
context.request = None
175176
internal_response.requester = context.state[STATE_KEY]["requester"]
177+
178+
# If configured construct the user id from attribute values.
176179
if "user_id_from_attrs" in self.config["INTERNAL_ATTRIBUTES"]:
177-
user_id = ["".join(internal_response.attributes[attr]) for attr in
178-
self.config["INTERNAL_ATTRIBUTES"]["user_id_from_attrs"]]
180+
user_id = [
181+
"".join(internal_response.attributes[attr]) for attr in
182+
self.config["INTERNAL_ATTRIBUTES"]["user_id_from_attrs"]
183+
]
179184
internal_response.user_id = "".join(user_id)
180-
# Hash the user id
181-
user_id = UserIdHasher.hash_data(self.config["USER_ID_HASH_SALT"], internal_response.user_id)
182-
internal_response.user_id = user_id
185+
186+
# The authentication response may not contain a user id. For example
187+
# a SAML IdP may not assert a SAML NameID in the subject and we may
188+
# not be configured to construct one from asserted attributes.
189+
# So only hash the user_id if it is not None.
190+
if internal_response.user_id:
191+
user_id = UserIdHasher.hash_data(self.config["USER_ID_HASH_SALT"],
192+
internal_response.user_id)
193+
internal_response.user_id = user_id
183194

184195
if self.response_micro_services:
185-
return self.response_micro_services[0].process(context, internal_response)
196+
return self.response_micro_services[0].process(context,
197+
internal_response)
186198

187199
return self._auth_resp_finish(context, internal_response)
188200

tests/satosa/backends/test_saml2.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,41 @@ def test_authn_response(self, context, idp_conf, sp_conf):
191191
self.assert_authn_response(internal_resp)
192192
assert self.samlbackend.name not in context.state
193193

194+
def test_authn_response_no_name_id(self, context, idp_conf, sp_conf):
195+
response_binding = BINDING_HTTP_REDIRECT
196+
fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False))
197+
fakeidp = FakeIdP(
198+
USERS,
199+
config=IdPConfig().load(
200+
idp_conf,
201+
metadata_construction=False
202+
)
203+
)
204+
destination, request_params = fakesp.make_auth_req(
205+
idp_conf["entityid"])
206+
207+
# Use the fake IdP to mock up an authentication request that has no
208+
# <NameID> element.
209+
url, auth_resp = fakeidp.handle_auth_req_no_name_id(
210+
request_params["SAMLRequest"],
211+
request_params["RelayState"],
212+
BINDING_HTTP_REDIRECT,
213+
"testuser1",
214+
response_binding=response_binding
215+
)
216+
217+
backend = self.samlbackend
218+
219+
context.request = auth_resp
220+
context.state[backend.name] = {
221+
"relay_state": request_params["RelayState"]
222+
}
223+
backend.authn_response(context, response_binding)
224+
225+
context, internal_resp = backend.auth_callback_func.call_args[0]
226+
self.assert_authn_response(internal_resp)
227+
assert backend.name not in context.state
228+
194229
def test_authn_response_with_encrypted_assertion(self, sp_conf, context):
195230
with open(os.path.join(TEST_RESOURCE_BASE_PATH,
196231
"idp_metadata_for_encrypted_signed_auth_response.xml")) as idp_metadata_file:

tests/util.py

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,23 +83,25 @@ def __init__(self, user_db, config):
8383
server.Server.__init__(self, config=config)
8484
self.user_db = user_db
8585

86-
def handle_auth_req(self, saml_request, relay_state, binding, userid,
87-
response_binding=BINDING_HTTP_POST):
86+
def __create_authn_response(self, saml_request, relay_state, binding,
87+
userid, response_binding=BINDING_HTTP_POST):
8888
"""
89-
Handles a SAML request, validates and creates a SAML response.
89+
Handles a SAML request, validates and creates a SAML response but
90+
does not apply the binding to encode it.
9091
:type saml_request: str
9192
:type relay_state: str
9293
:type binding: str
9394
:type userid: str
94-
:rtype:
95+
:rtype: tuple [string, saml2.samlp.Response]
9596
9697
:param saml_request:
97-
:param relay_state: RelayState is a parameter used by some SAML protocol implementations to
98-
identify the specific resource at the resource provider in an IDP initiated single sign on
99-
scenario.
98+
:param relay_state: RelayState is a parameter used by some SAML
99+
protocol implementations to identify the specific resource at the
100+
resource provider in an IDP initiated single sign on scenario.
100101
:param binding:
101102
:param userid: The user identification.
102-
:return: A tuple with
103+
:return: A tuple containing the destination and instance of
104+
saml2.samlp.Response
103105
"""
104106
auth_req = self.parse_authn_request(saml_request, binding)
105107
binding_out, destination = self.pick_binding(
@@ -114,17 +116,109 @@ def handle_auth_req(self, saml_request, relay_state, binding, userid,
114116
authn_broker.get_authn_by_accr(PASSWORD)
115117
resp_args['authn'] = authn_broker.get_authn_by_accr(PASSWORD)
116118

117-
_resp = self.create_authn_response(self.user_db[userid],
118-
userid=userid,
119-
**resp_args)
119+
resp = self.create_authn_response(self.user_db[userid],
120+
userid=userid,
121+
**resp_args)
122+
123+
return destination, resp
120124

125+
def __apply_binding_to_authn_response(self,
126+
resp,
127+
response_binding,
128+
relay_state,
129+
destination):
130+
"""
131+
Applies the binding to the response.
132+
"""
121133
if response_binding == BINDING_HTTP_POST:
122-
saml_response = base64.b64encode(str(_resp).encode("utf-8"))
134+
saml_response = base64.b64encode(str(resp).encode("utf-8"))
123135
resp = {"SAMLResponse": saml_response, "RelayState": relay_state}
124136
elif response_binding == BINDING_HTTP_REDIRECT:
125-
http_args = self.apply_binding(response_binding, '%s' % _resp,
126-
destination, relay_state, response=True)
127-
resp = dict(parse_qsl(urlparse(dict(http_args["headers"])["Location"]).query))
137+
http_args = self.apply_binding(
138+
response_binding,
139+
'%s' % resp,
140+
destination,
141+
relay_state,
142+
response=True
143+
)
144+
resp = dict(parse_qsl(urlparse(
145+
dict(http_args["headers"])["Location"]).query))
146+
147+
return resp
148+
149+
def handle_auth_req(self, saml_request, relay_state, binding, userid,
150+
response_binding=BINDING_HTTP_POST):
151+
"""
152+
Handles a SAML request, validates and creates a SAML response.
153+
:type saml_request: str
154+
:type relay_state: str
155+
:type binding: str
156+
:type userid: str
157+
:rtype: tuple
158+
159+
:param saml_request:
160+
:param relay_state: RelayState is a parameter used by some SAML
161+
protocol implementations to identify the specific resource at the
162+
resource provider in an IDP initiated single sign on scenario.
163+
:param binding:
164+
:param userid: The user identification.
165+
:return: A tuple with the destination and encoded response as a string
166+
"""
167+
168+
destination, _resp = self.__create_authn_response(
169+
saml_request,
170+
relay_state,
171+
binding,
172+
userid,
173+
response_binding
174+
)
175+
176+
resp = self.__apply_binding_to_authn_response(
177+
_resp,
178+
response_binding,
179+
relay_state,
180+
destination
181+
)
182+
183+
return destination, resp
184+
185+
def handle_auth_req_no_name_id(self, saml_request, relay_state, binding,
186+
userid, response_binding=BINDING_HTTP_POST):
187+
"""
188+
Handles a SAML request, validates and creates a SAML response but
189+
without a <NameID> element.
190+
:type saml_request: str
191+
:type relay_state: str
192+
:type binding: str
193+
:type userid: str
194+
:rtype: tuple
195+
196+
:param saml_request:
197+
:param relay_state: RelayState is a parameter used by some SAML
198+
protocol implementations to identify the specific resource at the
199+
resource provider in an IDP initiated single sign on scenario.
200+
:param binding:
201+
:param userid: The user identification.
202+
:return: A tuple with the destination and encoded response as a string
203+
"""
204+
205+
destination, _resp = self.__create_authn_response(
206+
saml_request,
207+
relay_state,
208+
binding,
209+
userid,
210+
response_binding
211+
)
212+
213+
# Remove the <NameID> element from the response.
214+
_resp.assertion.subject.name_id = None
215+
216+
resp = self.__apply_binding_to_authn_response(
217+
_resp,
218+
response_binding,
219+
relay_state,
220+
destination
221+
)
128222

129223
return destination, resp
130224

0 commit comments

Comments
 (0)