Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow accessing frigate with authentication #801

Merged
Merged
Show file tree
Hide file tree
Changes from 5 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
10 changes: 9 additions & 1 deletion custom_components/frigate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_URL
from homeassistant.const import (
ATTR_MODEL,
CONF_HOST,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
Expand Down Expand Up @@ -196,6 +202,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client = FrigateApiClient(
str(entry.data.get(CONF_URL)),
async_get_clientsession(hass),
entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD),
)
coordinator = FrigateDataUpdateCoordinator(hass, client=client)
await coordinator.async_config_entry_first_refresh()
Expand Down
106 changes: 105 additions & 1 deletion custom_components/frigate/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import datetime
import logging
import socket
from typing import Any, cast
Expand All @@ -11,6 +12,8 @@
import async_timeout
from yarl import URL

from homeassistant.auth import jwt_wrapper

TIMEOUT = 10

_LOGGER: logging.Logger = logging.getLogger(__name__)
Expand All @@ -31,10 +34,19 @@ class FrigateApiClientError(Exception):
class FrigateApiClient:
"""Frigate API client."""

def __init__(self, host: str, session: aiohttp.ClientSession) -> None:
def __init__(
self,
host: str,
session: aiohttp.ClientSession,
username: str | None = None,
password: str | None = None,
) -> None:
"""Construct API Client."""
self._host = host
self._session = session
self._username = username
self._password = password
self._token_data: dict[str, Any] = {}

async def async_get_version(self) -> str:
"""Get data from the API."""
Expand Down Expand Up @@ -216,27 +228,98 @@ async def async_get_recordings(
)
return cast(dict[str, Any], result) if decode_json else result

async def _get_token(self) -> None:
"""
Obtain a new JWT token using the provided username and password.
Sends a POST request to the login endpoint and extracts the token
and expiration date from the response headers.
"""
response = await self.api_wrapper(
method="post",
url=str(URL(self._host) / "api/login"),
data={"user": self._username, "password": self._password},
decode_json=False,
is_login_request=True,
)

set_cookie_header = response.headers.get("Set-Cookie", "")
if not set_cookie_header:
raise KeyError("Missing Set-Cookie header in response")

for cookie_prop in set_cookie_header.split(";"):
cookie_prop = cookie_prop.strip()
if cookie_prop.startswith("frigate_token="):
jwt_token = cookie_prop.split("=", 1)[1]
self._token_data["token"] = jwt_token
try:
decoded_token = jwt_wrapper.unverified_hs256_token_decode(jwt_token)
except Exception as e:
raise ValueError(f"Failed to decode JWT token: {e}")
exp_timestamp = decoded_token.get("exp")
if not exp_timestamp:
raise KeyError("JWT is missing 'exp' claim")
self._token_data["expires"] = datetime.datetime.fromtimestamp(
exp_timestamp, datetime.UTC
)
break
else:
raise KeyError("Missing 'frigate_token' in Set-Cookie header")

async def _refresh_token_if_needed(self) -> None:
"""
Refresh the JWT token if it is expired or about to expire.
"""
if "expires" not in self._token_data:
await self._get_token()
return

current_time = datetime.datetime.now(datetime.UTC)
if current_time >= self._token_data["expires"]: # Compare UTC-aware datetimes
await self._get_token()

async def _get_auth_headers(self) -> dict[str, str]:
"""
Get headers for API requests, including the JWT token if available.
Ensures that the token is refreshed if needed.
"""
headers = {}

if self._username and self._password:
await self._refresh_token_if_needed()

if "token" in self._token_data:
headers["Authorization"] = f"Bearer {self._token_data['token']}"

return headers

async def api_wrapper(
self,
method: str,
url: str,
data: dict | None = None,
headers: dict | None = None,
decode_json: bool = True,
is_login_request: bool = False,
) -> Any:
"""Get information from the API."""
if data is None:
data = {}
if headers is None:
headers = {}

if not is_login_request:
headers.update(await self._get_auth_headers())

try:
async with async_timeout.timeout(TIMEOUT):
func = getattr(self._session, method)
if func:
response = await func(
url, headers=headers, raise_for_status=True, json=data
)
response.raise_for_status()
if is_login_request:
return response
if decode_json:
return await response.json()
return await response.text()
Expand All @@ -249,6 +332,27 @@ async def api_wrapper(
)
raise FrigateApiClientError from exc

except aiohttp.ClientResponseError as exc:
if exc.status == 401:
_LOGGER.error(
"Unauthorized (401) error for URL %s: %s", url, exc.message
)
raise FrigateApiClientError(
"Unauthorized access - check credentials."
) from exc
elif exc.status == 403:
_LOGGER.error("Forbidden (403) error for URL %s: %s", url, exc.message)
raise FrigateApiClientError(
"Forbidden - insufficient permissions."
) from exc
else:
_LOGGER.error(
"Client response error (%d) for URL %s: %s",
exc.status,
url,
exc.message,
)
raise FrigateApiClientError from exc
except (KeyError, TypeError) as exc:
_LOGGER.error(
"Error parsing information from %s: %s",
Expand Down
17 changes: 14 additions & 3 deletions custom_components/frigate/config_flow.py
loispostula marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from yarl import URL

from homeassistant import config_entries
from homeassistant.const import CONF_URL
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
Expand Down Expand Up @@ -82,7 +82,12 @@ async def _handle_config_step(

try:
session = async_create_clientsession(self.hass)
client = FrigateApiClient(user_input[CONF_URL], session)
client = FrigateApiClient(
user_input[CONF_URL],
session,
user_input.get(CONF_USERNAME),
user_input.get(CONF_PASSWORD),
)
await client.async_get_stats()
except FrigateApiClientError:
return self._show_config_form(
Expand Down Expand Up @@ -122,7 +127,13 @@ def _show_config_form(
{
vol.Required(
CONF_URL, default=user_input.get(CONF_URL, DEFAULT_HOST)
): str
): str,
vol.Optional(
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
): str,
vol.Optional(
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)
): str,
}
),
errors=errors,
Expand Down
1 change: 1 addition & 0 deletions custom_components/frigate/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
CONF_MEDIA_BROWSER_ENABLE = "media_browser_enable"
CONF_NOTIFICATION_PROXY_ENABLE = "notification_proxy_enable"
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS = "notification_proxy_expire_after_seconds"
CONF_USERNAME = "username"
CONF_PASSWORD = "password"
CONF_PATH = "path"
CONF_RTSP_URL_TEMPLATE = "rtsp_url_template"
Expand Down
4 changes: 3 additions & 1 deletion custom_components/frigate/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"user": {
"description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 8554/8555 (rtsp, webrtc) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications",
"data": {
"url": "URL"
"url": "URL",
"username": "Username (optional)",
"password": "Password (optional)"
}
}
},
Expand Down
6 changes: 4 additions & 2 deletions custom_components/frigate/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"user": {
"description": "URL que vous utilisez pour accéder à Frigate (par exemple, `http://frigate:5000/`)\n\nSi vous utilisez HassOS avec l'addon, l'URL devrait être `http://ccab4aaf-frigate:5000/`\n\nHome Assistant a besoin d'accès au port 5000 (api) et 8554/8555 (rtsp, webrtc) pour toutes les fonctionnalités.\n\nL'intégration configurera des capteurs, des caméras et la fonctionnalité de navigateur multimédia.\n\nCapteurs :\n- Statistiques pour surveiller la performance de Frigate\n- Comptes d'objets pour toutes les zones et caméras\n\nCaméras :\n- Caméras pour l'image du dernier objet détecté pour chaque caméra\n- Entités de caméra avec support de flux\n\nNavigateur multimédia :\n- Interface riche avec miniatures pour parcourir les clips d'événements\n- Interface riche pour parcourir les enregistrements 24/7 par mois, jour, caméra, heure\n\nAPI :\n- API de notification avec des points de terminaison publics pour les images dans les notifications",
"data": {
"url": "URL"
"url": "URL",
"username": "Nom d'utilisateur (facultatif)",
"password": "Mot de passe (facultatif)"
}
}
},
Expand All @@ -31,4 +33,4 @@
"only_advanced_options": "Le mode avancé est désactivé et il n'y a que des options avancées"
}
}
}
}
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ homeassistant==2024.12.0
paho-mqtt
python-dateutil
yarl
hass-web-proxy-lib==0.0.7
hass-web-proxy-lib==0.0.7
Loading
Loading