Add on-demand Frigate+ API key validation (#24918)

* Add on-demand Frigate+ API key validation

The Frigate+ settings page reported the API key as validated whenever it
matched the expected format, without ever checking it with the server.

- Clarify the status text to say the key matches the expected format
- Add PlusApi.validate_key(), which requests a token from Frigate+ and
  reports the key as valid, invalid, unreachable, or not set; server
  errors and timeouts are not reported as an invalid key
- Add an admin-only POST /plus/validate endpoint and regenerate the API spec
- Add a "Validate with Frigate+" button that shows the result inline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Validate Frigate+ key asynchronously and keep cached token on bad replies

- Use aiohttp for the validation request so the endpoint awaits it
  instead of blocking a worker thread
- Only replace the cached token when the reply has a string accessToken
  and numeric expires; otherwise report unreachable so an unexpected
  response can't break later Frigate+ requests
- Test against a local aiohttp server, covering timeouts, connection
  failures, non-JSON replies and unusable token shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Honor proxy settings when validating the Frigate+ key

aiohttp ignores HTTP(S)_PROXY unless trust_env is set, so validation
reported Frigate+ as unreachable on installs that need a proxy while
other Frigate+ requests still worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-11-09 06:30:35 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 52d469ed20
commit 81cf7be89a
7 changed files with 380 additions and 17 deletions
+19
View File
@@ -3148,6 +3148,25 @@ paths:
- frigateUserAuth: []
x-required-role: any
description: '**Access:** Any authenticated user.'
/plus/validate:
post:
tags:
- App
summary: Validate Plus Key
description: |-
**Access:** Admin role required.
Validate the configured Frigate+ API key with the Frigate+ server.
operationId: validate_plus_key_plus_validate_post
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
security:
- frigateAdminAuth: []
x-required-role: admin
/recognized_license_plates:
get:
tags:
+11 -1
View File
@@ -54,7 +54,7 @@ from frigate.jobs.media_sync import (
start_media_sync_job,
)
from frigate.models import Event, Timeline
from frigate.plus import load_plus_model_info
from frigate.plus import PlusKeyStatus, load_plus_model_info
from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.types import JobStatusTypesEnum
from frigate.util.builtin import (
@@ -1675,6 +1675,16 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
return JSONResponse(content=validModels)
@router.post("/plus/validate", dependencies=[Depends(require_role(["admin"]))])
async def validate_plus_key(request: Request):
"""Validate the configured Frigate+ API key with the Frigate+ server."""
status = await request.app.frigate_config.plus_api.validate_key()
return JSONResponse(
content={"success": status == PlusKeyStatus.valid, "status": status.value}
)
@router.get(
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
)
+81 -1
View File
@@ -3,9 +3,11 @@ import json
import logging
import os
import re
from enum import Enum
from pathlib import Path
from typing import Any
from typing import Any, TypeGuard
import aiohttp
import cv2
import requests
from numpy import ndarray
@@ -17,6 +19,17 @@ from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
logger = logging.getLogger(__name__)
PLUS_VALIDATE_TIMEOUT = 10
class PlusKeyStatus(str, Enum):
"""Result of validating a Frigate+ API key with the server."""
valid = "valid"
invalid = "invalid"
unreachable = "unreachable"
not_set = "not_set"
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
"""Name the hailo detector by its current key as well as its old one.
@@ -72,6 +85,19 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
def _is_usable_token(token_data: Any) -> TypeGuard[dict[str, Any]]:
"""Whether a Frigate+ token response has a usable token and expiry."""
if not isinstance(token_data, dict):
return False
expires = token_data.get("expires")
return (
isinstance(token_data.get("accessToken"), str)
and isinstance(expires, (int, float))
and not isinstance(expires, bool)
)
class PlusApi:
def __init__(self) -> None:
self.host = PLUS_API_HOST
@@ -153,6 +179,60 @@ class PlusApi:
def is_active(self) -> bool:
return self._is_active
async def validate_key(self) -> PlusKeyStatus:
"""Check the configured API key against the Frigate+ server.
The key format is checked at startup, but only the server can tell
whether the key is actually valid. This requests a fresh token so the
result reflects the key's current state rather than a cached token.
Returns:
The key's status as reported by the Frigate+ server
"""
if self.key is None:
return PlusKeyStatus.not_set
key_id, _, secret = self.key.partition(":")
# Use a single request with a timeout instead of the retrying session,
# since this is user initiated and should fail fast when unreachable.
# trust_env honors proxy settings, matching the other Frigate+ requests.
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=PLUS_VALIDATE_TIMEOUT),
trust_env=True,
) as session:
async with session.get(
f"{self.host}/v1/auth/token",
auth=aiohttp.BasicAuth(key_id, secret),
) as r:
status = r.status
token_data = await r.json(content_type=None) if r.ok else None
except (aiohttp.ClientError, TimeoutError) as err:
logger.warning("Unable to reach Frigate+ to validate API key: %s", err)
return PlusKeyStatus.unreachable
except ValueError:
logger.warning("Invalid response validating Frigate+ API key")
return PlusKeyStatus.unreachable
if status in (401, 403):
return PlusKeyStatus.invalid
if status >= 400:
logger.warning(
"Unexpected response validating Frigate+ API key: %s", status
)
return PlusKeyStatus.unreachable
# Keep the cached token unless the reply is a usable one, so an
# unexpected response can't break later Frigate+ requests
if not _is_usable_token(token_data):
logger.warning("Invalid response validating Frigate+ API key")
return PlusKeyStatus.unreachable
self._token_data = token_data
return PlusKeyStatus.valid
def upload_image(self, image: ndarray, camera: str) -> str:
r = self._get("image/signed_urls")
if not r.ok:
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the Frigate+ API endpoints."""
import unittest
from unittest.mock import patch
from frigate.models import Event
from frigate.plus import PlusKeyStatus
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpPlusValidate(BaseTestHttp):
def setUp(self):
super().setUp([Event])
def _validate(self, status: PlusKeyStatus) -> dict:
app = self.create_app()
with patch.object(
app.frigate_config.plus_api, "validate_key", return_value=status
):
with AuthTestClient(app) as client:
response = client.post("/plus/validate")
self.assertEqual(response.status_code, 200)
return response.json()
def test_a_valid_key(self):
self.assertEqual(
self._validate(PlusKeyStatus.valid), {"success": True, "status": "valid"}
)
def test_an_invalid_key(self):
self.assertEqual(
self._validate(PlusKeyStatus.invalid),
{"success": False, "status": "invalid"},
)
def test_viewers_cannot_validate(self):
app = self.create_app()
with AuthTestClient(app) as client:
response = client.post(
"/plus/validate",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
if __name__ == "__main__":
unittest.main(verbosity=2)
+138 -1
View File
@@ -1,12 +1,17 @@
"""Tests for Frigate+ model metadata."""
import asyncio
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from frigate.plus import PlusApi, load_plus_model_info
import aiohttp
from aiohttp import web
from aiohttp.test_utils import TestServer
from frigate.plus import PlusApi, PlusKeyStatus, load_plus_model_info
class TestHailoAlias(unittest.TestCase):
@@ -75,5 +80,137 @@ class TestLoadPlusModelInfo(unittest.TestCase):
self.assertIsNone(load_plus_model_info("bad"))
class TestValidateKey(unittest.IsolatedAsyncioTestCase):
"""The key format is checked locally, but only the server knows if it is valid."""
CACHED_TOKEN = {"accessToken": "cached", "expires": 2000000000}
async def _serve(self, handler) -> str:
"""Serve the token endpoint locally and return its base url."""
app = web.Application()
app.router.add_get("/v1/auth/token", handler)
server = TestServer(app)
await server.start_server()
self.addAsyncCleanup(server.close)
return str(server.make_url("")).rstrip("/")
def _api(self, host: str, key: str | None = "id:secret") -> PlusApi:
api = PlusApi.__new__(PlusApi)
api.host = host
api.key = key
api._token_data = dict(self.CACHED_TOKEN)
return api
async def _validate(self, handler) -> tuple[PlusKeyStatus, PlusApi]:
api = self._api(await self._serve(handler))
return await api.validate_key(), api
def _replies(self, status: int = 200, body: object = None, text: str | None = None):
async def handler(request: web.Request) -> web.Response:
self.auth = request.headers.get("Authorization")
if text is not None:
return web.Response(status=status, text=text)
return web.json_response(body, status=status)
return handler
async def test_an_accepted_key_is_valid(self):
token = {"accessToken": "fresh", "expires": 2000000500.5}
status, api = await self._validate(self._replies(body=token))
self.assertEqual(status, PlusKeyStatus.valid)
self.assertEqual(api._token_data, token)
self.assertEqual(self.auth, aiohttp.BasicAuth("id", "secret").encode())
async def test_a_rejected_key_is_invalid(self):
for code in (401, 403):
status, api = await self._validate(self._replies(code, {"error": "no"}))
self.assertEqual(status, PlusKeyStatus.invalid)
self.assertEqual(api._token_data, self.CACHED_TOKEN)
async def test_a_server_error_is_not_reported_as_invalid(self):
status, _ = await self._validate(self._replies(500, {"error": "down"}))
self.assertEqual(status, PlusKeyStatus.unreachable)
async def test_unusable_replies_keep_the_cached_token(self):
# a successful reply without a usable token must not replace one that
# later Frigate+ requests depend on
for body in (
None,
[],
{},
{"accessToken": "t"},
{"expires": 2000000000},
{"accessToken": 1, "expires": 2000000000},
{"accessToken": "t", "expires": "soon"},
{"accessToken": "t", "expires": True},
):
with self.subTest(body=body):
status, api = await self._validate(self._replies(body=body))
self.assertEqual(status, PlusKeyStatus.unreachable)
self.assertEqual(api._token_data, self.CACHED_TOKEN)
async def test_a_non_json_reply_keeps_the_cached_token(self):
status, api = await self._validate(self._replies(text="<html>"))
self.assertEqual(status, PlusKeyStatus.unreachable)
self.assertEqual(api._token_data, self.CACHED_TOKEN)
async def test_a_connection_failure_is_unreachable(self):
# serve and stop, so the port refuses connections
server = TestServer(web.Application())
await server.start_server()
host = str(server.make_url("")).rstrip("/")
await server.close()
api = self._api(host)
self.assertEqual(await api.validate_key(), PlusKeyStatus.unreachable)
async def test_a_slow_server_times_out_as_unreachable(self):
async def slow(request: web.Request) -> web.Response:
await asyncio.sleep(5)
return web.json_response(self.CACHED_TOKEN)
with patch("frigate.plus.PLUS_VALIDATE_TIMEOUT", 0.1):
status, _ = await self._validate(slow)
self.assertEqual(status, PlusKeyStatus.unreachable)
async def test_the_request_goes_through_a_configured_proxy(self):
# installs that reach Frigate+ through a proxy must validate the same way
token = {"accessToken": "proxied", "expires": 2000000000}
proxied = []
async def proxy(request: web.Request) -> web.Response:
proxied.append(str(request.url))
return web.json_response(token)
proxy_url = await self._serve(proxy)
api = self._api("http://plus.invalid")
with patch.dict(
os.environ, {"HTTP_PROXY": proxy_url, "NO_PROXY": ""}, clear=False
):
status = await api.validate_key()
self.assertEqual(status, PlusKeyStatus.valid)
self.assertEqual(proxied, ["http://plus.invalid/v1/auth/token"])
async def test_no_key_is_not_sent_to_the_server(self):
requests_seen = []
async def handler(request: web.Request) -> web.Response:
requests_seen.append(request)
return web.json_response(self.CACHED_TOKEN)
api = self._api(await self._serve(handler), key=None)
self.assertEqual(await api.validate_key(), PlusKeyStatus.not_set)
self.assertEqual(requests_seen, [])
if __name__ == "__main__":
unittest.main(verbosity=2)
+7 -2
View File
@@ -1285,8 +1285,13 @@
},
"apiKey": {
"title": "Frigate+ API Key",
"validated": "Frigate+ API key is detected and validated",
"notValidated": "Frigate+ API key is not detected or not validated",
"formatValid": "Frigate+ API key is detected and matches the expected format",
"formatInvalid": "Frigate+ API key is not detected or does not match the expected format",
"validate": "Validate with Frigate+",
"validating": "Validating…",
"serverValid": "Frigate+ accepted this API key",
"serverInvalid": "Frigate+ rejected this API key",
"serverUnreachable": "Unable to reach Frigate+ to validate this API key",
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
"plusLink": "Read more about Frigate+"
},
@@ -1,8 +1,9 @@
import { useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import useSWR from "swr";
import { CheckCircle2, XCircle } from "lucide-react";
import axios from "axios";
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
import { LuExternalLink } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
@@ -18,12 +19,34 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { isReplayCamera } from "@/utils/cameraUtil";
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
import { getPrimaryModel } from "@/utils/modelUtil";
import { useIsAdmin } from "@/hooks/use-is-admin";
type PlusKeyStatus = "valid" | "invalid" | "unreachable" | "not_set";
type PlusValidateResponse = { success: boolean; status: PlusKeyStatus };
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
const { t } = useTranslation("views/settings");
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [isValidating, setIsValidating] = useState(false);
const [keyStatus, setKeyStatus] = useState<PlusKeyStatus>();
const validateKey = useCallback(async () => {
setIsValidating(true);
try {
const response = await axios.post<PlusValidateResponse>("plus/validate");
setKeyStatus(response.data.status);
} catch {
setKeyStatus("unreachable");
} finally {
setIsValidating(false);
}
}, []);
useEffect(() => {
document.title = t("documentTitle.frigatePlus");
@@ -68,17 +91,55 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
</>
}
content={
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
) : (
<XCircle className="size-5 shrink-0 text-red-500" />
)}
<span className="text-sm">
{config?.plus?.enabled
? t("frigatePlus.apiKey.formatValid")
: t("frigatePlus.apiKey.formatInvalid")}
</span>
</div>
{config?.plus?.enabled && isAdmin && (
<div className="flex flex-col items-start gap-2">
<Button
size="sm"
variant="outline"
disabled={isValidating}
onClick={validateKey}
aria-label={t("frigatePlus.apiKey.validate")}
>
{isValidating && (
<ActivityIndicator className="mr-2" size={16} />
)}
{isValidating
? t("frigatePlus.apiKey.validating")
: t("frigatePlus.apiKey.validate")}
</Button>
{keyStatus && !isValidating && (
<div className="flex items-center gap-2">
{keyStatus === "valid" ? (
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
) : keyStatus === "unreachable" ? (
<AlertTriangle className="size-5 shrink-0 text-yellow-500" />
) : (
<XCircle className="size-5 shrink-0 text-red-500" />
)}
<span className="text-sm">
{keyStatus === "valid"
? t("frigatePlus.apiKey.serverValid")
: keyStatus === "unreachable"
? t("frigatePlus.apiKey.serverUnreachable")
: t("frigatePlus.apiKey.serverInvalid")}
</span>
</div>
)}
</div>
)}
<span className="text-sm">
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</span>
</div>
}
/>