carconnectivity-connector-seatcupra 0.1a1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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