Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Bas van Oostveen
Brian Helba
Carl Schwan
Cihad GUNDOGDU
Craig de Stigter
Cristian Prigoana
Daniel Golding
Daniel 'Vector' Kerr
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Support for Django 5.2
* Support for Python 3.14 (Django >= 5.2.8)
* #1539 Add device authorization grant support
* RFC 8707 "Resource Indicators" support
- clients can optionally specify `resource` parameter during authorization or access token requests
- Resource binding stored in Grant and AccessToken models
- Token introspection endpoint returns `aud` claim for tokens with resource indicators


<!--
Expand Down
11 changes: 11 additions & 0 deletions docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,17 @@ Note the parameters we pass:

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

.. note::
**Optional: Binding Tokens to Specific Resources**

You can add a ``resource`` parameter to bind the access token to a specific API endpoint,
following `RFC 8707 <https://rfc-editor.org/rfc/rfc8707.html>`_::

&resource=https://api.example.com

This prevents the token from being used at other resource servers.
See :doc:`resource_server` for details on validating token audiences.

Go ahead and authorize the ``web-app``

.. image:: _images/application-authorize-web-app.png
Expand Down
82 changes: 81 additions & 1 deletion docs/resource_server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,13 @@ Example Response::
"client_id": "oUdofn7rfhRtKWbmhyVk",
"username": "jdoe",
"scope": "read write dolphin",
"exp": 1419356238
"exp": 1419356238,
"aud": ["https://api.example.com", "https://data.example.com"]
}

The ``aud`` field (audience) is included when the token has resource binding per RFC 8707.
Tokens without resource restrictions will not include this field.

Setup the Resource Server
-------------------------
Setup the :term:`Resource Server` like the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`.
Expand All @@ -71,3 +75,79 @@ As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authe
For these, use:
``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead
of ``RESOURCE_SERVER_AUTH_TOKEN``.


Token Audience Binding (RFC 8707)
==================================
Django OAuth Toolkit supports `RFC 8707 <https://rfc-editor.org/rfc/rfc8707.html>`_ Resource Indicators,
which allows clients to bind access tokens to specific resource servers. This prevents tokens from being
misused at unintended services.

How It Works
------------
Clients include a ``resource`` parameter in authorization and token requests to specify which
resource servers they want to access:

.. code-block:: http

GET /o/authorize/?client_id=CLIENT_ID
&response_type=code
&redirect_uri=https://client.example.com/callback
&scope=read
&resource=https://api.example.com

The issued access token will be bound to ``https://api.example.com`` and should only be accepted
by that resource server.

Validating Token Audiences
---------------------------
Django OAuth Toolkit automatically validates token audiences when using ``validate_bearer_token()``.
By default, it uses **prefix-based matching** where the token's audience URI acts as a base URI.

Automatic Validation
~~~~~~~~~~~~~~~~~~~~
When a resource server validates a bearer token, DOT automatically checks if the request URI
matches the token's audience claim:

.. code-block:: python

# In your Django REST Framework view or OAuth-protected endpoint
# DOT automatically validates audience - no manual check needed!

@require_oauth(['read'])
def my_api_view(request):
# If this executes, the token is valid AND authorized for this resource
return Response({'data': 'secret'})

The default validator uses **prefix matching**: a token with audience ``https://api.example.com/v1``
will be accepted for requests to ``https://api.example.com/v1/users`` but rejected for
``https://api.example.com/v2/users``.

Custom Validation Logic
~~~~~~~~~~~~~~~~~~~~~~~~
You can customize the validation logic by providing your own validator function:

.. code-block:: python

# myapp/validators.py
def exact_match_validator(request_uri, audiences):
"""Custom validator that requires exact audience match."""
# No audiences = unrestricted token (backward compat)
if not audiences:
return True

# Require exact match
return request_uri in audiences

# settings.py
OAUTH2_PROVIDER = {
'RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR': 'myapp.validators.exact_match_validator',
}

To disable automatic validation entirely, set the validator to ``None``:

.. code-block:: python

OAUTH2_PROVIDER = {
'RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR': None,
}
27 changes: 27 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,33 @@ The number of seconds an authorization token received from the introspection end
If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time
will be used.

RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``"oauth2_provider.oauth2_validators.validate_resource_as_url_prefix"``

A callable that validates whether an access token's audience (RFC 8707 resource indicators) matches
a request URI. The callable receives ``(request_uri, audiences)`` where ``request_uri`` is a string
and ``audiences`` is a list of audience URIs from the token. Returns ``True`` if the token
is authorized for the request, ``False`` otherwise.

The default validator uses **prefix matching**: a token with audience ``https://api.example.com/v1``
will accept requests to ``https://api.example.com/v1/users`` but reject ``https://api.example.com/v2``.

To use exact matching instead:

.. code-block:: python

def exact_match_validator(request_uri, audiences):
if not audiences:
return True # Unrestricted token
return request_uri in audiences

OAUTH2_PROVIDER = {
'RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR': 'myapp.validators.exact_match_validator',
}

Set to ``None`` to disable automatic audience validation entirely.

AUTHENTICATION_SERVER_EXP_TIME_ZONE
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The exp (expiration date) of Access Tokens must be defined in UTC (Unix Timestamp). Although its wrong, sometimes
Expand Down
1 change: 1 addition & 0 deletions oauth2_provider/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class AllowForm(forms.Form):
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
claims = forms.CharField(required=False, widget=forms.HiddenInput())
resource = forms.CharField(required=False, widget=forms.HiddenInput()) # RFC 8707


class ConfirmLogoutForm(forms.Form):
Expand Down
17 changes: 17 additions & 0 deletions oauth2_provider/migrations/0014_grant_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.9 on 2025-12-02 22:20

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("oauth2_provider", "0013_alter_application_authorization_grant_type_device"),
]

operations = [
migrations.AddField(
model_name="grant",
name="resource",
field=models.TextField(blank=True, default=""),
),
]
17 changes: 17 additions & 0 deletions oauth2_provider/migrations/0015_accesstoken_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.9 on 2025-12-02 22:28

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("oauth2_provider", "0014_grant_resource"),
]

operations = [
migrations.AddField(
model_name="accesstoken",
name="resource",
field=models.TextField(blank=True, default=""),
),
]
52 changes: 52 additions & 0 deletions oauth2_provider/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import hashlib
import json
import logging
import time
import uuid
Expand Down Expand Up @@ -320,6 +321,7 @@ class AbstractGrant(models.Model):
* :attr:`scope` Required scopes, optional
* :attr:`code_challenge` PKCE code challenge
* :attr:`code_challenge_method` PKCE code challenge transform algorithm
* :attr:`resource` RFC 8707 resource indicator(s), JSON-encoded array of URIs
"""

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

# RFC 8707: Resource Indicators - JSON-encoded array of resource URIs
resource = models.TextField(blank=True, default="")

def is_expired(self):
"""
Check token expiration with timezone awareness
Expand Down Expand Up @@ -384,6 +389,7 @@ class AbstractAccessToken(models.Model):
* :attr:`application` Application instance
* :attr:`expires` Date and time of token expiration, in DateTime format
* :attr:`scope` Allowed scopes
* :attr:`resource` RFC 8707 resource indicator(s) - JSON-encoded array of URIs
"""

id = models.BigAutoField(primary_key=True)
Expand Down Expand Up @@ -422,9 +428,13 @@ class AbstractAccessToken(models.Model):
blank=True,
null=True,
)

expires = models.DateTimeField()
scope = models.TextField(blank=True)

# RFC 8707: Resource Indicators - JSON-encoded array of resource URIs
resource = models.TextField(blank=True, default="")

created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)

Expand All @@ -445,6 +455,48 @@ def is_expired(self):

return timezone.now() >= self.expires

def get_audiences(self):
"""
Get list of audience URIs from the resource field.

RFC 8707: Returns the resource indicators as a list of URIs.
The resource field is stored as a JSON-encoded array.

:return: List of audience URI strings. Empty list means the token is not
restricted to specific resource servers (unrestricted access).
"""
if not self.resource:
return []

try:
return json.loads(self.resource)
except (json.JSONDecodeError, TypeError):
return []

def allows_audience(self, audience_uri):
"""
Check if the token is authorized for the given audience URI.

RFC 8707: Validates that the token includes the specified resource indicator
using the configured resource validator (RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR).

If the token has no resource indicators (empty list), it is unrestricted and
allows any audience (backward compatibility).

:param audience_uri: The URI of the resource server to check
:return: True if the token is authorized for this audience, False otherwise
"""
from .settings import oauth2_settings

audiences = self.get_audiences()
resource_validator = oauth2_settings.RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR

if resource_validator:
return resource_validator(audience_uri, audiences)
else:
# No validator configured - allow everything (backward compat)
return True

def allow_scopes(self, scopes):
"""
Check if the token allows the provided scopes
Expand Down
Loading