py-logingov-client 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
logingov/__init__.py ADDED
File without changes
logingov/apps.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class LoginGovConfig(AppConfig):
5
+ name = "logingov"
6
+ label = "logingov"
7
+ verbose_name = "Login.gov authentication client"
logingov/helpers.py ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python
2
+ # encoding: utf-8
3
+ """
4
+ copyright (c) 2024- Earth Advantage.
5
+ All rights reserved
6
+ ..codeauthor::Fable Turas <fable@rainsoftware.tech>
7
+
8
+ """
9
+
10
+ # Imports from Standard Library
11
+ import base64
12
+ import hashlib
13
+
14
+ # Imports from Third Party Modules
15
+
16
+ # Imports from Django
17
+
18
+ # Local Imports
19
+
20
+ # Setup
21
+
22
+ # Constants
23
+
24
+ # Data Structure Definitions
25
+
26
+ # Private Functions
27
+
28
+
29
+ # Public Classes and Functions
30
+ def load_private_key(key_path):
31
+ """load private key from file for encoding."""
32
+ with open(key_path, 'rb') as pem_in:
33
+ return pem_in.read()
34
+
35
+
36
+ def encode_left128bits(string):
37
+ # 128 bits / 8 bits per byte = 16
38
+ # ref https://github.com/18F/identity-idp/blob/799fc62621a30c54e7edba17e376d94606d0c956/app/services/id_token_builder.rb#L69 # noqa
39
+ hashed = hashlib.sha256(string.encode("utf-8")).digest()[0:16]
40
+ b64 = base64.urlsafe_b64encode(hashed)
41
+ return b64.decode("utf-8").rstrip("=")
File without changes
File without changes
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python
2
+ # encoding: utf-8
3
+ """
4
+ copyright (c) 2023- Earth Advantage.
5
+ All rights reserved
6
+ ..codeauthor::Fable Turas <fable@rainsoftware.tech>
7
+
8
+ """
9
+
10
+ # Imports from Standard Library
11
+ import os
12
+
13
+ # Imports from Django
14
+ from django.core.management.base import BaseCommand
15
+
16
+ from logingov.settings.oidc_settings import oidc_settings
17
+
18
+ # Setup
19
+
20
+ # Constants
21
+
22
+ # Data Structure Definitions
23
+
24
+ # Private Functions
25
+
26
+
27
+ # Public Classes and Functions
28
+ class Command(BaseCommand):
29
+ help = (
30
+ 'Write Private Key from env vars, or argument, to file'
31
+ )
32
+
33
+ def add_arguments(self, parser): # pragma: no cover
34
+ parser.add_argument(
35
+ '-k', '--key',
36
+ help=(
37
+ 'Private key string, with escaped line '
38
+ 'breaks, to write to file.'
39
+ )
40
+ )
41
+ parser.add_argument(
42
+ '-e', '--env',
43
+ help=(
44
+ 'Environment variable name where private key is stored'
45
+ )
46
+ )
47
+
48
+ def handle(self, *args, **options): # pragma: no cover
49
+ pk = options['key']
50
+ env = options['env'] or 'LOGIN_GOV_OIDC_PVT_KEY'
51
+ pk = pk if pk else os.environ[env]
52
+ pk = pk.replace('\\n', '\n')
53
+ with open(oidc_settings.PVT_KEY_PATH, 'w') as file:
54
+ file.write(pk)
55
+ self.stdout.write(self.style.SUCCESS(
56
+ f'Successfully generated private key file '
57
+ f'at {oidc_settings.PVT_KEY_PATH}'
58
+ ))
logingov/oidc.py ADDED
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env python
2
+ # encoding: utf-8
3
+ """
4
+ copyright (c) 2024- Earth Advantage.
5
+ All rights reserved
6
+ ..codeauthor::Fable Turas <fable@rainsoftware.tech>
7
+
8
+ """
9
+
10
+ # Imports from Standard Library
11
+ import secrets
12
+ import time
13
+ from collections import OrderedDict
14
+ from urllib.parse import urljoin, urlencode, urlparse, parse_qsl
15
+
16
+ # Imports from Third Party Modules
17
+ import jwt
18
+ import requests
19
+ from jwcrypto import jwk
20
+
21
+ # Local Imports
22
+ from logingov.settings.oidc_settings import (
23
+ oidc_settings,
24
+ OIDCSettings,
25
+ DEFAULTS
26
+ )
27
+ from logingov.helpers import load_private_key, encode_left128bits
28
+
29
+ # Setup
30
+
31
+ # Constants
32
+ SANDBOX_URL = 'https://idp.int.identitysandbox.gov/'
33
+ PRODUCTION_URL = 'https://secure.login.gov/'
34
+ CONFIG_SETTINGS_ATTRIBS = {
35
+ 'ACR_VALUES': 'acr_values_supported',
36
+ 'SIGNING_ALGORITHM': 'token_endpoint_auth_signing_alg_values_supported',
37
+ 'GRANT_TYPE': 'grant_types_supported',
38
+ 'RESPONSE_TYPE': 'response_types_supported',
39
+ 'SCOPE': 'scopes_supported',
40
+ }
41
+ SPACE_SEPARATED_ATTRIBS = [
42
+ 'ACR_VALUES',
43
+ 'SCOPE'
44
+ ]
45
+
46
+ # Data Structure Definitions
47
+
48
+ # Private Functions
49
+
50
+
51
+ # Public Classes and Functions
52
+ class LoginGovOIDCClientError(Exception):
53
+ pass
54
+
55
+
56
+ class LoginGovOIDCAuthorizationError(LoginGovOIDCClientError):
57
+ """Exception for errors in the Login.gov authorization response"""
58
+ pass
59
+
60
+
61
+ class LoginGovOIDCRequestError(LoginGovOIDCClientError):
62
+ """Exception for raising errors from Login.gov API requests"""
63
+ pass
64
+
65
+
66
+ class LoginGovOIDCInvalidTokenError(LoginGovOIDCClientError):
67
+ """
68
+ Exception for raising errors when Login.gov tokens are invalid
69
+ or cannot be decoded
70
+ """
71
+ pass
72
+
73
+
74
+ class LoginGovOIDCConfigurationError(LoginGovOIDCClientError):
75
+ """
76
+ Exception for raising errors where the provided settings or
77
+ client instantiation parameters are invalid
78
+ """
79
+ pass
80
+
81
+
82
+ class LoginGovOIDCClient(object):
83
+ """
84
+ A client for handling login.gov OIDC requests and responses.
85
+ The client is framework-agnostic, and can be used in any Python
86
+ application that needs to interact with the login.gov OIDC service.
87
+
88
+ The client does not generate state or nonce values, or provide for
89
+ storage of these values for use in later calls. The calling code
90
+ is responsible for generating and managing these values.
91
+
92
+ Since the authorization is a redirect to the login.gov site, and the
93
+ response is a redirect back to the client uri, with query params,
94
+ the client does not handle the actual redirect. Therefore, it cannot
95
+ directly call for the authorization response. The calling code must
96
+ handle the redirect and pass the query params to the client, as a
97
+ dict, or query string, for token and user info requests.
98
+
99
+ Most of the client parameters are handled via settings. Settings must be
100
+ passed to the client on instantiation as a dict or OIDCSettings instance,
101
+ or may be configured within Django settings under the `LOGIN_GOV_OIDC`
102
+ space (assuming the calling code is a Django app and has added `logingov`
103
+ to the `INSTALLED_APPS`).
104
+ """
105
+
106
+ def __init__(self, state=None, nonce=None, locale=None,
107
+ authorization_response=None, settings=None):
108
+ if isinstance(settings, OIDCSettings): # pragma: no cover
109
+ self.settings = settings
110
+ else:
111
+ self.settings = OIDCSettings(
112
+ user_settings=settings, defaults=DEFAULTS
113
+ ) if settings else oidc_settings
114
+ self.settings.check_required_settings()
115
+
116
+ self.state = state
117
+ self.nonce = nonce
118
+ self.locale = locale
119
+ self.discovery_data = self._discovery()
120
+ self._validate_settings()
121
+ self.code = None
122
+ self.jti = None
123
+ if authorization_response:
124
+ if isinstance(authorization_response, str):
125
+ query = urlparse(authorization_response).query
126
+ authorization_response = OrderedDict(parse_qsl(query))
127
+ self._validate_authorization_response(authorization_response)
128
+ self.code = authorization_response.get('code')
129
+
130
+ def _get_base_url(self):
131
+ """
132
+ Get the base url for talking to the login.gov servers
133
+ based on the `ENVIRONMENT` setting or the `BASE_URL` setting.
134
+ """
135
+ base_url = self.settings.BASE_URL
136
+ env = self.settings.ENVIRONMENT
137
+ if base_url:
138
+ return base_url
139
+ else:
140
+ if env.lower().startswith('sand'):
141
+ return SANDBOX_URL
142
+ if env.lower().startswith('prod'):
143
+ return PRODUCTION_URL
144
+
145
+ def _discovery(self):
146
+ """
147
+ Uses the base url and DISCOVERY_ENDPOINT to get the OIDC discovery
148
+ data. All other endpoints are pulled from the discovery results,
149
+ rather than being hard-coded, or settings values, to ensure the client
150
+ is always using the most up-to-date login.gov information.
151
+ """
152
+ endpoint = self.settings.DISCOVERY_ENDPOINT
153
+ url = urljoin(self._get_base_url(), endpoint)
154
+ return self.get(url)
155
+
156
+ def _validate_settings(self):
157
+ """
158
+ Validates the relevant settings values against the discovery data
159
+ supported values.
160
+ """
161
+ invalid = []
162
+ for setting, config_key in CONFIG_SETTINGS_ATTRIBS.items():
163
+ setting_val = getattr(self.settings, setting)
164
+ config_val = self.discovery_data[config_key]
165
+ if setting in SPACE_SEPARATED_ATTRIBS:
166
+ setting_val = setting_val.split(' ')
167
+ if any([val not in config_val for val in setting_val]):
168
+ invalid.append(setting)
169
+ elif setting_val not in config_val:
170
+ invalid.append(setting)
171
+ if invalid:
172
+ raise LoginGovOIDCConfigurationError(
173
+ f'The following "LOGIN_GOV_OIDC" settings have values '
174
+ f'that do not match the Login.gov supported values: '
175
+ f'{", ".join(invalid)}'
176
+ )
177
+
178
+ def _prepare_authorize_params(self):
179
+ """
180
+ Prepares the query string parameters for inclusion in the authorization
181
+ redirect URL based on provided settings and the client instantiation
182
+ values.
183
+ Both state and nonce are required, and should be setup and managed
184
+ inside the code calling the client. The same state and nonce will be
185
+ needed again later to validate the authorization and token responses.
186
+ """
187
+ if not self.state or not self.nonce:
188
+ raise LoginGovOIDCConfigurationError(
189
+ 'You must provide a state and nonce to the client '
190
+ 'to prepare an authorization redirect'
191
+ )
192
+ scope = self.settings.SCOPE.split(' ')
193
+ if 'openid' not in scope:
194
+ scope.insert(0, 'openid')
195
+ params = {
196
+ 'acr_values': self.settings.ACR_VALUES,
197
+ 'client_id': self.settings.CLIENT_ID,
198
+ 'prompt': self.settings.PROMPT,
199
+ 'response_type': self.settings.RESPONSE_TYPE,
200
+ 'redirect_uri': self.settings.LOGIN_REDIRECT_URI,
201
+ 'state': self.state,
202
+ 'nonce': self.nonce,
203
+ }
204
+ if self.locale in self.settings.ACCEPTED_LOCALES:
205
+ params['locale'] = self.locale
206
+ return f'?{urlencode(params)}&scope={"+".join(scope)}'
207
+
208
+ def _validate_authorization_response(self, auth_response):
209
+ """
210
+ Ensures the authorization response is not an error, contains a code,
211
+ and that the state used to generate the authorization request matches
212
+ the state returned in the response.
213
+ """
214
+ err = auth_response.get('error') or ''
215
+ if err:
216
+ err_desc = auth_response.get('error_description') or ''
217
+ err_msg = ': '.join([err, err_desc])
218
+ raise LoginGovOIDCAuthorizationError(err_msg)
219
+ if not auth_response.get('code'):
220
+ raise LoginGovOIDCAuthorizationError(
221
+ 'No authorization code provided'
222
+ )
223
+ if not self.state:
224
+ raise LoginGovOIDCConfigurationError(
225
+ 'You must provide the state from your authorization request '
226
+ 'to the client to validate the authorization response.'
227
+ )
228
+ if auth_response.get('state') != self.state:
229
+ raise LoginGovOIDCAuthorizationError('Invalid state in response')
230
+
231
+ def _prepare_token_assertion(self):
232
+ """Prepares the client assertion JWT for use in the token request."""
233
+ self.jti = secrets.token_hex(16)
234
+ args = {
235
+ 'iss': self.settings.CLIENT_ID,
236
+ 'sub': self.settings.CLIENT_ID,
237
+ 'aud': self.discovery_data['token_endpoint'],
238
+ 'jti': self.jti,
239
+ 'exp': int(time.time()) + 300,
240
+ }
241
+ if self.settings.PVT_KEY_PATH:
242
+ key = load_private_key(self.settings.PVT_KEY_PATH)
243
+ else:
244
+ key = self.settings.PVT_KEY_PEM
245
+ return jwt.encode(args, key, algorithm=self.settings.SIGNING_ALGORITHM)
246
+
247
+ def _prepare_token_params(self):
248
+ """Prepares the POST data for use in the token request."""
249
+ return {
250
+ 'client_assertion': self._prepare_token_assertion(),
251
+ 'client_assertion_type': self.settings.CLIENT_ASSERTION_TYPE,
252
+ 'code': self.code,
253
+ 'grant_type': self.settings.GRANT_TYPE,
254
+ }
255
+
256
+ def _login_gov_pub_keys(self):
257
+ """
258
+ Gets the login.gov public keys for use in decoding and validating
259
+ the token request response.
260
+ """
261
+ return self.get(self.discovery_data['jwks_uri'])['keys']
262
+
263
+ def _decoded_id_token(self, id_token):
264
+ """
265
+ Decodes the ID token using the login.gov public keys.
266
+ A LoginGovOIDCInvalidTokenError will be raised if the ID token cannot
267
+ be decoded, or the aud, iss, exp, nbf, or iat values are determined
268
+ to be invalid during the decoding.
269
+ """
270
+ for key in self._login_gov_pub_keys():
271
+ jwk_key = jwk.JWK(**key)
272
+ pem = jwk_key.export_to_pem().decode('utf-8')
273
+ try:
274
+ return jwt.decode(
275
+ id_token, pem,
276
+ algorithms=[self.settings.SIGNING_ALGORITHM],
277
+ audience=[self.settings.CLIENT_ID],
278
+ issuer=self.discovery_data['issuer'],
279
+ leeway=2
280
+ )
281
+ except Exception:
282
+ continue
283
+ else:
284
+ raise LoginGovOIDCInvalidTokenError('Unable to decode ID token')
285
+
286
+ def _validated_token(self, tokens):
287
+ """
288
+ Ensures the validity of the token response.
289
+ Raises a LoginGovOIDCInvalidTokenError if the nonce, access token hash,
290
+ code hash, or any of the base JWT values are invalid.
291
+ """
292
+ if not self.nonce:
293
+ raise LoginGovOIDCConfigurationError(
294
+ 'You must provide the nonce from your authorization request '
295
+ 'to the client to validate the authorization token.'
296
+ )
297
+ id_token = self._decoded_id_token(tokens['id_token'])
298
+ if id_token['nonce'] != self.nonce:
299
+ raise LoginGovOIDCInvalidTokenError('Invalid nonce in ID token')
300
+ if id_token['at_hash'] != encode_left128bits(tokens['access_token']): # pragma: no cover # noqa
301
+ raise LoginGovOIDCInvalidTokenError(
302
+ 'Invalid access token hash in ID token'
303
+ )
304
+ if id_token['c_hash'] != encode_left128bits(self.code): # pragma: no cover # noqa
305
+ raise LoginGovOIDCInvalidTokenError(
306
+ 'Invalid code hash in ID token'
307
+ )
308
+ tokens['id_token'] = id_token
309
+ return tokens
310
+
311
+ @staticmethod
312
+ def post(url, data):
313
+ """
314
+ Sends a POST request to the provided URL with the provided data,
315
+ handling any error statuses as a LoginGovOIDCRequestError.
316
+ """
317
+ rsp = requests.post(url, data=data)
318
+ if rsp.status_code != 200:
319
+ raise LoginGovOIDCRequestError(
320
+ f'Error {rsp.status_code} from {url}: {rsp.json()["error"]}'
321
+ )
322
+ return rsp.json()
323
+
324
+ @staticmethod
325
+ def get(url, params=None, headers=None):
326
+ """
327
+ Sends a GET request to the provided URL with the provided params and/or
328
+ headers, handling any error statuses as a LoginGovOIDCRequestError.
329
+ """
330
+ rsp = requests.get(url, params=params, headers=headers)
331
+ if rsp.status_code != 200:
332
+ raise LoginGovOIDCRequestError(
333
+ f'Error {rsp.status_code} from {url}: {rsp.json()["error"]}'
334
+ )
335
+ return rsp.json()
336
+
337
+ @property
338
+ def authorize_redirect(self):
339
+ """Generate the login.gov authorization redirect URI."""
340
+ url = self.discovery_data['authorization_endpoint']
341
+ return urljoin(url, self._prepare_authorize_params())
342
+
343
+ @property
344
+ def token_response(self):
345
+ """
346
+ Request tokens from login.gov using the code from
347
+ the authorization login redirect.
348
+ """
349
+ if not self.code:
350
+ raise LoginGovOIDCConfigurationError(
351
+ 'You must complete the Login.gov authorization and pass '
352
+ 'the results to the client before requesting tokens '
353
+ 'or user info.'
354
+ )
355
+ url = self.discovery_data['token_endpoint']
356
+ token_rsp = self.post(url, data=self._prepare_token_params())
357
+ return self._validated_token(token_rsp)
358
+
359
+ @property
360
+ def userinfo_id_token(self):
361
+ """
362
+ Request tokens and user info from login.gov using the code from
363
+ the authorization login redirect.
364
+ """
365
+ token_rsp = self.token_response
366
+ headers = {
367
+ 'Authorization':
368
+ f'{token_rsp["token_type"]} {token_rsp["access_token"]}'
369
+ }
370
+ url = self.discovery_data['userinfo_endpoint']
371
+ return self.get(url, headers=headers), token_rsp['id_token']
372
+
373
+ @property
374
+ def logout_redirect(self):
375
+ """Generate the login.gov logout redirect URI."""
376
+ redirect = self.settings.LOGOUT_REDIRECT_URI
377
+ if not redirect:
378
+ raise LoginGovOIDCConfigurationError(
379
+ 'You must configure a LOGOUT_REDIRECT_URI.'
380
+ )
381
+ params = {
382
+ 'client_id': self.settings.CLIENT_ID,
383
+ 'post_logout_redirect_uri': redirect
384
+ }
385
+ if self.state:
386
+ params['state'] = self.state
387
+ url = self.discovery_data['end_session_endpoint']
388
+ return urljoin(url, f'?{urlencode(params)}')
File without changes
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python
2
+ # encoding: utf-8
3
+ """
4
+ copyright (c) 2024- Earth Advantage.
5
+ All rights reserved
6
+ ..codeauthor::Fable Turas <fable@rainsoftware.tech>
7
+
8
+ Settings for py-logingov-client are all namespaced in the
9
+ LOGIN_GOV_OIDC setting.
10
+ For example your project's `settings.py` file might look like this:
11
+
12
+ LOGIN_GOV_OIDC = {
13
+ }
14
+
15
+ This module provides the `oidc_setting` object, that is used to access
16
+ py-logingov settings, checking for user settings first, then falling
17
+ back to the defaults."""
18
+
19
+ # Imports from Standard Library
20
+
21
+ # Imports from Third Party Modules
22
+
23
+ # Imports from Django
24
+ try: # pragma: no cover
25
+ from django.conf import settings
26
+ from django.core.signals import setting_changed
27
+ except ImportError:
28
+ settings = None
29
+
30
+ # Local Imports
31
+
32
+ # Setup
33
+
34
+ # Constants
35
+ DEFAULTS = {
36
+ 'CLIENT_ID': '',
37
+ 'LOGIN_REDIRECT_URI': '',
38
+ 'LOGOUT_REDIRECT_URI': '',
39
+ 'PVT_KEY_PATH': '',
40
+ 'PVT_KEY_PEM': '',
41
+ 'ACR_VALUES': 'http://idmanagement.gov/ns/assurance/ial/1',
42
+ 'SCOPE': 'openid email',
43
+ 'PROMPT': 'select_account',
44
+ 'RESPONSE_TYPE': 'code',
45
+ 'GRANT_TYPE': 'authorization_code',
46
+ 'CLIENT_ASSERTION_TYPE': (
47
+ 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
48
+ ),
49
+ 'SIGNING_ALGORITHM': 'RS256',
50
+ 'DISCOVERY_ENDPOINT': '.well-known/openid-configuration',
51
+ 'ENVIRONMENT': 'sandbox',
52
+ 'BASE_URL': '',
53
+ 'ACCEPTED_LOCALES': ['es', 'fr']
54
+ }
55
+ REQUIRED_SETTINGS = (
56
+ 'CLIENT_ID',
57
+ 'LOGIN_REDIRECT_URI',
58
+ 'SCOPE',
59
+ 'ACR_VALUES'
60
+ )
61
+ ALTERNATE_REQUIRED_SETTINGS = (
62
+ ('PVT_KEY_PATH', 'PVT_KEY_PEM'),
63
+ ('ENVIRONMENT', 'BASE_URL')
64
+ )
65
+ # Data Structure Definitions
66
+
67
+ # Private Functions
68
+
69
+
70
+ # Public Classes and Functions
71
+ class OIDCSettings:
72
+ """
73
+ A settings object, that allows settings to be accessed as properties.
74
+ For example:
75
+
76
+ from django_logingov_oidc.settings import oidc_settings
77
+ print(oidc_settings.DEFAULTS)
78
+
79
+ For Django usage, settings must be namespaced under
80
+ the `LOGIN_GOV_OIDC` name.
81
+ """
82
+
83
+ def __init__(self, user_settings=None, defaults=None):
84
+ self._user_settings = user_settings
85
+
86
+ self.defaults = defaults or DEFAULTS
87
+ self._registered_attrs = set()
88
+
89
+ @property
90
+ def user_settings(self):
91
+ return (
92
+ self._user_settings or getattr(settings, "LOGIN_GOV_OIDC", {})
93
+ )
94
+
95
+ def __getattr__(self, attr):
96
+ if attr != 'user_settings' and attr not in self.defaults:
97
+ raise AttributeError(f"Invalid OIDC setting: '{attr}'")
98
+ try:
99
+ val = self.user_settings[attr]
100
+ except KeyError:
101
+ val = self.defaults[attr]
102
+
103
+ self._registered_attrs.add(attr)
104
+ setattr(self, attr, val)
105
+ return val
106
+
107
+ def check_required_settings(self):
108
+ """Validates that all required settings are present and not empty"""
109
+ for setting in REQUIRED_SETTINGS:
110
+ setting_val = getattr(self, setting, None)
111
+ if setting_val in ['', None]:
112
+ raise ValueError(f"Setting {setting} must not be empty")
113
+ for keys in ALTERNATE_REQUIRED_SETTINGS:
114
+ vals = [
115
+ getattr(self, setting, None)
116
+ for setting in keys
117
+ ]
118
+ if not any(vals):
119
+ raise ValueError(
120
+ f'At least one of {", ".join(keys)} must not be empty'
121
+ )
122
+
123
+ def reload(self):
124
+ """
125
+ Resets all registered settings to ensure prior set values are not
126
+ lingering when Django settings are changed.
127
+ """
128
+ for attr in self._registered_attrs:
129
+ delattr(self, attr)
130
+ self._registered_attrs.clear()
131
+ self._user_settings = {}
132
+
133
+
134
+ oidc_settings = OIDCSettings(None, DEFAULTS)
135
+
136
+
137
+ def reload_oidc_settings(setting=None, *args, **kwargs): # pragma: no cover
138
+ if setting == "LOGIN_GOV_OIDC":
139
+ oidc_settings.reload()
140
+
141
+
142
+ if settings: # pragma: no cover
143
+ setting_changed.connect(reload_oidc_settings)
File without changes