From 17d416b55ecf86dc12565c356c3ee5cffa7318cd Mon Sep 17 00:00:00 2001 From: Jussi Alanara Date: Fri, 1 May 2026 11:06:27 +0300 Subject: [PATCH] fix(auth): adapt EU login flow to current Smart Gigya/OIDC cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smart's EU OIDC backend changed in several incompatible ways and broke the integration's login flow before any password reaches the wire. This commit ports the EU flow over to the same shape used by the working pySmartHashtag library (org.more.hashtag Android app). Step 1 — context redirect chain * Send a browser-shaped User-Agent (Hello Smart Android webview UA; constants.py: EU_WEBVIEW_USER_AGENT). Without it Smart's API Gateway returns 403 ForbiddenException. * Walk up to 5 redirect hops looking for the first Location whose query carries ?context=. The context= parameter moved from hop 1 (auth.smart.com) to hop 2 (app.id.smart.com/proxy) and may shift again. const.py URL_ALLOWLIST gains app.id.smart.com. Step 1.5 — Gigya bootstrap cookies (new) * GET socialize.eu1.gigya.com/socialize.getIDs to obtain a fresh gmid + ucid pair, then build a Cookie header: gmid=; ucid=; hasGmid=ver4; gig_bootstrap_=auth_ver4 This declares to Gigya that "the SDK has been bootstrapped" and is required for accounts.login to be accepted. * const.py: EU_GIGYA_SOCIALIZE_BASE. Step 2 — Gigya accounts.login * Send the bootstrap cookie, plus Origin: https://app.id.smart.com, x-requested-with header, and a pageURL body field. Without these Gigya returns errorCode=400006 "Invalid parameter value / Your request is blocked because of security issues" with the misleading "missingKey" flag. * Coerce sessionInfo.expires_in to int (Gigya returns it as a string in some responses). Step 3 — /authorize/continue * The OIDC server now authenticates this hop via cookies, not query parameters. Send only context+login_token in the URL, with the bootstrap cookie plus glt_= in the Cookie header. * Walk up to 5 redirect hops; extract access_token + refresh_token + id_token from the final Location's query string OR fragment. Smart's current flow lands at: /login-app/api/v1/FinalPage?code=... -> /login-app/api/v1/MobileData?access_token=... &id_token=... &refresh_token=... * Surface Smart's /proxy?mode=error redirects as actionable errors instead of opaque "no access_token in fragment". Step 4 — POST /auth/account/session/secure (signature) * Pre-serialize the body once with stdlib json.dumps and POST it via data= rather than json=. The HMAC-SHA1 signature is computed over body bytes; passing json= let aiohttp re-encode using HA's orjson serializer (no spaces), which produced different bytes than what we signed and tripped the cloud's "Check signature error" code 1445. Same approach the INTL flow already uses. Diagnostics + error reporting * _redact() helper scrubs access_token/refresh_token/code/login_token /password/authCode values from any URL or JSON body before it hits the log, so DEBUG output is safe to share. * Distinguish credential errors (Gigya 4xxxxx codes) from upstream errors (rate limit, API misconfiguration). Old code raised "EU Gigya login failed: invalid credentials" for both cases. * Surface the API gateway's message on Step 4 failures. End-to-end verified against a real EU account: returns Account with populated api_access_token, api_refresh_token, api_user_id, and expires_at. The INTL flow is unchanged. --- custom_components/hello_smart/auth.py | 342 ++++++++++++++++++++++--- custom_components/hello_smart/const.py | 15 ++ 2 files changed, 316 insertions(+), 41 deletions(-) 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"