Skip to content

Commit 224b65f

Browse files
Merge pull request #234 from c00kiemon5ter/feature-memorize-idp
Add configuration options to memorize a user-selected IdP
2 parents 79abc46 + 762cff7 commit 224b65f

File tree

9 files changed

+239
-73
lines changed

9 files changed

+239
-73
lines changed

doc/README.md

Lines changed: 55 additions & 1 deletion
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 cookie 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 |
@@ -232,7 +233,7 @@ in its UI. The following flow diagram shows the communcation:
232233
`SP -> optional discovery service -> selected proxy SAML entity -> target IdP`
233234

234235
3. The **SAMLVirtualCoFrontend** module enables multiple IdP frontends, each with its own distinct
235-
entityID and SSO endpoints, and each representing a distinct collaborative organization or CO.
236+
entityID and SSO endpoints, and each representing a distinct collaborative organization or CO.
236237
An example configuration can be found [here](../example/plugins/frontends/saml2_virtualcofrontend.yaml.example).
237238

238239
The following flow diagram shows the communication:
@@ -322,6 +323,59 @@ config:
322323
disco_srv: http://disco.example.com
323324
```
324325

326+
##### Mirror the SAML ForceAuthn option
327+
328+
By default when the SAML frontend receives a SAML authentication request
329+
with `ForceAuthn` set to `True`, this information is not mirrored in the SAML
330+
authentication request that is generated by the SAML backend towards the
331+
upstream identity provider. If the configuration option
332+
`mirror_force_authn` is set to `True`, then the default behaviour changes
333+
and the SAML backend will set `ForceAuthn` to true when it proxies a SAML
334+
authentication request with `ForceAuthn` set to `True`.
335+
336+
The default behaviour is `False`.
337+
338+
```yaml
339+
config:
340+
mirror_force_authn: True
341+
[...]
342+
```
343+
344+
##### Memorize the IdP selected through the discovery service
345+
346+
In the classic flow, the user is asked to select their home organization to
347+
authenticate to. The `memorize_idp` configuration option controls whether
348+
the user will have to always select a target provider when a discovery service
349+
is configured. If the parameter is set to `True` (and `ForceAuthn` is not set),
350+
the proxy will remember and reuse the selected target provider for the duration
351+
that the state cookie is valid. If `ForceAuthn` is set, then the
352+
`use_memorized_idp_when_force_authn` configuration option can overide
353+
this property and still reuse the selected target provider.
354+
355+
The default behaviour is `False`.
356+
357+
```yaml
358+
config:
359+
memorize_idp: True
360+
[...]
361+
```
362+
363+
##### Use the configured discovery service if ForceAuthn is set to true
364+
365+
The `use_memorized_idp_when_force_authn` configuration option controls
366+
whether the user will skip the configured discovery service when the SP sends a
367+
SAML authentication request with `ForceAuthn` set to `True` but the proxy has
368+
memorized the user's previous selection.
369+
370+
The default behaviour is `False`.
371+
372+
```yaml
373+
config:
374+
memorize_idp: True
375+
use_memorized_idp_when_force_authn: True
376+
[...]
377+
```
378+
325379
### <a name="openid_plugin" style="color:#000000">OpenID Connect plugins</a>
326380

327381
#### Backend

example/plugins/backends/saml2_backend.yaml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ module: satosa.backends.saml2.SAMLBackend
22
name: Saml2
33
config:
44
idp_blacklist_file: /path/to/blacklist.json
5+
6+
mirror_force_authn: no
7+
memorize_idp: no
8+
use_memorized_idp_when_force_authn: no
9+
510
sp_config:
611
key_file: backend.key
712
cert_file: backend.crt

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: yes
56
STATE_ENCRYPTION_KEY: "asdASD123"
67
CUSTOM_PLUGIN_MODULE_PATHS:
78
- "plugins/backends"

src/satosa/backends/saml2.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,40 @@
3535
logger = logging.getLogger(__name__)
3636

3737

38+
def get_memorized_idp(context, config, force_authn):
39+
memorized_idp = (
40+
config.get(SAMLBackend.KEY_MEMORIZE_IDP)
41+
and context.state.get(Context.KEY_MEMORIZED_IDP)
42+
)
43+
use_when_force_authn = config.get(
44+
SAMLBackend.KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN
45+
)
46+
value = (not force_authn or use_when_force_authn) and memorized_idp
47+
return value
48+
49+
50+
def get_force_authn(context, config, sp_config):
51+
"""
52+
Return the force_authn value.
53+
54+
The value comes from one of three place:
55+
- the configuration of the backend
56+
- the context, as it came through in the AuthnRequest handled by the frontend.
57+
note: the frontend should have been set to mirror the force_authn value.
58+
- the cookie, as it has been stored by the proxy on a redirect to the DS
59+
note: the frontend should have been set to mirror the force_authn value.
60+
61+
The value is either "true" or False
62+
"""
63+
mirror = config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN)
64+
from_state = mirror and context.state.get(Context.KEY_FORCE_AUTHN)
65+
from_context = mirror and context.get_decoration(Context.KEY_FORCE_AUTHN)
66+
from_config = sp_config.getattr("force_authn", "sp")
67+
is_set = str(from_state or from_context or from_config).lower() == "true"
68+
value = is_set and "true"
69+
return value
70+
71+
3872
class SAMLBackend(BackendModule, SAMLBaseModule):
3973
"""
4074
A saml2 backend module (acting as a SP).
@@ -43,6 +77,10 @@ class SAMLBackend(BackendModule, SAMLBaseModule):
4377
KEY_SAML_DISCOVERY_SERVICE_URL = 'saml_discovery_service_url'
4478
KEY_SAML_DISCOVERY_SERVICE_POLICY = 'saml_discovery_service_policy'
4579
KEY_SP_CONFIG = 'sp_config'
80+
KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn'
81+
KEY_MEMORIZE_IDP = 'memorize_idp'
82+
KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN = 'use_memorized_idp_when_force_authn'
83+
4684
VALUE_ACR_COMPARISON_DEFAULT = 'exact'
4785

4886
def __init__(self, outgoing, internal_attributes, config, base_url, name):
@@ -64,10 +102,12 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
64102
super().__init__(outgoing, internal_attributes, base_url, name)
65103
self.config = self.init_config(config)
66104

67-
sp_config = SPConfig().load(copy.deepcopy(config[self.KEY_SP_CONFIG]), False)
105+
sp_config = SPConfig().load(copy.deepcopy(
106+
config[SAMLBackend.KEY_SP_CONFIG]), False
107+
)
68108
self.sp = Base(sp_config)
69109

70-
self.discosrv = config.get(self.KEY_DISCO_SRV)
110+
self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV)
71111
self.encryption_keys = []
72112
self.outstanding_queries = {}
73113
self.idp_blacklist_file = config.get('idp_blacklist_file', None)
@@ -85,26 +125,60 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
85125
with open(p) as key_file:
86126
self.encryption_keys.append(key_file.read())
87127

128+
def get_idp_entity_id(self, context):
129+
"""
130+
:type context: satosa.context.Context
131+
:rtype: str | None
132+
133+
:param context: The current context
134+
:return: the entity_id of the idp or None
135+
"""
136+
137+
idps = self.sp.metadata.identity_providers()
138+
only_one_idp_in_metadata = (
139+
"mdq" not in self.config["sp_config"]["metadata"]
140+
and len(idps) == 1
141+
)
142+
143+
only_idp = only_one_idp_in_metadata and idps[0]
144+
target_entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID)
145+
force_authn = get_force_authn(context, self.config, self.sp.config)
146+
memorized_idp = get_memorized_idp(context, self.config, force_authn)
147+
entity_id = only_idp or target_entity_id or memorized_idp or None
148+
149+
satosa_logging(
150+
logger, logging.INFO,
151+
{
152+
"message": "Selected IdP",
153+
"only_one": only_idp,
154+
"target_entity_id": target_entity_id,
155+
"force_authn": force_authn,
156+
"memorized_idp": memorized_idp,
157+
"entity_id": entity_id,
158+
},
159+
context.state,
160+
)
161+
return entity_id
162+
88163
def start_auth(self, context, internal_req):
89164
"""
90165
See super class method satosa.backends.base.BackendModule#start_auth
166+
91167
:type context: satosa.context.Context
92168
:type internal_req: satosa.internal.InternalData
93169
:rtype: satosa.response.Response
94170
"""
95171

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)
100-
101-
# if there is only one IdP in the metadata, bypass the discovery service
102-
idps = self.sp.metadata.identity_providers()
103-
if len(idps) == 1 and "mdq" not in self.config["sp_config"]["metadata"]:
104-
entity_id = idps[0]
105-
return self.authn_request(context, entity_id)
172+
entity_id = self.get_idp_entity_id(context)
173+
if entity_id is None:
174+
# since context is not passed to disco_query
175+
# keep the information in the state cookie
176+
context.state[Context.KEY_FORCE_AUTHN] = get_force_authn(
177+
context, self.config, self.sp.config
178+
)
179+
return self.disco_query(context)
106180

107-
return self.disco_query(context)
181+
return self.authn_request(context, entity_id)
108182

109183
def disco_query(self, context):
110184
"""
@@ -122,9 +196,12 @@ def disco_query(self, context):
122196
return_url = endpoints["discovery_response"][0][0]
123197

124198
disco_url = (
125-
context.get_decoration(self.KEY_SAML_DISCOVERY_SERVICE_URL) or self.discosrv
199+
context.get_decoration(SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_URL)
200+
or self.discosrv
201+
)
202+
disco_policy = context.get_decoration(
203+
SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_POLICY
126204
)
127-
disco_policy = context.get_decoration(self.KEY_SAML_DISCOVERY_SERVICE_POLICY)
128205

129206
args = {"return": return_url}
130207
if disco_policy:
@@ -181,7 +258,11 @@ def authn_request(self, context, entity_id):
181258
kwargs = {}
182259
authn_context = self.construct_requested_authn_context(entity_id)
183260
if authn_context:
184-
kwargs['requested_authn_context'] = authn_context
261+
kwargs["requested_authn_context"] = authn_context
262+
if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN):
263+
kwargs["force_authn"] = get_force_authn(
264+
context, self.config, self.sp.config
265+
)
185266

186267
try:
187268
binding, destination = self.sp.pick_binding(
@@ -248,8 +329,11 @@ def authn_response(self, context, binding):
248329
raise SATOSAAuthenticationError(context.state, "State did not match relay state")
249330

250331
context.decorate(Context.KEY_BACKEND_METADATA_STORE, self.sp.metadata)
251-
252-
del context.state[self.name]
332+
if self.config.get(SAMLBackend.KEY_MEMORIZE_IDP):
333+
issuer = authn_response.response.issuer.text.strip()
334+
context.state[Context.KEY_MEMORIZED_IDP] = issuer
335+
context.state.pop(self.name, None)
336+
context.state.pop(Context.KEY_FORCE_AUTHN, None)
253337
return self.auth_callback_func(context, self._translate_response(authn_response, context.state))
254338

255339
def disco_response(self, context):

src/satosa/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,10 @@ 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
165+
context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True)
165166
context.request = None
166-
context.state.delete = True
167+
167168
frontend = self.module_router.frontend_routing(context)
168169
return frontend.handle_authn_response(context, internal_response)
169170

src/satosa/context.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class Context(object):
1717
"""
1818
KEY_BACKEND_METADATA_STORE = 'metadata_store'
1919
KEY_TARGET_ENTITYID = 'target_entity_id'
20+
KEY_FORCE_AUTHN = 'force_authn'
21+
KEY_MEMORIZED_IDP = 'memorized_idp'
2022

2123
def __init__(self):
2224
self._path = None

src/satosa/frontends/saml2.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ 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+
# keep the ForceAuthn value to be used by plugins
196+
context.decorate(Context.KEY_FORCE_AUTHN, authn_req.force_authn)
197+
195198
try:
196199
resp_args = idp.response_args(authn_req)
197200
except SAMLError as e:

0 commit comments

Comments
 (0)