Skip to content

Commit d9faac2

Browse files
committed
feat: add RFC 8707 Resource Indicators support
Fixes #1102 Implement RFC 8707 to bind access tokens to specific resource servers: - Add resource field to Grant and AccessToken models (migrations included) - Accept & validate resource parameter in authorization/token requests - token view: reject unauthorized resources with `invalid_target` error This change is backward compatible - clients not providing a `resource` in the request will receive a token providing unrestricted access, as before.
1 parent bade920 commit d9faac2

15 files changed

+982
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
* Support for Django 5.2
1111
* Support for Python 3.14 (Django >= 5.2.8)
1212
* #1539 Add device authorization grant support
13+
* RFC 8707 "Resource Indicators" support
14+
- clients can optionally specify `resource` parameter during authorization or access token requests
15+
- Resource binding stored in Grant and AccessToken models
16+
- Token introspection endpoint returns `aud` claim for tokens with resource indicators
1317

1418

1519
<!--

docs/getting_started.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,17 @@ Note the parameters we pass:
302302

303303
This identifies your application, the user is asked to authorize your application to access its resources.
304304

305+
.. note::
306+
**Optional: Binding Tokens to Specific Resources**
307+
308+
You can add a ``resource`` parameter to bind the access token to a specific API endpoint,
309+
following `RFC 8707 <https://rfc-editor.org/rfc/rfc8707.html>`_::
310+
311+
&resource=https://api.example.com
312+
313+
This prevents the token from being used at other resource servers.
314+
See :doc:`resource_server` for details on validating token audiences.
315+
305316
Go ahead and authorize the ``web-app``
306317

307318
.. image:: _images/application-authorize-web-app.png

docs/resource_server.rst

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ Example Response::
4444
"client_id": "oUdofn7rfhRtKWbmhyVk",
4545
"username": "jdoe",
4646
"scope": "read write dolphin",
47-
"exp": 1419356238
47+
"exp": 1419356238,
48+
"aud": ["https://api.example.com", "https://data.example.com"]
4849
}
4950

51+
The ``aud`` field (audience) is included when the token has resource binding per RFC 8707.
52+
Tokens without resource restrictions will not include this field.
53+
5054
Setup the Resource Server
5155
-------------------------
5256
Setup the :term:`Resource Server` like the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`.
@@ -71,3 +75,73 @@ As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authe
7175
For these, use:
7276
``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead
7377
of ``RESOURCE_SERVER_AUTH_TOKEN``.
78+
79+
80+
Token Audience Binding (RFC 8707)
81+
==================================
82+
Django OAuth Toolkit supports `RFC 8707 <https://rfc-editor.org/rfc/rfc8707.html>`_ Resource Indicators,
83+
which allows clients to bind access tokens to specific resource servers. This prevents tokens from being
84+
misused at unintended services.
85+
86+
How It Works
87+
------------
88+
Clients include a ``resource`` parameter in authorization and token requests to specify which
89+
resource servers they want to access:
90+
91+
.. code-block:: http
92+
93+
GET /o/authorize/?client_id=CLIENT_ID
94+
&response_type=code
95+
&redirect_uri=https://client.example.com/callback
96+
&scope=read
97+
&resource=https://api.example.com
98+
99+
The issued access token will be bound to ``https://api.example.com`` and should only be accepted
100+
by that resource server.
101+
102+
Validating Token Audiences
103+
---------------------------
104+
Resource servers should validate that tokens are intended for them using the ``allows_audience()`` method:
105+
106+
.. code-block:: python
107+
108+
from oauth2_provider.models import AccessToken
109+
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]
113+
114+
try:
115+
token = AccessToken.objects.get(token=token_string)
116+
117+
# Check token is not expired
118+
if token.is_expired():
119+
return False
120+
121+
# Check token audience (RFC 8707)
122+
if not token.allows_audience('https://api.example.com'):
123+
return False
124+
125+
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.
131+
132+
You can also retrieve all audiences for a token:
133+
134+
.. code-block:: python
135+
136+
audiences = token.get_audiences() # Returns list of resource URIs
137+
138+
Security Benefits
139+
-----------------
140+
RFC 8707 support provides important security benefits:
141+
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
145+
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.

oauth2_provider/forms.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class AllowForm(forms.Form):
1212
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
1313
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
1414
claims = forms.CharField(required=False, widget=forms.HiddenInput())
15+
resource = forms.CharField(required=False, widget=forms.HiddenInput()) # RFC 8707
1516

1617

1718
class ConfirmLogoutForm(forms.Form):
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.2.9 on 2025-12-02 22:20
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("oauth2_provider", "0013_alter_application_authorization_grant_type_device"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="grant",
14+
name="resource",
15+
field=models.TextField(blank=True, default=""),
16+
),
17+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.2.9 on 2025-12-02 22:28
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("oauth2_provider", "0014_grant_resource"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="accesstoken",
14+
name="resource",
15+
field=models.TextField(blank=True, default=""),
16+
),
17+
]

oauth2_provider/models.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import hashlib
2+
import json
23
import logging
34
import time
45
import uuid
@@ -320,6 +321,7 @@ class AbstractGrant(models.Model):
320321
* :attr:`scope` Required scopes, optional
321322
* :attr:`code_challenge` PKCE code challenge
322323
* :attr:`code_challenge_method` PKCE code challenge transform algorithm
324+
* :attr:`resource` RFC 8707 resource indicator(s), JSON-encoded array of URIs
323325
"""
324326

325327
CODE_CHALLENGE_PLAIN = "plain"
@@ -347,6 +349,9 @@ class AbstractGrant(models.Model):
347349
nonce = models.CharField(max_length=255, blank=True, default="")
348350
claims = models.TextField(blank=True)
349351

352+
# RFC 8707: Resource Indicators - JSON-encoded array of resource URIs
353+
resource = models.TextField(blank=True, default="")
354+
350355
def is_expired(self):
351356
"""
352357
Check token expiration with timezone awareness
@@ -384,6 +389,7 @@ class AbstractAccessToken(models.Model):
384389
* :attr:`application` Application instance
385390
* :attr:`expires` Date and time of token expiration, in DateTime format
386391
* :attr:`scope` Allowed scopes
392+
* :attr:`resource` RFC 8707 resource indicator(s) - JSON-encoded array of URIs
387393
"""
388394

389395
id = models.BigAutoField(primary_key=True)
@@ -422,9 +428,13 @@ class AbstractAccessToken(models.Model):
422428
blank=True,
423429
null=True,
424430
)
431+
425432
expires = models.DateTimeField()
426433
scope = models.TextField(blank=True)
427434

435+
# RFC 8707: Resource Indicators - JSON-encoded array of resource URIs
436+
resource = models.TextField(blank=True, default="")
437+
428438
created = models.DateTimeField(auto_now_add=True)
429439
updated = models.DateTimeField(auto_now=True)
430440

@@ -445,6 +455,43 @@ def is_expired(self):
445455

446456
return timezone.now() >= self.expires
447457

458+
def get_audiences(self):
459+
"""
460+
Get list of audience URIs from the resource field.
461+
462+
RFC 8707: Returns the resource indicators as a list of URIs.
463+
The resource field is stored as a JSON-encoded array.
464+
465+
:return: List of audience URI strings. Empty list means the token is not
466+
restricted to specific resource servers (unrestricted access).
467+
"""
468+
if not self.resource:
469+
return []
470+
471+
try:
472+
return json.loads(self.resource)
473+
except (json.JSONDecodeError, TypeError):
474+
return []
475+
476+
def allows_audience(self, audience_uri):
477+
"""
478+
Check if the token is authorized for the given audience URI.
479+
480+
RFC 8707: Validates that the token includes the specified resource indicator.
481+
If the token has no resource indicators (empty list), it is unrestricted and
482+
allows any audience (backward compatibility).
483+
484+
:param audience_uri: The URI of the resource server to check
485+
:return: True if the token is authorized for this audience, False otherwise
486+
"""
487+
audiences = self.get_audiences()
488+
489+
# Empty list means unrestricted access (backward compatibility)
490+
if not audiences:
491+
return True
492+
493+
return audience_uri in audiences
494+
448495
def allow_scopes(self, scopes):
449496
"""
450497
Check if the token allows the provided scopes

oauth2_provider/oauth2_validators.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,16 @@ def _load_application(self, client_id, request):
217217
if request.client:
218218
# check for cached client, to save the db hit if this has already been loaded
219219
if not isinstance(request.client, Application):
220-
# resetting request.client (client_id=%r): not an Application, something else set request.client erroneously
220+
# resetting request.client (client_id=%r): not an Application, something else set
221+
# request.client erroneously
221222
request.client = None
222223
elif request.client.client_id != client_id:
223-
# resetting request.client (client_id=%r): request.client.client_id does not match the given client_id
224+
# resetting request.client (client_id=%r): request.client.client_id does not match
225+
# the given client_id
224226
request.client = None
225227
elif not request.client.is_usable(request):
226-
# resetting request.client (client_id=%r): request.client is a valid Application, but is not usable
228+
# resetting request.client (client_id=%r): request.client is a valid Application,
229+
# but is not usable
227230
request.client = None
228231
else:
229232
# request.client is a valid Application, reusing it
@@ -614,6 +617,81 @@ def _save_bearer_token(self, token, request, *args, **kwargs):
614617
if "scope" not in token:
615618
raise FatalClientError("Failed to renew access token: missing scope")
616619

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 = ""
694+
617695
# expires_in is passed to Server on initialization
618696
# custom server class can have logic to override this
619697
expires = timezone.now() + timedelta(
@@ -717,11 +795,22 @@ def _create_access_token(self, expires, request, token, source_refresh_token=Non
717795
id_token=id_token,
718796
application=request.client,
719797
source_refresh_token=source_refresh_token,
798+
resource=getattr(request, "resource", ""), # RFC 8707
720799
)
721800

722801
def _create_authorization_code(self, request, code, expires=None):
723802
if not expires:
724803
expires = timezone.now() + timedelta(seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS)
804+
805+
# RFC 8707: Extract resource parameter
806+
# The resource parameter is already JSON-encoded from the view
807+
resource = getattr(request, "resource", None)
808+
if resource:
809+
# Resource is already JSON-encoded from the view, just use it
810+
resource_json = resource
811+
else:
812+
resource_json = ""
813+
725814
return Grant.objects.create(
726815
application=request.client,
727816
user=request.user,
@@ -733,6 +822,7 @@ def _create_authorization_code(self, request, code, expires=None):
733822
code_challenge_method=request.code_challenge_method or "",
734823
nonce=request.nonce or "",
735824
claims=json.dumps(request.claims or {}),
825+
resource=resource_json,
736826
)
737827

738828
def _create_refresh_token(self, request, refresh_token_code, access_token, previous_refresh_token):

0 commit comments

Comments
 (0)