mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
fix(auth): support JWT issuer verification, scope-warning when unscoped
When JWT auth is enabled but `JWT_AUDIENCE` is unset, `auth_jwt` disabled audience verification entirely. Tokens minted by any other application that shared the same IdP signing keys (Azure AD, Okta, etc.) were accepted as long as their signature checked out, even though their `aud` and `iss` claims pointed at unrelated apps. The proxy then fell into the no-team / no-user branch where access checks default-allow. This change: 1. Adds support for the `JWT_ISSUER` env var. When set, PyJWT verifies the token's `iss` claim — turning on the same defense for tokens that share an audience but come from a different IdP tenant. 2. Refactors the duplicated `jwt.decode` calls (RSA/EC/OKP path and x509 path) into a single `_build_decode_kwargs` helper that computes audience, issuer, and the corresponding `verify_*` opt-outs once per call. 3. Logs a single startup-time warning when JWT auth is enabled but neither `JWT_AUDIENCE` nor `JWT_ISSUER` is configured, so operators running the insecure default see a flag in their logs without getting spammed per-request. Default behavior (no env vars) is preserved for backward compatibility. Setting `JWT_AUDIENCE` and/or `JWT_ISSUER` opts into the verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
934ecdca78
commit
e55401e39c
2 changed files with 155 additions and 7 deletions
|
|
@ -705,11 +705,48 @@ class JWTHandler:
|
|||
verbose_proxy_logger.error(f"Error fetching OIDC UserInfo: {str(e)}")
|
||||
raise Exception(f"Failed to fetch OIDC UserInfo: {str(e)}")
|
||||
|
||||
async def auth_jwt(self, token: str) -> dict:
|
||||
_unscoped_jwt_warning_emitted = False
|
||||
|
||||
@classmethod
|
||||
def _build_decode_kwargs(cls) -> dict:
|
||||
"""Build the audience/issuer/options kwargs for ``jwt.decode``.
|
||||
|
||||
Setting ``JWT_AUDIENCE`` (and optionally ``JWT_ISSUER``) turns on the
|
||||
corresponding PyJWT verifications, blocking cross-tenant tokens
|
||||
minted by other applications that share the same IdP signing keys.
|
||||
When both are unset PyJWT only checks the signature and expiry, which
|
||||
is preserved for backward compatibility but logged once as a warning.
|
||||
"""
|
||||
audience = os.getenv("JWT_AUDIENCE")
|
||||
decode_options = None
|
||||
issuer = os.getenv("JWT_ISSUER")
|
||||
|
||||
if (
|
||||
audience is None
|
||||
and issuer is None
|
||||
and not cls._unscoped_jwt_warning_emitted
|
||||
):
|
||||
verbose_proxy_logger.warning(
|
||||
"JWT auth is enabled but neither JWT_AUDIENCE nor JWT_ISSUER "
|
||||
"is configured. Tokens minted by any application that shares "
|
||||
"the same IdP signing keys will be accepted. Set JWT_AUDIENCE "
|
||||
"(and ideally JWT_ISSUER) to scope this proxy."
|
||||
)
|
||||
cls._unscoped_jwt_warning_emitted = True
|
||||
|
||||
options: dict = {}
|
||||
if audience is None:
|
||||
decode_options = {"verify_aud": False}
|
||||
options["verify_aud"] = False
|
||||
if issuer is None:
|
||||
options["verify_iss"] = False
|
||||
|
||||
return {
|
||||
"audience": audience,
|
||||
"issuer": issuer,
|
||||
"options": options or None,
|
||||
}
|
||||
|
||||
async def auth_jwt(self, token: str) -> dict:
|
||||
decode_kwargs = self._build_decode_kwargs()
|
||||
|
||||
header = jwt.get_unverified_header(token)
|
||||
|
||||
|
|
@ -745,9 +782,8 @@ class JWTHandler:
|
|||
token,
|
||||
public_key_obj, # type: ignore
|
||||
algorithms=self.SUPPORTED_JWT_ALGORITHMS,
|
||||
options=decode_options, # type: ignore[arg-type]
|
||||
audience=audience,
|
||||
leeway=self.leeway, # allow testing of expired tokens
|
||||
**decode_kwargs,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
|
@ -773,8 +809,7 @@ class JWTHandler:
|
|||
token,
|
||||
key,
|
||||
algorithms=self.SUPPORTED_JWT_ALGORITHMS,
|
||||
audience=audience,
|
||||
options=decode_options,
|
||||
**decode_kwargs,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
|
|
|||
|
|
@ -2565,3 +2565,116 @@ async def test_auth_builder_single_team_fallback_membership_error_skips_no_raise
|
|||
assert result["team_membership"] is None
|
||||
mock_get_team.assert_called()
|
||||
mock_get_membership.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWTHandler._build_decode_kwargs — VERIA-27 (audience + issuer verification)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=False)
|
||||
def _reset_unscoped_warning_flag():
|
||||
"""Reset the once-per-process warning sentinel so each test sees a fresh
|
||||
state."""
|
||||
JWTHandler._unscoped_jwt_warning_emitted = False
|
||||
yield
|
||||
JWTHandler._unscoped_jwt_warning_emitted = False
|
||||
|
||||
|
||||
def test_build_decode_kwargs_no_env_disables_both_verifications(
|
||||
monkeypatch, _reset_unscoped_warning_flag
|
||||
):
|
||||
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
|
||||
monkeypatch.delenv("JWT_ISSUER", raising=False)
|
||||
|
||||
kwargs = JWTHandler._build_decode_kwargs()
|
||||
|
||||
assert kwargs["audience"] is None
|
||||
assert kwargs["issuer"] is None
|
||||
assert kwargs["options"] == {"verify_aud": False, "verify_iss": False}
|
||||
|
||||
|
||||
def test_build_decode_kwargs_audience_only_enables_aud_verification(
|
||||
monkeypatch, _reset_unscoped_warning_flag
|
||||
):
|
||||
monkeypatch.setenv("JWT_AUDIENCE", "my-proxy")
|
||||
monkeypatch.delenv("JWT_ISSUER", raising=False)
|
||||
|
||||
kwargs = JWTHandler._build_decode_kwargs()
|
||||
|
||||
assert kwargs["audience"] == "my-proxy"
|
||||
assert kwargs["issuer"] is None
|
||||
# verify_aud not in options means PyJWT will verify audience
|
||||
assert kwargs["options"] == {"verify_iss": False}
|
||||
|
||||
|
||||
def test_build_decode_kwargs_issuer_only_enables_iss_verification(
|
||||
monkeypatch, _reset_unscoped_warning_flag
|
||||
):
|
||||
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
|
||||
monkeypatch.setenv("JWT_ISSUER", "https://idp.example.com/")
|
||||
|
||||
kwargs = JWTHandler._build_decode_kwargs()
|
||||
|
||||
assert kwargs["audience"] is None
|
||||
assert kwargs["issuer"] == "https://idp.example.com/"
|
||||
assert kwargs["options"] == {"verify_aud": False}
|
||||
|
||||
|
||||
def test_build_decode_kwargs_both_set_enables_full_verification(
|
||||
monkeypatch, _reset_unscoped_warning_flag
|
||||
):
|
||||
monkeypatch.setenv("JWT_AUDIENCE", "my-proxy")
|
||||
monkeypatch.setenv("JWT_ISSUER", "https://idp.example.com/")
|
||||
|
||||
kwargs = JWTHandler._build_decode_kwargs()
|
||||
|
||||
assert kwargs["audience"] == "my-proxy"
|
||||
assert kwargs["issuer"] == "https://idp.example.com/"
|
||||
# No verification opt-outs — PyJWT verifies both claims by default.
|
||||
assert kwargs["options"] is None
|
||||
|
||||
|
||||
def test_build_decode_kwargs_warns_once_when_unscoped(
|
||||
monkeypatch, _reset_unscoped_warning_flag, caplog
|
||||
):
|
||||
"""The warning about unscoped JWT auth should fire on the first call but
|
||||
not on every subsequent decode."""
|
||||
import logging
|
||||
|
||||
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
|
||||
monkeypatch.delenv("JWT_ISSUER", raising=False)
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
JWTHandler._build_decode_kwargs()
|
||||
JWTHandler._build_decode_kwargs()
|
||||
JWTHandler._build_decode_kwargs()
|
||||
|
||||
matching = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if "JWT auth is enabled" in r.getMessage()
|
||||
and "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage()
|
||||
]
|
||||
assert (
|
||||
len(matching) == 1
|
||||
), f"Expected exactly one warning across 3 calls, got {len(matching)}"
|
||||
|
||||
|
||||
def test_build_decode_kwargs_no_warning_when_scoped(
|
||||
monkeypatch, _reset_unscoped_warning_flag, caplog
|
||||
):
|
||||
import logging
|
||||
|
||||
monkeypatch.setenv("JWT_AUDIENCE", "my-proxy")
|
||||
monkeypatch.delenv("JWT_ISSUER", raising=False)
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
JWTHandler._build_decode_kwargs()
|
||||
|
||||
matching = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage()
|
||||
]
|
||||
assert matching == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue