Skip to content

Commit 62c269a

Browse files
authored
feat(auth): optional audience and issuer enforcement for JWT auth (#14151)
1 parent 4a8ed76 commit 62c269a

6 files changed

Lines changed: 200 additions & 4 deletions

File tree

backend/onyx/auth/jwt.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,21 @@
66
import jwt
77
import requests
88
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
9-
from jwt import InvalidTokenError, PyJWTError
9+
from jwt import (
10+
InvalidAudienceError,
11+
InvalidIssuerError,
12+
InvalidTokenError,
13+
MissingRequiredClaimError,
14+
PyJWTError,
15+
)
1016
from jwt import decode as jwt_decode
1117
from jwt.algorithms import RSAAlgorithm # ty: ignore[possibly-missing-import]
1218

13-
from onyx.configs.app_configs import JWT_PUBLIC_KEY_URL
19+
from onyx.configs.app_configs import (
20+
JWT_EXPECTED_AUDIENCE,
21+
JWT_EXPECTED_ISSUER,
22+
JWT_PUBLIC_KEY_URL,
23+
)
1424
from onyx.utils.logger import setup_logger
1525

1626
logger = setup_logger()
@@ -136,12 +146,25 @@ async def verify_jwt_token(token: str) -> dict[str, Any] | None:
136146
return None
137147

138148
try:
149+
# Enforced only when configured: verify_aud=True with audience=None
150+
# would reject every token that carries an aud claim.
139151
payload = jwt_decode(
140152
token,
141153
public_key,
142154
algorithms=["RS256"],
143-
options={"verify_aud": False},
155+
audience=JWT_EXPECTED_AUDIENCE,
156+
issuer=JWT_EXPECTED_ISSUER,
157+
options={"verify_aud": JWT_EXPECTED_AUDIENCE is not None},
144158
)
159+
except (
160+
InvalidAudienceError,
161+
InvalidIssuerError,
162+
MissingRequiredClaimError,
163+
) as e:
164+
# Definitive claim rejection: refetched keys cannot change it, and a
165+
# cache clear would let bad tokens evict the signing key for everyone.
166+
logger.warning("JWT rejected by aud/iss enforcement: %s", str(e))
167+
return None
145168
except InvalidTokenError as e:
146169
logger.error("Invalid JWT token: %s", str(e))
147170
if attempt < _PUBLIC_KEY_FETCH_ATTEMPTS - 1:

backend/onyx/configs/app_configs.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,11 @@ def parse_idp_claim_map(raw: str | None) -> dict[str, list[str]]:
357357
MOBILE_ALLOWED_REDIRECT_URIS = _DEFAULT_MOBILE_REDIRECT_URIS
358358

359359
# JWT Public Key URL for JWT token verification
360-
JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
360+
JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL") or None
361+
# Optional aud/iss scoping for JWT_PUBLIC_KEY_URL auth, off when unset. Empty
362+
# counts as unset because compose files pass absent vars through as "".
363+
JWT_EXPECTED_AUDIENCE: str | None = os.getenv("JWT_EXPECTED_AUDIENCE") or None
364+
JWT_EXPECTED_ISSUER: str | None = os.getenv("JWT_EXPECTED_ISSUER") or None
361365

362366
USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "")
363367

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Optional aud/iss enforcement on the JWT_PUBLIC_KEY_URL flow. Unset settings
2+
leave the claims unrestricted, and a configured expectation must reject
3+
mismatched or absent claims."""
4+
5+
from typing import Any
6+
7+
import jwt as pyjwt
8+
import pytest
9+
from cryptography.hazmat.primitives import serialization
10+
from cryptography.hazmat.primitives.asymmetric import rsa
11+
12+
import onyx.auth.jwt as jwt_module
13+
from onyx.auth.jwt import verify_jwt_token
14+
15+
_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
16+
_PUBLIC_PEM = (
17+
_PRIVATE_KEY.public_key()
18+
.public_bytes(
19+
encoding=serialization.Encoding.PEM,
20+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
21+
)
22+
.decode()
23+
)
24+
25+
26+
def _mint(claims: dict[str, Any]) -> str:
27+
return pyjwt.encode(claims, _PRIVATE_KEY, algorithm="RS256")
28+
29+
30+
@pytest.fixture
31+
def signed_key(monkeypatch: pytest.MonkeyPatch) -> None:
32+
monkeypatch.setattr(jwt_module, "get_public_key", lambda _token: _PUBLIC_PEM)
33+
34+
35+
def _configure(
36+
monkeypatch: pytest.MonkeyPatch,
37+
audience: str | None,
38+
issuer: str | None,
39+
) -> None:
40+
monkeypatch.setattr(jwt_module, "JWT_EXPECTED_AUDIENCE", audience)
41+
monkeypatch.setattr(jwt_module, "JWT_EXPECTED_ISSUER", issuer)
42+
43+
44+
@pytest.mark.parametrize(
45+
"claims",
46+
[
47+
{"email": "a@b.c"},
48+
{"email": "a@b.c", "aud": "some-other-service", "iss": "https://idp"},
49+
],
50+
)
51+
@pytest.mark.asyncio
52+
@pytest.mark.usefixtures("signed_key")
53+
async def test_unset_settings_accept_any_signed_token(
54+
monkeypatch: pytest.MonkeyPatch, claims: dict[str, Any]
55+
) -> None:
56+
_configure(monkeypatch, None, None)
57+
payload = await verify_jwt_token(_mint(claims))
58+
assert payload is not None
59+
assert payload["email"] == "a@b.c"
60+
61+
62+
@pytest.mark.parametrize(
63+
"claims, accepted",
64+
[
65+
({"aud": "onyx"}, True),
66+
({"aud": ["onyx", "other"]}, True),
67+
({"aud": "some-other-service"}, False),
68+
({"aud": ["other-a", "other-b"]}, False),
69+
({}, False),
70+
],
71+
)
72+
@pytest.mark.asyncio
73+
@pytest.mark.usefixtures("signed_key")
74+
async def test_audience_enforced_when_configured(
75+
monkeypatch: pytest.MonkeyPatch,
76+
claims: dict[str, Any],
77+
accepted: bool,
78+
) -> None:
79+
_configure(monkeypatch, "onyx", None)
80+
payload = await verify_jwt_token(_mint(claims))
81+
assert (payload is not None) is accepted
82+
83+
84+
@pytest.mark.parametrize(
85+
"claims, accepted",
86+
[
87+
({"iss": "https://idp.example.com"}, True),
88+
({"iss": "https://evil.example.com"}, False),
89+
({}, False),
90+
],
91+
)
92+
@pytest.mark.asyncio
93+
@pytest.mark.usefixtures("signed_key")
94+
async def test_issuer_enforced_when_configured(
95+
monkeypatch: pytest.MonkeyPatch,
96+
claims: dict[str, Any],
97+
accepted: bool,
98+
) -> None:
99+
_configure(monkeypatch, None, "https://idp.example.com")
100+
payload = await verify_jwt_token(_mint(claims))
101+
assert (payload is not None) is accepted
102+
103+
104+
@pytest.mark.asyncio
105+
@pytest.mark.usefixtures("signed_key")
106+
async def test_both_enforced_and_matching(
107+
monkeypatch: pytest.MonkeyPatch,
108+
) -> None:
109+
_configure(monkeypatch, "onyx", "https://idp.example.com")
110+
payload = await verify_jwt_token(
111+
_mint({"aud": "onyx", "iss": "https://idp.example.com", "email": "a@b.c"})
112+
)
113+
assert payload is not None
114+
115+
116+
@pytest.mark.asyncio
117+
@pytest.mark.usefixtures("signed_key")
118+
async def test_both_enforced_rejects_wrong_issuer(
119+
monkeypatch: pytest.MonkeyPatch,
120+
) -> None:
121+
_configure(monkeypatch, "onyx", "https://idp.example.com")
122+
payload = await verify_jwt_token(
123+
_mint({"aud": "onyx", "iss": "https://evil.example.com"})
124+
)
125+
assert payload is None
126+
127+
128+
def test_empty_env_counts_as_unset(monkeypatch: pytest.MonkeyPatch) -> None:
129+
# Compose files pass absent vars through as "", which must not enable
130+
# enforcement (audience="" would reject every aud-less token).
131+
import importlib
132+
133+
import onyx.configs.app_configs as app_configs
134+
135+
monkeypatch.setenv("JWT_EXPECTED_AUDIENCE", "")
136+
monkeypatch.setenv("JWT_EXPECTED_ISSUER", "")
137+
monkeypatch.setenv("JWT_PUBLIC_KEY_URL", "")
138+
reloaded = importlib.reload(app_configs)
139+
try:
140+
assert reloaded.JWT_EXPECTED_AUDIENCE is None
141+
assert reloaded.JWT_EXPECTED_ISSUER is None
142+
assert reloaded.JWT_PUBLIC_KEY_URL is None
143+
finally:
144+
monkeypatch.undo()
145+
importlib.reload(app_configs)
146+
147+
148+
@pytest.mark.asyncio
149+
async def test_claim_rejection_does_not_refetch_keys(
150+
monkeypatch: pytest.MonkeyPatch,
151+
) -> None:
152+
calls: list[int] = []
153+
154+
def _counting_get(_token: str) -> str:
155+
calls.append(1)
156+
return _PUBLIC_PEM
157+
158+
monkeypatch.setattr(jwt_module, "get_public_key", _counting_get)
159+
_configure(monkeypatch, "onyx", None)
160+
assert await verify_jwt_token(_mint({"aud": "some-other-service"})) is None
161+
assert len(calls) == 1

cli/internal/deploy/deployfiles/embedded/docker_compose/env.template

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deployment/docker_compose/docker-compose.multitenant-dev.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ services:
5151
- OPENID_CONFIG_URL=${OPENID_CONFIG_URL:-}
5252
- TRACK_EXTERNAL_IDP_EXPIRY=${TRACK_EXTERNAL_IDP_EXPIRY:-}
5353
- CORS_ALLOWED_ORIGIN=${CORS_ALLOWED_ORIGIN:-}
54+
# JWT auth runs in the api_server request path only
55+
- JWT_PUBLIC_KEY_URL=${JWT_PUBLIC_KEY_URL:-}
56+
- JWT_EXPECTED_AUDIENCE=${JWT_EXPECTED_AUDIENCE:-}
57+
- JWT_EXPECTED_ISSUER=${JWT_EXPECTED_ISSUER:-}
5458
# Gen AI Settings
5559
- GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-}
5660
- LLM_SOCKET_READ_TIMEOUT=${LLM_SOCKET_READ_TIMEOUT:-}

deployment/docker_compose/env.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ LOG_ONYX_MODEL_INTERACTIONS=False
307307
# CORS_ALLOWED_ORIGIN=
308308
# INTEGRATION_TESTS_MODE=
309309
# JWT_PUBLIC_KEY_URL=
310+
# JWT_EXPECTED_AUDIENCE=
311+
# JWT_EXPECTED_ISSUER=
310312

311313
## Gen AI Settings
312314
# GEN_AI_MAX_TOKENS=

0 commit comments

Comments
 (0)