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