Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions custom_components/zaptec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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] = "********"

Expand All @@ -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
Expand All @@ -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,
Expand Down
36 changes: 32 additions & 4 deletions custom_components/zaptec/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
),
),
}
)

Expand Down
2 changes: 2 additions & 0 deletions custom_components/zaptec/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions custom_components/zaptec/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
79 changes: 79 additions & 0 deletions custom_components/zaptec/zaptec/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {}
Expand All @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
10 changes: 10 additions & 0 deletions custom_components/zaptec/zaptec/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading