carconnectivity-connector-seatcupra 0.1a1__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,440 @@
1
+ """Implements a session class that handles OpenID authentication."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING
4
+
5
+ from enum import Enum, auto
6
+ import time
7
+ import logging
8
+ import requests
9
+ from jwt import JWT
10
+ from datetime import datetime, timezone
11
+
12
+ from oauthlib.common import UNICODE_ASCII_CHARACTER_SET, generate_nonce, generate_token
13
+ from oauthlib.oauth2.rfc6749.parameters import parse_authorization_code_response, parse_token_response, prepare_grant_uri
14
+ from oauthlib.oauth2.rfc6749.errors import InsecureTransportError, TokenExpiredError, MissingTokenError
15
+ from oauthlib.oauth2.rfc6749.utils import is_secure_transport
16
+
17
+ from requests.adapters import HTTPAdapter
18
+
19
+ from carconnectivity.errors import AuthenticationError, RetrievalError
20
+
21
+ from carconnectivity_connectors.seatcupra.auth.auth_util import add_bearer_auth_header
22
+ from carconnectivity_connectors.seatcupra.auth.helpers.blacklist_retry import BlacklistRetry
23
+
24
+ if TYPE_CHECKING:
25
+ from typing import Dict
26
+
27
+ LOG = logging.getLogger("carconnectivity.connectors.seatcupra.auth")
28
+
29
+
30
+ class AccessType(Enum):
31
+ """
32
+ Enum representing different types of access tokens used in the authentication process.
33
+
34
+ Attributes:
35
+ NONE (auto): No access token.
36
+ ACCESS (auto): Access token used for accessing resources.
37
+ ID (auto): ID token used for identifying the user.
38
+ REFRESH (auto): Refresh token used for obtaining new access tokens.
39
+ """
40
+ NONE = auto()
41
+ ACCESS = auto()
42
+ ID = auto()
43
+ REFRESH = auto()
44
+
45
+
46
+ class OpenIDSession(requests.Session):
47
+ """
48
+ OpenIDSession is a subclass of requests.Session that handles OpenID Connect authentication.
49
+ """
50
+ def __init__(self, client_id=None, redirect_uri=None, refresh_url=None, scope=None, token=None, metadata=None, state=None, timeout=None,
51
+ force_relogin_after=None, **kwargs) -> None:
52
+ super(OpenIDSession, self).__init__(**kwargs)
53
+ self.client_id = client_id
54
+ self.redirect_uri = redirect_uri
55
+ self.refresh_url = refresh_url
56
+ self.scope = scope
57
+ self.state: str = state or generate_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET)
58
+
59
+ self.timeout = timeout
60
+ self._token = token
61
+ self.metadata = metadata or {}
62
+ self.last_login = None
63
+ self._force_relogin_after = force_relogin_after
64
+
65
+ self._retries: bool | int = False
66
+
67
+ @property
68
+ def force_relogin_after(self):
69
+ """
70
+ Get the number of seconds after which a forced re-login is required.
71
+
72
+ Returns:
73
+ Number of seconds until a forced re-login is required.
74
+ """
75
+ return self._force_relogin_after
76
+
77
+ @force_relogin_after.setter
78
+ def force_relogin_after(self, new_force_relogin_after_value):
79
+ """
80
+ Sets the time after which a forced re-login should occur.
81
+
82
+ Args:
83
+ new_force_relogin_after_value (float or None): The new value for the forced re-login time.
84
+ If None, no forced re-login will be set.
85
+ """
86
+ self._force_relogin_after = new_force_relogin_after_value
87
+ if new_force_relogin_after_value is not None and self.last_login is None:
88
+ self.last_login = time.time()
89
+
90
+ @property
91
+ def retries(self) -> bool | int:
92
+ """
93
+ Get the number of retries.
94
+
95
+ Returns:
96
+ bool | int: The number of retries. It can be a boolean or an integer.
97
+ """
98
+ return self._retries
99
+
100
+ @retries.setter
101
+ def retries(self, new_retries_value):
102
+ """
103
+ Set the number of retries for the session and configure retry behavior.
104
+
105
+ Args:
106
+ new_retries_value (int): The new number of retries to set. If provided,
107
+ configures the session to retry on internal server
108
+ errors (HTTP status code 500) and blacklist status
109
+ code 429 with a backoff factor of 0.1.
110
+
111
+ """
112
+ self._retries = new_retries_value
113
+ if new_retries_value:
114
+ # Retry on internal server error (500)
115
+ retries = BlacklistRetry(total=new_retries_value,
116
+ backoff_factor=0.1,
117
+ status_forcelist=[500],
118
+ status_blacklist=[429],
119
+ raise_on_status=False)
120
+ self.mount('https://', HTTPAdapter(max_retries=retries))
121
+
122
+ @property
123
+ def token(self):
124
+ """
125
+ Retrieve the current token.
126
+
127
+ Returns:
128
+ str: The current token.
129
+ """
130
+ return self._token
131
+
132
+ @token.setter
133
+ def token(self, new_token):
134
+ """
135
+ Updates the current token with a new token and sets expiration details if not provided.
136
+
137
+ Args:
138
+ new_token (dict): The new token to be set. If the token does not contain 'expires_in',
139
+ it will be set to the same value as the current token's 'expires_in'
140
+ or default to 3600 seconds. If 'expires_in' is provided but 'expires_at'
141
+ is not, 'expires_at' will be calculated based on the current time.
142
+
143
+ Returns:
144
+ None
145
+ """
146
+ if new_token is not None:
147
+ # If new token e.g. after refresh is missing expires_in we assume it is the same than before
148
+ if 'expires_in' not in new_token:
149
+ if self._token is not None and 'expires_in' in self._token:
150
+ new_token['expires_in'] = self._token['expires_in']
151
+ else:
152
+ if 'id_token' in new_token:
153
+ jwt_instance = JWT()
154
+ meta_data = jwt_instance.decode(new_token['id_token'], do_verify=False)
155
+ if 'exp' in meta_data:
156
+ new_token['expires_at'] = meta_data['exp']
157
+ expires_at = datetime.fromtimestamp(meta_data['exp'], tz=timezone.utc)
158
+ new_token['expires_in'] = (expires_at - datetime.now(tz=timezone.utc)).total_seconds()
159
+ else:
160
+ new_token['expires_in'] = 3600
161
+ else:
162
+ new_token['expires_in'] = 3600
163
+ # If expires_in is set and expires_at is not set we calculate expires_at from expires_in using the current time
164
+ if 'expires_in' in new_token and 'expires_at' not in new_token:
165
+ new_token['expires_at'] = time.time() + int(new_token.get('expires_in'))
166
+ self._token = new_token
167
+
168
+ @property
169
+ def access_token(self):
170
+ """
171
+ Retrieve the access token from the stored token.
172
+
173
+ Returns:
174
+ str: The access token if it exists in the stored token, otherwise None.
175
+ """
176
+ if self._token is not None and 'access_token' in self._token:
177
+ return self._token.get('access_token')
178
+ return None
179
+
180
+ @access_token.setter
181
+ def access_token(self, new_access_token):
182
+ """
183
+ Sets a new access token.
184
+
185
+ Args:
186
+ new_access_token (str): The new access token to be set.
187
+ """
188
+ if self._token is None:
189
+ self._token = {}
190
+ self._token['access_token'] = new_access_token
191
+
192
+ @property
193
+ def refresh_token(self):
194
+ """
195
+ Retrieves the refresh token from the stored token.
196
+
197
+ Returns:
198
+ str or None: The refresh token if it exists in the stored token, otherwise None.
199
+ """
200
+ if self._token is not None and 'refresh_token' in self._token:
201
+ return self._token.get('refresh_token')
202
+ return None
203
+
204
+ @property
205
+ def id_token(self):
206
+ """
207
+ Retrieve the ID token from the stored token.
208
+
209
+ Returns:
210
+ str or None: The ID token if it exists in the stored token, otherwise None.
211
+ """
212
+ if self._token is not None and 'id_token' in self._token:
213
+ return self._token.get('id_token')
214
+ return None
215
+
216
+ @property
217
+ def token_type(self):
218
+ """
219
+ Retrieve the token type from the stored token.
220
+
221
+ Returns:
222
+ str: The type of the token if available, otherwise None.
223
+ """
224
+ if self._token is not None and 'token_type' in self._token:
225
+ return self._token.get('token_type')
226
+ return None
227
+
228
+ @property
229
+ def expires_in(self):
230
+ """
231
+ Retrieve the expiration time of the current token.
232
+
233
+ Returns:
234
+ int or None: The number of seconds until the token expires if available,
235
+ otherwise None.
236
+ """
237
+ if self._token is not None and 'expires_in' in self._token:
238
+ return self._token.get('expires_in')
239
+ return None
240
+
241
+ @property
242
+ def expires_at(self):
243
+ """
244
+ Retrieve the expiration time of the current token.
245
+
246
+ Returns:
247
+ int or None: The expiration time of the token in epoch time if available,
248
+ otherwise None.
249
+ """
250
+ if self._token is not None and 'expires_at' in self._token:
251
+ return self._token.get('expires_at')
252
+ return None
253
+
254
+ @property
255
+ def authorized(self):
256
+ """
257
+ Check if the session is authorized.
258
+
259
+ Returns:
260
+ bool: True if the session has a valid access token, False otherwise.
261
+ """
262
+ return bool(self.access_token)
263
+
264
+ @property
265
+ def expired(self):
266
+ """
267
+ Check if the session has expired.
268
+
269
+ Returns:
270
+ bool: True if the session has expired, False otherwise.
271
+ """
272
+ return self.expires_at is not None and self.expires_at < time.time()
273
+
274
+ @property
275
+ def user_id(self):
276
+ """
277
+ Retrieve the user ID from the metadata.
278
+ """
279
+ if 'userId' in self.metadata:
280
+ return self.metadata['userId']
281
+ return None
282
+
283
+ @user_id.setter
284
+ def user_id(self, new_user_id):
285
+ """
286
+ Sets the user ID in the metadata.
287
+ """
288
+ self.metadata['userId'] = new_user_id
289
+
290
+ def login(self):
291
+ """
292
+ Logs in the user, needs to be implemetned in subclass
293
+
294
+ This method sets the `last_login` attribute to the current time.
295
+ """
296
+ self.last_login = time.time()
297
+
298
+ def refresh(self):
299
+ """
300
+ Refresh the current session, needs to be implemetned in subclass
301
+
302
+ This method is intended to refresh the authentication session.
303
+ Currently, it is not implemented and does not perform any actions.
304
+ """
305
+
306
+ def authorization_url(self, url, state=None, **kwargs):
307
+ """
308
+ Generates the authorization URL for the OpenID Connect flow.
309
+
310
+ Args:
311
+ url (str): The base URL for the authorization endpoint.
312
+ state (str, optional): An optional state parameter to maintain state between the request and callback. Defaults to None.
313
+ **kwargs: Additional parameters to include in the authorization URL.
314
+
315
+ Returns:
316
+ str: The complete authorization URL with the necessary query parameters.
317
+ """
318
+ state = state or self.state
319
+ auth_url = prepare_grant_uri(uri=url, client_id=self.client_id, redirect_uri=self.redirect_uri, response_type='code id_token token', scope=self.scope,
320
+ state=state, nonce=generate_nonce(), **kwargs)
321
+ return auth_url
322
+
323
+ def parse_from_fragment(self, authorization_response, state=None):
324
+ """
325
+ Parses the authorization response fragment and extracts the token.
326
+
327
+ Args:
328
+ authorization_response (str): The authorization response fragment containing the token.
329
+ state (str, optional): The state parameter to validate the response. Defaults to None.
330
+
331
+ Returns:
332
+ dict: The parsed token information.
333
+ """
334
+ state = state or self.state
335
+ self.token = parse_authorization_code_response(authorization_response, state=state)
336
+ return self.token
337
+
338
+ def parse_from_body(self, token_response, state=None):
339
+ """
340
+ Parse the JSON token response body into a dict.
341
+ """
342
+ del state
343
+ self.token = parse_token_response(token_response, scope=self.scope)
344
+ return self.token
345
+
346
+ def request( # noqa: C901
347
+ self,
348
+ method,
349
+ url,
350
+ data=None,
351
+ headers=None,
352
+ timeout=None,
353
+ withhold_token=False,
354
+ access_type=AccessType.ACCESS,
355
+ token=None,
356
+ **kwargs
357
+ ) -> requests.Response:
358
+ """Intercept all requests and add the OAuth 2 token if present."""
359
+ if not is_secure_transport(url):
360
+ raise InsecureTransportError()
361
+ if access_type != AccessType.NONE and not withhold_token:
362
+ if self.force_relogin_after is not None and self.last_login is not None and (self.last_login + self.force_relogin_after) < time.time():
363
+ LOG.debug("Forced new login after %ds", self.force_relogin_after)
364
+ self.login()
365
+ try:
366
+ url, headers, data = self.add_token(url, body=data, headers=headers, access_type=access_type, token=token)
367
+ # Attempt to retrieve and save new access token if expired
368
+ except TokenExpiredError:
369
+ LOG.info('Token expired')
370
+ self.access_token = None
371
+ try:
372
+ self.refresh()
373
+ except AuthenticationError:
374
+ self.login()
375
+ except TokenExpiredError:
376
+ self.login()
377
+ except MissingTokenError:
378
+ self.login()
379
+ except RetrievalError:
380
+ LOG.error('Retrieval Error while refreshing token. Probably the token was invalidated. Trying to do a new login instead.')
381
+ self.login()
382
+ url, headers, data = self.add_token(url, body=data, headers=headers, access_type=access_type, token=token)
383
+ except MissingTokenError:
384
+ LOG.info('Missing token, need new login')
385
+ self.login()
386
+ url, headers, data = self.add_token(url, body=data, headers=headers, access_type=access_type, token=token)
387
+
388
+ if timeout is None:
389
+ timeout = self.timeout
390
+
391
+ return super(OpenIDSession, self).request(
392
+ method, url, headers=headers, data=data, timeout=timeout, **kwargs
393
+ )
394
+
395
+ def add_token(self, uri, body=None, headers=None, access_type=AccessType.ACCESS, token=None, **_): # pylint: disable=too-many-arguments
396
+ """
397
+ Adds an authorization token to the request headers based on the specified access type.
398
+
399
+ Args:
400
+ uri (str): The URI to which the request is being made.
401
+ body (Optional[Any]): The body of the request. Defaults to None.
402
+ headers (Optional[Dict[str, str]]): The headers of the request. Defaults to None.
403
+ access_type (AccessType): The type of access token to use (ID, REFRESH, or ACCESS). Defaults to AccessType.ACCESS.
404
+ token (Optional[str]): The token to use. If None, the method will use the appropriate token based on the access_type. Defaults to None.
405
+ **_ (Any): Additional keyword arguments.
406
+
407
+ Raises:
408
+ InsecureTransportError: If the URI does not use a secure transport (HTTPS).
409
+ MissingTokenError: If the required token (ID, REFRESH, or ACCESS) is missing.
410
+ TokenExpiredError: If the access token has expired.
411
+
412
+ Returns:
413
+ Tuple[str, Dict[str, str], Optional[Any]]: The URI, updated headers with the authorization token, and the body of the request.
414
+ """
415
+ # Check if the URI uses a secure transport
416
+ if not is_secure_transport(uri):
417
+ raise InsecureTransportError()
418
+
419
+ # Only add token if it is not explicitly withheld
420
+ if token is None:
421
+ if access_type == AccessType.ID:
422
+ if not self.id_token:
423
+ raise MissingTokenError(description="Missing id token.")
424
+ token = self.id_token
425
+ elif access_type == AccessType.REFRESH:
426
+ if not self.refresh_token:
427
+ raise MissingTokenError(description="Missing refresh token.")
428
+ token = self.refresh_token
429
+ else:
430
+ if not self.authorized:
431
+ self.login()
432
+ if not self.access_token:
433
+ raise MissingTokenError(description="Missing access token.")
434
+ if self.expired:
435
+ raise TokenExpiredError()
436
+ token = self.access_token
437
+
438
+ return_headers: Dict[str, str] = add_bearer_auth_header(token, headers)
439
+
440
+ return (uri, return_headers, body)
@@ -0,0 +1,150 @@
1
+ """Module implementing the SessionManager class."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING, Tuple
4
+
5
+ from enum import Enum
6
+
7
+ import hashlib
8
+
9
+ import logging
10
+
11
+ from carconnectivity_connectors.seatcupra.auth.my_cupra_session import MyCupraSession
12
+
13
+ if TYPE_CHECKING:
14
+ from typing import Dict, Any
15
+ from carconnectivity_connectors.seatcupra.auth.vw_web_session import VWWebSession
16
+
17
+ LOG = logging.getLogger("carconnectivity.connectors.seatcupra.auth")
18
+
19
+
20
+ class SessionUser():
21
+ """
22
+ A class to represent a session user with a username and password.
23
+
24
+ Attributes:
25
+ ----------
26
+ username : str
27
+ The username of the session user.
28
+ password : str
29
+ The password of the session user.
30
+
31
+ Methods:
32
+ -------
33
+ __str__():
34
+ Returns a string representation of the session user in the format 'username:password'.
35
+ """
36
+ def __init__(self, username: str, password: str) -> None:
37
+ self.username: str = username
38
+ self.password: str = password
39
+
40
+ def __str__(self) -> str:
41
+ return f'{self.username}:{self.password}'
42
+
43
+
44
+ class Service(Enum):
45
+ """
46
+ An enumeration representing different services.
47
+
48
+ Attributes:
49
+ MY_CUPRA (str): Represents the 'MyCupra' service.
50
+
51
+ Methods:
52
+ __str__() -> str: Returns the string representation of the service.
53
+ """
54
+ MY_CUPRA = 'MyCupra'
55
+
56
+ def __str__(self) -> str:
57
+ return self.value
58
+
59
+
60
+ class SessionManager():
61
+ """
62
+ Manages sessions for different services and users, handling token storage and caching.
63
+ """
64
+ def __init__(self, tokenstore: Dict[str, Any], cache: Dict[str, Any]) -> None:
65
+ self.tokenstore: Dict[str, Any] = tokenstore
66
+ self.cache: Dict[str, Any] = cache
67
+ self.sessions: Dict[Tuple[Service, SessionUser], VWWebSession] = {}
68
+
69
+ @staticmethod
70
+ def generate_hash(service: Service, session_user: SessionUser) -> str:
71
+ """
72
+ Generates a SHA-512 hash for the given service and session user.
73
+
74
+ Args:
75
+ service (Service): The service for which the hash is being generated.
76
+ session_user (SessionUser): The session user for which the hash is being generated.
77
+
78
+ Returns:
79
+ str: The generated SHA-512 hash as a hexadecimal string.
80
+ """
81
+ hash_str: str = service.value + str(session_user)
82
+ return hashlib.sha512(hash_str.encode()).hexdigest()
83
+
84
+ @staticmethod
85
+ def generate_identifier(service: Service, session_user: SessionUser) -> str:
86
+ """
87
+ Generate a unique identifier for a given service and session user.
88
+
89
+ Args:
90
+ service (Service): The service for which the identifier is being generated.
91
+ session_user (SessionUser): The session user for whom the identifier is being generated.
92
+
93
+ Returns:
94
+ str: A unique identifier string.
95
+ """
96
+ return 'CarConnectivity-connector-cupra:' + SessionManager.generate_hash(service, session_user)
97
+
98
+ def get_session(self, service: Service, session_user: SessionUser) -> VWWebSession:
99
+ """
100
+ Retrieves a session for the given service and session user. If a session already exists in the sessions cache,
101
+ it is returned. Otherwise, a new session is created using the token, metadata, and cache from the tokenstore
102
+ and cache if available.
103
+
104
+ Args:
105
+ service (Service): The service for which the session is being requested.
106
+ session_user (SessionUser): The user for whom the session is being requested.
107
+
108
+ Returns:
109
+ Session: The session object for the given service and session user.
110
+ """
111
+ session = None
112
+ if (service, session_user) in self.sessions:
113
+ return self.sessions[(service, session_user)]
114
+
115
+ identifier: str = SessionManager.generate_identifier(service, session_user)
116
+ token = None
117
+ cache = {}
118
+ metadata = {}
119
+
120
+ if identifier in self.tokenstore:
121
+ if 'token' in self.tokenstore[identifier]:
122
+ LOG.info('Reusing tokens from previous session')
123
+ token = self.tokenstore[identifier]['token']
124
+ if 'metadata' in self.tokenstore[identifier]:
125
+ metadata = self.tokenstore[identifier]['metadata']
126
+ if identifier in self.cache:
127
+ cache = self.cache[identifier]
128
+
129
+ if service == Service.MY_CUPRA:
130
+ session = MyCupraSession(session_user=session_user, token=token, metadata=metadata, cache=cache)
131
+ else:
132
+ raise ValueError(f"Unsupported service: {service}")
133
+
134
+ self.sessions[(service, session_user)] = session
135
+ return session
136
+
137
+ def persist(self) -> None:
138
+ """
139
+ Persist the current sessions into the token store and cache.
140
+
141
+ This method iterates over the sessions and stores each session's token and metadata
142
+ in the token store using a generated identifier. It also stores the session's cache
143
+ in the cache.
144
+ """
145
+ for (service, user), session in self.sessions.items():
146
+ identifier: str = SessionManager.generate_identifier(service, user)
147
+ self.tokenstore[identifier] = {}
148
+ self.tokenstore[identifier]['token'] = session.token
149
+ self.tokenstore[identifier]['metadata'] = session.metadata
150
+ self.cache[identifier] = session.cache