From d9d2b6dc4199c27bbb83b304d2e85eb8348a2bff Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Sun, 16 Aug 2026 21:30:24 +0200 Subject: [PATCH 1/2] feat(auth): support OAuth2 refresh-token login via auth.zaptec.com Zaptec migrated account login to OIDC (auth.zaptec.com) and retired the legacy password grant at api.zaptec.com/oauth/token. Accounts registered on the new identity provider can no longer authenticate with the current password-based login. Adds an optional OAuth2 path: the config flow accepts an OAuth2 refresh token (obtained from the Zaptec web portal session, client_id defaulting to the portal public client) and mints api.zaptec.com access tokens via the refresh_token grant at https://auth.zaptec.com/oauth2/token. The legacy username/password flow is preserved unchanged (password is now optional). Files: zaptec/const.py (AUTH_URL, OAUTH_SCOPE, OAUTH_CLIENT_ID), const.py (CONF_CLIENT_ID, CONF_REFRESH_TOKEN), zaptec/api.py (client_id/refresh_token args; refresh dispatcher with OAuth + legacy paths; token rotation), config_flow.py (optional client_id/refresh_token in setup and reauth), __init__.py (thread options, redact new secrets). NOTE: interim mechanism relying on the Zaptec web portal public OAuth client; a production-grade solution needs Zaptec to register an OAuth client for Home Assistant. --- custom_components/zaptec/__init__.py | 10 ++-- custom_components/zaptec/config_flow.py | 29 ++++++++-- custom_components/zaptec/const.py | 2 + custom_components/zaptec/zaptec/api.py | 68 ++++++++++++++++++++++++ custom_components/zaptec/zaptec/const.py | 10 ++++ 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/custom_components/zaptec/__init__.py b/custom_components/zaptec/__init__.py index acb5e111..a44a86cf 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, @@ -74,7 +76,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 +95,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 diff --git a/custom_components/zaptec/config_flow.py b/custom_components/zaptec/config_flow.py index d0e3da6b..fc9a004c 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,7 +51,9 @@ 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() @@ -160,12 +169,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 +266,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/zaptec/api.py b/custom_components/zaptec/zaptec/api.py index cd818dd4..1774f816 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 = {} @@ -1109,6 +1116,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.""" From a8897d6f400bde35b1c73cba3714edf329f687bc Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Sun, 16 Aug 2026 22:33:20 +0200 Subject: [PATCH 2/2] fix(oauth): persist rotating refresh token so setup works The Zaptec OIDC provider (Ory) issues single-use rotating refresh tokens. The config flow validated by consuming the supplied token, but the entry was created with the (now spent) original token, so runtime setup re-used it and failed with 'refresh token re-use was detected'. - config_flow: store the rotated token from login() into the new entry - api: expose the current refresh_token via a property - runtime: persist the current token to the config entry after login and after each poll, so restarts keep working without re-auth --- custom_components/zaptec/__init__.py | 19 +++++++++++++++++++ custom_components/zaptec/config_flow.py | 7 +++++++ custom_components/zaptec/coordinator.py | 15 +++++++++++++++ custom_components/zaptec/zaptec/api.py | 11 +++++++++++ 4 files changed, 52 insertions(+) diff --git a/custom_components/zaptec/__init__.py b/custom_components/zaptec/__init__.py index a44a86cf..4fc918a0 100644 --- a/custom_components/zaptec/__init__.py +++ b/custom_components/zaptec/__init__.py @@ -62,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, @@ -112,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 fc9a004c..7d623083 100644 --- a/custom_components/zaptec/config_flow.py +++ b/custom_components/zaptec/config_flow.py @@ -57,6 +57,13 @@ async def _validate_account(self, user_input: dict[str, Any]) -> dict[str, str]: 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: 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 1774f816..5d651845 100644 --- a/custom_components/zaptec/zaptec/api.py +++ b/custom_components/zaptec/zaptec/api.py @@ -853,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