Skip to content

Commit b14d52c

Browse files
Merge pull request #367 from bajnokk/redis_ttl
OIDC frontend: support Redis and session expiration
2 parents c969900 + 3c8d95f commit b14d52c

File tree

2 files changed

+175
-95
lines changed

2 files changed

+175
-95
lines changed

doc/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,22 +459,22 @@ Connect Relying Parties (RPs). The default configuration file can be found
459459
[here](../example/plugins/frontends/openid_connect_frontend.yaml.example).
460460
461461
As opposed to the other plugins, this plugin is NOT stateless (due to the nature of OpenID Connect using any other
462-
flow than "Implicit Flow"). However, the frontend supports using a MongoDB instance as its backend storage, so as long
462+
flow than "Implicit Flow"). However, the frontend supports using a MongoDB or Redis instance as its backend storage, so as long
463463
that's reachable from all machines it should not be a problem.
464464
465465
The configuration parameters available:
466466
* `signing_key_path`: path to a RSA Private Key file (PKCS#1). MUST be configured.
467-
* `db_uri`: connection URI to MongoDB instance where the data will be persisted, if it's not specified all data will only
467+
* `db_uri`: connection URI to MongoDB or Redis instance where the data will be persisted, if it's not specified all data will only
468468
be stored in-memory (not suitable for production use).
469-
* `client_db_uri`: connection URI to MongoDB instance where the client data will be persistent, if it's not specified the clients list will be received from the `client_db_path`.
469+
* `client_db_uri`: connection URI to MongoDB or Redis instance where the client data will be persistent, if it's not specified the clients list will be received from the `client_db_path`.
470470
* `client_db_path`: path to a file containing the client database in json format. It will only be used if `client_db_uri` is not set. If `client_db_uri` and `client_db_path` are not set, clients will only be stored in-memory (not suitable for production use).
471471
* `sub_hash_salt`: salt which is hashed into the `sub` claim. If it's not specified, SATOSA will generate a random salt on each startup, which means that users will get new `sub` value after every restart.
472472
* `provider`: provider configuration information. MUST be configured, the following configuration are supported:
473473
* `response_types_supported` (default: `[id_token]`): list of all supported response types, see [Section 3 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#Authentication).
474474
* `subject_types_supported` (default: `[pairwise]`): list of all supported subject identifier types, see [Section 8 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes)
475475
* `scopes_supported` (default: `[openid]`): list of all supported scopes, see [Section 5.4 of OIDC Core](http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims)
476476
* `client_registration_supported` (default: `No`): boolean whether [dynamic client registration is supported](https://openid.net/specs/openid-connect-registration-1_0.html).
477-
If dynamic client registration is not supported all clients must exist in the MongoDB instance configured by the `db_uri` in the `"clients"` collection of the `"satosa"` database.
477+
If dynamic client registration is not supported all clients must exist in the MongoDB or Redis instance configured by the `db_uri` in the `"clients"` collection of the `"satosa"` database.
478478
The registration info must be stored using the client id as a key, and use the parameter names of a [OIDC Registration Response](https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse).
479479
* `authorization_code_lifetime`: how long authorization codes should be valid, see [default](https://github.com/IdentityPython/pyop#token-lifetimes)
480480
* `access_token_lifetime`: how long access tokens should be valid, see [default](https://github.com/IdentityPython/pyop#token-lifetimes)

src/satosa/frontends/openid_connect.py

Lines changed: 171 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pyop.exceptions import (InvalidAuthenticationRequest, InvalidClientRegistrationRequest,
1717
InvalidClientAuthentication, OAuthError, BearerTokenError, InvalidAccessToken)
1818
from pyop.provider import Provider
19-
from pyop.storage import MongoWrapper
19+
from pyop.storage import StorageBase
2020
from pyop.subject_identifier import HashBasedSubjectIdentifierFactory
2121
from pyop.userinfo import Userinfo
2222
from pyop.util import should_fragment_encode
@@ -40,86 +40,53 @@ class OpenIDConnectFrontend(FrontendModule):
4040
"""
4141

4242
def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, name):
43-
self._validate_config(conf)
43+
_validate_config(conf)
4444
super().__init__(auth_req_callback_func, internal_attributes, base_url, name)
4545

4646
self.config = conf
47-
self.signing_key = RSAKey(key=rsa_load(conf["signing_key_path"]), use="sig", alg="RS256",
48-
kid=conf.get("signing_key_id", ""))
49-
50-
def _create_provider(self, endpoint_baseurl):
51-
response_types_supported = self.config["provider"].get("response_types_supported", ["id_token"])
52-
subject_types_supported = self.config["provider"].get("subject_types_supported", ["pairwise"])
53-
scopes_supported = self.config["provider"].get("scopes_supported", ["openid"])
54-
extra_scopes = self.config["provider"].get("extra_scopes")
55-
capabilities = {
56-
"issuer": self.base_url,
57-
"authorization_endpoint": "{}/{}".format(endpoint_baseurl, AuthorizationEndpoint.url),
58-
"jwks_uri": "{}/jwks".format(endpoint_baseurl),
59-
"response_types_supported": response_types_supported,
60-
"id_token_signing_alg_values_supported": [self.signing_key.alg],
61-
"response_modes_supported": ["fragment", "query"],
62-
"subject_types_supported": subject_types_supported,
63-
"claim_types_supported": ["normal"],
64-
"claims_parameter_supported": True,
65-
"claims_supported": [attribute_map["openid"][0]
66-
for attribute_map in self.internal_attributes["attributes"].values()
67-
if "openid" in attribute_map],
68-
"request_parameter_supported": False,
69-
"request_uri_parameter_supported": False,
70-
"scopes_supported": scopes_supported
71-
}
72-
73-
if 'code' in response_types_supported:
74-
capabilities["token_endpoint"] = "{}/{}".format(endpoint_baseurl, TokenEndpoint.url)
75-
76-
if self.config["provider"].get("client_registration_supported", False):
77-
capabilities["registration_endpoint"] = "{}/{}".format(endpoint_baseurl, RegistrationEndpoint.url)
78-
79-
authz_state = self._init_authorization_state()
47+
provider_config = self.config["provider"]
48+
provider_config["issuer"] = base_url
49+
50+
self.signing_key = RSAKey(
51+
key=rsa_load(self.config["signing_key_path"]),
52+
use="sig",
53+
alg="RS256",
54+
kid=self.config.get("signing_key_id", ""),
55+
)
56+
8057
db_uri = self.config.get("db_uri")
58+
self.user_db = (
59+
StorageBase.from_uri(db_uri, db_name="satosa", collection="authz_codes")
60+
if db_uri
61+
else {}
62+
)
63+
64+
sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16))
65+
authz_state = _init_authorization_state(provider_config, db_uri, sub_hash_salt)
66+
8167
client_db_uri = self.config.get("client_db_uri")
8268
cdb_file = self.config.get("client_db_path")
8369
if client_db_uri:
84-
cdb = MongoWrapper(client_db_uri, "satosa", "clients")
70+
cdb = StorageBase.from_uri(
71+
client_db_uri, db_name="satosa", collection="clients", ttl=None
72+
)
8573
elif cdb_file:
8674
with open(cdb_file) as f:
8775
cdb = json.loads(f.read())
8876
else:
8977
cdb = {}
90-
self.user_db = MongoWrapper(db_uri, "satosa", "authz_codes") if db_uri else {}
91-
self.provider = Provider(
78+
79+
self.endpoint_baseurl = "{}/{}".format(self.base_url, self.name)
80+
self.provider = _create_provider(
81+
provider_config,
82+
self.endpoint_baseurl,
83+
self.internal_attributes,
9284
self.signing_key,
93-
capabilities,
9485
authz_state,
86+
self.user_db,
9587
cdb,
96-
Userinfo(self.user_db),
97-
extra_scopes=extra_scopes,
98-
id_token_lifetime=self.config["provider"].get("id_token_lifetime", 3600),
9988
)
10089

101-
def _init_authorization_state(self):
102-
sub_hash_salt = self.config.get("sub_hash_salt", rndstr(16))
103-
db_uri = self.config.get("db_uri")
104-
if db_uri:
105-
authz_code_db = MongoWrapper(db_uri, "satosa", "authz_codes")
106-
access_token_db = MongoWrapper(db_uri, "satosa", "access_tokens")
107-
refresh_token_db = MongoWrapper(db_uri, "satosa", "refresh_tokens")
108-
sub_db = MongoWrapper(db_uri, "satosa", "subject_identifiers")
109-
else:
110-
authz_code_db = None
111-
access_token_db = None
112-
refresh_token_db = None
113-
sub_db = None
114-
115-
token_lifetimes = {k: self.config["provider"][k] for k in ["authorization_code_lifetime",
116-
"access_token_lifetime",
117-
"refresh_token_lifetime",
118-
"refresh_token_threshold"]
119-
if k in self.config["provider"]}
120-
return AuthorizationState(HashBasedSubjectIdentifierFactory(sub_hash_salt), authz_code_db, access_token_db,
121-
refresh_token_db, sub_db, **token_lifetimes)
122-
12390
def _get_extra_id_token_claims(self, user_id, client_id):
12491
if "extra_id_token_claims" in self.config["provider"]:
12592
config = self.config["provider"]["extra_id_token_claims"].get(client_id, [])
@@ -196,9 +163,6 @@ def register_endpoints(self, backend_names):
196163
else:
197164
backend_name = backend_names[0]
198165

199-
endpoint_baseurl = "{}/{}".format(self.base_url, self.name)
200-
self._create_provider(endpoint_baseurl)
201-
202166
provider_config = ("^.well-known/openid-configuration$", self.provider_config)
203167
jwks_uri = ("^{}/jwks$".format(self.name), self.jwks)
204168

@@ -209,42 +173,36 @@ def register_endpoints(self, backend_names):
209173
auth_path = urlparse(auth_endpoint).path.lstrip("/")
210174
else:
211175
auth_path = "{}/{}".format(self.name, AuthorizationEndpoint.url)
176+
212177
authentication = ("^{}$".format(auth_path), self.handle_authn_request)
213178
url_map = [provider_config, jwks_uri, authentication]
214179

215180
if any("code" in v for v in self.provider.configuration_information["response_types_supported"]):
216-
self.provider.configuration_information["token_endpoint"] = "{}/{}".format(endpoint_baseurl,
217-
TokenEndpoint.url)
218-
token_endpoint = ("^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint)
181+
self.provider.configuration_information["token_endpoint"] = "{}/{}".format(
182+
self.endpoint_baseurl, TokenEndpoint.url
183+
)
184+
token_endpoint = (
185+
"^{}/{}".format(self.name, TokenEndpoint.url), self.token_endpoint
186+
)
219187
url_map.append(token_endpoint)
220188

221-
self.provider.configuration_information["userinfo_endpoint"] = "{}/{}".format(endpoint_baseurl,
222-
UserinfoEndpoint.url)
223-
userinfo_endpoint = ("^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint)
189+
self.provider.configuration_information["userinfo_endpoint"] = (
190+
"{}/{}".format(self.endpoint_baseurl, UserinfoEndpoint.url)
191+
)
192+
userinfo_endpoint = (
193+
"^{}/{}".format(self.name, UserinfoEndpoint.url), self.userinfo_endpoint
194+
)
224195
url_map.append(userinfo_endpoint)
196+
225197
if "registration_endpoint" in self.provider.configuration_information:
226-
client_registration = ("^{}/{}".format(self.name, RegistrationEndpoint.url), self.client_registration)
198+
client_registration = (
199+
"^{}/{}".format(self.name, RegistrationEndpoint.url),
200+
self.client_registration,
201+
)
227202
url_map.append(client_registration)
228203

229204
return url_map
230205

231-
def _validate_config(self, config):
232-
"""
233-
Validates that all necessary config parameters are specified.
234-
:type config: dict[str, dict[str, Any] | str]
235-
:param config: the module config
236-
"""
237-
if config is None:
238-
raise ValueError("OIDCFrontend conf can't be 'None'.")
239-
240-
for k in {"signing_key_path", "provider"}:
241-
if k not in config:
242-
raise ValueError("Missing configuration parameter '{}' for OpenID Connect frontend.".format(k))
243-
244-
if "signing_key_id" in config and type(config["signing_key_id"]) is not str:
245-
raise ValueError(
246-
"The configuration parameter 'signing_key_id' is not defined as a string for OpenID Connect frontend.")
247-
248206
def _get_authn_request_from_state(self, state):
249207
"""
250208
Extract the clietns request stoed in the SATOSA state.
@@ -411,6 +369,128 @@ def userinfo_endpoint(self, context):
411369
return response
412370

413371

372+
def _validate_config(config):
373+
"""
374+
Validates that all necessary config parameters are specified.
375+
:type config: dict[str, dict[str, Any] | str]
376+
:param config: the module config
377+
"""
378+
if config is None:
379+
raise ValueError("OIDCFrontend configuration can't be 'None'.")
380+
381+
for k in {"signing_key_path", "provider"}:
382+
if k not in config:
383+
raise ValueError("Missing configuration parameter '{}' for OpenID Connect frontend.".format(k))
384+
385+
if "signing_key_id" in config and type(config["signing_key_id"]) is not str:
386+
raise ValueError(
387+
"The configuration parameter 'signing_key_id' is not defined as a string for OpenID Connect frontend.")
388+
389+
390+
def _create_provider(
391+
provider_config,
392+
endpoint_baseurl,
393+
internal_attributes,
394+
signing_key,
395+
authz_state,
396+
user_db,
397+
cdb,
398+
):
399+
response_types_supported = provider_config.get("response_types_supported", ["id_token"])
400+
subject_types_supported = provider_config.get("subject_types_supported", ["pairwise"])
401+
scopes_supported = provider_config.get("scopes_supported", ["openid"])
402+
extra_scopes = provider_config.get("extra_scopes")
403+
capabilities = {
404+
"issuer": provider_config["issuer"],
405+
"authorization_endpoint": "{}/{}".format(endpoint_baseurl, AuthorizationEndpoint.url),
406+
"jwks_uri": "{}/jwks".format(endpoint_baseurl),
407+
"response_types_supported": response_types_supported,
408+
"id_token_signing_alg_values_supported": [signing_key.alg],
409+
"response_modes_supported": ["fragment", "query"],
410+
"subject_types_supported": subject_types_supported,
411+
"claim_types_supported": ["normal"],
412+
"claims_parameter_supported": True,
413+
"claims_supported": [
414+
attribute_map["openid"][0]
415+
for attribute_map in internal_attributes["attributes"].values()
416+
if "openid" in attribute_map
417+
],
418+
"request_parameter_supported": False,
419+
"request_uri_parameter_supported": False,
420+
"scopes_supported": scopes_supported
421+
}
422+
423+
if 'code' in response_types_supported:
424+
capabilities["token_endpoint"] = "{}/{}".format(
425+
endpoint_baseurl, TokenEndpoint.url
426+
)
427+
428+
if provider_config.get("client_registration_supported", False):
429+
capabilities["registration_endpoint"] = "{}/{}".format(
430+
endpoint_baseurl, RegistrationEndpoint.url
431+
)
432+
433+
provider = Provider(
434+
signing_key,
435+
capabilities,
436+
authz_state,
437+
cdb,
438+
Userinfo(user_db),
439+
extra_scopes=extra_scopes,
440+
id_token_lifetime=provider_config.get("id_token_lifetime", 3600),
441+
)
442+
return provider
443+
444+
445+
def _init_authorization_state(provider_config, db_uri, sub_hash_salt):
446+
if db_uri:
447+
authz_code_db = StorageBase.from_uri(
448+
db_uri,
449+
db_name="satosa",
450+
collection="authz_codes",
451+
ttl=provider_config.get("authorization_code_lifetime", 600),
452+
)
453+
access_token_db = StorageBase.from_uri(
454+
db_uri,
455+
db_name="satosa",
456+
collection="access_tokens",
457+
ttl=provider_config.get("access_token_lifetime", 3600),
458+
)
459+
refresh_token_db = StorageBase.from_uri(
460+
db_uri,
461+
db_name="satosa",
462+
collection="refresh_tokens",
463+
ttl=provider_config.get("refresh_token_lifetime", None),
464+
)
465+
sub_db = StorageBase.from_uri(
466+
db_uri, db_name="satosa", collection="subject_identifiers", ttl=None
467+
)
468+
else:
469+
authz_code_db = None
470+
access_token_db = None
471+
refresh_token_db = None
472+
sub_db = None
473+
474+
token_lifetimes = {
475+
k: provider_config[k]
476+
for k in [
477+
"authorization_code_lifetime",
478+
"access_token_lifetime",
479+
"refresh_token_lifetime",
480+
"refresh_token_threshold",
481+
]
482+
if k in provider_config
483+
}
484+
return AuthorizationState(
485+
HashBasedSubjectIdentifierFactory(sub_hash_salt),
486+
authz_code_db,
487+
access_token_db,
488+
refresh_token_db,
489+
sub_db,
490+
**token_lifetimes,
491+
)
492+
493+
414494
def combine_return_input(values):
415495
return values
416496

0 commit comments

Comments
 (0)