diff --git a/custom_components/hello_smart/auth.py b/custom_components/hello_smart/auth.py index c4a2ed3..8db5dda 100644 --- a/custom_components/hello_smart/auth.py +++ b/custom_components/hello_smart/auth.py @@ -7,6 +7,7 @@ import hmac import json import logging +import re import secrets import time import uuid @@ -23,9 +24,11 @@ EU_AUTH_BASE_URL, EU_CONTEXT_URL, EU_DEVICE_ID_LENGTH, + EU_GIGYA_SOCIALIZE_BASE, EU_IDENTITY_TYPE, EU_OPERATOR_CODE, EU_SIGNING_SECRET, + EU_WEBVIEW_USER_AGENT, GIGYA_API_KEY, INTL_APP_ID, INTL_AUTH_BASE_URL, @@ -42,6 +45,37 @@ _LOGGER = logging.getLogger(__name__) +# Token-like keys to scrub from bodies/URLs before they hit the log. +_REDACT_KEYS = ( + "access_token", + "accessToken", + "id_token", + "idToken", + "refresh_token", + "refreshToken", + "login_token", + "code", + "authCode", + "password", +) +_REDACT_JSON_RE = re.compile( + r'("(?:' + "|".join(_REDACT_KEYS) + r')"\s*:\s*")[^"]*(")' +) +_REDACT_QUERY_RE = re.compile( + r"((?:" + "|".join(_REDACT_KEYS) + r")=)[^&\s\"<>]+" +) + + +def _redact(text: str, *, limit: int = 2048) -> str: + """Best-effort scrub of token-like values from a response body or URL.""" + if not text: + return text + out = _REDACT_JSON_RE.sub(r"\1***\2", text) + out = _REDACT_QUERY_RE.sub(r"\1***", out) + if len(out) > limit: + out = out[:limit] + f"...<+{len(out) - limit}b>" + return out + def _generate_device_id(length: int) -> str: """Generate a random hex device identifier.""" @@ -489,30 +523,120 @@ async def async_login_eu( "x-app-id": EU_APP_ID, "x-requested-with": "com.smart.hellosmart", "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "user-agent": EU_WEBVIEW_USER_AGENT, } - async with session.get( - context_url, headers=context_headers, allow_redirects=False - ) as resp: - if resp.status not in (301, 302, 303, 307): - _LOGGER.error("EU context request did not redirect: status=%s", resp.status) - account.state = AuthState.AUTH_FAILED - raise AuthenticationError("EU context request failed") - - location = resp.headers.get("Location", "") - - parsed_location = urlparse(location) - context_params = parse_qs(parsed_location.query) - context = context_params.get("context", [None])[0] + # Walk the OIDC redirect chain looking for the first hop whose Location + # contains ?context=. Smart's chain currently looks like: + # awsapi.future.smart.com -> auth.smart.com/oidc/.../authorize + # -> app.id.smart.com/proxy?context=&... + # The context= used to be on hop 1; it moved to hop 2 (different host). + # Walking up to MAX_HOPS keeps this resilient if Smart shifts hops again. + MAX_HOPS = 5 + current_url = context_url + context: str | None = None + last_status: int | None = None + last_location = "" + for hop in range(MAX_HOPS): + async with session.get( + current_url, headers=context_headers, allow_redirects=False + ) as resp: + last_status = resp.status + if resp.status not in (301, 302, 303, 307, 308): + if hop == 0 and resp.status == 403: + _LOGGER.error( + "EU auth gateway rejected request " + "(HTTP 403): possible UA filtering or API change" + ) + else: + _LOGGER.error( + "EU context redirect chain ended at hop %d " + "with non-redirect status=%s", + hop, + resp.status, + ) + account.state = AuthState.AUTH_FAILED + raise AuthenticationError( + f"EU context request failed: HTTP {resp.status}" + ) + last_location = resp.headers.get("Location", "") + + parsed_location = urlparse(last_location) + params = parse_qs(parsed_location.query) + if "context" in params: + context = params["context"][0] + _LOGGER.debug( + "EU login step 1: extracted context= at redirect hop %d " + "(host=%s)", + hop + 1, + parsed_location.hostname or "?", + ) + break + current_url = last_location if not context: - _LOGGER.error("EU context extraction failed from redirect URL") + _LOGGER.error( + "EU context extraction failed: redirect chain ended without " + "context= parameter (last_status=%s, last_location_host=%s)", + last_status, + urlparse(last_location).hostname or "?", + ) account.state = AuthState.AUTH_FAILED - raise AuthenticationError("EU context extraction failed") + raise AuthenticationError( + "EU context extraction failed: redirect chain ended without " + "context= parameter" + ) _LOGGER.debug("EU login step 1 complete (context obtained)") - # Step 2: POST Gigya login + # Step 1.5: fetch Gigya machine identifiers (gmid + ucid). + # + # Smart's Gigya tenant won't accept accounts.login from a "fresh" + # client; it expects a bootstrapped Gigya session (gmid+ucid+ + # hasGmid+gig_bootstrap cookies). socialize.getIDs is the canonical + # endpoint that issues these to a JS-SDK client. We send them in + # the Cookie header on Step 2 and Step 3. + ids_url = ( + f"{EU_GIGYA_SOCIALIZE_BASE}/socialize.getIDs" + f"?APIKey={GIGYA_API_KEY}&format=json&includeTicket=true" + ) + async with session.get(ids_url) as resp: + resp.raise_for_status() + ids_data = await resp.json(content_type=None) + + if ids_data.get("errorCode") != 0: + _LOGGER.error( + "EU socialize.getIDs failed: code=%s message=%s", + ids_data.get("errorCode"), + ids_data.get("errorMessage"), + ) + account.state = AuthState.AUTH_FAILED + raise AuthenticationError( + "EU socialize.getIDs failed: cannot bootstrap Gigya session" + ) + + gmid = ids_data.get("gmid", "") + ucid = ids_data.get("ucid", "") + if not gmid: + _LOGGER.error("EU socialize.getIDs returned no gmid") + account.state = AuthState.AUTH_FAILED + raise AuthenticationError("EU Gigya gmid missing — cannot proceed") + + # Cookie header that declares the SDK has bootstrapped a Gigya + # session. gig_bootstrap_=auth_ver4 is the literal value + # the JS SDK sets to mark "Gigya bootstrap complete, login allowed". + gigya_cookie_base = ( + f"gmid={gmid}; ucid={ucid}; hasGmid=ver4; " + f"gig_bootstrap_{GIGYA_API_KEY}=auth_ver4" + ) + _LOGGER.debug("EU login step 1.5 complete (Gigya bootstrap cookies built)") + + # Step 2: POST Gigya login. + # + # Smart's Gigya rejects accounts.login without the bootstrap cookie + # (returns 400006 "request is blocked because of security issues"). + # With the bootstrap cookie in place, sdk=js_latest is accepted again, + # which matches what the Hello Smart Android webview sends. gigya_url = f"{EU_AUTH_BASE_URL}/accounts.login" gigya_body = urlencode( { @@ -529,9 +653,18 @@ async def async_login_eu( "sdk": "js_latest", "sdkBuild": "15482", "format": "json", + "pageURL": "https://app.id.smart.com/login?gig_ui_locales=de-DE", } ) - gigya_headers = {"Content-Type": "application/x-www-form-urlencoded"} + gigya_headers = { + "content-type": "application/x-www-form-urlencoded", + "accept": "*/*", + "accept-language": "de", + "origin": "https://app.id.smart.com", + "x-requested-with": "com.smart.hellosmart", + "user-agent": EU_WEBVIEW_USER_AGENT, + "cookie": gigya_cookie_base, + } async with session.post(gigya_url, data=gigya_body, headers=gigya_headers) as resp: resp.raise_for_status() @@ -539,63 +672,185 @@ async def async_login_eu( # Gigya returns 200 even on errors if "errorCode" in gigya_data and gigya_data["errorCode"] != 0: + gigya_code = gigya_data.get("errorCode") + gigya_message = gigya_data.get("errorMessage", "") + gigya_details = gigya_data.get("errorDetails", "") + # Gigya 4xxxxx codes are credential / account errors; everything else + # is upstream (rate-limit, API key invalid, schema, etc.). + is_credential_error = ( + isinstance(gigya_code, int) and 400000 <= gigya_code < 500000 + ) _LOGGER.error( - "EU Gigya login failed: code=%s", gigya_data.get("errorCode") + "EU Gigya login error: code=%s message=%s details=%s", + gigya_code, + gigya_message, + gigya_details, ) account.state = AuthState.AUTH_FAILED - raise AuthenticationError("EU Gigya login failed: invalid credentials") + if is_credential_error: + raise AuthenticationError( + f"EU Gigya rejected credentials (code={gigya_code}): " + f"{gigya_message}" + ) + raise AuthenticationError( + f"EU Gigya login error (code={gigya_code}): {gigya_message}" + ) session_info = gigya_data.get("sessionInfo", {}) login_token = session_info.get("login_token", "") - expires_in = session_info.get("expires_in", 3600) + # Gigya returns expires_in as a string ("3600"); coerce so the + # later timedelta() call doesn't blow up. + try: + expires_in = int(session_info.get("expires_in", 3600)) + except (TypeError, ValueError): + expires_in = 3600 if not login_token: - _LOGGER.error("EU Gigya login failed: no login_token") + _LOGGER.error( + "EU Gigya login returned no login_token; payload keys=%s", + list(gigya_data.keys()), + ) account.state = AuthState.AUTH_FAILED - raise AuthenticationError("EU Gigya login failed") + raise AuthenticationError( + "EU Gigya login succeeded but returned no login_token" + ) _LOGGER.debug("EU login step 2 complete (login_token obtained)") - # Step 3: GET authorize continue + # Step 3: GET authorize/continue and walk its redirect chain. + # + # Smart returns the OAuth tokens on the LAST hop of a 2-hop chain, + # in the URL's *query string* (not fragment). Earlier flows used + # the implicit fragment form (#access_token=...) — handle both for + # forward compatibility. + # + # The /authorize/continue handler authenticates the request via + # cookies, not query params: the Gigya bootstrap cookie (gmid + + # ucid + hasGmid + gig_bootstrap) PLUS the session cookie + # glt_=. With those, no DeviceId / gmidTicket + # / sig query parameters are required. authorize_url = ( f"{EU_AUTH_BASE_URL}/oidc/op/v1.0/{GIGYA_API_KEY}/authorize/continue" f"?context={context}&login_token={login_token}" ) + authorize_cookie = ( + f"{gigya_cookie_base}; " + f"glt_{GIGYA_API_KEY}={login_token}" + ) + authorize_headers = { + "accept": "*/*", + "accept-language": "de-DE,de;q=0.9,en-DE;q=0.8,en-US;q=0.7,en;q=0.6", + "x-requested-with": "com.smart.hellosmart", + "user-agent": EU_WEBVIEW_USER_AGENT, + "cookie": authorize_cookie, + } - async with session.get(authorize_url, allow_redirects=False) as resp: - if resp.status not in (301, 302, 303, 307): + eu_access_token: str | None = None + eu_refresh_token: str | None = None + current_url = authorize_url + last_status: int | None = None + last_location = "" + last_content_type = "" + last_body = "" + for hop in range(MAX_HOPS): + async with session.get( + current_url, headers=authorize_headers, allow_redirects=False + ) as resp: + last_status = resp.status + last_location = resp.headers.get("Location", "") + last_content_type = resp.headers.get("Content-Type", "") + try: + last_body = await resp.text() + except Exception: # noqa: BLE001 + last_body = "" + + _LOGGER.debug( + "EU step 3 hop %d: status=%s content-type=%s " + "location=%s body=%s", + hop, + last_status, + last_content_type, + _redact(last_location, limit=512), + _redact(last_body, limit=512), + ) + + if last_status not in (301, 302, 303, 307, 308): + break + + parsed_redirect = urlparse(last_location) + fragment_params = parse_qs(parsed_redirect.fragment) + query_params = parse_qs(parsed_redirect.query) + + # Smart surfaces auth errors via a redirect to /proxy?mode=error + if query_params.get("mode", [None])[0] == "error": + err_code = query_params.get("errorCode", [""])[0] + err_msg = query_params.get("errorMessage", [""])[0] _LOGGER.error( - "EU authorize continue did not redirect: status=%s", resp.status + "EU authorize/continue redirected to error page: " + "code=%s message=%s", + err_code, + err_msg, ) account.state = AuthState.AUTH_FAILED - raise AuthenticationError("EU authorize continue failed") + raise AuthenticationError( + f"EU authorize/continue error (code={err_code}): {err_msg}" + ) - redirect_location = resp.headers.get("Location", "") + eu_access_token = ( + query_params.get("access_token", [None])[0] + or fragment_params.get("access_token", [None])[0] + ) + eu_refresh_token = ( + query_params.get("refresh_token", [None])[0] + or fragment_params.get("refresh_token", [None])[0] + ) + if eu_access_token: + _LOGGER.debug( + "EU step 3: access_token extracted from redirect hop %d " + "(host=%s, source=%s)", + hop + 1, + parsed_redirect.hostname or "?", + "query" if query_params.get("access_token") else "fragment", + ) + break - # Extract access_token from URL fragment - fragment = urlparse(redirect_location).fragment - fragment_params = parse_qs(fragment) - eu_access_token = fragment_params.get("access_token", [None])[0] + # No access_token yet — follow this hop and try again. + current_url = last_location if not eu_access_token: - _LOGGER.error("EU access token extraction failed from redirect fragment") + _LOGGER.error( + "EU access token extraction failed after %d hops; " + "last_status=%s last_host=%s", + MAX_HOPS, + last_status, + urlparse(last_location).hostname or "?", + ) account.state = AuthState.AUTH_FAILED - raise AuthenticationError("EU access token extraction failed") + raise AuthenticationError( + "EU access token extraction failed: walked redirect chain " + "without finding access_token" + ) _LOGGER.debug("EU login step 3 complete (access_token obtained)") - # Step 4: POST session exchange + # Step 4: POST session exchange. + # + # Pre-serialize the body once with stdlib json.dumps and POST it via + # data= rather than json=. The signature is computed over + # the exact bytes we send; passing json= lets aiohttp re-encode + # later (and HA's shared session swaps stdlib json for orjson, which + # omits spaces — producing different bytes than what we signed and + # tripping the cloud's "Check signature error" code 1445). session_url = ( f"{EU_API_BASE_URL}/auth/account/session/secure" f"?identity_type={EU_IDENTITY_TYPE}" ) - session_body = {"accessToken": eu_access_token} - session_body_bytes = aiohttp.payload.JsonPayload(session_body)._value + session_body_str = json.dumps({"accessToken": eu_access_token}) signed_headers = build_signed_headers( "POST", session_url, - session_body_bytes, + session_body_str, Account( username=email, region=Region.EU, @@ -605,17 +860,22 @@ async def async_login_eu( ) async with session.post( - session_url, json=session_body, headers=signed_headers + session_url, data=session_body_str, headers=signed_headers ) as resp: resp.raise_for_status() session_data = await resp.json() if session_data.get("code") != 1000: _LOGGER.error( - "EU session exchange failed: code=%s", session_data.get("code") + "EU session exchange failed: code=%s message=%s", + session_data.get("code"), + session_data.get("message"), ) account.state = AuthState.AUTH_FAILED - raise AuthenticationError("EU session exchange failed") + raise AuthenticationError( + f"EU session exchange failed (code={session_data.get('code')}): " + f"{session_data.get('message')}" + ) data = session_data.get("data", {}) account.api_access_token = data.get("accessToken", "") diff --git a/custom_components/hello_smart/const.py b/custom_components/hello_smart/const.py index b81ccc2..0141ecf 100644 --- a/custom_components/hello_smart/const.py +++ b/custom_components/hello_smart/const.py @@ -32,6 +32,7 @@ "sg-app.smart.com", "auth.smart.com", "awsapi.future.smart.com", + "app.id.smart.com", "ota.srv.smart.com", "vehicle.vbs.srv.smart.com", } @@ -49,6 +50,10 @@ GIGYA_API_KEY = ( "3_L94eyQ-wvJhWm7Afp1oBhfTGXZArUfSHHW9p9Pncg513hZELXsxCfMWHrF8f5P5a" ) +# Gigya socialize endpoint for fetching gmid/ucid (used to bootstrap a +# Gigya session before posting accounts.login). +EU_GIGYA_SOCIALIZE_BASE = "https://socialize.eu1.gigya.com" + INTL_X_CA_KEY = "204587190" INTL_VC_APP_SECRET = "vxnzkHbpQrkKKQKmFBZlOnL780rjXLFT" @@ -66,6 +71,16 @@ EU_DEVICE_ID_LENGTH = 16 INTL_DEVICE_ID_LENGTH = 32 +# --- EU OIDC webview User-Agent --- +# Smart's API gateway (awsapi.future.smart.com) rejects requests with no UA or +# with the iOS-app UA used elsewhere; the OIDC redirect chain only accepts a +# browser-shaped UA matching what the Hello Smart Android app's webview sends. +EU_WEBVIEW_USER_AGENT = ( + "Mozilla/5.0 (Linux; Android 14; SM-S911B) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Mobile Safari/537.36" +) + # --- Accept header used in signing --- ACCEPT_HEADER = "application/json;responseformat=3"