Skip to content

Commit 68e6ab3

Browse files
authored
Merge pull request #865 from azmeuk/730-rfc7523-aud
2 parents 2b45027 + 9a3c477 commit 68e6ab3

6 files changed

Lines changed: 141 additions & 9 deletions

File tree

authlib/oauth2/rfc7523/client.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ class JWTBearerClientAssertion:
2828
#: Name of the client authentication method
2929
CLIENT_AUTH_METHOD = "client_assertion_jwt"
3030

31-
def __init__(self, token_url, validate_jti=True, leeway=60):
31+
def __init__(self, token_url=None, validate_jti=True, leeway=60):
32+
if token_url is not None: # pragma: no cover
33+
deprecate(
34+
"'token_url' is deprecated. Override 'get_audiences' instead.",
35+
version="1.8",
36+
)
3237
self.token_url = token_url
3338
self._validate_jti = validate_jti
3439
# A small allowance of time, typically no more than a few minutes,
@@ -67,7 +72,7 @@ def verify_claims(self, claims: jwt.Claims):
6772
options = {
6873
"iss": {"essential": True},
6974
"sub": {"essential": True},
70-
"aud": {"essential": True, "value": self.token_url},
75+
"aud": {"essential": True, "values": self.get_audiences()},
7176
"exp": {"essential": True},
7277
}
7378
claims_requests = jwt.JWTClaimsRegistry(leeway=self.leeway, **options)
@@ -88,6 +93,22 @@ def verify_claims(self, claims: jwt.Claims):
8893
if not self.validate_jti(claims, claims["jti"]):
8994
raise InvalidClientError(description="JWT ID is used before.")
9095

96+
def get_audiences(self):
97+
"""Return a list of valid audience identifiers for this authorization
98+
server. Per RFC 7523 Section 3, the audience identifies the
99+
authorization server as an intended audience.
100+
101+
Developers MUST implement this method::
102+
103+
def get_audiences(self):
104+
return ["https://example.com/oauth/token", "https://example.com"]
105+
106+
:return: list of valid audience strings
107+
"""
108+
if self.token_url is not None: # pragma: no cover
109+
return [self.token_url]
110+
raise NotImplementedError() # pragma: no cover
111+
91112
def process_assertion_claims(self, assertion, resolve_key):
92113
"""Extract JWT payload claims from request "assertion", per
93114
`Section 3.1`_.

authlib/oauth2/rfc7523/jwt_bearer.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,18 @@ def sign(
5353
)
5454

5555
def verify_claims(self, claims: jwt.Claims):
56-
claims_requests = jwt.JWTClaimsRegistry(
57-
leeway=self.LEEWAY, **self.CLAIMS_OPTIONS
58-
)
56+
options = dict(self.CLAIMS_OPTIONS)
57+
audiences = self.get_audiences()
58+
if audiences:
59+
options["aud"] = {"essential": True, "values": audiences}
60+
else:
61+
deprecate(
62+
"'get_audiences' must return a non-empty list. "
63+
"Audience validation will become mandatory.",
64+
version="1.8",
65+
)
66+
67+
claims_requests = jwt.JWTClaimsRegistry(leeway=self.LEEWAY, **options)
5968
try:
6069
claims_requests.validate(claims)
6170
except JoseError as e:
@@ -217,6 +226,27 @@ def authenticate_user(self, subject):
217226
"""
218227
raise NotImplementedError()
219228

229+
def get_audiences(self):
230+
"""Return a list of valid audience identifiers for this authorization
231+
server. Per RFC 7523 Section 3:
232+
233+
The authorization server MUST reject any JWT that does not
234+
contain its own identity as the intended audience.
235+
236+
Developers SHOULD implement this method to return the list of valid
237+
audience values, typically including the token endpoint URL and/or
238+
the issuer identifier. For example::
239+
240+
def get_audiences(self):
241+
return ["https://example.com/oauth/token", "https://example.com"]
242+
243+
If this method returns an empty list, audience value validation is
244+
skipped (only presence is checked).
245+
246+
:return: list of valid audience strings
247+
"""
248+
return []
249+
220250
def has_granted_permission(self, client, user):
221251
"""Check if the client has permission to access the given user's resource.
222252
Developers MUST implement it in subclass, e.g.::

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Version 1.7.0
2323
- Allow ``ResourceProtector`` decorator to be used without parentheses. :issue:`604`
2424
- Implement RFC9700 PKCE downgrade countermeasure.
2525
- Set ``User-Agent`` header when fetching server metadata and JWKs. :issue:`704`
26+
- RFC7523 accepts the issuer URL as a valid audience. :issue:`730`
2627

2728
Upgrade Guide: :ref:`joserfc_upgrade`.
2829

docs/specs/rfc7523.rst

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ methods in order to use it. Here is an example::
3535
from authlib.oauth2.rfc7523 import JWTBearerGrant as _JWTBearerGrant
3636

3737
class JWTBearerGrant(_JWTBearerGrant):
38+
def get_audiences(self):
39+
# Per RFC 7523 Section 3, both the token endpoint URL and the
40+
# authorization server's issuer identifier are valid audience values.
41+
return ['https://example.com/oauth/token', 'https://example.com']
42+
3843
def resolve_issuer_client(self, issuer):
3944
# if using client_id as issuer
4045
return Client.objects.get(client_id=issuer)
@@ -90,6 +95,11 @@ In Authlib, ``client_secret_jwt`` and ``private_key_jwt`` share the same API,
9095
using :class:`JWTBearerClientAssertion` to create a new client authentication::
9196

9297
class JWTClientAuth(JWTBearerClientAssertion):
98+
def get_audiences(self):
99+
# Per RFC 7523 Section 3, both the token endpoint URL and the
100+
# authorization server's issuer identifier are valid audience values.
101+
return ['https://example.com/oauth/token', 'https://example.com']
102+
93103
def validate_jti(self, claims, jti):
94104
# validate_jti is required by OpenID Connect
95105
# but it is optional by RFC7523
@@ -109,11 +119,13 @@ using :class:`JWTBearerClientAssertion` to create a new client authentication::
109119

110120
authorization_server.register_client_auth_method(
111121
JWTClientAuth.CLIENT_AUTH_METHOD,
112-
JWTClientAuth('https://example.com/oauth/token')
122+
JWTClientAuth()
113123
)
114124

115-
The value ``https://example.com/oauth/token`` is your authorization server's
116-
token endpoint, which is used as ``aud`` value in JWT.
125+
The ``get_audiences`` method returns the list of valid ``aud`` values
126+
accepted in client assertion JWTs. Per RFC 7523 Section 3,
127+
both the token endpoint URL and the authorization server's issuer
128+
identifier are valid audience values.
117129

118130
Now we have added this client auth method to authorization server, but no
119131
grant types support this authentication method, you need to add it to the

tests/flask/test_oauth2/test_jwt_bearer_client_auth.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ def client(client, db):
4141

4242
def register_jwt_client_auth(server, validate_jti=True):
4343
class JWTClientAuth(JWTBearerClientAssertion):
44+
def get_audiences(self):
45+
return ["https://provider.test/oauth/token"]
46+
4447
def validate_jti(self, claims, jti):
4548
return jti != "used"
4649

@@ -51,7 +54,7 @@ def resolve_client_public_key(self, client, headers):
5154

5255
server.register_client_auth_method(
5356
JWTClientAuth.CLIENT_AUTH_METHOD,
54-
JWTClientAuth("https://provider.test/oauth/token", validate_jti),
57+
JWTClientAuth(validate_jti=validate_jti),
5558
)
5659

5760

@@ -299,3 +302,43 @@ def test_missing_jti(test_client, server):
299302
resp = json.loads(rv.data)
300303
assert "error" in resp
301304
assert resp["error_description"] == "Missing JWT ID."
305+
306+
307+
def test_issuer_as_audience(test_client, server):
308+
"""Per RFC 7523 Section 3 and draft-ietf-oauth-rfc7523bis, the AS issuer
309+
identifier should be a valid audience value for client assertion JWTs."""
310+
311+
class JWTClientAuth(JWTBearerClientAssertion):
312+
def get_audiences(self):
313+
return ["https://provider.test/oauth/token", "https://provider.test"]
314+
315+
def validate_jti(self, claims, jti):
316+
return True
317+
318+
def resolve_client_public_key(self, client, headers):
319+
return client.client_secret
320+
321+
server.register_client_auth_method(
322+
JWTClientAuth.CLIENT_AUTH_METHOD,
323+
JWTClientAuth(),
324+
)
325+
326+
key = OctKey.import_key("client-secret")
327+
claims = {
328+
"iss": "client-id",
329+
"sub": "client-id",
330+
"aud": "https://provider.test",
331+
"exp": int(time.time() + 3600),
332+
"jti": "nonce",
333+
}
334+
client_assertion = jwt.encode({"alg": "HS256"}, claims, key)
335+
rv = test_client.post(
336+
"/oauth/token",
337+
data={
338+
"grant_type": "client_credentials",
339+
"client_assertion_type": JWTBearerClientAssertion.CLIENT_ASSERTION_TYPE,
340+
"client_assertion": client_assertion,
341+
},
342+
)
343+
resp = json.loads(rv.data)
344+
assert "access_token" in resp

tests/flask/test_oauth2/test_jwt_bearer_grant.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,28 @@ def test_missing_assertion_claims(test_client):
187187
)
188188
resp = json.loads(rv.data)
189189
assert "Missing claim" in resp["error_description"]
190+
191+
192+
def test_invalid_audience(test_client, server):
193+
"""RFC 7523 Section 3: The authorization server MUST reject any JWT that
194+
does not contain its own identity as the intended audience."""
195+
196+
class StrictAudienceGrant(JWTBearerGrant):
197+
def get_audiences(self):
198+
return ["https://provider.test/token"]
199+
200+
server._token_grants.clear()
201+
server.register_grant(StrictAudienceGrant)
202+
assertion = StrictAudienceGrant.sign(
203+
"foo",
204+
issuer="client-id",
205+
audience="https://evil.test/token",
206+
subject=None,
207+
header={"alg": "HS256", "kid": "1"},
208+
)
209+
rv = test_client.post(
210+
"/oauth/token",
211+
data={"grant_type": StrictAudienceGrant.GRANT_TYPE, "assertion": assertion},
212+
)
213+
resp = json.loads(rv.data)
214+
assert resp["error"] == "invalid_grant"

0 commit comments

Comments
 (0)