Skip to content

Commit 5eb1b67

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 7301e3a commit 5eb1b67

File tree

8 files changed

+387
-110
lines changed

8 files changed

+387
-110
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Bas van Oostveen
3636
Brian Helba
3737
Carl Schwan
3838
Cihad GUNDOGDU
39+
Craig de Stigter
3940
Cristian Prigoana
4041
Daniel Golding
4142
Daniel 'Vector' Kerr

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: 95 additions & 74 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)
@@ -605,6 +650,55 @@ def save_bearer_token(self, token, request, *args, **kwargs):
605650
with transaction.atomic(using=router.db_for_write(AccessToken)):
606651
return self._save_bearer_token(token, request, *args, **kwargs)
607652

653+
def _check_and_set_request_resource(self, request):
654+
"""
655+
Handle 'resource' parameter from token requests (RFC 8707).
656+
Normalizes request.resource to a JSON-encoded array of URIs.
657+
658+
request.resource will be set to one of:
659+
- Empty string "" (no resources)
660+
- JSON-encoded array '["https://api.example.com"]' or '["https://a.com", "https://b.com"]'
661+
"""
662+
resource = getattr(request, "resource", None) or ""
663+
if isinstance(resource, list):
664+
request.resource = json.dumps(resource)
665+
elif resource and resource.strip():
666+
# It's a non-empty string - check if already JSON-encoded
667+
try:
668+
parsed = json.loads(resource)
669+
except (json.JSONDecodeError, TypeError):
670+
# Not JSON, it's a single URI string from token endpoint POST
671+
request.resource = json.dumps([resource])
672+
else:
673+
assert isinstance(parsed, list)
674+
request.resource = resource
675+
else:
676+
request.resource = ""
677+
678+
if request.grant_type == "authorization_code":
679+
# Handle grant resource narrowing
680+
grant = Grant.objects.filter(code=request.code, application=request.client).first()
681+
grant_resource = grant.resource if (grant and grant.resource) else ""
682+
683+
if request.resource and grant_resource:
684+
# Token request is narrowing the resource scope
685+
# Validate that requested resources are a subset of granted resources
686+
requested_list = json.loads(request.resource)
687+
granted_list = json.loads(grant_resource)
688+
689+
for res in requested_list:
690+
if res not in granted_list:
691+
raise errors.CustomOAuth2Error(
692+
error="invalid_target",
693+
description=(
694+
f"Requested resource '{res}' was not included in the "
695+
"original authorization grant"
696+
),
697+
request=request,
698+
)
699+
elif grant_resource:
700+
request.resource = grant_resource
701+
608702
def _save_bearer_token(self, token, request, *args, **kwargs):
609703
"""
610704
Save access and refresh token.
@@ -617,80 +711,7 @@ def _save_bearer_token(self, token, request, *args, **kwargs):
617711
if "scope" not in token:
618712
raise FatalClientError("Failed to renew access token: missing scope")
619713

620-
# RFC 8707: Extract resource parameter from request
621-
# For authorization_code grant, resource comes from the grant (already JSON-encoded)
622-
# but can be narrowed by the token request
623-
# For other grants, it comes from the request directly and needs encoding
624-
if request.grant_type == "authorization_code":
625-
# Get resource from the grant that was validated
626-
grant = Grant.objects.filter(code=request.code, application=request.client).first()
627-
grant_resource = grant.resource if (grant and grant.resource) else ""
628-
629-
# Check if token request specifies a subset of resources
630-
requested_resource = getattr(request, "resource", None)
631-
if requested_resource:
632-
# RFC 8707: Token request is narrowing the resource scope
633-
# Validate that requested resources are a subset of granted resources
634-
if isinstance(requested_resource, str):
635-
requested_list = [requested_resource]
636-
else:
637-
requested_list = requested_resource
638-
639-
# Parse granted resources
640-
if grant_resource:
641-
try:
642-
granted_list = json.loads(grant_resource)
643-
except (json.JSONDecodeError, TypeError):
644-
granted_list = []
645-
else:
646-
granted_list = []
647-
648-
# Validate that all requested resources were granted
649-
if granted_list: # Only validate if resources were originally granted
650-
for res in requested_list:
651-
if res not in granted_list:
652-
# RFC 8707: Use invalid_target error per spec
653-
raise errors.CustomOAuth2Error(
654-
error="invalid_target",
655-
description=(
656-
f"Requested resource '{res}' was not included in the "
657-
"original authorization grant"
658-
),
659-
request=request,
660-
)
661-
662-
request.resource = json.dumps(requested_list)
663-
elif grant_resource:
664-
# Use all resources from the grant
665-
request.resource = grant_resource
666-
else:
667-
request.resource = ""
668-
else:
669-
# For other grant types (client_credentials, password, implicit, etc.)
670-
# Extract resource from request and JSON-encode it if needed
671-
resource = getattr(request, "resource", None)
672-
if resource:
673-
# Check if already JSON-encoded (from authorization endpoint)
674-
# vs raw from token endpoint
675-
if isinstance(resource, str):
676-
# Could be either a single URI or already JSON-encoded
677-
try:
678-
# Try to parse as JSON
679-
parsed = json.loads(resource)
680-
if isinstance(parsed, list):
681-
# Already JSON-encoded, use as-is
682-
request.resource = resource
683-
else:
684-
# Single URI, needs encoding
685-
request.resource = json.dumps([resource])
686-
except (json.JSONDecodeError, TypeError):
687-
# Not JSON, it's a single URI
688-
request.resource = json.dumps([resource])
689-
else:
690-
# It's a list, encode it
691-
request.resource = json.dumps(resource)
692-
else:
693-
request.resource = ""
714+
self._check_and_set_request_resource(request)
694715

695716
# expires_in is passed to Server on initialization
696717
# custom server class can have logic to override this

oauth2_provider/settings.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@
114114
"RESOURCE_SERVER_AUTH_TOKEN": None,
115115
"RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None,
116116
"RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000,
117-
# Authentication Server Exp Timezone: the time zone use dby Auth Server for generate EXP
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+
),
121+
# Authentication Server Exp Timezone: the time zone used by Auth Server for generate EXP
118122
"AUTHENTICATION_SERVER_EXP_TIME_ZONE": "UTC",
119123
# Whether or not PKCE is required
120124
"PKCE_REQUIRED": True,
@@ -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

tests/test_introspection_auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class TestTokenIntrospectionAuth(TestCase):
111111
def setUpTestData(cls):
112112
cls.validator = OAuth2Validator()
113113
cls.request = mock.MagicMock(wraps=Request)
114+
cls.request.uri = "https://example.com/resource"
114115
cls.resource_server_user = UserModel.objects.create_user(
115116
"resource_server", "[email protected]", "123456"
116117
)

0 commit comments

Comments
 (0)