pdmv-http-client 2.1.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.
@@ -0,0 +1,102 @@
1
+ """
2
+ Main operations to load, save and renew credentials
3
+ for an HTTP client session.
4
+ """
5
+
6
+ from abc import ABC, abstractmethod
7
+
8
+ from requests import Response, Session
9
+
10
+
11
+ class AuthInterface(ABC):
12
+ """
13
+ Defines the main operations to set a credential
14
+ to authenticate and authorize HTTP requests targeting CERN
15
+ web applications.
16
+
17
+ For this context, a credential could be:
18
+ - OAuth2 token: Requested via `Client Credentials` or `Device Authorization` grants.
19
+ - Format: JSON Web Tokens (JWT)
20
+ - HTTP cookie: A cookie file requested using CERN internal packages.
21
+ - Format: Netscape Cookie
22
+
23
+ For more details, check the `handlers` module.
24
+ """
25
+
26
+ @abstractmethod
27
+ def _load_credential(self):
28
+ """
29
+ Loads a credential from the given path.
30
+
31
+ Returns:
32
+ None: In case it was not possible to load a credential
33
+ from the given path.
34
+ """
35
+ ...
36
+
37
+ @abstractmethod
38
+ def _request_credential(self):
39
+ """
40
+ Requests a credential to the CERN Auth service.
41
+
42
+ Raises:
43
+ PermissionError: If it is not possible to obtain a credential
44
+ because the CERN Auth service refused the request.
45
+ RuntimeError: Wrapper for any other possible error cause, the
46
+ original exception must be chained with this one.
47
+ """
48
+ ...
49
+
50
+ @abstractmethod
51
+ def _save_credential(self) -> None:
52
+ """
53
+ Saves a valid credential in a file into the provided path.
54
+
55
+ Raises:
56
+ OSError: In case it is not possible to store the file
57
+ in the provided path because lack of permissions to write
58
+ in the destination.
59
+ """
60
+ ...
61
+
62
+ @abstractmethod
63
+ def authenticate(self) -> None:
64
+ """
65
+ Provides an entry point to automatically scan if it is possible
66
+ to set credentials by picking them from default locations
67
+ or requests them to the CERN Auth service. Also, this is useful
68
+ for renewing credentials when they are expired.
69
+ """
70
+ ...
71
+
72
+ @abstractmethod
73
+ def configure(self, session: Session) -> Session:
74
+ """
75
+ Configures the given `request.Session` instance to set
76
+ the credential to perform authenticated requests.
77
+ """
78
+ ...
79
+
80
+ @classmethod
81
+ def validate_response(cls, response: Response) -> bool:
82
+ """
83
+ Checks that a given response has been redirected to
84
+ the CERN Authentication login server or been rejected by it
85
+ server because of a lack of permissions.
86
+
87
+ Args:
88
+ response: Response to check
89
+
90
+ Returns:
91
+ True: If the response was resolved by the requested web server
92
+ and its status code is not 401 nor 403.
93
+ False: If the response code was 401 or 403 or the resolver
94
+ was the CERN Authentication service.
95
+ """
96
+ # Intercepted and redirected to the authentication page.
97
+ if response.url.startswith(
98
+ "https://auth.cern.ch/auth/realms/cern"
99
+ ) or response.status_code in (401, 403):
100
+ return False
101
+
102
+ return True
File without changes
@@ -0,0 +1,307 @@
1
+ """
2
+ This module provides a handler
3
+ to authenticate requests using OAuth2 tokens.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from json import JSONDecodeError
9
+ from pathlib import Path
10
+
11
+ import requests
12
+ from requests.sessions import Session
13
+
14
+ from rest.client.auth.auth_interface import AuthInterface
15
+ from rest.utils.logger import LoggerFactory
16
+
17
+
18
+ class AccessTokenHandler(AuthInterface):
19
+ """
20
+ Loads an access token from a JSON file and configures
21
+ an HTTP session to make use of it. This implements the API
22
+ access procedure described in:
23
+
24
+ - https://auth.docs.cern.ch/user-documentation/oidc/api-access/
25
+
26
+ Attributes:
27
+ _url: Target web application URL.
28
+ _credential: A JSON object including the access token, the refresh token
29
+ and some metadata.
30
+ _credential_path: Path to load the access token from or to persist in.
31
+ _client_id: ID for the client application, registered in the application portal,
32
+ to be use
33
+ -client_secret: Secret for the client application.
34
+ _target_application: Client ID linked to the target web
35
+ application.
36
+ """
37
+
38
+ TOKEN_ENDPOINT = "https://auth.cern.ch/auth/realms/cern/api-access/token"
39
+
40
+ def __init__(
41
+ self,
42
+ url: str,
43
+ credential_path: Path,
44
+ client_id: str,
45
+ client_secret: str,
46
+ target_application: str,
47
+ ) -> None:
48
+ self._url = url
49
+ self._credential_path = credential_path
50
+ self._client_id = client_id
51
+ self._client_secret = client_secret
52
+ self._target_application = target_application
53
+ self._credential: dict = {}
54
+ self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
55
+
56
+ def _load_credential(self) -> dict:
57
+ try:
58
+ with open(file=self._credential_path, encoding="utf-8") as f:
59
+ credential = json.load(f)
60
+ return credential
61
+ except (OSError, JSONDecodeError):
62
+ return {}
63
+
64
+ def _request_credential(self) -> dict:
65
+ access_token = requests.post(
66
+ url=AccessTokenHandler.TOKEN_ENDPOINT,
67
+ data={
68
+ "grant_type": "client_credentials",
69
+ "client_id": self._client_id,
70
+ "client_secret": self._client_secret,
71
+ "audience": self._target_application,
72
+ },
73
+ )
74
+
75
+ if access_token.status_code != 200:
76
+ msg = (
77
+ f"Error requesting access token ({access_token.status_code}): "
78
+ f"{json.dumps(access_token.json(), indent=4)}"
79
+ )
80
+ raise PermissionError(msg)
81
+
82
+ return access_token.json()
83
+
84
+ def _save_credential(self) -> None:
85
+ with open(file=self._credential_path, mode="w", encoding="utf-8") as f:
86
+ json.dump(obj=self._credential, fp=f, indent=4)
87
+
88
+ os.chmod(path=self._credential_path, mode=0o600)
89
+
90
+ def authenticate(self) -> None:
91
+ loaded_access_token = self._load_credential()
92
+ if self._validate(access_token=loaded_access_token):
93
+ self._credential = loaded_access_token
94
+ return
95
+
96
+ # Access token is not valid anymore, request another one
97
+ self._logger.debug("Access token is not valid, requesting a new one")
98
+ new_access_token = self._request_credential()
99
+ if self._validate(access_token=new_access_token):
100
+ self._credential = new_access_token
101
+ self._save_credential()
102
+ return
103
+
104
+ def configure(self, session: Session) -> Session:
105
+ if not self._credential:
106
+ self.authenticate()
107
+
108
+ raw_access_token = self._credential.get("access_token", "")
109
+ session.headers.update({"Authorization": f"Bearer {raw_access_token}"})
110
+ return session
111
+
112
+ def _validate(self, access_token: dict) -> bool:
113
+ """
114
+ Checks if the provided access token is valid to consume a resource in
115
+ the target web application.
116
+
117
+ Args:
118
+ access_token: OAuth2 access token retrieve from
119
+ CERN Authentication service.
120
+ """
121
+ raw_access_token = access_token.get("access_token", "")
122
+ test_response = requests.get(
123
+ self._url, headers={"Authorization": f"Bearer {raw_access_token}"}
124
+ )
125
+ return self.validate_response(test_response)
126
+
127
+
128
+ class IDTokenHandler(AuthInterface):
129
+ """
130
+ Loads an ID token from a file or requests a new one
131
+ via Device Code Authorization Grant or refresh tokens (in case it is
132
+ provided in the JSON file). This implements the process described at:
133
+
134
+ - https://auth.docs.cern.ch/user-documentation/oidc/device-code/
135
+
136
+ Attributes:
137
+ _url: Target web application URL.
138
+ _credential: A JSON object including the access token, the refresh token
139
+ and some metadata.
140
+ _credential_path: Path to load the access token from or to persist in.
141
+ _client_id: ID for the client application, registered in the application portal,
142
+ to use. This application MUST be configured as a public client, so that no
143
+ client secret is required.
144
+ _target_application: Client ID linked to the target web
145
+ application.
146
+ """
147
+
148
+ TOKEN_ENDPOINT = (
149
+ "https://auth.cern.ch/auth/realms/cern/protocol/openid-connect/token"
150
+ )
151
+ DEVICE_ENDPOINT = (
152
+ "https://auth.cern.ch/auth/realms/cern/protocol/openid-connect/auth/device"
153
+ )
154
+
155
+ def __init__(
156
+ self,
157
+ url: str,
158
+ credential_path: Path,
159
+ target_application: str,
160
+ ) -> None:
161
+ self._url = url
162
+ self._credential_path = credential_path
163
+ self._target_application = target_application
164
+ self._credential: dict = {}
165
+ self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
166
+
167
+ def _load_credential(self) -> dict:
168
+ try:
169
+ with open(file=self._credential_path, encoding="utf-8") as f:
170
+ credential = json.load(f)
171
+ return credential
172
+ except (OSError, JSONDecodeError):
173
+ return {}
174
+
175
+ def _refresh_token(self) -> dict:
176
+ """
177
+ Renews the current ID token by requesting a new one
178
+ using the available refresh token.
179
+
180
+ Returns:
181
+ dict: ID token.
182
+ """
183
+ refresh_request = requests.post(
184
+ url=IDTokenHandler.TOKEN_ENDPOINT,
185
+ data={
186
+ "grant_type": "refresh_token",
187
+ "client_id": self._target_application,
188
+ "refresh_token": self._credential.get("refresh_token", ""),
189
+ },
190
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
191
+ )
192
+ details = refresh_request.json()
193
+ if refresh_request.status_code != 200:
194
+ self._logger.debug(
195
+ "Unable to refresh ID token (HTTP code: %s), details: %s",
196
+ refresh_request.status_code,
197
+ json.dumps(refresh_request.json(), indent=4),
198
+ )
199
+ return {}
200
+
201
+ id_token = details
202
+ return id_token
203
+
204
+ def _request_new_id_token(self) -> dict:
205
+ """
206
+ Request a new ID token to the authentication service
207
+ using a Device Code Authorization Grant. This requires
208
+ human interaction to complete the flow
209
+
210
+ Returns:
211
+ dict: ID token.
212
+ """
213
+ # Request a device code pre-authentication.
214
+ device_code = requests.post(
215
+ url=IDTokenHandler.DEVICE_ENDPOINT,
216
+ data={"client_id": self._target_application},
217
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
218
+ )
219
+ self._logger.info(
220
+ "Go to: %s\n", device_code.json()["verification_uri_complete"]
221
+ )
222
+ input("Press Enter once you have authenticated...")
223
+
224
+ code_details: dict = device_code.json()
225
+ if device_code.status_code == 401:
226
+ msg = (
227
+ f"Make sure the application ({self._target_application}) is configured as a public client"
228
+ f"Error: {json.dumps(code_details, indent=4)}"
229
+ )
230
+ raise PermissionError(msg)
231
+
232
+ # Request the ID token
233
+ device_completion = requests.post(
234
+ url=IDTokenHandler.TOKEN_ENDPOINT,
235
+ data={
236
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
237
+ "device_code": code_details["device_code"],
238
+ "client_id": self._target_application,
239
+ },
240
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
241
+ )
242
+
243
+ completion_details = device_completion.json()
244
+ if device_completion.status_code != 200:
245
+ msg = (
246
+ f"Unable to request an ID token\n"
247
+ f"Error: {json.dumps(completion_details, indent=4)}"
248
+ )
249
+ raise PermissionError(msg)
250
+
251
+ id_token = completion_details
252
+ return id_token
253
+
254
+ def _request_credential(self) -> dict:
255
+ # Attempt to refresh the token
256
+ id_token = self._refresh_token()
257
+ if id_token:
258
+ return id_token
259
+
260
+ # Request a new ID token
261
+ self._logger.debug(
262
+ "Requesting new ID token, asking the user to manually complete the flow"
263
+ )
264
+ return self._request_new_id_token()
265
+
266
+ def _save_credential(self) -> None:
267
+ with open(file=self._credential_path, mode="w", encoding="utf-8") as f:
268
+ json.dump(obj=self._credential, fp=f, indent=4)
269
+
270
+ os.chmod(path=self._credential_path, mode=0o600)
271
+
272
+ def authenticate(self) -> None:
273
+ loaded_id_token = self._load_credential()
274
+ if self._validate(id_token=loaded_id_token):
275
+ self._credential = loaded_id_token
276
+ return
277
+
278
+ # ID token is not valid anymore, request another one
279
+ self._logger.debug("ID token is not valid, requesting a new one")
280
+ new_id_token = self._request_credential()
281
+ if self._validate(id_token=new_id_token):
282
+ self._credential = new_id_token
283
+ self._save_credential()
284
+ return
285
+
286
+ def configure(self, session: Session) -> Session:
287
+ if not self._credential:
288
+ self.authenticate()
289
+
290
+ raw_access_token = self._credential.get("access_token", "")
291
+ session.headers.update({"Authorization": f"Bearer {raw_access_token}"})
292
+ return session
293
+
294
+ def _validate(self, id_token: dict) -> bool:
295
+ """
296
+ Checks if the provided ID token is valid to consume a resource in
297
+ the target web application.
298
+
299
+ Args:
300
+ id_token: OIDC ID token retrieve from
301
+ CERN Authentication service.
302
+ """
303
+ raw_access_token = id_token.get("access_token", "")
304
+ test_response = requests.get(
305
+ self._url, headers={"Authorization": f"Bearer {raw_access_token}"}
306
+ )
307
+ return self.validate_response(test_response)
@@ -0,0 +1,110 @@
1
+ """
2
+ This module provides a handler
3
+ to load a cookie from the file system.
4
+ """
5
+
6
+ import os
7
+ from http.cookiejar import LoadError, MozillaCookieJar
8
+ from pathlib import Path
9
+ from typing import Union
10
+
11
+ import requests
12
+ from requests.sessions import Session
13
+
14
+ from rest.client.auth.auth_interface import AuthInterface
15
+ from rest.utils.logger import LoggerFactory
16
+ from rest.utils.shell import run_command
17
+
18
+
19
+ class SessionCookieHandler(AuthInterface):
20
+ """
21
+ Loads a Netscape cookie from a file and configures
22
+ an HTTP session to make use of it.
23
+
24
+ Attributes:
25
+ _url: Target web application URL.
26
+ _credential: Session cookie loaded from a Netscape file.
27
+ """
28
+
29
+ def __init__(self, url: str, credential_path: Path):
30
+ self._url = url
31
+ self._credential_path = credential_path
32
+ self._credential: Union[MozillaCookieJar, None] = None
33
+ self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
34
+
35
+ def _load_credential(self) -> Union[MozillaCookieJar, None]:
36
+ try:
37
+ cookie = MozillaCookieJar(self._credential_path)
38
+ cookie.load()
39
+ return cookie
40
+ except (LoadError, OSError):
41
+ return None
42
+
43
+ def _request_credential(self) -> MozillaCookieJar:
44
+ # Remove the cookie file in case there's available
45
+ self._credential_path.unlink(missing_ok=True)
46
+
47
+ # Request a session cookie using CERN internal packages.
48
+ command = (
49
+ f"auth-get-sso-cookie -u '{self._url}' -o '{str(self._credential_path)}'"
50
+ )
51
+ _, stderr, exit_code = run_command(command=command)
52
+
53
+ # Most likely the package is not available or
54
+ # there's no valid Kerberos ticket.
55
+ if exit_code != 0 or stderr:
56
+ msg = (
57
+ f"Session cookie requested via: '{command}'\n"
58
+ f"Standard error: {stderr}\n"
59
+ )
60
+ raise RuntimeError(msg)
61
+
62
+ # Load the cookie
63
+ return self._load_credential()
64
+
65
+ def _save_credential(self) -> None:
66
+ # Credential is already stored, just change its permissions.
67
+ os.chmod(path=self._credential_path, mode=0o600)
68
+
69
+ def authenticate(self) -> None:
70
+ loaded_cookie = self._load_credential()
71
+ if self._validate(cookie=loaded_cookie):
72
+ self._credential = loaded_cookie
73
+ self._save_credential()
74
+ return
75
+
76
+ # The credential is not valid anymore, renew it.
77
+ self._logger.debug("Session cookie is not valid, requesting a new one")
78
+ renewed_cookie = self._request_credential()
79
+ if self._validate(cookie=renewed_cookie):
80
+ self._credential = renewed_cookie
81
+ self._save_credential()
82
+ return
83
+
84
+ # It is not possible to authenticate a request
85
+ # using session cookies.
86
+ msg = (
87
+ f"Unable to consume a resource in the target web application ({self._url}) "
88
+ "using session cookies.\n"
89
+ "Remember this method only works if 2FA is not enabled.\n"
90
+ "Please use another authentication strategy instead."
91
+ )
92
+ raise PermissionError(msg)
93
+
94
+ def configure(self, session: Session) -> Session:
95
+ if not self._credential:
96
+ self.authenticate()
97
+
98
+ session.cookies.update(self._credential)
99
+ return session
100
+
101
+ def _validate(self, cookie: MozillaCookieJar) -> bool:
102
+ """
103
+ Checks if the provided cookie is valid to consume a resource in
104
+ the target web application.
105
+
106
+ Args:
107
+ cookie: Cookie to check.
108
+ """
109
+ test_response = requests.get(self._url, cookies=cookie)
110
+ return self.validate_response(test_response)
rest/client/session.py ADDED
@@ -0,0 +1,99 @@
1
+ """
2
+ Configures a request HTTP session so that it handles authentication
3
+ to the web service automatically.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+ import requests
10
+
11
+ from rest.client.auth.auth_interface import AuthInterface
12
+ from rest.client.auth.handlers.oauth2_tokens import AccessTokenHandler, IDTokenHandler
13
+ from rest.client.auth.handlers.session_cookies import SessionCookieHandler
14
+ from rest.utils.logger import LoggerFactory
15
+
16
+
17
+ class AuthenticatedSession(requests.Session):
18
+ def __init__(self, handler: AuthInterface) -> None:
19
+ super().__init__()
20
+ self._handler = handler
21
+ self._max_attempts = 3
22
+ self._logger = LoggerFactory.getLogger("pdmv-http-client.client")
23
+ self._handler.configure(session=self)
24
+
25
+ def request(
26
+ self, method: Union[str, bytes], url: Union[str, bytes], *args, **kwargs
27
+ ) -> requests.Response:
28
+ for attempt, _ in enumerate(range(self._max_attempts), start=1):
29
+ # Restart the session and send the request
30
+ with self:
31
+ response = super().request(method, url, *args, **kwargs)
32
+
33
+ if self._handler.validate_response(response):
34
+ return response
35
+ else:
36
+ # Re-authenticate and return the response.
37
+ self._logger.debug(
38
+ "(%s/%s) Credentials expired, renewing them and retrying the request",
39
+ attempt,
40
+ self._max_attempts,
41
+ )
42
+ self._handler.authenticate()
43
+ self._handler.configure(session=self)
44
+
45
+ self._logger.warning(
46
+ "Unable to renew credentials for (%s) after %s attempts: HTTP code %s",
47
+ response.url,
48
+ self._max_attempts,
49
+ response.status_code,
50
+ )
51
+ return response
52
+
53
+
54
+ class SessionFactory:
55
+ """
56
+ Provides a pre-configured `request.Session` with the
57
+ required authentication method.
58
+ """
59
+
60
+ @classmethod
61
+ def configure_by_session_cookie(
62
+ cls, url: str, credential_path: Path
63
+ ) -> requests.Session:
64
+ session_cookie_handler = SessionCookieHandler(
65
+ url=url, credential_path=credential_path
66
+ )
67
+ return AuthenticatedSession(handler=session_cookie_handler)
68
+
69
+ @classmethod
70
+ def configure_by_access_token(
71
+ cls,
72
+ url: str,
73
+ credential_path: Path,
74
+ client_id: str,
75
+ client_secret: str,
76
+ target_application: str,
77
+ ) -> requests.Session:
78
+ access_token_handler = AccessTokenHandler(
79
+ url=url,
80
+ credential_path=credential_path,
81
+ client_id=client_id,
82
+ client_secret=client_secret,
83
+ target_application=target_application,
84
+ )
85
+ return AuthenticatedSession(handler=access_token_handler)
86
+
87
+ @classmethod
88
+ def configure_by_id_token(
89
+ cls,
90
+ url: str,
91
+ credential_path: Path,
92
+ target_application: str,
93
+ ) -> requests.Session:
94
+ id_token_handler = IDTokenHandler(
95
+ url=url,
96
+ credential_path=credential_path,
97
+ target_application=target_application,
98
+ )
99
+ return AuthenticatedSession(handler=id_token_handler)
rest/utils/__init__.py ADDED
File without changes
rest/utils/logger.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ This module configures the library logger,
3
+ its message format and its level.
4
+ """
5
+
6
+ import logging
7
+ import sys
8
+
9
+
10
+ class LoggerFactory:
11
+ _instances: dict[str, logging.Logger] = {}
12
+
13
+ @classmethod
14
+ def _create_logger(cls, name: str) -> logging.Logger:
15
+ logger = logging.getLogger(name=name)
16
+ if not logger.hasHandlers():
17
+ format = "[%(asctime)s][%(levelname)s][%(name)s]: %(message)s"
18
+ date_format = "%Y-%m-%d %H:%M:%S %z"
19
+ handler = logging.StreamHandler(stream=sys.stdout)
20
+ formatter = logging.Formatter(fmt=format, datefmt=date_format)
21
+
22
+ handler.setFormatter(formatter)
23
+ logger.addHandler(handler)
24
+ logger.setLevel(logging.INFO)
25
+
26
+ return logger
27
+
28
+ @classmethod
29
+ def getLogger(cls, name: str) -> logging.Logger:
30
+ logger = cls._instances.get(name)
31
+ if logger:
32
+ return logger
33
+
34
+ logger = cls._create_logger(name)
35
+ cls._instances[name] = logger
36
+ return logger