carconnectivity-connector-skoda 0.1__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.

Potentially problematic release.


This version of carconnectivity-connector-skoda might be problematic. Click here for more details.

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