Skip to content

Commit bbd4c6b

Browse files
authored
Merge branch 'master' into discussions
2 parents 9ab9252 + 9561866 commit bbd4c6b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+399
-86
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Rodney Richardson
102102
Rustem Saiargaliev
103103
Rustem Saiargaliev
104104
Sandro Rodrigues
105+
Sean 'Shaleh' Perry
105106
Shaheed Haque
106107
Shaun Stanworth
107108
Sayyid Hamid Mahdavi

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
* Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims
2424
* Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation.
2525
* #1446 use generic models pk instead of id.
26-
* Bump oauthlib version to 3.2.0 and above
26+
* Transactions wrapping writes of the Tokens now rely on Django's database routers to determine the correct
27+
database to use instead of assuming that 'default' is the correct one.
28+
* Bump oauthlib version to 3.2.2 and above
2729
* Update the OAuth2Validator's invalidate_authorization_code method to return an InvalidGrantError if the associated grant does not exist.
2830

2931
### Deprecated

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Requirements
4545

4646
* Python 3.8+
4747
* Django 4.2, 5.0 or 5.1
48-
* oauthlib 3.2+
48+
* oauthlib 3.2.2+
4949

5050
Installation
5151
------------

docs/advanced_topics.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ That's all, now Django OAuth Toolkit will use your model wherever an Application
6565
is because of the way Django currently implements swappable models.
6666
See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details.
6767

68+
Configuring multiple databases
69+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
70+
71+
There is no requirement that the tokens are stored in the default database or that there is a
72+
default database provided the database routers can determine the correct Token locations. Because the
73+
Tokens have foreign keys to the ``User`` model, you likely want to keep the tokens in the same database
74+
as your User model. It is also important that all of the tokens are stored in the same database.
75+
This could happen for instance if one of the Tokens is locally overridden and stored in a separate database.
76+
The reason for this is transactions will only be made for the database where AccessToken is stored
77+
even when writing to RefreshToken or other tokens.
78+
6879
Multiple Grants
6980
~~~~~~~~~~~~~~~
7081

docs/contributing.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,26 @@ Open :file:`mycoverage/index.html` in your browser and you can see a coverage su
252252

253253
There's no need to wait for Codecov to complain after you submit your PR.
254254

255+
The tests are generic and written to work with both single database and multiple database configurations. tox will run
256+
tests both ways. You can see the configurations used in tests/settings.py and tests/multi_db_settings.py.
257+
258+
When there are multiple databases defined, Django tests will not work unless they are told which database(s) to work with.
259+
For test writers this means any test must either:
260+
- instead of Django's TestCase or TransactionTestCase use the versions of those
261+
classes defined in tests/common_testing.py
262+
- when using pytest's `django_db` mark, define it like this:
263+
`@pytest.mark.django_db(databases=retrieve_current_databases())`
264+
265+
In test code, anywhere the database is referenced the Django router needs to be used exactly like the package's code.
266+
267+
.. code-block:: python
268+
269+
token_database = router.db_for_write(AccessToken)
270+
with self.assertNumQueries(1, using=token_database):
271+
# call something using the database
272+
273+
Without the 'using' option, this test fails in the multiple database scenario because 'default' will be used instead.
274+
255275
Code conventions matter
256276
-----------------------
257277

docs/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Requirements
2323

2424
* Python 3.8+
2525
* Django 4.2, 5.0 or 5.1
26-
* oauthlib 3.2+
26+
* oauthlib 3.2.2+
2727

2828
Index
2929
=====

docs/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
Django
2-
oauthlib>=3.2.0
2+
oauthlib>=3.2.2
33
m2r>=0.2.1
44
mistune<2
55
sphinx==7.2.6

oauth2_provider/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@
44
class DOTConfig(AppConfig):
55
name = "oauth2_provider"
66
verbose_name = "Django OAuth Toolkit"
7+
8+
def ready(self):
9+
# Import checks to ensure they run.
10+
from . import checks # noqa: F401

oauth2_provider/checks.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from django.apps import apps
2+
from django.core import checks
3+
from django.db import router
4+
5+
from .settings import oauth2_settings
6+
7+
8+
@checks.register(checks.Tags.database)
9+
def validate_token_configuration(app_configs, **kwargs):
10+
databases = set(
11+
router.db_for_write(apps.get_model(model))
12+
for model in (
13+
oauth2_settings.ACCESS_TOKEN_MODEL,
14+
oauth2_settings.ID_TOKEN_MODEL,
15+
oauth2_settings.REFRESH_TOKEN_MODEL,
16+
)
17+
)
18+
19+
# This is highly unlikely, but let's warn people just in case it does.
20+
# If the tokens were allowed to be in different databases this would require all
21+
# writes to have a transaction around each database. Instead, let's enforce that
22+
# they all live together in one database.
23+
# The tokens are not required to live in the default database provided the Django
24+
# routers know the correct database for them.
25+
if len(databases) > 1:
26+
return [checks.Error("The token models are expected to be stored in the same database.")]
27+
28+
return []

oauth2_provider/models.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
import logging
33
import time
44
import uuid
5+
from contextlib import suppress
56
from datetime import timedelta
67
from urllib.parse import parse_qsl, urlparse
78

89
from django.apps import apps
910
from django.conf import settings
1011
from django.contrib.auth.hashers import identify_hasher, make_password
1112
from django.core.exceptions import ImproperlyConfigured
12-
from django.db import models, transaction
13+
from django.db import models, router, transaction
1314
from django.urls import reverse
1415
from django.utils import timezone
1516
from django.utils.translation import gettext_lazy as _
@@ -512,17 +513,19 @@ def revoke(self):
512513
Mark this refresh token revoked and revoke related access token
513514
"""
514515
access_token_model = get_access_token_model()
516+
access_token_database = router.db_for_write(access_token_model)
515517
refresh_token_model = get_refresh_token_model()
516-
with transaction.atomic():
518+
519+
# Use the access_token_database instead of making the assumption it is in 'default'.
520+
with transaction.atomic(using=access_token_database):
517521
token = refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True)
518522
if not token:
519523
return
520524
self = list(token)[0]
521525

522-
try:
523-
access_token_model.objects.get(pk=self.access_token_id).revoke()
524-
except access_token_model.DoesNotExist:
525-
pass
526+
with suppress(access_token_model.DoesNotExist):
527+
access_token_model.objects.get(id=self.access_token_id).revoke()
528+
526529
self.access_token = None
527530
self.revoked = timezone.now()
528531
self.save()
@@ -655,7 +658,7 @@ def get_access_token_model():
655658

656659

657660
def get_id_token_model():
658-
"""Return the AccessToken model that is active in this project."""
661+
"""Return the IDToken model that is active in this project."""
659662
return apps.get_model(oauth2_settings.ID_TOKEN_MODEL)
660663

661664

oauth2_provider/oauth2_validators.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from django.contrib.auth import authenticate, get_user_model
1616
from django.contrib.auth.hashers import check_password, identify_hasher
1717
from django.core.exceptions import ObjectDoesNotExist
18-
from django.db import transaction
18+
from django.db import router, transaction
1919
from django.http import HttpRequest
2020
from django.utils import dateformat, timezone
2121
from django.utils.crypto import constant_time_compare
@@ -567,11 +567,23 @@ def rotate_refresh_token(self, request):
567567
"""
568568
return oauth2_settings.ROTATE_REFRESH_TOKEN
569569

570-
@transaction.atomic
571570
def save_bearer_token(self, token, request, *args, **kwargs):
572571
"""
573-
Save access and refresh token, If refresh token is issued, remove or
574-
reuse old refresh token as in rfc:`6`
572+
Save access and refresh token.
573+
574+
Override _save_bearer_token and not this function when adding custom logic
575+
for the storing of these token. This allows the transaction logic to be
576+
separate from the token handling.
577+
"""
578+
# Use the AccessToken's database instead of making the assumption it is in 'default'.
579+
with transaction.atomic(using=router.db_for_write(AccessToken)):
580+
return self._save_bearer_token(token, request, *args, **kwargs)
581+
582+
def _save_bearer_token(self, token, request, *args, **kwargs):
583+
"""
584+
Save access and refresh token.
585+
586+
If refresh token is issued, remove or reuse old refresh token as in rfc:`6`.
575587
576588
@see: https://rfc-editor.org/rfc/rfc6749.html#section-6
577589
"""
@@ -793,7 +805,6 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs
793805

794806
return rt.application == client
795807

796-
@transaction.atomic
797808
def _save_id_token(self, jti, request, expires, *args, **kwargs):
798809
scopes = request.scope or " ".join(request.scopes)
799810

@@ -894,7 +905,9 @@ def finalize_id_token(self, id_token, token, token_handler, request):
894905
claims=json.dumps(id_token, default=str),
895906
)
896907
jwt_token.make_signed_token(request.client.jwk_key)
897-
id_token = self._save_id_token(id_token["jti"], request, expiration_time)
908+
# Use the IDToken's database instead of making the assumption it is in 'default'.
909+
with transaction.atomic(using=router.db_for_write(IDToken)):
910+
id_token = self._save_id_token(id_token["jti"], request, expiration_time)
898911
# this is needed by django rest framework
899912
request.access_token = id_token
900913
request.id_token = id_token

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ classifiers = [
3636
dependencies = [
3737
"django >= 4.2",
3838
"requests >= 2.13.0",
39-
"oauthlib >= 3.2.0",
39+
"oauthlib >= 3.2.2",
4040
"jwcrypto >= 0.8.0",
4141
]
4242

tests/common_testing.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django.conf import settings
2+
from django.test import TestCase as DjangoTestCase
3+
from django.test import TransactionTestCase as DjangoTransactionTestCase
4+
5+
6+
# The multiple database scenario setup for these tests purposefully defines 'default' as
7+
# an empty database in order to catch any assumptions in this package about database names
8+
# and in particular to ensure there is no assumption that 'default' is a valid database.
9+
#
10+
# When there are multiple databases defined, Django tests will not work unless they are
11+
# told which database(s) to work with.
12+
13+
14+
def retrieve_current_databases():
15+
if len(settings.DATABASES) > 1:
16+
return [name for name in settings.DATABASES if name != "default"]
17+
else:
18+
return ["default"]
19+
20+
21+
class OAuth2ProviderBase:
22+
@classmethod
23+
def setUpClass(cls):
24+
cls.databases = retrieve_current_databases()
25+
super().setUpClass()
26+
27+
28+
class OAuth2ProviderTestCase(OAuth2ProviderBase, DjangoTestCase):
29+
"""Place holder to allow overriding behaviors."""
30+
31+
32+
class OAuth2ProviderTransactionTestCase(OAuth2ProviderBase, DjangoTransactionTestCase):
33+
"""Place holder to allow overriding behaviors."""

tests/db_router.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
apps_in_beta = {"some_other_app", "this_one_too"}
2+
3+
# These are bare minimum routers to fake the scenario where there is actually a
4+
# decision around where an application's models might live.
5+
6+
7+
class AlphaRouter:
8+
# alpha is where the core Django models are stored including user. To keep things
9+
# simple this is where the oauth2 provider models are stored as well because they
10+
# have a foreign key to User.
11+
12+
def db_for_read(self, model, **hints):
13+
if model._meta.app_label not in apps_in_beta:
14+
return "alpha"
15+
return None
16+
17+
def db_for_write(self, model, **hints):
18+
if model._meta.app_label not in apps_in_beta:
19+
return "alpha"
20+
return None
21+
22+
def allow_relation(self, obj1, obj2, **hints):
23+
if obj1._state.db == "alpha" and obj2._state.db == "alpha":
24+
return True
25+
return None
26+
27+
def allow_migrate(self, db, app_label, model_name=None, **hints):
28+
if app_label not in apps_in_beta:
29+
return db == "alpha"
30+
return None
31+
32+
33+
class BetaRouter:
34+
def db_for_read(self, model, **hints):
35+
if model._meta.app_label in apps_in_beta:
36+
return "beta"
37+
return None
38+
39+
def db_for_write(self, model, **hints):
40+
if model._meta.app_label in apps_in_beta:
41+
return "beta"
42+
return None
43+
44+
def allow_relation(self, obj1, obj2, **hints):
45+
if obj1._state.db == "beta" and obj2._state.db == "beta":
46+
return True
47+
return None
48+
49+
def allow_migrate(self, db, app_label, model_name=None, **hints):
50+
if app_label in apps_in_beta:
51+
return db == "beta"
52+
53+
54+
class CrossDatabaseRouter:
55+
# alpha is where the core Django models are stored including user. To keep things
56+
# simple this is where the oauth2 provider models are stored as well because they
57+
# have a foreign key to User.
58+
def db_for_read(self, model, **hints):
59+
if model._meta.model_name == "accesstoken":
60+
return "beta"
61+
return None
62+
63+
def db_for_write(self, model, **hints):
64+
if model._meta.model_name == "accesstoken":
65+
return "beta"
66+
return None
67+
68+
def allow_relation(self, obj1, obj2, **hints):
69+
if obj1._state.db == "beta" and obj2._state.db == "beta":
70+
return True
71+
return None
72+
73+
def allow_migrate(self, db, app_label, model_name=None, **hints):
74+
if model_name == "accesstoken":
75+
return db == "beta"
76+
return None
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 3.2.25 on 2024-08-08 22:47
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
('tests', '0006_basetestapplication_token_family'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='LocalIDToken',
20+
fields=[
21+
('id', models.BigAutoField(primary_key=True, serialize=False)),
22+
('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')),
23+
('expires', models.DateTimeField()),
24+
('scope', models.TextField(blank=True)),
25+
('created', models.DateTimeField(auto_now_add=True)),
26+
('updated', models.DateTimeField(auto_now=True)),
27+
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
28+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_localidtoken', to=settings.AUTH_USER_MODEL)),
29+
],
30+
options={
31+
'abstract': False,
32+
},
33+
),
34+
]

0 commit comments

Comments
 (0)