Skip to content

Commit 8a6b623

Browse files
committed
Support alternate SAML auth flow with ForceAuthn query param (PP-4792)
1 parent c54cedb commit 8a6b623

4 files changed

Lines changed: 170 additions & 32 deletions

File tree

src/palace/manager/integration/patron_auth/saml/auth.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from palace.manager.integration.patron_auth.saml.metadata.parser import (
2020
SAMLSubjectParser,
2121
)
22-
from palace.manager.util.problem_detail import ProblemDetail as pd
22+
from palace.manager.util.problem_detail import ProblemDetail, ProblemDetail as pd
2323

2424
if TYPE_CHECKING:
2525
import sqlalchemy.orm.session
@@ -177,20 +177,26 @@ def configuration(self):
177177
"""
178178
return self._configuration
179179

180-
def start_authentication(self, db, idp_entity_id, return_to_url):
180+
def start_authentication(
181+
self,
182+
db: sqlalchemy.orm.session.Session,
183+
idp_entity_id: str,
184+
return_to_url: str,
185+
force_authn: bool = False,
186+
) -> str | ProblemDetail:
181187
"""Start the SAML authentication workflow by sending a AuthnRequest to the IdP.
182188
183189
:param db: Database session
184-
:type db: sqlalchemy.orm.session.Session
185190
186191
:param idp_entity_id: IdP's entityID
187-
:type idp_entity_id: string
188192
189193
:param return_to_url: URL which will the user agent will be redirected to after authentication
190-
:type return_to_url: string
191194
192-
:return: Redirection URL
193-
:rtype: string
195+
:param force_authn: Whether to set ForceAuthn="true" on the AuthnRequest,
196+
telling the IdP to re-authenticate the user even if they have an
197+
existing session (SAML 2.0 Core 3.4.1)
198+
199+
:return: Redirection URL, or a ProblemDetail on error
194200
"""
195201
self._logger.info(
196202
"Started authentication workflow for IdP '{}' (redirection URL = '{}')".format(
@@ -200,7 +206,7 @@ def start_authentication(self, db, idp_entity_id, return_to_url):
200206

201207
try:
202208
auth = self._get_auth_object(db, idp_entity_id)
203-
redirect_url = auth.login(return_to_url)
209+
redirect_url = auth.login(return_to_url, force_authn=force_authn)
204210

205211
if self._logger.isEnabledFor(logging.DEBUG):
206212
self._logger.debug(f"SAML request: {auth.get_last_request_xml()}")
@@ -281,7 +287,7 @@ def start_logout(
281287
name_id: SAMLNameID,
282288
sp_slo_callback_url: str,
283289
relay_state: str,
284-
) -> str | pd:
290+
) -> str | ProblemDetail:
285291
"""Initiate SP-Initiated SAML SLO by sending a LogoutRequest to the IdP.
286292
287293
:param db: Database session

src/palace/manager/integration/patron_auth/saml/controller.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from flask_babel import lazy_gettext as _
1717
from lxml import etree
1818
from werkzeug import Response as wkResponse
19-
from werkzeug.datastructures import ImmutableMultiDict
19+
from werkzeug.datastructures import MultiDict
2020

2121
from palace.manager.api.util.flask import get_request_library
2222
from palace.manager.integration.patron_auth.constants import LOGOUT_REDIRECT_QUERY_PARAM
@@ -69,6 +69,9 @@ class SAMLController:
6969
ACCESS_TOKEN = "access_token"
7070
PATRON_INFO = "patron_info"
7171
LOGOUT_STATUS = "logout_status"
72+
FORCE_AUTHN = "force_authn"
73+
74+
VALID_FORCE_AUTHN_VALUES: ClassVar[frozenset[str]] = frozenset({"true", "false"})
7275

7376
_SITE_WIDE_METADATA_CACHE_KEY: ClassVar[str | None] = None
7477
_sp_metadata_cache: ClassVar[dict[str | None, str | None]] = {}
@@ -273,21 +276,26 @@ def _redirect_with_error(self, redirect_uri, problem_detail):
273276
"""
274277
return redirect(self._error_uri(redirect_uri, problem_detail))
275278

276-
def saml_authentication_redirect(self, params, db):
279+
def saml_authentication_redirect(
280+
self,
281+
params: MultiDict[str, str],
282+
db: sqlalchemy.orm.session.Session,
283+
) -> wkResponse | ProblemDetail:
277284
"""Redirects an unauthenticated patron to the authentication URL of the
278285
appropriate SAML IdP.
279286
Over on that other site, the patron will authenticate and be
280287
redirected back to the circulation manager, ending up in
281288
saml_authentication_callback.
282289
283-
:param params: Query parameters
284-
:type params: Dict
290+
:param params: Query parameters. In addition to the required `provider`,
291+
`idp_entity_id`, and `redirect_uri` parameters, an optional
292+
`force_authn` parameter ("true" or "false") may be supplied to
293+
request that the IdP re-authenticate the patron even if the patron
294+
has an existing IdP session.
285295
286296
:param db: Database session
287-
:type db: sqlalchemy.orm.session.Session
288297
289-
:return: Redirection response
290-
:rtype: Response
298+
:return: Redirection response, or a ProblemDetail on error
291299
"""
292300
provider_name = self._get_request_parameter(params, self.PROVIDER_NAME)
293301
if isinstance(provider_name, ProblemDetail):
@@ -301,6 +309,22 @@ def saml_authentication_redirect(self, params, db):
301309
if isinstance(redirect_uri, ProblemDetail):
302310
return redirect_uri
303311

312+
# Optional parameter. Clients pass force_authn=true to request that the
313+
# IdP re-authenticate the patron even if the patron has an existing IdP
314+
# session, e.g. a session for an account from a different tenant.
315+
force_authn = params.get(self.FORCE_AUTHN)
316+
if force_authn is not None and force_authn not in self.VALID_FORCE_AUTHN_VALUES:
317+
return self._redirect_with_error(
318+
redirect_uri,
319+
SAML_INVALID_REQUEST.detailed(
320+
_(
321+
'Invalid %(name)s value; expected "true" or "false"',
322+
name=self.FORCE_AUTHN,
323+
)
324+
),
325+
)
326+
force_authn_requested = force_authn == "true"
327+
304328
provider = self._authenticator.saml_provider_lookup(provider_name)
305329
if isinstance(provider, ProblemDetail):
306330
return self._redirect_with_error(redirect_uri, provider)
@@ -334,7 +358,7 @@ def saml_authentication_redirect(self, params, db):
334358
},
335359
)
336360
redirect_uri = authentication_manager.start_authentication(
337-
db, idp_entity_id, relay_state
361+
db, idp_entity_id, relay_state, force_authn=force_authn_requested
338362
)
339363
if isinstance(redirect_uri, ProblemDetail):
340364
return redirect_uri
@@ -425,7 +449,7 @@ def saml_authentication_callback(self, request, db):
425449

426450
def saml_logout_redirect(
427451
self,
428-
params: ImmutableMultiDict[str, str],
452+
params: MultiDict[str, str],
429453
db: sqlalchemy.orm.session.Session,
430454
) -> wkResponse | ProblemDetail:
431455
"""Initiate SP-Initiated SAML SLO.

tests/manager/integration/patron_auth/saml/test_auth.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,22 @@ class TestSAMLAuthenticationManager:
9494
),
9595
],
9696
)
97+
@pytest.mark.parametrize(
98+
"force_authn, expected_force_authn_attribute",
99+
[
100+
pytest.param(False, None, id="without-force-authn"),
101+
pytest.param(True, "true", id="with-force-authn"),
102+
],
103+
)
97104
def test_start_authentication(
98105
self,
99106
controller_fixture: ControllerFixture,
100107
create_mock_onelogin_configuration: Callable[..., SAMLOneLoginConfiguration],
101-
service_provider,
102-
identity_providers,
103-
):
108+
service_provider: SAMLServiceProviderMetadata,
109+
identity_providers: list[SAMLIdentityProviderMetadata],
110+
force_authn: bool,
111+
expected_force_authn_attribute: str | None,
112+
) -> None:
104113
onelogin_configuration = create_mock_onelogin_configuration(
105114
service_provider, identity_providers
106115
)
@@ -111,9 +120,13 @@ def test_start_authentication(
111120

112121
with controller_fixture.app.test_request_context("/"):
113122
result = authentication_manager.start_authentication(
114-
controller_fixture.db.session, saml_strings.IDP_1_ENTITY_ID, ""
123+
controller_fixture.db.session,
124+
saml_strings.IDP_1_ENTITY_ID,
125+
"",
126+
force_authn=force_authn,
115127
)
116128

129+
assert isinstance(result, str)
117130
query_items = parse_qs(urlsplit(result).query)
118131
saml_request = query_items["SAMLRequest"][0]
119132
decoded_saml_request = OneLogin_Saml2_Utils.decode_base64_and_inflate(
@@ -139,6 +152,8 @@ def test_start_authentication(
139152
sso_url = saml_request_dom.get("Destination")
140153
assert sso_url == IDENTITY_PROVIDERS[0].sso_service.url
141154

155+
assert saml_request_dom.get("ForceAuthn") == expected_force_authn_attribute
156+
142157
name_id_policy_nodes = OneLogin_Saml2_XML.query(
143158
saml_request_dom, "./samlp:NameIDPolicy"
144159
)

tests/manager/integration/patron_auth/saml/test_controller.py

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88
from flask import request
99
from lxml import etree
10+
from werkzeug import Response as wkResponse
1011

1112
from palace.manager.api.authentication.base import PatronData
1213
from palace.manager.api.authenticator import Authenticator
@@ -191,26 +192,22 @@ class TestSAMLController:
191192
def test_saml_authentication_redirect(
192193
self,
193194
controller_fixture: ControllerFixture,
194-
provider_name,
195-
idp_entity_id,
196-
redirect_uri,
197-
expected_problem,
198-
expected_relay_state,
199-
):
195+
provider_name: str | None,
196+
idp_entity_id: str | None,
197+
redirect_uri: str | None,
198+
expected_problem: ProblemDetail | None,
199+
expected_relay_state: str | None,
200+
) -> None:
200201
"""Make sure that SAMLController.saml_authentication_redirect creates a correct RelayState or
201202
returns a correct ProblemDetail object in the case of any error.
202203
203204
:param provider_name: Name of the authentication provider which should be passed as a request parameter
204-
:type provider_name: str
205205
206206
:param idp_entity_id: Identity Provider's ID which should be passed as a request parameter
207-
:type idp_entity_id: str
208207
209208
:param expected_problem: (Optional) Expected ProblemDetail object describing the error occurred (if any)
210-
:type expected_problem: Optional[ProblemDetail]
211209
212210
:param expected_relay_state: (Optional) String containing the expected RelayState value
213-
:type expected_relay_state: Optional[str]
214211
"""
215212
# Arrange
216213
expected_authentication_redirect_uri = "https://idp.circulationmanager.org"
@@ -262,6 +259,7 @@ def test_saml_authentication_redirect(
262259
assert isinstance(result, ProblemDetail)
263260
assert result.response == expected_problem.response
264261
else:
262+
assert isinstance(result, wkResponse)
265263
assert 302 == result.status_code
266264
assert expected_authentication_redirect_uri == result.headers.get(
267265
"Location"
@@ -271,6 +269,101 @@ def test_saml_authentication_redirect(
271269
controller_fixture.db.session,
272270
idp_entity_id,
273271
expected_relay_state,
272+
force_authn=False,
273+
)
274+
275+
@pytest.mark.parametrize(
276+
"force_authn, expected_force_authn, expected_invalid",
277+
[
278+
pytest.param(None, False, False, id="without-force-authn"),
279+
pytest.param("true", True, False, id="with-force-authn-true"),
280+
pytest.param("false", False, False, id="with-force-authn-false"),
281+
pytest.param("TRUE", None, True, id="with-invalid-force-authn-uppercase"),
282+
pytest.param("1", None, True, id="with-invalid-force-authn-numeric"),
283+
pytest.param("", None, True, id="with-invalid-force-authn-empty"),
284+
],
285+
)
286+
def test_saml_authentication_redirect_force_authn(
287+
self,
288+
controller_fixture: ControllerFixture,
289+
force_authn: str | None,
290+
expected_force_authn: bool | None,
291+
expected_invalid: bool,
292+
) -> None:
293+
"""Make sure that the optional force_authn parameter is validated and
294+
passed through to SAMLAuthenticationManager.start_authentication, and
295+
that invalid values redirect back to the client with an error.
296+
"""
297+
# Arrange
298+
expected_authentication_redirect_uri = "https://idp.circulationmanager.org"
299+
client_redirect_uri = "http://localhost"
300+
authentication_manager = create_autospec(spec=SAMLAuthenticationManager)
301+
authentication_manager.start_authentication = MagicMock(
302+
return_value=expected_authentication_redirect_uri
303+
)
304+
provider = create_autospec(spec=SAMLWebSSOAuthenticationProvider)
305+
provider.label = MagicMock(
306+
return_value=SAMLWebSSOAuthenticationProvider.label()
307+
)
308+
provider.get_authentication_manager = MagicMock(
309+
return_value=authentication_manager
310+
)
311+
provider.library = MagicMock(
312+
return_value=controller_fixture.db.default_library()
313+
)
314+
authenticator = Authenticator(
315+
controller_fixture.db.session,
316+
controller_fixture.db.session.query(Library),
317+
)
318+
319+
authenticator.library_authenticators["default"].register_saml_provider(provider)
320+
321+
controller = SAMLController(controller_fixture.app.manager, authenticator)
322+
params = {
323+
SAMLController.PROVIDER_NAME: SAMLWebSSOAuthenticationProvider.label(),
324+
SAMLController.IDP_ENTITY_ID: IDENTITY_PROVIDERS[0].entity_id,
325+
SAMLController.REDIRECT_URI: client_redirect_uri,
326+
}
327+
328+
if force_authn is not None:
329+
params[SAMLController.FORCE_AUTHN] = force_authn
330+
331+
query = urlencode(params)
332+
333+
with controller_fixture.app.test_request_context("/saml_authenticate?" + query):
334+
request.library = controller_fixture.db.default_library() # type: ignore[attr-defined]
335+
336+
# Act
337+
result = controller.saml_authentication_redirect(
338+
request.args, controller_fixture.db.session
339+
)
340+
341+
# Assert
342+
assert isinstance(result, wkResponse)
343+
assert 302 == result.status_code
344+
location = result.headers.get("Location")
345+
346+
if expected_invalid:
347+
# The patron is redirected back to the client with the error
348+
# encoded in the query string.
349+
authentication_manager.start_authentication.assert_not_called()
350+
assert location is not None
351+
location_parse_result = urlsplit(location)
352+
location_params = parse_qs(location_parse_result.query)
353+
error = json.loads(location_params[SAMLController.ERROR][0])
354+
assert error["type"] == SAML_INVALID_REQUEST.uri
355+
# The static detail message must not reflect the submitted value.
356+
assert (
357+
error["detail"]
358+
== 'Invalid force_authn value; expected "true" or "false"'
359+
)
360+
else:
361+
assert expected_authentication_redirect_uri == location
362+
assert (
363+
authentication_manager.start_authentication.call_args.kwargs[
364+
"force_authn"
365+
]
366+
== expected_force_authn
274367
)
275368

276369
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)