diff --git a/packages/modules/vehicles/vweuda/libeuda.py b/packages/modules/vehicles/vweuda/libeuda.py index caba609a1a..2763719f99 100755 --- a/packages/modules/vehicles/vweuda/libeuda.py +++ b/packages/modules/vehicles/vweuda/libeuda.py @@ -121,10 +121,9 @@ def get_oidc_state(brand: str = DEFAULT_BRAND) -> str: LIST_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/list" DOWNLOAD_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/download" -USER_AGENT = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" -) +DOMAIN = "vw_eu_data_act" +VERSION = "0.3.3" +USER_AGENT = f"{DOMAIN}/{VERSION}" # --- Config entry keys ---------------------------------------------------- CONF_EMAIL = "email" @@ -182,13 +181,57 @@ def get_oidc_state(brand: str = DEFAULT_BRAND) -> str: class ApiError(Exception): - """Generic API failure.""" + """Generic API failure. + + Carries the HTTP ``status`` when the failure came from an HTTP response, so + callers can branch on it (e.g. retry 5xx) without grepping the message + string. ``None`` for non-HTTP failures (connection errors, bad JSON, …). + """ + + def __init__(self, message: str, *, status: int | None = None) -> None: + super().__init__(message) + self.status = status class AuthError(ApiError): """Authentication failed or session expired.""" +# Non-5xx statuses the identity provider returns for reasons unrelated to the +# credentials: 404 when the sign-in service is (re)deploying / a step URL is +# temporarily missing, 429 when we are rate limited. +_TRANSIENT_LOGIN_STATUSES = frozenset({404, 429}) + + +def _is_transient_login_status(status: int) -> bool: + """True when a login-step HTTP status is an upstream problem, not bad credentials.""" + return status >= 500 or status in _TRANSIENT_LOGIN_STATUSES + + +# Redirect statuses the identity provider / portal use while bouncing the +# browser back after the credentials POST. All of them are followed with a GET. +_LOGIN_REDIRECT_STATUSES = frozenset({301, 302, 303}) +# Observed chain is 6 hops (authenticate -> oauth/sso -> consent -> oauth +# callback/success -> portal /login -> /services/callbacklogin); anything much +# longer is a loop. +_MAX_LOGIN_REDIRECTS = 10 +# Cookie the portal callback sets once the OIDC code has been exchanged. +_SESSION_COOKIE = "access_token" + + +def _redact_query(url: str) -> str: + """Drop the query string for logging: redirect URLs carry the OIDC code.""" + return url.split("?", 1)[0] + + +def _is_portal_callback(url: str) -> bool: + """True if ``url`` is the portal's OIDC callback (``/services/callbacklogin``).""" + parsed = urlparse(url) + return parsed.netloc == urlparse(BASE_URL).netloc and parsed.path.startswith( + CALLBACK_LOGIN_PATH + ) + + class _FormParser(HTMLParser): """Extract the first
action and all hidden/input fields.""" @@ -331,6 +374,47 @@ async def _get(self, url: str, *, headers: dict | None = None, allow_redirects: h = {"User-Agent": USER_AGENT, **(headers or {})} return await self._session.get(url, headers=h, allow_redirects=allow_redirects) + async def _post_login_form(self, url: str, *, data: dict[str, str], referer: str): + """POST a login form and follow redirects only as far as the portal callback. + + The browser flow after the credentials POST is a redirect chain that ends + on ``/services/callbacklogin``, which exchanges the OIDC code, sets the + ``access_token`` session cookie and redirects once more to a localized + CMS landing page. That last page is irrelevant to authentication (and is + missing for some locales), so redirects are followed by hand and the + chain is cut at the callback. aiohttp stores each hop's cookies in the + session jar whether or not it follows the redirect itself. + + Returns the callback response, or the first non-redirect response if + the chain ends elsewhere (bad credentials, identity-provider error). + """ + resp = await self._session.post( + url, + data=data, + headers={"User-Agent": USER_AGENT, "Referer": referer}, + allow_redirects=False, + ) + for redirects in range(_MAX_LOGIN_REDIRECTS + 1): + if _is_portal_callback(str(resp.url)): + return resp + if resp.status not in _LOGIN_REDIRECT_STATUSES: + return resp + if redirects == _MAX_LOGIN_REDIRECTS: + resp.release() + raise ApiError(f"Login redirect chain exceeded {_MAX_LOGIN_REDIRECTS} hops") + location = resp.headers.get("Location") + if not location: + return resp + next_url = urljoin(str(resp.url), location) + previous_url = str(resp.url) + _LOGGER.debug("login step4: %s -> %s", resp.status, _redact_query(next_url)) + resp.release() + resp = await self._get( + next_url, + headers={"Referer": previous_url}, + allow_redirects=False, + ) + # -- authentication ---------------------------------------------------- async def async_login(self) -> None: @@ -342,14 +426,6 @@ async def async_login(self) -> None: self._logged_in = True async def _do_login(self) -> None: - # 0. Prime the portal session (the browser loads the site first; this - # sets the AEM load-balancer/session cookies the callback needs). - try: - async with await self._get(f"{BASE_URL}/") as resp: - await resp.read() - except aiohttp.ClientError as err: - _LOGGER.debug("login step0: priming GET failed (ignored): %s", err) - # 1. Start the OIDC flow directly at the identity provider. We build the # authorize URL ourselves because the portal's # /services/redirect/authentication servlet returns HTTP 500 for @@ -359,6 +435,11 @@ async def _do_login(self) -> None: async with await self._get(authorize_url) as resp: signin_url = str(resp.url) signin_html = await resp.text() + if _is_transient_login_status(resp.status): + raise ApiError( + f"Identity provider error on sign-in page (HTTP {resp.status})", + status=resp.status, + ) _LOGGER.debug("login step2: signin page = %s (%d bytes)", signin_url, len(signin_html)) # 2. POST the email (identifier step). Fields come from HTML inputs @@ -382,6 +463,11 @@ async def _do_login(self) -> None: _LOGGER.debug( "login step3: after identifier POST status=%s url=%s", status, authenticate_url ) + if _is_transient_login_status(status): + raise ApiError( + f"Identity provider error on identifier step (HTTP {status})", + status=status, + ) # 3. The identifier step lands on the password (authenticate) page, # whose hidden fields live in the JS templateModel, not HTML inputs. @@ -405,31 +491,51 @@ async def _do_login(self) -> None: authenticate_action = authenticate_url.split("?", 1)[0] _LOGGER.debug("login step4: POST credentials to %s", authenticate_action) - # 4. POST credentials; follow the redirect chain back to the portal, - # which sets the session cookies via /services/callbacklogin. - async with self._session.post( + # 4. POST credentials; follow the redirect chain back to the portal + # and stop at /services/callbacklogin, which sets the session cookie. + async with await self._post_login_form( authenticate_action, data=fields2, - headers={"User-Agent": USER_AGENT, "Referer": authenticate_url}, + referer=authenticate_url, ) as resp: landing = str(resp.url) + if _is_portal_callback(landing) and resp.status < 400: + # The callback's own redirect (to the CMS landing page) is + # deliberately not followed; the access_token cookie it set is + # the proof the login worked. A failing callback (e.g. 5xx on + # the code exchange) goes through the error handling below. + cookie = resp.cookies.get(_SESSION_COOKIE) + if not getattr(cookie, "value", cookie): + raise AuthError( + "Login callback did not establish a session " + f"({_SESSION_COOKIE} cookie missing)" + ) + _LOGGER.debug("login step4: callback set %s cookie", _SESSION_COOKIE) + return landing_html = await resp.text() if resp.status >= 400: _LOGGER.debug( "login step4: HTTP %s body[:500]=%s", resp.status, landing_html[:500] ) + # 5xx / 404 / 429 is the identity provider failing (or + # throttling us), not our credentials — surface it as a + # retryable ApiError so the coordinator keeps the entry alive + # instead of demanding reauthentication. + if _is_transient_login_status(resp.status): + raise ApiError( + f"Identity provider error (HTTP {resp.status})", + status=resp.status, + ) err = _login_error(landing_html) raise AuthError(err or f"Login rejected (HTTP {resp.status})") _LOGGER.debug("login step4: landed on %s", landing) - # Positively confirm success: a completed flow lands back on the portal - # host (via /services/callbacklogin). Bad credentials re-render the + # A completed flow returns above from the portal callback. Anything else + # that ended the chain is a failure: bad credentials re-render the # identity sign-in page (URL still on identity.vwgroup.io/signin-service). - portal_host = urlparse(BASE_URL).netloc if "signin-service" in landing or "/error" in landing: raise AuthError("Login failed - check email and password") - if urlparse(landing).netloc != portal_host: - raise AuthError(f"Login did not complete (ended at {landing})") + raise AuthError(f"Login did not complete (ended at {_redact_query(landing)})") @staticmethod def _build_authorize_url(brand: str = "volkswagen") -> str: @@ -721,6 +827,8 @@ def parse_vehicle_data(payload: dict) -> dict: range = get_field_value_by_key(data, '153e8c40-4c6c-3c17-a11b-0ecc35d55b81', 'range') if range is None: range = get_field_value_by_key(data, '0ca40e18-0564-3eda-bcc0-7aee9ef44f04', 'range') + if range is None: + range = get_field_value_by_key(data, '55e0d40b-38ed-3cb5-9dcd-6193df6fc493', 'range') odometer = get_field_value_by_key(data, '41c0805c-43e5-313e-9dfb-356cb8d20f7c', 'odometer') if odometer is None: