@@ -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+ )
0 commit comments