Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 130 additions & 22 deletions packages/modules/vehicles/vweuda/libeuda.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <form> action and all hidden/input fields."""

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading