diff --git a/custom_components/zaptec/__init__.py b/custom_components/zaptec/__init__.py index acb5e111..4fc918a0 100644 --- a/custom_components/zaptec/__init__.py +++ b/custom_components/zaptec/__init__.py @@ -13,8 +13,10 @@ from .const import ( CONF_CHARGERS, + CONF_CLIENT_ID, CONF_MANUAL_SELECT, CONF_PREFIX, + CONF_REFRESH_TOKEN, REDACT_DUMP_ON_STARTUP, REDACT_LOGS, ZAPTEC_POLL_INTERVAL_BUILD, @@ -60,6 +62,22 @@ def _config_entry_error( return ConfigEntryError(str(err)) +def _persist_refresh_token(hass: HomeAssistant, entry: ConfigEntry, zaptec: Zaptec) -> None: + """Persist the current OAuth2 refresh token to the config entry. + + The Zaptec OIDC provider (Ory) issues single-use rotating refresh tokens: + every refresh consumes the token and issues a new one. Persisting the + current token after each rotation keeps the integration working across + Home Assistant restarts without requiring re-auth. + """ + new_token = zaptec.refresh_token + if new_token and new_token != entry.data.get(CONF_REFRESH_TOKEN): + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_REFRESH_TOKEN: new_token} + ) + _LOGGER.debug("Persisted a new Zaptec refresh token") + + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -74,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up zaptec as config entry.""" redacted_data = {**entry.data} - for key in ("password", "username"): + for key in ("password", "username", "client_id", "refresh_token"): if key in redacted_data: redacted_data[key] = "********" @@ -93,8 +111,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create the Zaptec object zaptec = Zaptec( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], + entry.data.get(CONF_USERNAME, ""), + entry.data.get(CONF_PASSWORD, ""), + client_id=entry.data.get(CONF_CLIENT_ID), + refresh_token=entry.data.get(CONF_REFRESH_TOKEN), client=async_get_clientsession(hass), max_time=ZAPTEC_POLL_INTERVAL_CHARGING, # The shortest of the intervals show_all_updates=True, # During setup we'd like to log all updates @@ -108,6 +128,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Zaptec login failed: %s", err) raise _config_entry_error(err) from err + # Login rotates the (single-use) refresh token; persist the new one now. + _persist_refresh_token(hass, entry, zaptec) + # Get the structure of devices from Zaptec and determine the zaptec objects to track tracked_devices = await ZaptecManager.first_time_setup( zaptec=zaptec, diff --git a/custom_components/zaptec/config_flow.py b/custom_components/zaptec/config_flow.py index d0e3da6b..7d623083 100644 --- a/custom_components/zaptec/config_flow.py +++ b/custom_components/zaptec/config_flow.py @@ -12,7 +12,14 @@ from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, TextSelectorType import voluptuous as vol -from .const import CONF_CHARGERS, CONF_MANUAL_SELECT, CONF_PREFIX, DOMAIN +from .const import ( + CONF_CHARGERS, + CONF_CLIENT_ID, + CONF_MANUAL_SELECT, + CONF_PREFIX, + CONF_REFRESH_TOKEN, + DOMAIN, +) from .zaptec import ( AuthenticationError, Charger, @@ -44,10 +51,19 @@ async def _validate_account(self, user_input: dict[str, Any]) -> dict[str, str]: try: self.zaptec = Zaptec( username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], + password=user_input.get(CONF_PASSWORD, ""), + refresh_token=user_input.get(CONF_REFRESH_TOKEN), + client_id=user_input.get(CONF_CLIENT_ID), client=async_get_clientsession(self.hass), ) await self.zaptec.login() + + # The Zaptec OIDC provider (Ory) issues single-use rotating refresh + # tokens: loging in consumes the supplied token and yields a new one. + # Record the rotated token so the config entry stores the current + # (still valid) token instead of the consumed one. + if self.zaptec.refresh_token: + user_input[CONF_REFRESH_TOKEN] = self.zaptec.refresh_token except (RequestConnectionError, RequestTimeoutError): errors["base"] = "cannot_connect" except AuthenticationError: @@ -160,12 +176,18 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con autocomplete="email", ), ), - vol.Required(CONF_PASSWORD): TextSelector( + vol.Optional(CONF_PASSWORD, default=""): TextSelector( TextSelectorConfig( type=TextSelectorType.PASSWORD, autocomplete="current-password", ), ), + vol.Optional(CONF_CLIENT_ID, default=""): str, + vol.Optional(CONF_REFRESH_TOKEN, default=""): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), vol.Optional(CONF_PREFIX): str, vol.Optional(CONF_MANUAL_SELECT): bool, } @@ -251,12 +273,18 @@ async def async_step_reauth_confirm( schema = vol.Schema( { - vol.Required(CONF_PASSWORD): TextSelector( + vol.Optional(CONF_PASSWORD, default=""): TextSelector( TextSelectorConfig( type=TextSelectorType.PASSWORD, autocomplete="current-password", ), ), + vol.Optional(CONF_CLIENT_ID, default=""): str, + vol.Optional(CONF_REFRESH_TOKEN, default=""): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), } ) diff --git a/custom_components/zaptec/const.py b/custom_components/zaptec/const.py index 739326f3..94e9eee9 100644 --- a/custom_components/zaptec/const.py +++ b/custom_components/zaptec/const.py @@ -44,6 +44,8 @@ CONF_MANUAL_SELECT = "manual_select" CONF_CHARGERS = "chargers" CONF_PREFIX = "prefix" +CONF_CLIENT_ID = "client_id" +CONF_REFRESH_TOKEN = "refresh_token" # noqa: S105 # These keys will not be checked at startup for entity availability. This is # useful for keys that are not always present in the API response, such as diff --git a/custom_components/zaptec/coordinator.py b/custom_components/zaptec/coordinator.py index cce4419a..6f18aa7f 100644 --- a/custom_components/zaptec/coordinator.py +++ b/custom_components/zaptec/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + CONF_REFRESH_TOKEN, DOMAIN, REQUEST_REFRESH_DELAY, ZAPTEC_POLL_CHARGER_TRIGGER_DELAYS, @@ -121,6 +122,20 @@ async def _async_update_data(self) -> None: _LOGGER.exception("Fetching data failed") raise UpdateFailed(err) from err + # A poll may have refreshed (and thus rotated) the OAuth2 refresh + # token; persist the current one so it survives HA restarts. + self._persist_refresh_token() + + def _persist_refresh_token(self) -> None: + """Persist the current OAuth2 refresh token to the config entry.""" + new_token = self.zaptec.refresh_token + if new_token and new_token != self.config_entry.data.get(CONF_REFRESH_TOKEN): + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_REFRESH_TOKEN: new_token}, + ) + _LOGGER.debug("Persisted a new Zaptec refresh token") + async def _trigger_poll(self, zaptec_obj: ZaptecBase) -> None: """Trigger a poll update sequence for the given object. diff --git a/custom_components/zaptec/zaptec/api.py b/custom_components/zaptec/zaptec/api.py index cd818dd4..5d651845 100644 --- a/custom_components/zaptec/zaptec/api.py +++ b/custom_components/zaptec/zaptec/api.py @@ -31,10 +31,13 @@ API_RETRY_MAXTIME, API_TIMEOUT, API_URL, + AUTH_URL, CHARGER_EXCLUDES, DEFAULT_MAX_CURRENT, MAX_DEBUG_TEXT_LEN_ON_500, MISSING, + OAUTH_CLIENT_ID, + OAUTH_SCOPE, RETRYABLE_HTTP_STATUSES, TOKEN_URL, TRUTHY, @@ -818,6 +821,8 @@ def __init__( username: str, password: str, *, + client_id: str | None = None, + refresh_token: str | None = None, client: aiohttp.ClientSession | None = None, max_time: float = API_RETRY_MAXTIME, show_all_updates: bool = False, @@ -826,6 +831,8 @@ def __init__( """Initialize the Zaptec account handler.""" self._username = username self._password = password + self._client_id = client_id or OAUTH_CLIENT_ID + self._oauth_refresh_token = refresh_token self._client = client or aiohttp.ClientSession() self._client_internal = client is None self._token_info = {} @@ -846,6 +853,17 @@ def __init__( self.show_all_updates: bool = show_all_updates """Flag to indicate if all updates should be logged, even if no changes.""" + @property + def refresh_token(self) -> str | None: + """Return the current (rotated) OAuth2 refresh token, if any. + + Used to persist the latest refresh token so the integration keeps + working across restarts. The Zaptec OIDC provider (Ory) issues + single-use rotating refresh tokens, so the current one must be + saved back to the config entry after every rotation. + """ + return self._oauth_refresh_token + async def __aenter__(self) -> Self: """Enter the context manager.""" return self @@ -1109,6 +1127,67 @@ async def login(self) -> None: await self._refresh_token() async def _refresh_token(self) -> None: + """Refresh the access token for the Zaptec API. + + When an OAuth2 refresh token was supplied (accounts managed through the + new auth.zaptec.com identity provider), the token is renewed against the + OIDC token endpoint. Otherwise the legacy password grant is attempted. + """ + if self._oauth_refresh_token: + await self._refresh_token_oauth() + else: + await self._refresh_token_legacy() + + async def _refresh_token_oauth(self) -> None: + """Refresh the access token using an OAuth2 refresh token.""" + p = { + "grant_type": "refresh_token", + "refresh_token": self._oauth_refresh_token, + "client_id": self._client_id, + "scope": OAUTH_SCOPE, + } + if DEBUG_API_CALLS: + _LOGGER.debug("@@@ REFRESH TOKEN (oauth)") + + async with aclosing( + self._request_worker( + AUTH_URL, + method="post", + data=p, + retries=API_RETRIES, + timeout=self._timeout, + ) + ) as ctx: + async for response, log_exc in ctx: + if response.status == HTTPStatus.OK: + data = await response.json() + self._token_info.update(data) + self._access_token = data.get("access_token") + # Rotate the stored refresh token if the provider returns one + if data.get("refresh_token"): + self._oauth_refresh_token = data["refresh_token"] + if DEBUG_API_CALLS: + _LOGGER.debug(" TOKEN OK (oauth)") + return + + if response.status == HTTPStatus.BAD_REQUEST: + # invalid_grant means the token is expired/revoked and the + # user needs to obtain a fresh one from the Zaptec portal. + data = await response.json() + raise log_exc( + AuthenticationError( + f"Failed to refresh OAuth token. {data.get('error_description', '')}" + ) + ) + + raise log_exc( + RequestError( + f"POST request to {AUTH_URL} failed with status {response.status}: {response}", + response.status, + ) + ) + + async def _refresh_token_legacy(self) -> None: # So for some reason they used grant_type password.. # what the point with oauth then? Anyway this is valid for 24 hour p = { diff --git a/custom_components/zaptec/zaptec/const.py b/custom_components/zaptec/zaptec/const.py index 10e9f589..5a0d8fb8 100644 --- a/custom_components/zaptec/zaptec/const.py +++ b/custom_components/zaptec/zaptec/const.py @@ -17,6 +17,16 @@ class Missing: API_URL = "https://api.zaptec.com/api/" CONST_URL = "https://api.zaptec.com/api/constants" +# Zaptec has migrated account login to OAuth2 via auth.zaptec.com. The legacy +# password grant at TOKEN_URL is no longer accepted for accounts registered on +# the new identity provider. This integration therefore supports authenticating +# with an OAuth2 refresh token obtained from the Zaptec web portal session +# (grant_type=refresh_token against AUTH_URL), which mints access tokens that +# the API accepts. +AUTH_URL = "https://auth.zaptec.com/oauth2/token" +OAUTH_SCOPE = "openid offline_access" +OAUTH_CLIENT_ID = "97b4c92b-9032-44c2-bbfe-78ba3704cea7" # Zaptec web portal public client + API_RETRIES = 8 # Corresponds to median ~100 seconds of retries before giving up """Number of retries for API requests."""