Skip to content

Commit 77f32ee

Browse files
skanctc00kiemon5ter
authored andcommitted
Configurable memoization of IdP selection when using MDQ
This commit introduces four optional configuration parameters that can be used to modify the default SATOSA behaviour: - CONTEXT_STATE_DELETE - remember_selected_idp_from_disco - use_disco_when_forceauthn - mirror_saml_forceauthn By default, SATOSA deletes the context state when it receives an authentication response from an identity provider. The first configuration option, CONTEXT_STATE_DELETE, allows us disable this behaviour and thus keeping state across different authentication flows, when the user uses the same browser. The second configuration option, remember_selected_idp_from_disco, controls whether SATOSA will remember and reuse the IdP that is returned from a discovery service. If ForceAuthn is set in the authentication request, then the user will then the user the IdP will be redirected to the discovery service (if it is configured) and ForceAuthn will be set in the authentication request towards the selected IdP. These two options together allow us to modify the current behaviour so that within a given session, a user will select only once the identity provider and then SATOSA will store this information in the state cookie. When the cookie expires, the user will be redirected again to the discovery service. The third configuration option, use_disco_when_forceauthn, controls whether SATOSA will redirect the use to the discovery service, even when remember_selected_idp_from_disco is true and for the current session there is the entity id of an IdP stored in the cookie state. This behaviour provides a way for SPs that need to force a new IdP selection (e.g. for account linking purposes) to use ForceAuthn to achieve this. The fourth configuration option, mirror_saml_forceauthn, adds configuration option to mirror ForceAuthn. By default, when the SATOSA SAML frontend receives a SAML authentication request with ForceAuthn set to `True`, this information is not mirrored in the SAML authentication request that is generated by the SATOSA SAML backend towards the upstream identity provider. If the configuration parameter `mirror_saml_forceauthn` is set to `True`, then the default behaviour changes and the SATOSA SAML backend will set ForceAuthn to true when it proxies a SAML authentication request with ForceAuthn set to `True`. The default values of these configuration options are tuned so that the default behaviour of SATOSA is not changed. Signed-off-by: Ivan Kanakarakis <[email protected]>
1 parent 86537fb commit 77f32ee

File tree

8 files changed

+208
-13
lines changed

8 files changed

+208
-13
lines changed

doc/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ in the [example directory](../example).
3737
| -------------- | --------- | ------------- | ----------- |
3838
| `BASE` | string | `https://proxy.example.com` | base url of the proxy |
3939
| `COOKIE_STATE_NAME` | string | `satosa_state` | name of cooke SATOSA uses for preserving state between requests |
40+
| `CONTEXT_STATE_DELETE` | bool | `True` | controls whether SATOSA will delete the state after receiving the authentication response from the upstream IdP|
4041
| `STATE_ENCRYPTION_KEY` | string | `52fddd3528a44157` | key used for encrypting the state cookie, will be overriden by the environment variable `SATOSA_STATE_ENCRYPTION_KEY` if it is set |
4142
| `INTERNAL_ATTRIBUTES` | string | `example/internal_attributes.yaml` | path to attribute mapping
4243
| `CUSTOM_PLUGIN_MODULE_PATHS` | string[] | `[example/plugins/backends, example/plugins/frontends]` | list of directory paths containing any front-/backend plugin modules |
@@ -322,6 +323,45 @@ config:
322323
disco_srv: http://disco.example.com
323324
```
324325

326+
##### Remember the IdP provided in the discovery service
327+
328+
The `remember_selected_idp_from_disco` parameter controls whether the user will have to always select a
329+
target provider when a discovery service is configured. If the parameter is set to `True` and ForceAutn is not set,
330+
SATOSA will remember and reuse the selected target provider for the duration that context.state is valid.
331+
The default behaviour is `False`.
332+
333+
```yaml
334+
config:
335+
sp_config: [...]
336+
remember_selected_idp_from_disco: True
337+
```
338+
339+
##### Use the configured discovery service if ForceAuthn is set to true
340+
341+
The `use_disco_when_forceauthn` parameter controls whether the user will be redirected to the configured
342+
discovery service when the SP sends a SAML authentication request with `ForceAuthn` set to `True`. The
343+
default behaviour is `False`.
344+
345+
```yaml
346+
config:
347+
sp_config: [...]
348+
use_disco_when_forceauthn: True
349+
```
350+
351+
##### Mirror the SAML ForceAuthn option
352+
353+
By default when the SATOSA SAML frontend receives a SAML authentication request with ForceAuthn set to `True`,
354+
this information is not mirrored in the SAML authentication request that is generated by the SATOSA SAML backend
355+
towards the upstream identity provider. If the configuration parameter `mirror_saml_forceauthn` is set to `True`,
356+
then the default behaviour changes and the SATOSA SAML backend will set ForceAuthn to true when it proxies a SAML
357+
authentication request with ForceAuthn set to `True`. The default behaviour is `False`.
358+
359+
```yaml
360+
config:
361+
sp_config: [...]
362+
mirror_saml_forceauthn: True
363+
```
364+
325365
### <a name="openid_plugin" style="color:#000000">OpenID Connect plugins</a>
326366

327367
#### Backend

example/plugins/backends/saml2_backend.yaml.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ name: Saml2
33
config:
44
idp_blacklist_file: /path/to/blacklist.json
55
sp_config:
6+
remember_selected_idp_from_disco: False
67
key_file: backend.key
78
cert_file: backend.crt
89
organization: {display_name: Example Identities, name: Example Identities Org., url: 'http://www.example.com'}

example/proxy_conf.yaml.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
BASE: https://example.com
33
INTERNAL_ATTRIBUTES: "internal_attributes.yaml"
44
COOKIE_STATE_NAME: "SATOSA_STATE"
5+
CONTEXT_STATE_DELETE: False
56
STATE_ENCRYPTION_KEY: "asdASD123"
67
CUSTOM_PLUGIN_MODULE_PATHS:
78
- "plugins/backends"

src/satosa/backends/saml2.py

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ class SAMLBackend(BackendModule, SAMLBaseModule):
4343
KEY_SAML_DISCOVERY_SERVICE_URL = 'saml_discovery_service_url'
4444
KEY_SAML_DISCOVERY_SERVICE_POLICY = 'saml_discovery_service_policy'
4545
KEY_SP_CONFIG = 'sp_config'
46+
KEY_SELECTED_IDP_FROM_DISCO = 'selected_idp_from_disco'
47+
KEY_REMEMBER_SELECTED_IDP_FROM_DISCO = 'remember_selected_idp_from_disco'
48+
KEY_USE_DISCO_WHEN_FORCEAUTHN = 'use_disco_when_forceauthn'
49+
KEY_MIRROR_SAML_FORCEAUTHN = 'mirror_saml_forceauthn'
4650
VALUE_ACR_COMPARISON_DEFAULT = 'exact'
4751

4852
def __init__(self, outgoing, internal_attributes, config, base_url, name):
@@ -67,6 +71,11 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
6771
sp_config = SPConfig().load(copy.deepcopy(config[self.KEY_SP_CONFIG]), False)
6872
self.sp = Base(sp_config)
6973

74+
# If use_disco_when_forceauthn is not set in the SP config,
75+
# then False is the default behaviour
76+
if self.KEY_USE_DISCO_WHEN_FORCEAUTHN not in self.config['sp_config']:
77+
self.config['sp_config'][self.KEY_USE_DISCO_WHEN_FORCEAUTHN] = False
78+
7079
self.discosrv = config.get(self.KEY_DISCO_SRV)
7180
self.encryption_keys = []
7281
self.outstanding_queries = {}
@@ -85,26 +94,76 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
8594
with open(p) as key_file:
8695
self.encryption_keys.append(key_file.read())
8796

88-
def start_auth(self, context, internal_req):
97+
def get_idp_entity_id(self, context):
8998
"""
90-
See super class method satosa.backends.base.BackendModule#start_auth
9199
:type context: satosa.context.Context
92-
:type internal_req: satosa.internal.InternalData
93-
:rtype: satosa.response.Response
100+
:rtype: str | None
101+
102+
:param context: The current context
103+
:return: the entity_id of the idp or None
94104
"""
95105

96-
target_entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID)
97-
if target_entity_id:
98-
entity_id = target_entity_id
99-
return self.authn_request(context, entity_id)
106+
# XXX TODO should not look into sp_config
107+
# XXX TODO make sense of the config options
100108

101109
# if there is only one IdP in the metadata, bypass the discovery service
102110
idps = self.sp.metadata.identity_providers()
103111
if len(idps) == 1 and "mdq" not in self.config["sp_config"]["metadata"]:
104112
entity_id = idps[0]
105-
return self.authn_request(context, entity_id)
113+
# if the user has selected an IdP and it is available in the context.state,
114+
# then set entity_id to that unless ForceAuthn is set to true and
115+
# use_disco_when_forcauthn is false
116+
elif (
117+
self.config["sp_config"].get(self.KEY_REMEMBER_SELECTED_IDP_FROM_DISCO)
118+
and self.KEY_SELECTED_IDP_FROM_DISCO in context.state
119+
120+
and not context.get_decoration(Context.KEY_FORCE_AUTHN)
121+
):
122+
satosa_logging(
123+
logger, logging.INFO,
124+
"Bypassing discovery service. Using IdP %s" %
125+
context.state[self.KEY_SELECTED_IDP_FROM_DISCO],
126+
context.state,
127+
)
128+
entity_id = context.state[self.KEY_SELECTED_IDP_FROM_DISCO]
129+
elif (
130+
self.config["sp_config"].get(self.KEY_REMEMBER_SELECTED_IDP_FROM_DISCO)
131+
and self.KEY_SELECTED_IDP_FROM_DISCO in context.state
132+
133+
and context.get_decoration(Context.KEY_FORCE_AUTHN)
134+
and not self.config["sp_config"][self.KEY_USE_DISCO_WHEN_FORCEAUTHN]
135+
):
136+
satosa_logging(
137+
logger, logging.INFO,
138+
"Bypassing discovery service. Using IdP %s" %
139+
context.state[self.KEY_SELECTED_IDP_FROM_DISCO],
140+
context.state,
141+
)
142+
entity_id = context.state[self.KEY_SELECTED_IDP_FROM_DISCO]
143+
else:
144+
entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID)
145+
146+
return entity_id
106147

107-
return self.disco_query(context)
148+
def start_auth(self, context, internal_req):
149+
"""
150+
See super class method satosa.backends.base.BackendModule#start_auth
151+
152+
:type context: satosa.context.Context
153+
:type internal_req: satosa.internal.InternalData
154+
:rtype: satosa.response.Response
155+
"""
156+
157+
entity_id = self.get_idp_entity_id(context)
158+
if entity_id is None:
159+
# since context is not passed to disco_query
160+
# keep the information in the state cookie
161+
context.state[Context.KEY_FORCE_AUTHN] = context.get_decoration(
162+
Context.KEY_FORCE_AUTHN
163+
)
164+
return self.disco_query(context)
165+
166+
return self.authn_request(context, entity_id)
108167

109168
def disco_query(self, context):
110169
"""
@@ -155,6 +214,17 @@ def construct_requested_authn_context(self, entity_id):
155214

156215
return authn_context
157216

217+
def mirror_saml_forceauthn(self, context, kwargs):
218+
if (self.KEY_MIRROR_SAML_FORCEAUTHN in self.config['sp_config']
219+
and self.config['sp_config'][self.KEY_MIRROR_SAML_FORCEAUTHN]):
220+
# If ForceAuthn is found in the state cookie, use that
221+
if (Context.KEY_FORCE_AUTHN in context.state
222+
and context.state[Context.KEY_FORCE_AUTHN] == 'true'):
223+
kwargs['force_authn'] = context.state[Context.KEY_FORCE_AUTHN]
224+
elif context.get_decoration(Context.KEY_FORCE_AUTHN) == 'true':
225+
kwargs['force_authn'] = context.get_decoration(Context.KEY_FORCE_AUTHN)
226+
return kwargs
227+
158228
def authn_request(self, context, entity_id):
159229
"""
160230
Do an authorization request on idp with given entity id.
@@ -183,6 +253,8 @@ def authn_request(self, context, entity_id):
183253
if authn_context:
184254
kwargs['requested_authn_context'] = authn_context
185255

256+
kwargs = self.mirror_saml_forceauthn(context, kwargs)
257+
186258
try:
187259
binding, destination = self.sp.pick_binding(
188260
"single_sign_on_service", None, "idpsso", entity_id=entity_id)
@@ -250,6 +322,8 @@ def authn_response(self, context, binding):
250322
context.decorate(Context.KEY_BACKEND_METADATA_STORE, self.sp.metadata)
251323

252324
del context.state[self.name]
325+
# we should not remember ForceAuthn any longer
326+
context.state[Context.KEY_FORCE_AUTHN] = None
253327
return self.auth_callback_func(context, self._translate_response(authn_response, context.state))
254328

255329
def disco_response(self, context):
@@ -271,6 +345,10 @@ def disco_response(self, context):
271345
satosa_logging(logger, logging.DEBUG, "No IDP chosen for state", state, exc_info=True)
272346
raise SATOSAAuthenticationError(state, "No IDP chosen") from err
273347

348+
if (self.KEY_REMEMBER_SELECTED_IDP_FROM_DISCO in self.config['sp_config']
349+
and self.config['sp_config'][self.KEY_REMEMBER_SELECTED_IDP_FROM_DISCO]):
350+
context.state[self.KEY_SELECTED_IDP_FROM_DISCO] = entity_id
351+
274352
return self.authn_request(context, entity_id)
275353

276354
def _translate_response(self, response, state):

src/satosa/base.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,13 @@ def _auth_resp_finish(self, context, internal_response):
161161
self.config.get("USER_ID_HASH_SALT", ""),
162162
)
163163

164-
# remove all session state
164+
# remove all session state unless CONTEXT_STATE_DELETE is false
165165
context.request = None
166-
context.state.delete = True
166+
if "CONTEXT_STATE_DELETE" in self.config:
167+
context.state.delete = self.config["CONTEXT_STATE_DELETE"]
168+
else:
169+
context.state.delete = True
170+
167171
frontend = self.module_router.frontend_routing(context)
168172
return frontend.handle_authn_response(context, internal_response)
169173

src/satosa/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Context(object):
1717
"""
1818
KEY_BACKEND_METADATA_STORE = 'metadata_store'
1919
KEY_TARGET_ENTITYID = 'target_entity_id'
20+
KEY_FORCE_AUTHN = 'force_authn'
2021

2122
def __init__(self):
2223
self._path = None

src/satosa/frontends/saml2.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ def _handle_authn_request(self, context, binding_in, idp):
192192
authn_req = req_info.message
193193
satosa_logging(logger, logging.DEBUG, "%s" % authn_req, context.state)
194194

195+
context.decorate(Context.KEY_FORCE_AUTHN, authn_req.force_authn)
196+
195197
try:
196198
resp_args = idp.response_args(authn_req)
197199
except SAMLError as e:

tests/satosa/backends/test_saml2.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def test_redirect_to_idp_if_only_one_idp_in_metadata(self, context, sp_conf, idp
169169
resp = samlbackend.start_auth(context, InternalData())
170170
self.assert_redirect_to_idp(resp, idp_conf)
171171

172-
def test_always_redirect_to_discovery_service_if_using_mdq(self, context, sp_conf, idp_conf):
172+
def test_default_redirect_to_discovery_service_if_using_mdq(self, context, sp_conf, idp_conf):
173173
# one IdP in the metadata, but MDQ also configured so should always redirect to the discovery service
174174
sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)]
175175
sp_conf["metadata"]["mdq"] = ["https://mdq.example.com"]
@@ -178,12 +178,80 @@ def test_always_redirect_to_discovery_service_if_using_mdq(self, context, sp_con
178178
resp = samlbackend.start_auth(context, InternalData())
179179
self.assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL)
180180

181+
def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_not_set(self, context, sp_conf, idp_conf):
182+
sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)]
183+
sp_conf["metadata"]["mdq"] = ["https://mdq.example.com"]
184+
185+
sp_conf["remember_selected_idp_from_disco"] = True
186+
samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,},
187+
"base_url", "saml_backend")
188+
resp = samlbackend.start_auth(context, InternalRequest(None, None))
189+
self.assert_redirect_to_discovery_server(resp, sp_conf)
190+
191+
context.state["selected_idp_from_disco"] = idp_conf['entityid']
192+
samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,},
193+
"base_url", "saml_backend")
194+
resp = samlbackend.start_auth(context, InternalRequest(None, None))
195+
self.assert_redirect_to_idp(resp, idp_conf)
196+
197+
sp_conf["remember_selected_idp_from_disco"] = False
198+
samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,},
199+
"base_url", "saml_backend")
200+
resp = samlbackend.start_auth(context, InternalRequest(None, None))
201+
self.assert_redirect_to_discovery_server(resp, sp_conf)
202+
203+
def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_set(self, context, sp_conf, idp_conf):
204+
context.decorate(Context.KEY_FORCE_AUTHN, 'true')
205+
sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)]
206+
sp_conf["metadata"]["mdq"] = ["https://mdq.example.com"]
207+
208+
sp_conf["remember_selected_idp_from_disco"] = True
209+
context.state["selected_idp_from_disco"] = idp_conf['entityid']
210+
211+
sp_conf["use_disco_when_forceauthn"] = True
212+
samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,},
213+
"base_url", "saml_backend")
214+
resp = samlbackend.start_auth(context, InternalRequest(None, None))
215+
self.assert_redirect_to_discovery_server(resp, sp_conf)
216+
217+
sp_conf["use_disco_when_forceauthn"] = False
218+
samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,},
219+
"base_url", "saml_backend")
220+
resp = samlbackend.start_auth(context, InternalRequest(None, None))
221+
self.assert_redirect_to_idp(resp, idp_conf)
222+
181223
def test_authn_request(self, context, idp_conf):
182224
resp = self.samlbackend.authn_request(context, idp_conf["entityid"])
183225
self.assert_redirect_to_idp(resp, idp_conf)
184226
req_params = dict(parse_qsl(urlparse(resp.message).query))
185227
assert context.state[self.samlbackend.name]["relay_state"] == req_params["RelayState"]
186228

229+
def test_mirror_saml_forceauthn(self, context, sp_conf):
230+
sp_conf["metadata"]["mdq"] = ["https://mdq.example.com"]
231+
samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,},
232+
"base_url", "saml_backend")
233+
234+
context.state[Context.KEY_FORCE_AUTHN] = 'true'
235+
236+
kwargs = {}
237+
kwargs = samlbackend.mirror_saml_forceauthn(context, kwargs)
238+
assert kwargs == {}
239+
240+
sp_conf[samlbackend.KEY_MIRROR_SAML_FORCEAUTHN] = True
241+
242+
kwargs = samlbackend.mirror_saml_forceauthn(context, kwargs)
243+
assert kwargs == {'force_authn': 'true'}
244+
245+
kwargs = {}
246+
del context.state[Context.KEY_FORCE_AUTHN]
247+
kwargs = samlbackend.mirror_saml_forceauthn(context, kwargs)
248+
assert kwargs == {}
249+
250+
kwargs = {}
251+
context.decorate(Context.KEY_FORCE_AUTHN, 'true')
252+
kwargs = samlbackend.mirror_saml_forceauthn(context, kwargs)
253+
assert kwargs == {'force_authn': 'true'}
254+
187255
def test_authn_response(self, context, idp_conf, sp_conf):
188256
response_binding = BINDING_HTTP_REDIRECT
189257
fakesp = FakeSP(SPConfig().load(sp_conf, metadata_construction=False))

0 commit comments

Comments
 (0)