77import pytest
88from flask import request
99from lxml import etree
10+ from werkzeug import Response as wkResponse
1011
1112from palace .manager .api .authentication .base import PatronData
1213from 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