Skip to content

Commit 802ec54

Browse files
authored
saml2 backend: support using multiple ACS URLs (#409)
* saml2 backend: support using multiple ACS URLs When Satosa sends out a SAML2 AuthnRequest, it specifies the AssertionConsumerServiceUrl parameter as well, unless the `hide_assertion_consumer_service` configuration parameter is set. However, Satosa might be deployed in an environment where not all interfaces and host names are accessible for all users. After this change, Satosa tries to select the ACS URL based on the current request, and falls back to the first ACS if there is no match. * squash! saml2 backend: support using multiple ACS URLs Make ACS selection configurable with the `acs_selection_strategy` parameter, keeping the default backwards-compatible (`use_first_acs`). Added the relevant example and documentation. Additionally, log an error (instead of debug) message if the authentication request can not be constructed, since most of the time this is a configuration or environment error.
1 parent 0a57dab commit 802ec54

File tree

4 files changed

+137
-3
lines changed

4 files changed

+137
-3
lines changed

doc/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,26 @@ config:
424424
[...]
425425
```
426426
427+
#### Assertion Consumer Service selection
428+
429+
When SATOSA sends the SAML2 authentication request to the IDP, it always
430+
specifies the AssertionConsumerServiceURL and binding. When
431+
`acs_selection_strategy` configuration option is set to `use_first_acs` (the
432+
default), then the first element of the `assertion_consumer_service` list will
433+
be selected. If `acs_selection_strategy` is `prefer_matching_host`, then SATOSA
434+
will try to select the `assertion_consumer_service`, which matches the host in
435+
the HTTP request (in simple words, it tries to select an ACS that matches the
436+
URL in the user's browser). If there is no match, it will fall back to using the
437+
first assertion consumer service.
438+
439+
Default value: `use_first_acs`.
440+
441+
```yaml
442+
config:
443+
acs_selection_strategy: prefer_matching_host
444+
[...]
445+
```
446+
427447
## OpenID Connect plugins
428448
429449
### OIDC Frontend

example/plugins/backends/saml2_backend.yaml.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ config:
1616
use_memorized_idp_when_force_authn: no
1717
send_requester_id: no
1818
enable_metadata_reload: no
19+
acs_selection_strategy: prefer_matching_host
1920

2021
sp_config:
2122
name: "SP Name"

src/satosa/backends/saml2.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,18 +289,19 @@ def authn_request(self, context, entity_id):
289289
kwargs["is_passive"] = "true"
290290

291291
try:
292-
acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0]
292+
acs_endp, response_binding = self._get_acs(context)
293293
relay_state = util.rndstr()
294294
req_id, binding, http_info = self.sp.prepare_for_negotiated_authenticate(
295295
entityid=entity_id,
296+
assertion_consumer_service_url=acs_endp,
296297
response_binding=response_binding,
297298
relay_state=relay_state,
298299
**kwargs,
299300
)
300301
except Exception as e:
301302
msg = "Failed to construct the AuthnRequest for state"
302303
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
303-
logger.debug(logline, exc_info=True)
304+
logger.error(logline, exc_info=True)
304305
raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from e
305306

306307
if self.sp.config.getattr('allow_unsolicited', 'sp') is False:
@@ -314,6 +315,58 @@ def authn_request(self, context, entity_id):
314315
context.state[self.name] = {"relay_state": relay_state}
315316
return make_saml_response(binding, http_info)
316317

318+
def _get_acs(self, context):
319+
"""
320+
Select the AssertionConsumerServiceURL and binding.
321+
322+
:param context: The current context
323+
:type context: satosa.context.Context
324+
:return: Selected ACS URL and binding
325+
:rtype: tuple(str, str)
326+
"""
327+
acs_strategy = self.config.get("acs_selection_strategy", "use_first_acs")
328+
if acs_strategy == "use_first_acs":
329+
acs_strategy_fn = self._use_first_acs
330+
elif acs_strategy == "prefer_matching_host":
331+
acs_strategy_fn = self._prefer_matching_host
332+
else:
333+
msg = "Invalid value for '{}' ({}). Using the first ACS instead".format(
334+
"acs_selection_strategy", acs_strategy
335+
)
336+
logger.error(msg)
337+
acs_strategy_fn = self._use_first_acs
338+
return acs_strategy_fn(context)
339+
340+
def _use_first_acs(self, context):
341+
return self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][
342+
0
343+
]
344+
345+
def _prefer_matching_host(self, context):
346+
acs_config = self.sp.config.getattr("endpoints", "sp")[
347+
"assertion_consumer_service"
348+
]
349+
try:
350+
hostname = context.http_headers["HTTP_HOST"]
351+
for acs, binding in acs_config:
352+
parsed_acs = urlparse(acs)
353+
if hostname == parsed_acs.netloc:
354+
msg = "Selected ACS '{}' based on the request".format(acs)
355+
logline = lu.LOG_FMT.format(
356+
id=lu.get_session_id(context.state), message=msg
357+
)
358+
logger.debug(logline)
359+
return acs, binding
360+
except (TypeError, KeyError):
361+
pass
362+
363+
msg = "Can't find an ACS URL to this hostname ({}), selecting the first one".format(
364+
context.http_headers.get("HTTP_HOST", "") if context.http_headers else ""
365+
)
366+
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
367+
logger.debug(logline)
368+
return self._use_first_acs(context)
369+
317370
def authn_response(self, context, binding):
318371
"""
319372
Endpoint for the idp response

tests/satosa/backends/test_saml2.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
import pytest
1313

1414
import saml2
15-
from saml2 import BINDING_HTTP_REDIRECT
15+
from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
1616
from saml2.authn_context import PASSWORD
1717
from saml2.config import IdPConfig, SPConfig
18+
from saml2.entity import Entity
19+
from saml2.samlp import authn_request_from_string
1820
from saml2.s_utils import deflate_and_base64_encode
1921

2022
from satosa.backends.saml2 import SAMLBackend
@@ -179,6 +181,64 @@ def test_authn_request(self, context, idp_conf):
179181
req_params = dict(parse_qsl(urlparse(resp.message).query))
180182
assert context.state[self.samlbackend.name]["relay_state"] == req_params["RelayState"]
181183

184+
@pytest.mark.parametrize("hostname", ["example.com:8443", "example.net"])
185+
@pytest.mark.parametrize(
186+
"strat",
187+
["", "use_first_acs", "prefer_matching_host", "invalid"],
188+
)
189+
def test_acs_selection_strategy(self, context, sp_conf, idp_conf, hostname, strat):
190+
acs_endpoints = [
191+
("https://example.com/saml2/acs/post", BINDING_HTTP_POST),
192+
("https://example.net/saml2/acs/post", BINDING_HTTP_POST),
193+
("https://example.com:8443/saml2/acs/post", BINDING_HTTP_POST),
194+
]
195+
config = {"sp_config": sp_conf}
196+
config["sp_config"]["service"]["sp"]["endpoints"][
197+
"assertion_consumer_service"
198+
] = acs_endpoints
199+
if strat:
200+
config["acs_selection_strategy"] = strat
201+
202+
req = self._make_authn_request(hostname, context, config, idp_conf["entityid"])
203+
204+
if strat == "prefer_matching_host":
205+
expected_acs = hostname
206+
else:
207+
expected_acs = urlparse(acs_endpoints[0][0]).netloc
208+
assert urlparse(req.assertion_consumer_service_url).netloc == expected_acs
209+
210+
def _make_authn_request(self, http_host, context, config, entity_id):
211+
context.http_headers = {"HTTP_HOST": http_host} if http_host else {}
212+
self.samlbackend = SAMLBackend(
213+
Mock(),
214+
INTERNAL_ATTRIBUTES,
215+
config,
216+
"base_url",
217+
"samlbackend",
218+
)
219+
resp = self.samlbackend.authn_request(context, entity_id)
220+
req_params = dict(parse_qsl(urlparse(resp.message).query))
221+
req_xml = Entity.unravel(req_params["SAMLRequest"], BINDING_HTTP_REDIRECT)
222+
return authn_request_from_string(req_xml)
223+
224+
@pytest.mark.parametrize("hostname", ["unknown-hostname", None])
225+
def test_unknown_or_no_hostname_selects_first_acs(
226+
self, context, sp_conf, idp_conf, hostname
227+
):
228+
config = {"sp_config": sp_conf}
229+
config["sp_config"]["service"]["sp"]["endpoints"][
230+
"assertion_consumer_service"
231+
] = (
232+
("https://first-hostname/saml2/acs/post", BINDING_HTTP_POST),
233+
("https://other-hostname/saml2/acs/post", BINDING_HTTP_POST),
234+
)
235+
config["acs_selection_strategy"] = "prefer_matching_host"
236+
req = self._make_authn_request(hostname, context, config, idp_conf["entityid"])
237+
assert (
238+
req.assertion_consumer_service_url
239+
== "https://first-hostname/saml2/acs/post"
240+
)
241+
182242
def test_authn_response(self, context, idp_conf, sp_conf):
183243
response_binding = BINDING_HTTP_REDIRECT
184244
fakesp = FakeSP(SPConfig().load(sp_conf))

0 commit comments

Comments
 (0)