Skip to content
Merged
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ Click this button to skip steps 1 and 2 below: [![Open your Home Assistant insta

This integration previously used a cookie-fishing authentication workflow. As of PR #267, it has now been replaced by a standard OAuth workflow similar to the mobile app, using your username and password, with auto-refreshing token retrieval. You no longer need to enter your cookie's string!

### Session expiry (every ~90 days)

Generac's sign-in sessions expire 90 days after you log in, no matter how often the integration uses them. You have two options:

- **Remember password** (checkbox at sign-in, reauth and reconfigure): the password is stored in Home Assistant and the integration signs in again on its own when the session expires. This doesn't work for accounts that require a verification code (SMS / email / authenticator), because the code can't be entered unattended.
- **Without it**: a Repairs warning appears about 10 days before expiry. Use **Reconfigure** on the integration to sign in again before your devices stop updating.

> [!IMPORTANT]
>
> ## Fair Warning
Expand Down
25 changes: 25 additions & 0 deletions custom_components/generac/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
https://github.com/binarydev/ha-generac
"""
import logging
import time

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand All @@ -16,12 +17,15 @@
from .auth import GeneracAuth
from .auth import InvalidGrantError
from .const import CONF_DPOP_PEM
from .const import CONF_PASSWORD
from .const import CONF_REFRESH_TOKEN
from .const import CONF_TOKEN_ISSUED_AT
from .const import CONF_USERNAME
from .const import DOMAIN
from .const import PLATFORMS
from .const import STARTUP_MESSAGE
from .coordinator import GeneracDataUpdateCoordinator
from .issues import async_delete_token_expiry_issue
from .utils import async_client_session

_LOGGER: logging.Logger = logging.getLogger(__package__)
Expand Down Expand Up @@ -56,6 +60,22 @@ async def _persist_rt(new_rt: str) -> None:

auth.set_refresh_token_persist_callback(_persist_rt)

password = entry.data.get(CONF_PASSWORD)
if password:

async def _persist_relogin(fresh: GeneracAuth) -> None:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_REFRESH_TOKEN: fresh.refresh_token,
CONF_DPOP_PEM: fresh.pem_str,
CONF_TOKEN_ISSUED_AT: time.time(),
},
)

auth.enable_auto_relogin(password, _persist_relogin)

client = GeneracApiClient(session, auth)
coordinator = GeneracDataUpdateCoordinator(hass, client=client, config_entry=entry)
try:
Expand Down Expand Up @@ -94,3 +114,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
await hass.config_entries.async_reload(entry.entry_id)


async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Clean up the expiry Repairs issue when the entry is deleted."""
async_delete_token_expiry_issue(hass, entry)
75 changes: 70 additions & 5 deletions custom_components/generac/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@
* Email + password universal-login against `auth.ecobee.com` (Auth0 tenant
shared with the ecobee mobile apps).
* PKCE + DPoP-bound authorization code exchange.
* Refresh-token rotation off — the same RT is reusable indefinitely as
long as we keep proving possession of the original DPoP key.
* Refresh-token rotation off — the same RT is reused for every refresh
as long as we keep proving possession of the original DPoP key.

The DPoP private key is therefore part of the credential and must be
persisted alongside the refresh token. We expose the key as a PEM string
so it can live in the ConfigEntry's normal `data` dict.

Refresh tokens for this client are NOT rotated by Auth0 (verified
empirically with multiple successive refreshes). We never need to
rewrite the ConfigEntry on a successful refresh.
empirically with multiple successive refreshes), but they DO have an
absolute lifetime of ~90 days from login (observed in production: the
token endpoint returns `invalid_grant` 90 days after login even though
it was refreshed every 2 h). Refreshing does not extend it; only a new
login does. When the user opts in to storing their password, the RT
expiry is handled by a silent re-login (see `enable_auto_relogin`).
"""
from __future__ import annotations

Expand Down Expand Up @@ -906,6 +910,22 @@ def __init__(
self._dpop_nonce: Optional[str] = None
self._refresh_lock = asyncio.Lock()
self._rt_persist_cb: Optional[Callable[[str], Awaitable[None]]] = None
self._password: Optional[str] = None
self._relogin_cb: Optional[Callable[["GeneracAuth"], Awaitable[None]]] = None

def enable_auto_relogin(
self,
password: str,
persist_cb: Callable[["GeneracAuth"], Awaitable[None]],
) -> None:
"""Re-run the full login when the refresh token is rejected.

The callback receives this instance after it has adopted the fresh
refresh token + DPoP key, and must persist both (`refresh_token`,
`pem_str`) — the old pair is dead.
"""
self._password = password
self._relogin_cb = persist_cb

def set_refresh_token_persist_callback(
self, cb: Optional[Callable[[str], Awaitable[None]]]
Expand Down Expand Up @@ -991,10 +1011,55 @@ async def ensure_access_token(self) -> str:
and time.time() < self._access_token_exp - self._ACCESS_TOKEN_LEEWAY
):
return self._access_token
await self._refresh()
try:
await self._refresh()
except InvalidGrantError:
if self._password is None or not self._email:
raise
_LOGGER.warning(
"Generac refresh token rejected; logging in again with "
"the stored password"
)
await self._relogin()
assert self._access_token is not None
return self._access_token

async def _relogin(self) -> None:
"""Mint a new RT + DPoP key via a full login and adopt them.

Raises InvalidGrantError when the login can't complete unattended
(wrong password, MFA) so the caller still lands in HA's reauth flow.
Transport / unexpected errors propagate unchanged so the coordinator
retries on the next poll instead of prompting.
"""
assert self._password is not None and self._email
try:
fresh = await GeneracAuth.login(self._session, self._email, self._password)
except MfaRequiredError as ex:
await ex.flow.aclose()
raise InvalidGrantError(
"refresh token expired and re-login requires a one-time MFA code"
) from ex
except (InvalidCredentialsError, MfaUnsupportedError) as ex:
raise InvalidGrantError(
f"refresh token expired and re-login failed: {ex}"
) from ex

self._key = fresh._key
self._refresh_token = fresh._refresh_token
self._access_token = fresh._access_token
self._access_token_exp = fresh._access_token_exp
self._dpop_nonce = None
_LOGGER.info("Generac re-login OK; new refresh token issued")
if self._relogin_cb is not None:
try:
await self._relogin_cb(self)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Re-login succeeded but persisting the new credentials "
"failed; the next HA restart will log in again"
)

async def _refresh(self) -> None:
body = {
"grant_type": "refresh_token",
Expand Down
50 changes: 40 additions & 10 deletions custom_components/generac/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"""
import asyncio
import logging
import time

import aiohttp
import voluptuous as vol
Expand All @@ -40,7 +41,9 @@
from .const import CONF_OPTIONS
from .const import CONF_PASSWORD
from .const import CONF_REFRESH_TOKEN
from .const import CONF_REMEMBER_PASSWORD
from .const import CONF_SCAN_INTERVAL
from .const import CONF_TOKEN_ISSUED_AT
from .const import CONF_USERNAME
from .const import DEFAULT_SCAN_INTERVAL
from .const import DOMAIN
Expand Down Expand Up @@ -91,12 +94,24 @@ def __init__(self):
self._pending_scan_interval: int | None = None

@staticmethod
def _entry_data(auth: GeneracAuth, email: str) -> dict:
return {
def _entry_data(auth: GeneracAuth, email: str, password: str | None) -> dict:
data = {
CONF_USERNAME: email,
CONF_REFRESH_TOKEN: auth.refresh_token,
CONF_DPOP_PEM: auth.pem_str,
CONF_TOKEN_ISSUED_AT: time.time(),
}
if password:
data[CONF_PASSWORD] = password
return data

@staticmethod
def _merge_entry_data(entry: config_entries.ConfigEntry, entry_data: dict) -> dict:
"""Overlay fresh credentials, dropping a stored password on opt-out."""
data = {**entry.data, **entry_data}
if CONF_PASSWORD not in entry_data:
data.pop(CONF_PASSWORD, None)
return data

def _reload_if_setup_failed(self, entry: config_entries.ConfigEntry) -> None:
"""Force a reload when the entry isn't currently loaded.
Expand All @@ -118,7 +133,7 @@ def _reload_if_setup_failed(self, entry: config_entries.ConfigEntry) -> None:
self.hass.config_entries.async_schedule_reload(entry.entry_id)

async def _try_login(
self, email: str, password: str
self, email: str, password: str, remember: bool = False
) -> tuple[dict | None, str | None]:
"""Run the full login flow. Returns (entry_data, error_key).

Expand Down Expand Up @@ -150,7 +165,7 @@ async def _try_login(
_LOGGER.error("Unexpected error during login: %s", ex, exc_info=True)
return None, "internal"

return self._entry_data(auth, email), None
return self._entry_data(auth, email, password if remember else None), None

async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
Expand All @@ -159,9 +174,10 @@ async def async_step_user(self, user_input=None):
if user_input is not None:
email = user_input[CONF_USERNAME].strip()
password = user_input[CONF_PASSWORD]
remember = user_input.get(CONF_REMEMBER_PASSWORD, False)

try:
entry_data, error = await self._try_login(email, password)
entry_data, error = await self._try_login(email, password, remember)
except MfaRequiredError as ex:
self._login_flow = ex.flow
self._mfa_origin = "user"
Expand All @@ -178,6 +194,7 @@ async def async_step_user(self, user_input=None):
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_REMEMBER_PASSWORD, default=False): bool,
}
),
errors=errors,
Expand All @@ -199,9 +216,10 @@ async def async_step_reconfigure(self, user_input=None):
email = user_input[CONF_USERNAME].strip()
password = user_input[CONF_PASSWORD]
scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
remember = user_input.get(CONF_REMEMBER_PASSWORD, False)

try:
entry_data, error = await self._try_login(email, password)
entry_data, error = await self._try_login(email, password, remember)
except MfaRequiredError as ex:
self._login_flow = ex.flow
self._mfa_origin = "reconfigure"
Expand Down Expand Up @@ -229,6 +247,10 @@ async def async_step_reconfigure(self, user_input=None):
vol.Required(
CONF_SCAN_INTERVAL, default=default_scan_interval
): int,
vol.Optional(
CONF_REMEMBER_PASSWORD,
default=bool(entry and entry.data.get(CONF_PASSWORD)),
): bool,
}
),
errors=errors,
Expand Down Expand Up @@ -260,7 +282,7 @@ async def _finish_reconfigure(self, entry_data: dict):
# unload, surfacing as "failed to unload".
self.hass.config_entries.async_update_entry(
entry,
data={**entry.data, **entry_data},
data=self._merge_entry_data(entry, entry_data),
options=new_options,
)
self._reload_if_setup_failed(entry)
Expand Down Expand Up @@ -290,9 +312,10 @@ async def async_step_reauth_confirm(self, user_input=None):
# integration. This prevents silently rebinding the entry
# (and all its entities) to a different Generac account.
email = default_email
remember = user_input.get(CONF_REMEMBER_PASSWORD, False)

try:
entry_data, error = await self._try_login(email, password)
entry_data, error = await self._try_login(email, password, remember)
except MfaRequiredError as ex:
self._login_flow = ex.flow
self._mfa_origin = "reauth"
Expand All @@ -308,6 +331,10 @@ async def async_step_reauth_confirm(self, user_input=None):
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
vol.Optional(
CONF_REMEMBER_PASSWORD,
default=bool(entry.data.get(CONF_PASSWORD)),
): bool,
}
),
description_placeholders={"username": default_email},
Expand All @@ -325,7 +352,7 @@ async def _finish_reauth(self, entry_data: dict):
# reload here would race the listener-driven one in the loaded case and
# surface as "failed to unload".
self.hass.config_entries.async_update_entry(
entry, data={**entry.data, **entry_data}
entry, data=self._merge_entry_data(entry, entry_data)
)
self._reload_if_setup_failed(entry)
return self.async_abort(reason="reauth_successful")
Expand Down Expand Up @@ -371,7 +398,10 @@ async def async_step_mfa(self, user_input=None):
await self._discard_login_flow()
return self.async_abort(reason="mfa_failed")
else:
entry_data = self._entry_data(auth, self._pending_email)
# No password is stored for MFA accounts: an unattended
# re-login would stall on the same challenge. They get the
# expiry Repairs warning instead.
entry_data = self._entry_data(auth, self._pending_email, None)
self._login_flow = None
return await self._finish_origin(entry_data)

Expand Down
13 changes: 13 additions & 0 deletions custom_components/generac/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@
CONF_REFRESH_TOKEN = "refresh_token"
CONF_DPOP_PEM = "dpop_pem"
CONF_SCAN_INTERVAL = "scan_interval"
# Opt-in: keep the password in entry.data so an expired refresh token can be
# replaced by a silent re-login instead of a manual reauth.
CONF_REMEMBER_PASSWORD = "remember_password"
# Unix timestamp of the login that minted the current refresh token.
CONF_TOKEN_ISSUED_AT = "token_issued_at"

# Generac's Auth0 tenant caps refresh tokens at an absolute lifetime of
# ~90 days (observed: invalid_grant 90 days after login despite refreshing
# every 2 h). Using the token does not extend it; only a fresh login does.
REFRESH_TOKEN_LIFETIME_DAYS = 90
# Raise a Repairs warning this many days before the expected expiry when
# there is no stored password to re-login with.
REFRESH_TOKEN_WARN_DAYS = 10

# Options
bool_opts = {}
Expand Down
2 changes: 2 additions & 0 deletions custom_components/generac/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .const import CONF_SCAN_INTERVAL
from .const import DEFAULT_SCAN_INTERVAL
from .const import DOMAIN
from .issues import async_update_token_expiry_issue
from .models import Item

_LOGGER: logging.Logger = logging.getLogger(__package__)
Expand Down Expand Up @@ -48,6 +49,7 @@ async def _async_update_data(self):
items = await self.api.async_get_data()
self.is_online = items is not None
_LOGGER.info("Generac poll OK: %d device(s)", len(items) if items else 0)
async_update_token_expiry_issue(self.hass, self._config_entry)
return items
except (InvalidCredentialsException, InvalidGrantError) as ex:
# Refresh token / login no longer valid — trigger HA reauth flow.
Expand Down
Loading
Loading