Skip to content

Commit 912e132

Browse files
Merge pull request #190 from skoranda/no_saml_nameid
Accept authn response with no NameID element
2 parents 17c0ce8 + 4dc51fb commit 912e132

File tree

5 files changed

+170
-30
lines changed

5 files changed

+170
-30
lines changed

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
package_dir={'': 'src'},
1717
install_requires=[
1818
"pyop",
19-
"pysaml2==4.5.0",
19+
"pysaml2>=4.6.1",
2020
"pycryptodomex",
2121
"requests",
2222
"PyYAML",

src/satosa/backends/saml2.py

Lines changed: 12 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,23 @@ 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, response.ava)
291+
292+
satosa_logging(logger, logging.DEBUG,
293+
"backend received attributes:\n%s" %
294+
json.dumps(response.ava, indent=4), state)
290295
return internal_resp
291296

292297
def _metadata_endpoint(self, context):

src/satosa/base.py

Lines changed: 20 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,28 @@ 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(
192+
self.config["USER_ID_HASH_SALT"],
193+
internal_response.user_id)
194+
internal_response.user_id = user_id
183195

184196
if self.response_micro_services:
185-
return self.response_micro_services[0].process(context, internal_response)
197+
return self.response_micro_services[0].process(
198+
context, internal_response)
186199

187200
return self._auth_resp_finish(context, internal_response)
188201

tests/satosa/backends/test_saml2.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,39 @@ def test_authn_response(self, context, idp_conf, sp_conf):
189189
self.assert_authn_response(internal_resp)
190190
assert self.samlbackend.name not in context.state
191191

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

tests/util.py

Lines changed: 104 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,104 @@ 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+
resp = dict(parse_qsl(urlparse(
144+
dict(http_args["headers"])["Location"]).query))
145+
146+
return resp
147+
148+
def handle_auth_req(self, saml_request, relay_state, binding, userid,
149+
response_binding=BINDING_HTTP_POST):
150+
"""
151+
Handles a SAML request, validates and creates a SAML response.
152+
:type saml_request: str
153+
:type relay_state: str
154+
:type binding: str
155+
:type userid: str
156+
:rtype: tuple
157+
158+
:param saml_request:
159+
:param relay_state: RelayState is a parameter used by some SAML
160+
protocol implementations to identify the specific resource at the
161+
resource provider in an IDP initiated single sign on scenario.
162+
:param binding:
163+
:param userid: The user identification.
164+
:return: A tuple with the destination and encoded response as a string
165+
"""
166+
167+
destination, _resp = self.__create_authn_response(
168+
saml_request,
169+
relay_state,
170+
binding,
171+
userid,
172+
response_binding)
173+
174+
resp = self.__apply_binding_to_authn_response(
175+
_resp,
176+
response_binding,
177+
relay_state,
178+
destination)
179+
180+
return destination, resp
181+
182+
def handle_auth_req_no_name_id(self, saml_request, relay_state, binding,
183+
userid, response_binding=BINDING_HTTP_POST):
184+
"""
185+
Handles a SAML request, validates and creates a SAML response but
186+
without a <NameID> element.
187+
:type saml_request: str
188+
:type relay_state: str
189+
:type binding: str
190+
:type userid: str
191+
:rtype: tuple
192+
193+
:param saml_request:
194+
:param relay_state: RelayState is a parameter used by some SAML
195+
protocol implementations to identify the specific resource at the
196+
resource provider in an IDP initiated single sign on scenario.
197+
:param binding:
198+
:param userid: The user identification.
199+
:return: A tuple with the destination and encoded response as a string
200+
"""
201+
202+
destination, _resp = self.__create_authn_response(
203+
saml_request,
204+
relay_state,
205+
binding,
206+
userid,
207+
response_binding)
208+
209+
# Remove the <NameID> element from the response.
210+
_resp.assertion.subject.name_id = None
211+
212+
resp = self.__apply_binding_to_authn_response(
213+
_resp,
214+
response_binding,
215+
relay_state,
216+
destination)
128217

129218
return destination, resp
130219

0 commit comments

Comments
 (0)