Skip to content

Commit 4470a27

Browse files
rustyconoverclaude
andcommitted
Support multiple audiences in jwt_authenticate and bump version to 0.1.23
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 46578c5 commit 4470a27

3 files changed

Lines changed: 63 additions & 6 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-rpc"
3-
version = "0.1.22"
3+
version = "0.1.23"
44
description = "Vector Gateway Interface - RPC framework based on Apache Arrow"
55
readme = "README.md"
66
requires-python = ">=3.13"

tests/test_oauth.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,12 @@ def _make_local_auth(
8686
public_key: dict[str, object],
8787
*,
8888
issuer: str = "https://auth.example.com",
89-
audience: str = "https://api.example.com/vgi",
89+
audience: str | tuple[str, ...] = "https://api.example.com/vgi",
9090
principal_claim: str = "sub",
9191
domain: str = "jwt",
9292
) -> Callable[[falcon.Request], AuthContext]:
9393
"""Create a local JWT authenticate callback (no JWKS endpoint needed)."""
94+
audiences = (audience,) if isinstance(audience, str) else audience
9495

9596
def authenticate(req: falcon.Request) -> AuthContext:
9697
auth_header = req.get_header("Authorization") or ""
@@ -103,7 +104,7 @@ def authenticate(req: falcon.Request) -> AuthContext:
103104
public_key,
104105
claims_options={
105106
"iss": {"essential": True, "value": issuer},
106-
"aud": {"essential": True, "value": audience},
107+
"aud": {"essential": True, "values": list(audiences)},
107108
},
108109
)
109110
claims.validate()
@@ -858,3 +859,53 @@ def test_jwt_authenticate_factory_creates_callable(self) -> None:
858859
jwks_uri="https://auth.example.com/.well-known/jwks.json",
859860
)
860861
assert callable(auth_fn)
862+
863+
def test_multiple_audiences_first_matches(self) -> None:
864+
"""A JWT matching the first of multiple audiences is accepted."""
865+
priv, pub = _make_rsa_key()
866+
aud1 = "https://api.example.com/vgi"
867+
aud2 = "https://api.example.com/other"
868+
token = _mint_jwt(priv, aud=aud1)
869+
auth_fn = _make_local_auth(pub, audience=(aud1, aud2))
870+
req = falcon.testing.helpers.create_req(headers={"Authorization": f"Bearer {token}"})
871+
auth = auth_fn(req)
872+
assert auth.authenticated is True
873+
assert auth.principal == "testuser"
874+
875+
def test_multiple_audiences_second_matches(self) -> None:
876+
"""A JWT matching the second of multiple audiences is accepted."""
877+
priv, pub = _make_rsa_key()
878+
aud1 = "https://api.example.com/vgi"
879+
aud2 = "https://api.example.com/other"
880+
token = _mint_jwt(priv, aud=aud2)
881+
auth_fn = _make_local_auth(pub, audience=(aud1, aud2))
882+
req = falcon.testing.helpers.create_req(headers={"Authorization": f"Bearer {token}"})
883+
auth = auth_fn(req)
884+
assert auth.authenticated is True
885+
886+
def test_multiple_audiences_none_match(self) -> None:
887+
"""A JWT with an unrecognized audience is rejected."""
888+
priv, pub = _make_rsa_key()
889+
token = _mint_jwt(priv, aud="https://unknown.example.com")
890+
auth_fn = _make_local_auth(pub, audience=("https://a.example.com", "https://b.example.com"))
891+
req = falcon.testing.helpers.create_req(headers={"Authorization": f"Bearer {token}"})
892+
with pytest.raises(ValueError):
893+
auth_fn(req)
894+
895+
def test_single_audience_string_still_works(self) -> None:
896+
"""Passing audience as a plain string still works (backwards compat)."""
897+
priv, pub = _make_rsa_key()
898+
token = _mint_jwt(priv)
899+
auth_fn = _make_local_auth(pub, audience="https://api.example.com/vgi")
900+
req = falcon.testing.helpers.create_req(headers={"Authorization": f"Bearer {token}"})
901+
auth = auth_fn(req)
902+
assert auth.authenticated is True
903+
904+
def test_empty_audience_tuple_raises(self) -> None:
905+
"""Passing an empty audience tuple raises ValueError eagerly."""
906+
with pytest.raises(ValueError, match="audience must not be empty"):
907+
jwt_authenticate(
908+
issuer="https://auth.example.com",
909+
audience=(),
910+
jwks_uri="https://auth.example.com/.well-known/jwks.json",
911+
)

vgi_rpc/http/_oauth_jwt.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
def jwt_authenticate(
3232
*,
3333
issuer: str,
34-
audience: str,
34+
audience: str | tuple[str, ...],
3535
jwks_uri: str | None = None,
3636
claims_options: Mapping[str, Any] | None = None,
3737
principal_claim: str = "sub",
@@ -45,7 +45,9 @@ def jwt_authenticate(
4545
4646
Args:
4747
issuer: Expected ``iss`` claim in the JWT.
48-
audience: Expected ``aud`` claim in the JWT.
48+
audience: Expected ``aud`` claim(s) in the JWT. A single string
49+
or a tuple of strings. When multiple audiences are given, a
50+
token matching **any** of them is accepted.
4951
jwks_uri: URL to fetch the JWKS from. When ``None``, discovered
5052
from ``{issuer}/.well-known/openid-configuration``.
5153
claims_options: Additional Authlib claim validation options.
@@ -92,9 +94,13 @@ def _get_key_set(force_refresh: bool = False) -> _KeySet:
9294
return _fetch_jwks()
9395
return key_set
9496

97+
audiences = (audience,) if isinstance(audience, str) else audience
98+
if not audiences:
99+
raise ValueError("audience must not be empty")
100+
95101
base_claims_options: dict[str, Any] = {
96102
"iss": {"essential": True, "value": issuer},
97-
"aud": {"essential": True, "value": audience},
103+
"aud": {"essential": True, "values": list(audiences)},
98104
}
99105
if claims_options:
100106
base_claims_options.update(claims_options)

0 commit comments

Comments
 (0)