Skip to content

Commit 7fbbeac

Browse files
committed
feat: add pluggable resource validator for resource servers
This validates the token audience (if there is one) against the request URI. Specs are unclear on *exactly* how this validation should be done. Most implementations seem to require an *exact* match between the token `aud` and the resource server identifier (Auth0, Okta, AWS Cognito) This is a pain because it requires some extra configuration on the resource server to define what exactly is 'the resource server identifier' - is it the host name, a hardcoded identifier or some subresource path? I have opted for a pluggable system so the project can define what approach to use, but I've chosen a default approach which I hope is more flexible and requires no configuration - we match the token audience claim to the request using a url prefix. i.e. when requesting `https://example.com/users/foo`, a token with `aud: ["https://example.com/users"]` would match. This approach is implemented by Ory Hydra: https://www.ory.com/docs/hydra/guides/audiences#audience-in-authorization-code-implicit-and-hybrid-flows I also found a ticket requesting this feature for Ory Oathkeeper: ory/oathkeeper#656 Changes: - Add `validate_resource_as_url_prefix()` with URL prefix matching logic - New setting: `RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR` (pluggable) - Use the validator function in `validate_bearer_token()` when the token has an audience claim
1 parent d9faac2 commit 7fbbeac

File tree

6 files changed

+334
-35
lines changed

6 files changed

+334
-35
lines changed

docs/resource_server.rst

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -101,47 +101,53 @@ by that resource server.
101101

102102
Validating Token Audiences
103103
---------------------------
104-
Resource servers should validate that tokens are intended for them using the ``allows_audience()`` method:
104+
Django OAuth Toolkit automatically validates token audiences when using ``validate_bearer_token()``.
105+
By default, it uses **prefix-based matching** where the token's audience URI acts as a base URI.
106+
107+
Automatic Validation
108+
~~~~~~~~~~~~~~~~~~~~
109+
When a resource server validates a bearer token, DOT automatically checks if the request URI
110+
matches the token's audience claim:
105111

106112
.. code-block:: python
107113
108-
from oauth2_provider.models import AccessToken
114+
# In your Django REST Framework view or OAuth-protected endpoint
115+
# DOT automatically validates audience - no manual check needed!
109116
110-
def validate_request(request):
111-
"""Validate that the token is intended for this resource server."""
112-
token_string = request.META.get('HTTP_AUTHORIZATION', '').split(' ')[-1]
117+
@require_oauth(['read'])
118+
def my_api_view(request):
119+
# If this executes, the token is valid AND authorized for this resource
120+
return Response({'data': 'secret'})
113121
114-
try:
115-
token = AccessToken.objects.get(token=token_string)
122+
The default validator uses **prefix matching**: a token with audience ``https://api.example.com/v1``
123+
will be accepted for requests to ``https://api.example.com/v1/users`` but rejected for
124+
``https://api.example.com/v2/users``.
116125

117-
# Check token is not expired
118-
if token.is_expired():
119-
return False
126+
Custom Validation Logic
127+
~~~~~~~~~~~~~~~~~~~~~~~~
128+
You can customize the validation logic by providing your own validator function:
120129

121-
# Check token audience (RFC 8707)
122-
if not token.allows_audience('https://api.example.com'):
123-
return False
130+
.. code-block:: python
124131
132+
# myapp/validators.py
133+
def exact_match_validator(request_uri, audiences):
134+
"""Custom validator that requires exact audience match."""
135+
# No audiences = unrestricted token (backward compat)
136+
if not audiences:
125137
return True
126-
except AccessToken.DoesNotExist:
127-
return False
128-
129-
The ``allows_audience()`` method checks if the token's resource field includes the specified URI.
130-
Tokens without resource restrictions (legacy tokens) will allow any audience for backward compatibility.
131138
132-
You can also retrieve all audiences for a token:
133-
134-
.. code-block:: python
139+
# Require exact match
140+
return request_uri in audiences
135141
136-
audiences = token.get_audiences() # Returns list of resource URIs
142+
# settings.py
143+
OAUTH2_PROVIDER = {
144+
'RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR': 'myapp.validators.exact_match_validator',
145+
}
137146
138-
Security Benefits
139-
-----------------
140-
RFC 8707 support provides important security benefits:
147+
To disable automatic validation entirely, set the validator to ``None``:
141148

142-
* **Prevents privilege escalation**: Tokens can only be used at authorized resource servers
143-
* **Defense in depth**: Even if a token is stolen, it cannot be used at unintended services
144-
* **Explicit authorization**: Users see which specific resources will be accessed
149+
.. code-block:: python
145150
146-
The authorization server validates that token requests only specify resources from the original
147-
authorization, rejecting attempts to escalate privileges with an ``invalid_target`` error.
151+
OAUTH2_PROVIDER = {
152+
'RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR': None,
153+
}

docs/settings.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,33 @@ The number of seconds an authorization token received from the introspection end
288288
If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time
289289
will be used.
290290

291+
RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR
292+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
293+
Default: ``"oauth2_provider.oauth2_validators.validate_resource_as_url_prefix"``
294+
295+
A callable that validates whether an access token's audience (RFC 8707 resource indicators) matches
296+
a request URI. The callable receives ``(request_uri, audiences)`` where ``request_uri`` is a string
297+
and ``audiences`` is a list of audience URIs from the token. Returns ``True`` if the token
298+
is authorized for the request, ``False`` otherwise.
299+
300+
The default validator uses **prefix matching**: a token with audience ``https://api.example.com/v1``
301+
will accept requests to ``https://api.example.com/v1/users`` but reject ``https://api.example.com/v2``.
302+
303+
To use exact matching instead:
304+
305+
.. code-block:: python
306+
307+
def exact_match_validator(request_uri, audiences):
308+
if not audiences:
309+
return True # Unrestricted token
310+
return request_uri in audiences
311+
312+
OAUTH2_PROVIDER = {
313+
'RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR': 'myapp.validators.exact_match_validator',
314+
}
315+
316+
Set to ``None`` to disable automatic audience validation entirely.
317+
291318
AUTHENTICATION_SERVER_EXP_TIME_ZONE
292319
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
293320
The exp (expiration date) of Access Tokens must be defined in UTC (Unix Timestamp). Although its wrong, sometimes

oauth2_provider/models.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,21 +477,26 @@ def allows_audience(self, audience_uri):
477477
"""
478478
Check if the token is authorized for the given audience URI.
479479
480-
RFC 8707: Validates that the token includes the specified resource indicator.
480+
RFC 8707: Validates that the token includes the specified resource indicator
481+
using the configured resource validator (RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR).
482+
481483
If the token has no resource indicators (empty list), it is unrestricted and
482484
allows any audience (backward compatibility).
483485
484486
:param audience_uri: The URI of the resource server to check
485487
:return: True if the token is authorized for this audience, False otherwise
486488
"""
489+
from .settings import oauth2_settings
490+
487491
audiences = self.get_audiences()
492+
resource_validator = oauth2_settings.RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR
488493

489-
# Empty list means unrestricted access (backward compatibility)
490-
if not audiences:
494+
if resource_validator:
495+
return resource_validator(audience_uri, audiences)
496+
else:
497+
# No validator configured - allow everything (backward compat)
491498
return True
492499

493-
return audience_uri in audiences
494-
495500
def allow_scopes(self, scopes):
496501
"""
497502
Check if the token allows the provided scopes

oauth2_provider/oauth2_validators.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,43 @@
4141
from .utils import get_timezone
4242

4343

44+
def validate_resource_as_url_prefix(request_uri, audiences):
45+
"""
46+
Default resource validator using URL prefix matching (RFC 8707).
47+
48+
Validates that the request URI matches one of the token's audience claims
49+
using prefix matching. The audience URI acts as a base URI that the request
50+
must start with.
51+
52+
Examples:
53+
- Token audience: "https://api.example.com/foo"
54+
- Matches: "https://api.example.com/foo"
55+
- Matches: "https://api.example.com/foo/"
56+
- Matches: "https://api.example.com/foo/bar"
57+
- Rejects: "https://other.example.com/foo/bar"
58+
- Rejects: "https://api.example.com/bar"
59+
- Rejects: "https://api.example.com/food-blog"
60+
61+
:param request_uri: String URI of the current request (without query string)
62+
:param audiences: List of audience URI strings from token
63+
:return: True if token is valid for this request, False otherwise
64+
"""
65+
# No audiences = unrestricted token (backward compatibility)
66+
if not audiences:
67+
return True
68+
69+
request_normalized = request_uri.rstrip("/") + "/"
70+
71+
# Check if request URI starts with any of the audience URIs
72+
for audience in audiences:
73+
audience_normalized = audience.rstrip("/") + "/"
74+
75+
if request_normalized.startswith(audience_normalized):
76+
return True
77+
78+
return False
79+
80+
4481
log = logging.getLogger("oauth2_provider")
4582

4683
GRANT_TYPE_MAPPING = {
@@ -481,6 +518,14 @@ def validate_bearer_token(self, token, scopes, request):
481518
)
482519

483520
if access_token and access_token.is_valid(scopes):
521+
# RFC 8707: Validate token audience against request resource
522+
# Use request.uri which is the full URI from the oauthlib Request object
523+
request_uri = request.uri.split("?")[0]
524+
if not access_token.allows_audience(request_uri):
525+
# Token is valid but not authorized for this resource
526+
self._set_oauth2_error_on_request(request, access_token, scopes)
527+
return False
528+
484529
request.client = access_token.application
485530
request.user = access_token.user
486531
request.scopes = list(access_token.scopes)

oauth2_provider/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@
114114
"RESOURCE_SERVER_AUTH_TOKEN": None,
115115
"RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None,
116116
"RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000,
117+
# Resource Server Token Resource Validator (RFC 8707)
118+
"RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR": (
119+
"oauth2_provider.oauth2_validators.validate_resource_as_url_prefix"
120+
),
117121
# Authentication Server Exp Timezone: the time zone use dby Auth Server for generate EXP
118122
"AUTHENTICATION_SERVER_EXP_TIME_ZONE": "UTC",
119123
# Whether or not PKCE is required
@@ -154,6 +158,7 @@
154158
"GRANT_ADMIN_CLASS",
155159
"ID_TOKEN_ADMIN_CLASS",
156160
"REFRESH_TOKEN_ADMIN_CLASS",
161+
"RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR",
157162
)
158163

159164

0 commit comments

Comments
 (0)