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

Potentially problematic release.


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

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