From 160635c33ba4c6a114ab08f4290b086e7a86525a Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 13:01:53 -0700 Subject: [PATCH 1/3] Add username+password and JWT auth alongside the API key pfRest v2 exposes three mutually-exclusive auth schemes; the v2 rewrite only wired up the API key. This adds the other two. Client (pypfsense): - `Client(url, session, *, auth_method, api_key|username/password, verify_ssl)`. api_key -> `x-api-key`; basic -> `Authorization: Basic`; jwt -> a Bearer token minted from `POST /api/v2/auth/jwt` (with Basic), cached, and re-minted + retried once on a 401. Header-only, so no deprecated `aiohttp.BasicAuth`. - `client_from_config(url, session, data, verify_ssl)` builds the right client from a config-entry mapping; used by setup and the config flow. Config flow (VERSION 3 -> 4): - `async_step_user` is now a menu (API key / username+password / JWT); each method has its own form. Reauth re-prompts for whichever credentials the entry's `auth_method` needs. - `async_migrate_entry` v3 -> v4 stamps `auth_method: api_key` on existing entries (unique id unchanged, entities re-attach). Strings gain the menu + per-method steps; `invalid_auth` text is now method-neutral and mentions enabling the method under System > REST API > Settings. Tests updated for the new signature and flow; added basic/jwt header, jwt-refresh, missing-credential, and v3->v4 migration cases. Co-Authored-By: Claude Sonnet 5 --- custom_components/pfsense/__init__.py | 27 +++- custom_components/pfsense/config_flow.py | 150 ++++++++++++------ custom_components/pfsense/const.py | 14 +- .../pfsense/pypfsense/__init__.py | 134 ++++++++++++++-- custom_components/pfsense/strings.json | 45 +++++- .../pfsense/translations/en.json | 45 +++++- tests/test_binary_sensor.py | 2 +- tests/test_config_flow.py | 148 ++++++++++++----- tests/test_init.py | 46 +++++- tests/test_pypfsense.py | 76 ++++++++- 10 files changed, 556 insertions(+), 131 deletions(-) diff --git a/custom_components/pfsense/__init__.py b/custom_components/pfsense/__init__.py index 7f6f5e3..b9e83b3 100644 --- a/custom_components/pfsense/__init__.py +++ b/custom_components/pfsense/__init__.py @@ -25,7 +25,8 @@ ) from .const import ( - CONF_API_KEY, + AUTH_METHOD_API_KEY, + CONF_AUTH_METHOD, CONF_DEVICE_TRACKER_ENABLED, CONF_DEVICE_TRACKER_SCAN_INTERVAL, CONF_TLS_INSECURE, @@ -43,7 +44,12 @@ SHOULD_RELOAD, UNDO_UPDATE_LISTENER, ) -from .pypfsense import Client as pfSenseClient, PfSenseAuthError, PfSensePrivilegeError +from .pypfsense import ( + Client as pfSenseClient, + PfSenseAuthError, + PfSensePrivilegeError, + client_from_config, +) from .services import ServiceRegistrar _LOGGER = logging.getLogger(__name__) @@ -155,17 +161,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): options = entry.options url = config[CONF_URL] - api_key = config.get(CONF_API_KEY) - if not api_key: - # Migrated-from-password entry that hasn't been re-authed yet. - raise ConfigEntryAuthFailed("no pfSense REST API key configured") verify_ssl = config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) device_tracker_enabled = options.get( CONF_DEVICE_TRACKER_ENABLED, DEFAULT_DEVICE_TRACKER_ENABLED ) session = async_get_clientsession(hass, verify_ssl) - client = pfSenseClient(url, api_key, session, {"verify_ssl": verify_ssl}) + try: + client = client_from_config(url, session, config, verify_ssl) + except PfSenseAuthError as err: + # Missing credentials (e.g. a half-migrated entry): send the user to + # reauth rather than retrying setup forever. + raise ConfigEntryAuthFailed(str(err)) from err data = PfSenseData(client, entry, hass) scan_interval = options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) @@ -284,6 +291,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> v2 -> v3: XML-RPC username/password auth is gone. Strip the stored password and force a reauth so the user can enter a REST API key. The unique id (``slugify(netgate id)``) is unchanged, so entities re-attach afterwards. + v3 -> v4: stamp ``auth_method`` on entries that predate the multi-method + config flow. Every v3 entry used an API key. """ data = dict(config_entry.data) @@ -299,6 +308,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # No API key yet: async_setup_entry raises ConfigEntryAuthFailed, which # starts the reauth flow so the user can paste a key. + if config_entry.version == 3: + data.setdefault(CONF_AUTH_METHOD, AUTH_METHOD_API_KEY) + hass.config_entries.async_update_entry(config_entry, data=data, version=4) + return True diff --git a/custom_components/pfsense/config_flow.py b/custom_components/pfsense/config_flow.py index ee9eff6..8e1027b 100644 --- a/custom_components/pfsense/config_flow.py +++ b/custom_components/pfsense/config_flow.py @@ -8,14 +8,25 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from .const import ( + AUTH_METHOD_API_KEY, + AUTH_METHOD_BASIC, + AUTH_METHOD_JWT, CONF_API_KEY, + CONF_AUTH_METHOD, CONF_DEVICE_TRACKER_CONSIDER_HOME, CONF_DEVICE_TRACKER_ENABLED, CONF_DEVICE_TRACKER_SCAN_INTERVAL, @@ -30,15 +41,24 @@ DOMAIN, ) from .pypfsense import ( - Client, PfSenseAuthError, PfSenseConnectionError, PfSenseNotFoundError, PfSensePrivilegeError, + client_from_config, ) _LOGGER = logging.getLogger(__name__) +_EXAMPLE_URL = "https://pfsense.local:8444" + +# Credential fields shown per auth method (besides URL / verify_ssl / name). +_CRED_FIELDS: dict[str, tuple[str, ...]] = { + AUTH_METHOD_API_KEY: (CONF_API_KEY,), + AUTH_METHOD_BASIC: (CONF_USERNAME, CONF_PASSWORD), + AUTH_METHOD_JWT: (CONF_USERNAME, CONF_PASSWORD), +} + class InvalidURL(Exception): """The URL is missing a scheme or host.""" @@ -51,36 +71,70 @@ def _normalize_url(raw: str) -> str: return f"{parts.scheme}://{parts.netloc}" -async def _validate(hass, url: str, api_key: str, verify_ssl: bool) -> dict: - """Return the system_info dict, or raise a typed pypfsense error.""" +def _cred_schema(method: str, defaults: dict) -> dict: + """Return a voluptuous schema fragment for one method's credential fields.""" + return { + vol.Required(field, default=defaults.get(field, "")): str + for field in _CRED_FIELDS[method] + } + + +async def _get_system_info(hass, url: str, verify_ssl: bool, creds: dict) -> dict: + """Build the right client and return its system_info, or raise a typed error.""" session = async_get_clientsession(hass, verify_ssl) - client = Client(url, api_key, session, {"verify_ssl": verify_ssl}) + client = client_from_config(url, session, creds, verify_ssl) return await client.get_system_info() class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for pfSense.""" - # Bumping this triggers async_migrate_entry. v3 == XML-RPC -> REST API v2. - VERSION = 3 + # v3 == XML-RPC -> REST API v2 (api key only). + # v4 == multi-method auth; entries carry ``auth_method``. + VERSION = 4 def __init__(self) -> None: """Initialize the flow state.""" self._reauth_entry: config_entries.ConfigEntry | None = None async def async_step_user(self, user_input=None): - """Initial setup step.""" + """Let the user pick an authentication method.""" + return self.async_show_menu( + step_id="user", + menu_options=[ + AUTH_METHOD_API_KEY, + AUTH_METHOD_BASIC, + AUTH_METHOD_JWT, + ], + ) + + async def async_step_api_key(self, user_input=None): + """Configure API-key auth.""" + return await self._auth_step(AUTH_METHOD_API_KEY, user_input) + + async def async_step_basic(self, user_input=None): + """Configure username + password (HTTP Basic) auth.""" + return await self._auth_step(AUTH_METHOD_BASIC, user_input) + + async def async_step_jwt(self, user_input=None): + """Configure JWT auth (token minted from username + password).""" + return await self._auth_step(AUTH_METHOD_JWT, user_input) + + async def _auth_step(self, method: str, user_input): + """Show the per-method form and validate the connection on submit.""" errors: dict[str, str] = {} user_input = user_input or {} if user_input: + creds = { + CONF_AUTH_METHOD: method, + **{f: str(user_input[f]).strip() for f in _CRED_FIELDS[method]}, + } + verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + name = user_input.get(CONF_NAME) or None try: url = _normalize_url(user_input[CONF_URL]) - api_key = user_input[CONF_API_KEY].strip() - verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) - name = user_input.get(CONF_NAME) or None - - system_info = await _validate(self.hass, url, api_key, verify_ssl) + system_info = await _get_system_info(self.hass, url, verify_ssl, creds) await self.async_set_unique_id( slugify(system_info["netgate_device_id"]) @@ -94,11 +148,7 @@ async def async_step_user(self, user_input=None): return self.async_create_entry( title=name, - data={ - CONF_URL: url, - CONF_API_KEY: api_key, - CONF_VERIFY_SSL: verify_ssl, - }, + data={CONF_URL: url, CONF_VERIFY_SSL: verify_ssl, **creds}, ) except InvalidURL: errors["base"] = "invalid_url_format" @@ -109,7 +159,8 @@ async def async_step_user(self, user_input=None): except PfSenseNotFoundError: errors["base"] = "api_not_found" except PfSenseConnectionError as err: - if "certificate" in str(err).lower() or "ssl" in str(err).lower(): + text = str(err).lower() + if "certificate" in text or "ssl" in text: errors["base"] = "cannot_connect_ssl" else: errors["base"] = "cannot_connect" @@ -117,49 +168,49 @@ async def async_step_user(self, user_input=None): _LOGGER.exception("Unexpected error validating pfSense connection") errors["base"] = "unknown" - schema = vol.Schema( - { - vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str, - vol.Required( - CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") - ): str, - vol.Optional( - CONF_VERIFY_SSL, - default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), - ): bool, - vol.Optional(CONF_NAME, default=user_input.get(CONF_NAME, "")): str, - } - ) + schema = { + vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str, + **_cred_schema(method, user_input), + vol.Optional( + CONF_VERIFY_SSL, + default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ): bool, + vol.Optional(CONF_NAME, default=user_input.get(CONF_NAME, "")): str, + } return self.async_show_form( - step_id="user", - data_schema=schema, + step_id=method, + data_schema=vol.Schema(schema), errors=errors, - description_placeholders={"example_url": "https://pfsense.local:8444"}, + description_placeholders={"example_url": _EXAMPLE_URL}, ) async def async_step_import(self, user_input): - """Handle YAML import.""" - return await self.async_step_user(user_input) + """Handle YAML import (legacy entries carried an API key).""" + return await self.async_step_api_key(user_input) async def async_step_reauth(self, entry_data): - """Triggered when auth fails, or by the v2 -> v3 migration.""" + """Triggered when auth fails, or by an older-version migration.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm(self, user_input=None): - """Ask the user for an API key for an existing entry.""" + """Re-prompt for whichever credentials the entry's auth method needs.""" errors: dict[str, str] = {} entry = self._reauth_entry assert entry is not None + method = entry.data.get(CONF_AUTH_METHOD, AUTH_METHOD_API_KEY) if user_input is not None: - api_key = user_input[CONF_API_KEY].strip() url = entry.data[CONF_URL] verify_ssl = entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + creds = { + CONF_AUTH_METHOD: method, + **{f: str(user_input[f]).strip() for f in _CRED_FIELDS[method]}, + } try: - system_info = await _validate(self.hass, url, api_key, verify_ssl) + system_info = await _get_system_info(self.hass, url, verify_ssl, creds) except PfSenseAuthError: errors["base"] = "invalid_auth" except PfSensePrivilegeError: @@ -173,18 +224,16 @@ async def async_step_reauth_confirm(self, user_input=None): new_unique_id = slugify(system_info["netgate_device_id"]) if entry.unique_id and entry.unique_id != new_unique_id: return self.async_abort(reason="wrong_device") - new_data = { - CONF_URL: url, - CONF_API_KEY: api_key, - CONF_VERIFY_SSL: verify_ssl, - } - self.hass.config_entries.async_update_entry(entry, data=new_data) + self.hass.config_entries.async_update_entry( + entry, + data={CONF_URL: url, CONF_VERIFY_SSL: verify_ssl, **creds}, + ) await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + data_schema=vol.Schema(_cred_schema(method, {})), errors=errors, description_placeholders={"name": entry.title}, ) @@ -200,7 +249,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Handle the pfSense options flow.""" def __init__(self) -> None: - """Initialize the object.""" + """Initialize the flow state.""" self.new_options: dict | None = None async def async_step_init(self, user_input=None): @@ -251,10 +300,9 @@ async def async_step_device_tracker(self, user_input=None): """Let the user pick which MACs to track from the live ARP table.""" entry = self.config_entry url = entry.data[CONF_URL] - api_key = entry.data[CONF_API_KEY] verify_ssl = entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) session = async_get_clientsession(self.hass, verify_ssl) - client = Client(url, api_key, session, {"verify_ssl": verify_ssl}) + client = client_from_config(url, session, entry.data, verify_ssl) if user_input is None and (arp_table := await client.get_arp_table(True)): selected_devices = entry.options.get(CONF_DEVICES, []) diff --git a/custom_components/pfsense/const.py b/custom_components/pfsense/const.py index 33f742a..33df734 100644 --- a/custom_components/pfsense/const.py +++ b/custom_components/pfsense/const.py @@ -14,10 +14,18 @@ DEFAULT_USERNAME = "admin" DOMAIN = "pfsense" -# REST API v2 auth. The pre-2.x integration used username + password over -# XML-RPC; v2 authenticates with an API key generated on the box at -# System > REST API > Keys and sent as the ``x-api-key`` header. +# REST API v2 auth. pfRest v2 accepts three mutually-exclusive schemes; the +# config entry records which one it uses in ``CONF_AUTH_METHOD``. +# api_key -> ``x-api-key`` header (key generated at System > REST API > Keys) +# basic -> HTTP Basic with a pfSense local username + password +# jwt -> Bearer token minted from POST /api/v2/auth/jwt with those creds CONF_API_KEY = "api_key" +CONF_AUTH_METHOD = "auth_method" + +AUTH_METHOD_API_KEY = "api_key" +AUTH_METHOD_BASIC = "basic" +AUTH_METHOD_JWT = "jwt" +AUTH_METHODS = (AUTH_METHOD_API_KEY, AUTH_METHOD_BASIC, AUTH_METHOD_JWT) UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/custom_components/pfsense/pypfsense/__init__.py b/custom_components/pfsense/pypfsense/__init__.py index 6d5a844..c36bdaa 100644 --- a/custom_components/pfsense/pypfsense/__init__.py +++ b/custom_components/pfsense/pypfsense/__init__.py @@ -1,7 +1,9 @@ """Async client for the pfSense REST API v2 (pfSense-pkg-RESTAPI >= 2.10). This replaces the previous XML-RPC / ``exec_php`` client entirely. Every call is a -JSON HTTP request against ``/api/v2`` authenticated with an ``x-api-key`` header. +JSON HTTP request against ``/api/v2``. One of three pfRest auth schemes is used +per client: an ``x-api-key`` header, HTTP Basic, or a Bearer JWT the client mints +from ``POST /api/v2/auth/jwt`` (with Basic) and refreshes on expiry. The response envelope for every endpoint is:: @@ -13,6 +15,7 @@ from __future__ import annotations import asyncio +import base64 import contextlib import ipaddress import logging @@ -27,6 +30,11 @@ DEFAULT_TIMEOUT = 30 API_BASE = "/api/v2" +AUTH_API_KEY = "api_key" +AUTH_BASIC = "basic" +AUTH_JWT = "jwt" +_JWT_MINT_PATH = "/auth/jwt" + def dict_get(data: dict, path: str, default=None): """Traverse a nested dict/list by a dotted path. Numeric segments index lists.""" @@ -79,36 +87,78 @@ class Client: url: Base URL of the API, e.g. ``https://pfsense.example:8444``. Any path is stripped; ``/api/v2`` is appended internally. - api_key: - Value for the ``x-api-key`` header (System > REST API > Keys on the box). session: Shared :class:`aiohttp.ClientSession`, normally ``homeassistant.helpers.aiohttp_client.async_get_clientsession(hass)``. - opts: - Optional dict; ``{"verify_ssl": bool}`` is honoured for parity with the - old client (TLS verification is really controlled by ``session``, so the - caller should pick the session accordingly). + auth_method: + ``"api_key"`` (default), ``"basic"`` or ``"jwt"``. + api_key: + Value for the ``x-api-key`` header (System > REST API > Keys). Required + for ``auth_method="api_key"``. + username / password: + A pfSense local user's credentials. Required for ``"basic"`` and + ``"jwt"`` (the JWT is minted from them and refreshed on expiry). + verify_ssl: + Kept for parity with the old client; TLS verification is really governed + by ``session``, so the caller should pick the session accordingly. """ def __init__( self, url: str, - api_key: str, session: aiohttp.ClientSession, - opts: dict | None = None, + *, + auth_method: str = AUTH_API_KEY, + api_key: str | None = None, + username: str | None = None, + password: str | None = None, + verify_ssl: bool = True, ) -> None: - """Store the base URL, API key, aiohttp session and options.""" - opts = opts or {} + """Store the base URL, aiohttp session and the chosen auth scheme.""" parts = urlparse(url.rstrip("/")) self._base = f"{parts.scheme}://{parts.netloc}{API_BASE}" - self._api_key = api_key self._session = session - self._verify_ssl = opts.get("verify_ssl", True) + self._verify_ssl = verify_ssl # Serialize write -> /apply sequences; concurrent applies race on-box. self._write_lock = asyncio.Lock() + self._auth_method = auth_method + self._api_key = api_key + self._basic_header: str | None = None + if auth_method in (AUTH_BASIC, AUTH_JWT): + if not username or not password: + raise PfSenseAuthError( + f"{auth_method} auth needs a username and password" + ) + token = base64.b64encode(f"{username}:{password}".encode()).decode() + self._basic_header = f"Basic {token}" + elif auth_method == AUTH_API_KEY: + if not api_key: + raise PfSenseAuthError("api_key auth needs an API key") + else: + raise PfSenseAuthError(f"unknown auth method {auth_method!r}") + + self._jwt: str | None = None + self._jwt_lock = asyncio.Lock() + # ------------------------------------------------------------------ core + async def _mint_jwt(self) -> None: + """Exchange the stored Basic credentials for a fresh JWT.""" + data = await self._request( + "POST", _JWT_MINT_PATH, payload={}, _auth_header=self._basic_header + ) + token = (data or {}).get("token") + if not token: + raise PfSenseAuthError("auth/jwt did not return a token") + self._jwt = token + + async def _ensure_jwt(self) -> None: + if self._jwt is None: + async with self._jwt_lock: + if self._jwt is None: + await self._mint_jwt() + async def _request( self, method: str, @@ -116,17 +166,30 @@ async def _request( *, params: dict | None = None, payload: dict | None = None, + _auth_header: str | None = None, + _retried: bool = False, ) -> Any: """Perform a request and return the unwrapped ``data`` field.""" url = f"{self._base}{path}" - headers = {"x-api-key": self._api_key} + headers: dict[str, str] = {} + + if _auth_header is not None: + headers["Authorization"] = _auth_header + elif self._auth_method == AUTH_API_KEY: + headers["x-api-key"] = self._api_key + elif self._auth_method == AUTH_BASIC: + headers["Authorization"] = self._basic_header + elif self._auth_method == AUTH_JWT: + await self._ensure_jwt() + headers["Authorization"] = f"Bearer {self._jwt}" + try: async with self._session.request( method, url, params=_flatten_params(params), json=payload, - headers=headers, + headers=headers or None, ssl=self._verify_ssl, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), ) as resp: @@ -139,6 +202,23 @@ async def _request( raise PfSenseConnectionError(f"timeout contacting {url}") from err except aiohttp.ClientError as err: raise PfSenseConnectionError(str(err)) from err + except PfSenseAuthError: + # A JWT can expire mid-session; mint a new one and retry once. + if ( + self._auth_method == AUTH_JWT + and _auth_header is None + and not _retried + and path != _JWT_MINT_PATH + ): + self._jwt = None # forces a re-mint on the retry + return await self._request( + method, + path, + params=params, + payload=payload, + _retried=True, + ) + raise @staticmethod def _unwrap(http_status: int, body: dict) -> Any: @@ -634,6 +714,30 @@ async def exec_command(self, command: str, background: bool = False) -> str: # ---------------------------------------------------------------- helpers +def client_from_config( + url: str, + session: aiohttp.ClientSession, + data: dict, + verify_ssl: bool, +) -> Client: + """Build a :class:`Client` from a config-entry ``data`` mapping. + + Reads ``auth_method`` (default ``"api_key"``) and the credential keys + (``api_key`` / ``username`` / ``password``). Shared by the integration + setup and the config flow so the auth branching lives in one place. + """ + method = data.get("auth_method", AUTH_API_KEY) + return Client( + url, + session, + auth_method=method, + api_key=data.get("api_key"), + username=data.get("username"), + password=data.get("password"), + verify_ssl=verify_ssl, + ) + + def _shq(value: str) -> str: """Minimal shell single-quote escaping for command_prompt payloads.""" return "'" + str(value).replace("'", "'\\''") + "'" diff --git a/custom_components/pfsense/strings.json b/custom_components/pfsense/strings.json index 71fd5ab..e785a06 100644 --- a/custom_components/pfsense/strings.json +++ b/custom_components/pfsense/strings.json @@ -4,35 +4,68 @@ "step": { "user": { "title": "Connect to the pfSense firewall / router", + "description": "Choose how Home Assistant should authenticate to the pfSense REST API. Only one method is used per connection.", + "menu_options": { + "api_key": "API key", + "basic": "Username and password", + "jwt": "JWT (from username and password)" + } + }, + "api_key": { + "title": "pfSense REST API — API key", "description": "Enter the base URL of the pfSense REST API (including the port, e.g. {example_url}) and an API key generated at System › REST API › Keys.", "data": { "url": "URL", "api_key": "API key", - "name": "Firewall Name", + "name": "Firewall name", + "verify_ssl": "Verify SSL certificate" + } + }, + "basic": { + "title": "pfSense REST API — username and password", + "description": "Enter the base URL of the pfSense REST API (including the port, e.g. {example_url}) and a pfSense local user's credentials. HTTP Basic authentication must be enabled at System › REST API › Settings.", + "data": { + "url": "URL", + "username": "Username", + "password": "Password", + "name": "Firewall name", + "verify_ssl": "Verify SSL certificate" + } + }, + "jwt": { + "title": "pfSense REST API — JWT", + "description": "Enter the base URL of the pfSense REST API (including the port, e.g. {example_url}) and a pfSense local user's credentials. Home Assistant obtains and refreshes a JWT from them. JWT authentication must be enabled at System › REST API › Settings.", + "data": { + "url": "URL", + "username": "Username", + "password": "Password", + "name": "Firewall name", "verify_ssl": "Verify SSL certificate" } }, "reauth_confirm": { "title": "Re-authenticate with pfSense", - "description": "The pfSense integration now authenticates with a REST API key instead of a password. Enter an API key for {name} (System › REST API › Keys).", + "description": "Re-enter the credentials for {name}.", "data": { - "api_key": "API key" + "api_key": "API key", + "username": "Username", + "password": "Password" } } }, "error": { "cannot_connect": "Failed to connect", "cannot_connect_ssl": "SSL failure", - "invalid_auth": "Invalid API key", + "invalid_auth": "Authentication failed — check the credentials, and that this method is enabled at System › REST API › Settings", "invalid_url_format": "Invalid URL format", - "privilege_missing": "The API key's user lacks the privileges required for this integration", + "privilege_missing": "The account lacks the privileges required for this integration", "api_not_found": "REST API not found at that URL - check the address, the port, and that the REST API package is installed", "unknown": "Unexpected error" }, "abort": { "already_configured": "Device is already configured", "reauth_successful": "Re-authentication was successful", - "wrong_device": "The API key belongs to a different pfSense device" + "wrong_device": "The credentials belong to a different pfSense device" } }, "options": { diff --git a/custom_components/pfsense/translations/en.json b/custom_components/pfsense/translations/en.json index 71fd5ab..e785a06 100644 --- a/custom_components/pfsense/translations/en.json +++ b/custom_components/pfsense/translations/en.json @@ -4,35 +4,68 @@ "step": { "user": { "title": "Connect to the pfSense firewall / router", + "description": "Choose how Home Assistant should authenticate to the pfSense REST API. Only one method is used per connection.", + "menu_options": { + "api_key": "API key", + "basic": "Username and password", + "jwt": "JWT (from username and password)" + } + }, + "api_key": { + "title": "pfSense REST API — API key", "description": "Enter the base URL of the pfSense REST API (including the port, e.g. {example_url}) and an API key generated at System › REST API › Keys.", "data": { "url": "URL", "api_key": "API key", - "name": "Firewall Name", + "name": "Firewall name", + "verify_ssl": "Verify SSL certificate" + } + }, + "basic": { + "title": "pfSense REST API — username and password", + "description": "Enter the base URL of the pfSense REST API (including the port, e.g. {example_url}) and a pfSense local user's credentials. HTTP Basic authentication must be enabled at System › REST API › Settings.", + "data": { + "url": "URL", + "username": "Username", + "password": "Password", + "name": "Firewall name", + "verify_ssl": "Verify SSL certificate" + } + }, + "jwt": { + "title": "pfSense REST API — JWT", + "description": "Enter the base URL of the pfSense REST API (including the port, e.g. {example_url}) and a pfSense local user's credentials. Home Assistant obtains and refreshes a JWT from them. JWT authentication must be enabled at System › REST API › Settings.", + "data": { + "url": "URL", + "username": "Username", + "password": "Password", + "name": "Firewall name", "verify_ssl": "Verify SSL certificate" } }, "reauth_confirm": { "title": "Re-authenticate with pfSense", - "description": "The pfSense integration now authenticates with a REST API key instead of a password. Enter an API key for {name} (System › REST API › Keys).", + "description": "Re-enter the credentials for {name}.", "data": { - "api_key": "API key" + "api_key": "API key", + "username": "Username", + "password": "Password" } } }, "error": { "cannot_connect": "Failed to connect", "cannot_connect_ssl": "SSL failure", - "invalid_auth": "Invalid API key", + "invalid_auth": "Authentication failed — check the credentials, and that this method is enabled at System › REST API › Settings", "invalid_url_format": "Invalid URL format", - "privilege_missing": "The API key's user lacks the privileges required for this integration", + "privilege_missing": "The account lacks the privileges required for this integration", "api_not_found": "REST API not found at that URL - check the address, the port, and that the REST API package is installed", "unknown": "Unexpected error" }, "abort": { "already_configured": "Device is already configured", "reauth_successful": "Re-authentication was successful", - "wrong_device": "The API key belongs to a different pfSense device" + "wrong_device": "The credentials belong to a different pfSense device" } }, "options": { diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index b701298..db37c5f 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -72,7 +72,7 @@ def _entry(entry_id): async def _setup(hass, entry, client): entry.add_to_hass(hass) with ( - patch("custom_components.pfsense.pfSenseClient", return_value=client), + patch("custom_components.pfsense.client_from_config", return_value=client), patch("custom_components.pfsense.async_load_cache", return_value=None), patch("custom_components.pfsense.async_save_cache"), ): diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0e8403f..b8f735d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,4 +1,4 @@ -"""Config flow tests for the REST API v2 auth model.""" +"""Config flow tests for the REST API v2 multi-method auth model.""" from unittest.mock import AsyncMock, patch @@ -6,13 +6,17 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pfsense.const import ( + AUTH_METHOD_API_KEY, + AUTH_METHOD_BASIC, + AUTH_METHOD_JWT, CONF_API_KEY, + CONF_AUTH_METHOD, CONF_DEVICE_TRACKER_ENABLED, CONF_DEVICES, DOMAIN, ) from custom_components.pfsense.pypfsense import PfSenseAuthError -from homeassistant.const import CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -29,61 +33,133 @@ def _client_mock(**overrides): return client -@pytest.mark.asyncio -async def test_form_user_success(hass: HomeAssistant): - """Test form user success.""" +def _patch_client(client): + return patch( + "custom_components.pfsense.config_flow.client_from_config", + return_value=client, + ) + + +async def _menu_pick(hass, step): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} ) + assert result["type"] == FlowResultType.MENU + return await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": step} + ) + + +@pytest.mark.asyncio +async def test_form_api_key_success(hass: HomeAssistant): + """The API-key path creates an entry stamped auth_method=api_key.""" + form = await _menu_pick(hass, AUTH_METHOD_API_KEY) + assert form["step_id"] == AUTH_METHOD_API_KEY + with ( - patch( - "custom_components.pfsense.config_flow.Client", - return_value=_client_mock(), - ), + _patch_client(_client_mock()), patch("custom_components.pfsense.async_setup_entry", return_value=True), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result = await hass.config_entries.flow.async_configure( + form["flow_id"], { CONF_URL: "https://192.168.1.1:8444", CONF_API_KEY: "abc123", CONF_VERIFY_SSL: False, }, ) - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "router.local" - assert result2["data"] == { + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "router.local" + assert result["data"] == { CONF_URL: "https://192.168.1.1:8444", + CONF_VERIFY_SSL: False, + CONF_AUTH_METHOD: AUTH_METHOD_API_KEY, CONF_API_KEY: "abc123", + } + + +@pytest.mark.asyncio +async def test_form_basic_success(hass: HomeAssistant): + """The username/password path stores auth_method=basic + credentials.""" + form = await _menu_pick(hass, AUTH_METHOD_BASIC) + assert form["step_id"] == AUTH_METHOD_BASIC + + with ( + _patch_client(_client_mock()), + patch("custom_components.pfsense.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + form["flow_id"], + { + CONF_URL: "https://192.168.1.1:8444", + CONF_USERNAME: "admin", + CONF_PASSWORD: "pfsense", + CONF_VERIFY_SSL: False, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "https://192.168.1.1:8444", CONF_VERIFY_SSL: False, + CONF_AUTH_METHOD: AUTH_METHOD_BASIC, + CONF_USERNAME: "admin", + CONF_PASSWORD: "pfsense", } @pytest.mark.asyncio -async def test_form_user_invalid_auth(hass: HomeAssistant): - """Test form user invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"} - ) +async def test_form_jwt_success(hass: HomeAssistant): + """The JWT path stores auth_method=jwt + the credentials it mints from.""" + form = await _menu_pick(hass, AUTH_METHOD_JWT) + assert form["step_id"] == AUTH_METHOD_JWT + + with ( + _patch_client(_client_mock()), + patch("custom_components.pfsense.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + form["flow_id"], + { + CONF_URL: "https://192.168.1.1:8444", + CONF_USERNAME: "admin", + CONF_PASSWORD: "pfsense", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_AUTH_METHOD] == AUTH_METHOD_JWT + assert result["data"][CONF_USERNAME] == "admin" + assert result["data"][CONF_PASSWORD] == "pfsense" + + +@pytest.mark.asyncio +async def test_form_invalid_auth(hass: HomeAssistant): + """A rejected credential re-shows the form with an error.""" + form = await _menu_pick(hass, AUTH_METHOD_API_KEY) client = AsyncMock() client.get_system_info.side_effect = PfSenseAuthError("bad key") - with patch("custom_components.pfsense.config_flow.Client", return_value=client): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + with _patch_client(client): + result = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_URL: "https://192.168.1.1:8444", CONF_API_KEY: "nope"}, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"]["base"] == "invalid_auth" + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == "invalid_auth" @pytest.mark.asyncio -async def test_reauth_flow_updates_key(hass: HomeAssistant): - """Test reauth flow updates key.""" +async def test_reauth_flow_basic_entry(hass: HomeAssistant): + """Reauth for a basic entry re-prompts for username + password.""" entry = MockConfigEntry( domain=DOMAIN, - version=3, + version=4, unique_id="mock_id_12345", - data={CONF_URL: "https://192.168.1.1:8444", CONF_VERIFY_SSL: False}, + data={ + CONF_URL: "https://192.168.1.1:8444", + CONF_VERIFY_SSL: False, + CONF_AUTH_METHOD: AUTH_METHOD_BASIC, + CONF_USERNAME: "admin", + CONF_PASSWORD: "old", + }, ) entry.add_to_hass(hass) @@ -91,18 +167,17 @@ async def test_reauth_flow_updates_key(hass: HomeAssistant): assert result["step_id"] == "reauth_confirm" with ( - patch( - "custom_components.pfsense.config_flow.Client", - return_value=_client_mock(), - ), + _patch_client(_client_mock()), patch("custom_components.pfsense.async_setup_entry", return_value=True), ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_API_KEY: "freshkey"} + result["flow_id"], + {CONF_USERNAME: "admin", CONF_PASSWORD: "new"}, ) assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reauth_successful" - assert entry.data[CONF_API_KEY] == "freshkey" + assert entry.data[CONF_PASSWORD] == "new" + assert entry.data[CONF_AUTH_METHOD] == AUTH_METHOD_BASIC @pytest.mark.asyncio @@ -110,9 +185,10 @@ async def test_options_flow(hass: HomeAssistant): """Test options flow.""" entry = MockConfigEntry( domain=DOMAIN, - version=3, + version=4, data={ CONF_URL: "https://192.168.1.1:8444", + CONF_AUTH_METHOD: AUTH_METHOD_API_KEY, CONF_API_KEY: "k", CONF_VERIFY_SSL: False, }, @@ -129,7 +205,7 @@ async def test_options_flow(hass: HomeAssistant): } ] ) - with patch("custom_components.pfsense.config_flow.Client", return_value=client): + with _patch_client(client): result = await hass.config_entries.options.async_init(entry.entry_id) result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_DEVICE_TRACKER_ENABLED: True} diff --git a/tests/test_init.py b/tests/test_init.py index ded4237..bc65b65 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -5,7 +5,12 @@ import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.pfsense.const import CONF_API_KEY, DOMAIN +from custom_components.pfsense.const import ( + AUTH_METHOD_API_KEY, + CONF_API_KEY, + CONF_AUTH_METHOD, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant @@ -74,7 +79,7 @@ async def test_setup_and_unload_entry(hass: HomeAssistant): with ( patch( - "custom_components.pfsense.pfSenseClient", + "custom_components.pfsense.client_from_config", return_value=_full_client_mock(), ), patch("custom_components.pfsense.async_load_cache", return_value=None), @@ -112,9 +117,44 @@ async def test_migrate_v2_password_entry_requires_reauth(hass: HomeAssistant): assert await hass.config_entries.async_setup(entry.entry_id) is False await hass.async_block_till_done() - assert entry.version == 3 + assert entry.version == 4 + assert entry.data[CONF_AUTH_METHOD] == AUTH_METHOD_API_KEY assert "password" not in entry.data assert "username" not in entry.data assert CONF_API_KEY not in entry.data flows = hass.config_entries.flow.async_progress() assert any(f["context"]["source"] == "reauth" for f in flows) + + +@pytest.mark.asyncio +async def test_migrate_v3_stamps_auth_method(hass: HomeAssistant): + """A v3 (api-key) entry gains auth_method=api_key and becomes v4.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=3, + title="router.local", + unique_id="abc", + data={ + CONF_URL: "https://192.168.1.1:8444", + CONF_API_KEY: "k", + CONF_VERIFY_SSL: False, + }, + options={"device_tracker_enabled": False}, + entry_id="v3_entry", + ) + entry.add_to_hass(hass) + + with ( + patch( + "custom_components.pfsense.client_from_config", + return_value=_full_client_mock(), + ), + patch("custom_components.pfsense.async_load_cache", return_value=None), + patch("custom_components.pfsense.async_save_cache"), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 4 + assert entry.data[CONF_AUTH_METHOD] == AUTH_METHOD_API_KEY + assert entry.data[CONF_API_KEY] == "k" diff --git a/tests/test_pypfsense.py b/tests/test_pypfsense.py index e664c1c..904ee16 100644 --- a/tests/test_pypfsense.py +++ b/tests/test_pypfsense.py @@ -1,5 +1,6 @@ """Unit tests for the async pfSense REST API v2 client.""" +import base64 import re import aiohttp @@ -32,9 +33,15 @@ def _envelope(data, code=200, status="ok", response_id="SUCCESS", message=""): @pytest.fixture async def client(): - """Test helper.""" + """An api-key client bound to a real aiohttp session.""" async with aiohttp.ClientSession() as session: - yield Client(BASE, "test-key", session, {"verify_ssl": False}) + yield Client( + BASE, + session, + auth_method="api_key", + api_key="test-key", + verify_ssl=False, + ) def test_dict_get(): @@ -48,10 +55,22 @@ def test_dict_get(): def test_base_url_strips_path(): """Test base url strips path.""" - c = Client("https://pf.example:8444/ui/", "k", object()) + c = Client( + "https://pf.example:8444/ui/", object(), auth_method="api_key", api_key="k" + ) assert c._base == "https://pf.example:8444/api/v2" +def test_missing_credentials_raise(): + """A method without its credentials fails fast.""" + with pytest.raises(PfSenseAuthError): + Client(BASE, object(), auth_method="api_key") + with pytest.raises(PfSenseAuthError): + Client(BASE, object(), auth_method="basic", username="u") + with pytest.raises(PfSenseAuthError): + Client(BASE, object(), auth_method="jwt", password="p") + + async def test_request_unwraps_data(client): """Test request unwraps data.""" with aioresponses() as m: @@ -88,6 +107,57 @@ async def test_error_codes_map_to_exceptions(client, code, exc): await client._get("/system/hostname") +def _last(m, method): + for (mthd, _url), reqs in m.requests.items(): + if mthd == method.upper(): + return reqs[-1] + raise AssertionError(f"no {method} request recorded") + + +_BASIC_UP = "Basic " + base64.b64encode(b"u:p").decode() + + +async def test_basic_auth_sends_authorization_header(): + """HTTP Basic auth adds an ``Authorization: Basic`` header to every request.""" + async with aiohttp.ClientSession() as session: + c = Client(BASE, session, auth_method="basic", username="u", password="p") + with aioresponses() as m: + m.get(f"{API}/system/dns", payload=_envelope({"dnsserver": []})) + await c.get_dns_servers() + assert _last(m, "GET").kwargs["headers"]["Authorization"] == _BASIC_UP + + +async def test_jwt_mints_then_authorizes_with_bearer(): + """JWT auth mints a token once (with Basic), then sends it as a Bearer.""" + async with aiohttp.ClientSession() as session: + c = Client(BASE, session, auth_method="jwt", username="u", password="p") + with aioresponses() as m: + m.post(f"{API}/auth/jwt", payload=_envelope({"token": "tok-1"})) + m.get(f"{API}/system/dns", payload=_envelope({"dnsserver": []})) + await c.get_dns_servers() + + assert _last(m, "POST").kwargs["headers"]["Authorization"] == _BASIC_UP + assert _last(m, "GET").kwargs["headers"]["Authorization"] == "Bearer tok-1" + + +async def test_jwt_refreshes_on_401(): + """An expired JWT (401) is re-minted and the call retried once.""" + async with aiohttp.ClientSession() as session: + c = Client(BASE, session, auth_method="jwt", username="u", password="p") + with aioresponses() as m: + m.post(f"{API}/auth/jwt", payload=_envelope({"token": "tok-1"})) + m.get( + f"{API}/system/dns", + status=401, + payload=_envelope([], code=401, message="expired"), + ) + m.post(f"{API}/auth/jwt", payload=_envelope({"token": "tok-2"})) + m.get(f"{API}/system/dns", payload=_envelope({"dnsserver": ["1.1.1.1"]})) + + assert await c.get_dns_servers() == ["1.1.1.1"] + assert _last(m, "GET").kwargs["headers"]["Authorization"] == "Bearer tok-2" + + async def test_get_system_info_merges_endpoints(client): """Test get system info merges endpoints.""" with aioresponses() as m: From a114066c86f5a0bab6ac22f888ca6bb60913afbd Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 13:05:11 -0700 Subject: [PATCH 2/3] Bump version to 0.10.2 Adds the username+password and JWT authentication methods to the config flow (see previous commit). Co-Authored-By: Claude Sonnet 5 --- custom_components/pfsense/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pfsense/manifest.json b/custom_components/pfsense/manifest.json index 03dec7f..2bcdf54 100644 --- a/custom_components/pfsense/manifest.json +++ b/custom_components/pfsense/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "mac-vendor-lookup>=0.1.11" ], - "version": "0.10.1" + "version": "0.10.2" } From e0daacc07437efa49c2727ace5dc6781f5a40dea Mon Sep 17 00:00:00 2001 From: Nate Olsen Date: Sun, 6 Sep 2026 13:08:19 -0700 Subject: [PATCH 3/3] release.yml: preserve the release's prerelease/draft state action-gh-release updates the existing release to attach the zip; pass back its current prerelease/draft flags so a pre-release isn't silently promoted to a full release. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d723c68..eea5bcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,3 +53,6 @@ jobs: uses: softprops/action-gh-release@v3 with: files: custom_components/${{ steps.integration.outputs.integration }}/${{ steps.integration.outputs.integration }}.zip + # Echo the release's current state so attaching the asset can't flip it. + prerelease: ${{ github.event.release.prerelease }} + draft: ${{ github.event.release.draft }}