fix(auth): wrap JWT secret in OctKey for joserfc 1.x compatibility

joserfc 1.x requires symmetric-algorithm keys to be `OctKey` instances
rather than raw strings. Since `requirements-wheels.txt` pins
`joserfc == 1.2.*`, every call to `create_encoded_jwt()` currently raises
`joserfc.errors.MissingKeyError` at `jwt.encode`, which nginx surfaces as
`500 Internal Server Error` on `POST /api/login`. The symmetric-verify
path in `is_logged_in_without_redirect` has the same problem on
`jwt.decode`.

Reproduction

  docker exec <container> pip show joserfc    # 1.2.2
  curl -X POST -H 'Content-Type: application/json' \
       -d '{"user":"<valid-user>","password":"<valid-pw>"}' \
       http://<frigate>:5000/api/login
  # -> 500 Internal Server Error
  # log shows: joserfc.errors.MissingKeyError: missing_key

Minimal repro inside the container:

  python3 -c "
  from joserfc import jwt
  jwt.encode({'alg':'HS256'}, {'sub':'t','exp':0}, 'some-secret')
  "
  # ValueError: Invalid key

  python3 -c "
  from joserfc import jwt
  from joserfc.jwk import OctKey
  print(jwt.encode({'alg':'HS256'}, {'sub':'t','exp':0},
                   OctKey.import_key('some-secret'))[:40])
  "
  # eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...

The Home Assistant Frigate integration trips this path on every
configuration attempt because it always does `POST /api/login` with the
credentials supplied in the HA config flow, producing a confusing
"failed to connect" symptom rather than an auth error.

Fix

Wrap the secret with `OctKey.import_key(...)` at the two sites that call
into `joserfc.jwt`. The stored secret file format and everything else
stays the same; `get_jwt_secret()` continues to return a string.

AI disclosure

AI-assisted. I reviewed and tested the change end-to-end against a live
Frigate 0.17.1 container running joserfc 1.2.2: reproduced the 500 via
Home Assistant, identified the stack (auth.py line 366 in 0.17.1 / 404
on dev, and the decode call on 680), verified `OctKey.import_key(str)`
accepts the existing secret format, applied the patch in-container,
restarted, and confirmed `POST /api/login` now returns a clean
`401 {"message":"Login failed"}` for bad credentials and issues a valid
JWT cookie for good credentials. No new dependencies; `OctKey` is part
of `joserfc` which is already pinned.
This commit is contained in:
Jason Vallery 2026-04-18 20:57:08 -06:00
parent cfb87f9744
commit 25b1db3118
2 changed files with 38 additions and 2 deletions

View File

@ -16,6 +16,7 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt from joserfc import jwt
from joserfc.jwk import OctKey
from peewee import DoesNotExist from peewee import DoesNotExist
from slowapi import Limiter from slowapi import Limiter
@ -401,10 +402,14 @@ def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
def create_encoded_jwt(user, role, expiration, secret): def create_encoded_jwt(user, role, expiration, secret):
# joserfc 1.x requires an OctKey for symmetric algorithms instead of a
# raw string; passing the string raises joserfc.errors.MissingKeyError
# (surfaces as a 500 on POST /api/login). See requirements pin
# `joserfc == 1.2.*`.
return jwt.encode( return jwt.encode(
{"alg": "HS256"}, {"alg": "HS256"},
{"sub": user, "role": role, "exp": expiration, "iat": int(time.time())}, {"sub": user, "role": role, "exp": expiration, "iat": int(time.time())},
secret, OctKey.import_key(secret),
) )
@ -677,7 +682,7 @@ def auth(request: Request):
return fail_response return fail_response
try: try:
token = jwt.decode(encoded_token, request.app.jwt_token) token = jwt.decode(encoded_token, OctKey.import_key(request.app.jwt_token))
if "sub" not in token.claims: if "sub" not in token.claims:
logger.debug("user not set in jwt token") logger.debug("user not set in jwt token")
return fail_response return fail_response

View File

@ -0,0 +1,31 @@
"""Regression test for create_encoded_jwt + joserfc 1.x OctKey handling.
joserfc 1.x requires an OctKey for symmetric algorithms; passing a raw
string raises MissingKeyError / ValueError inside jwt.encode. This test
pins the fix so a future refactor can't silently reintroduce the crash.
"""
import time
import unittest
from joserfc import jwt
from joserfc.jwk import OctKey
from frigate.api.auth import create_encoded_jwt
class TestCreateEncodedJwtRoundTrip(unittest.TestCase):
def test_round_trip_with_string_secret(self):
secret = "unit-test-secret-string-abc123"
expiration = int(time.time()) + 60
token = create_encoded_jwt("alice", "admin", expiration, secret)
decoded = jwt.decode(token, OctKey.import_key(secret))
self.assertEqual(decoded.claims["sub"], "alice")
self.assertEqual(decoded.claims["role"], "admin")
self.assertEqual(decoded.claims["exp"], expiration)
if __name__ == "__main__":
unittest.main()