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 +0 -0
- logingov/apps.py +7 -0
- logingov/helpers.py +41 -0
- logingov/management/__init__.py +0 -0
- logingov/management/commands/__init__.py +0 -0
- logingov/management/commands/logingov_pk_to_file.py +58 -0
- logingov/oidc.py +388 -0
- logingov/settings/__init__.py +0 -0
- logingov/settings/oidc_settings.py +143 -0
- logingov/tests/__init__.py +0 -0
- logingov/tests/mock_server.py +270 -0
- logingov/tests/test_oidc.py +305 -0
- logingov/tests/test_oidc_settings.py +78 -0
- py_logingov_client-0.0.0.dist-info/LICENSE +29 -0
- py_logingov_client-0.0.0.dist-info/METADATA +25 -0
- py_logingov_client-0.0.0.dist-info/RECORD +18 -0
- py_logingov_client-0.0.0.dist-info/WHEEL +5 -0
- py_logingov_client-0.0.0.dist-info/top_level.txt +1 -0
logingov/__init__.py
ADDED
|
File without changes
|
logingov/apps.py
ADDED
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
|