Skip to content

Commit 6c7260d

Browse files
Add extension points for custom token generators (jazzband#732) (jazzband#749)
Pass variables generated based on settings when initializing Server. Add a property to settings class for convenience. Update settings documentation to reflect additions. Fix tests.
1 parent a1dcd37 commit 6c7260d

13 files changed

+234
-23
lines changed

Diff for: .travis.yml

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ dist: xenial
44
language: python
55

66
python:
7+
- "3.8"
8+
- "3.7"
9+
- "3.6"
10+
- "3.5"
711
- "3.4"
812

913
cache:

Diff for: docs/settings.rst

+25
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ The import string of the class (model) representing your access tokens. Overwrit
3939
this value if you wrote your own implementation (subclass of
4040
``oauth2_provider.models.AccessToken``).
4141

42+
ACCESS_TOKEN_GENERATOR
43+
~~~~~~~~~~~~~~~~~~~~~~
44+
Import path of a callable used to generate access tokens.
45+
oauthlib.oauth2.tokens.random_token_generator is (normally) used if not provided.
46+
4247
ALLOWED_REDIRECT_URI_SCHEMES
4348
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4449

@@ -78,6 +83,14 @@ CLIENT_SECRET_GENERATOR_LENGTH
7883
The length of the generated secrets, in characters. If this value is too low,
7984
secrets may become subject to bruteforce guessing.
8085

86+
EXTRA_SERVER_KWARGS
87+
~~~~~~~~~~~~~~~~~~~
88+
A dictionary to be passed to oauthlib's Server class. Three options
89+
are natively supported: token_expires_in, token_generator,
90+
refresh_token_generator. There's no extra processing so callables (every one
91+
of those three can be a callable) must be passed here directly and classes
92+
must be instantiated (callables should accept request as their only argument).
93+
8194
GRANT_MODEL
8295
~~~~~~~~~~~~~~~~~
8396
The import string of the class (model) representing your grants. Overwrite
@@ -103,6 +116,9 @@ REFRESH_TOKEN_EXPIRE_SECONDS
103116
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
104117
The number of seconds before a refresh token gets removed from the database by
105118
the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info.
119+
NOTE: This value is completely ignored when validating refresh tokens.
120+
If you don't change the validator code and don't run cleartokens all refresh
121+
tokens will last until revoked or the end of time.
106122

107123
REFRESH_TOKEN_GRACE_PERIOD_SECONDS
108124
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -123,6 +139,15 @@ this value if you wrote your own implementation (subclass of
123139
ROTATE_REFRESH_TOKEN
124140
~~~~~~~~~~~~~~~~~~~~
125141
When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token.
142+
Known bugs: `False` currently has a side effect of immediately revoking both access and refresh token on refreshing.
143+
See also: validator's rotate_refresh_token method can be overridden to make this variable
144+
(could be usable with expiring refresh tokens, in particular, so that they are rotated
145+
when close to expiration, theoretically).
146+
147+
REFRESH_TOKEN_GENERATOR
148+
~~~~~~~~~~~~~~~~~~~~~~~~~~
149+
See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens.
150+
Defaults to access token generator if not provided.
126151

127152
REQUEST_APPROVAL_PROMPT
128153
~~~~~~~~~~~~~~~~~~~~~~~

Diff for: oauth2_provider/oauth2_backends.py

+15-5
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,21 @@
1010

1111
class OAuthLibCore(object):
1212
"""
13-
TODO: add docs
13+
Wrapper for oauth Server providing django-specific interfaces.
14+
15+
Meant for things like extracting request data and converting
16+
everything to formats more palatable for oauthlib's Server.
1417
"""
1518
def __init__(self, server=None):
1619
"""
1720
:params server: An instance of oauthlib.oauth2.Server class
1821
"""
19-
self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(oauth2_settings.OAUTH2_VALIDATOR_CLASS())
22+
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
23+
validator = validator_class()
24+
server_kwargs = oauth2_settings.server_kwargs
25+
self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(
26+
validator, **server_kwargs
27+
)
2028

2129
def _get_escaped_full_path(self, request):
2230
"""
@@ -189,9 +197,11 @@ def extract_body(self, request):
189197

190198
def get_oauthlib_core():
191199
"""
192-
Utility function that take a request and returns an instance of
200+
Utility function that returns an instance of
193201
`oauth2_provider.backends.OAuthLibCore`
194202
"""
195-
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
196-
server = oauth2_settings.OAUTH2_SERVER_CLASS(validator)
203+
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
204+
validator = validator_class()
205+
server_kwargs = oauth2_settings.server_kwargs
206+
server = oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs)
197207
return oauth2_settings.OAUTH2_BACKEND_CLASS(server)

Diff for: oauth2_provider/oauth2_validators.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,11 @@ def save_bearer_token(self, token, request, *args, **kwargs):
477477
if "scope" not in token:
478478
raise FatalClientError("Failed to renew access token: missing scope")
479479

480-
expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
480+
# expires_in is passed to Server on initialization
481+
# custom server class can have logic to override this
482+
expires = timezone.now() + timedelta(seconds=token.get(
483+
'expires_in', oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS,
484+
))
481485

482486
if request.grant_type == "client_credentials":
483487
request.user = None
@@ -558,9 +562,6 @@ def save_bearer_token(self, token, request, *args, **kwargs):
558562
else:
559563
self._create_access_token(expires, request, token)
560564

561-
# TODO: check out a more reliable way to communicate expire time to oauthlib
562-
token["expires_in"] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
563-
564565
def _create_access_token(self, expires, request, token, source_refresh_token=None):
565566
return AccessToken.objects.create(
566567
user=request.user,

Diff for: oauth2_provider/settings.py

+30
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
"CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator",
3333
"CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator",
3434
"CLIENT_SECRET_GENERATOR_LENGTH": 128,
35+
"ACCESS_TOKEN_GENERATOR": None,
36+
"REFRESH_TOKEN_GENERATOR": None,
37+
"EXTRA_SERVER_KWARGS": {},
3538
"OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server",
3639
"OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator",
3740
"OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore",
@@ -82,6 +85,8 @@
8285
IMPORT_STRINGS = (
8386
"CLIENT_ID_GENERATOR_CLASS",
8487
"CLIENT_SECRET_GENERATOR_CLASS",
88+
"ACCESS_TOKEN_GENERATOR",
89+
"REFRESH_TOKEN_GENERATOR",
8590
"OAUTH2_SERVER_CLASS",
8691
"OAUTH2_VALIDATOR_CLASS",
8792
"OAUTH2_BACKEND_CLASS",
@@ -171,5 +176,30 @@ def validate_setting(self, attr, val):
171176
if not val and attr in self.mandatory:
172177
raise AttributeError("OAuth2Provider setting: %r is mandatory" % (attr))
173178

179+
@property
180+
def server_kwargs(self):
181+
"""
182+
This is used to communicate settings to oauth server.
183+
184+
Takes relevant settings and format them accordingly.
185+
There's also EXTRA_SERVER_KWARGS that can override every value
186+
and is more flexible regarding keys and acceptable values
187+
but doesn't have import string magic or any additional
188+
processing, callables have to be assigned directly.
189+
For the likes of signed_token_generator it means something like
190+
191+
{'token_generator': signed_token_generator(privkey, **kwargs)}
192+
"""
193+
kwargs = {
194+
key: getattr(self, value)
195+
for key, value in [
196+
('token_expires_in', 'ACCESS_TOKEN_EXPIRE_SECONDS'),
197+
('refresh_token_expires_in', 'REFRESH_TOKEN_EXPIRE_SECONDS'),
198+
('token_generator', 'ACCESS_TOKEN_GENERATOR'),
199+
('refresh_token_generator', 'REFRESH_TOKEN_GENERATOR'),
200+
]
201+
}
202+
kwargs.update(self.EXTRA_SERVER_KWARGS)
203+
return kwargs
174204

175205
oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY)

Diff for: oauth2_provider/views/mixins.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ def get_server(cls):
7474
"""
7575
server_class = cls.get_server_class()
7676
validator_class = cls.get_validator_class()
77-
return server_class(validator_class())
77+
server_kwargs = oauth2_settings.server_kwargs
78+
return server_class(validator_class(), **server_kwargs)
7879

7980
@classmethod
8081
def get_oauthlib_core(cls):

Diff for: tests/migrations/0001_initial.py

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Generated by Django 2.2.6 on 2019-10-24 20:21
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import oauth2_provider.generators
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
initial = True
12+
13+
dependencies = [
14+
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
15+
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
16+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17+
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
18+
]
19+
20+
operations = [
21+
migrations.CreateModel(
22+
name='SampleGrant',
23+
fields=[
24+
('id', models.BigAutoField(primary_key=True, serialize=False)),
25+
('code', models.CharField(max_length=255, unique=True)),
26+
('expires', models.DateTimeField()),
27+
('redirect_uri', models.CharField(max_length=255)),
28+
('scope', models.TextField(blank=True)),
29+
('created', models.DateTimeField(auto_now_add=True)),
30+
('updated', models.DateTimeField(auto_now=True)),
31+
('code_challenge', models.CharField(blank=True, default='', max_length=128)),
32+
('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)),
33+
('custom_field', models.CharField(max_length=255)),
34+
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
35+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplegrant', to=settings.AUTH_USER_MODEL)),
36+
],
37+
options={
38+
'abstract': False,
39+
},
40+
),
41+
migrations.CreateModel(
42+
name='SampleApplication',
43+
fields=[
44+
('id', models.BigAutoField(primary_key=True, serialize=False)),
45+
('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)),
46+
('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')),
47+
('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
48+
('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)),
49+
('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)),
50+
('name', models.CharField(blank=True, max_length=255)),
51+
('skip_authorization', models.BooleanField(default=False)),
52+
('created', models.DateTimeField(auto_now_add=True)),
53+
('updated', models.DateTimeField(auto_now=True)),
54+
('custom_field', models.CharField(max_length=255)),
55+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)),
56+
],
57+
options={
58+
'abstract': False,
59+
},
60+
),
61+
migrations.CreateModel(
62+
name='SampleAccessToken',
63+
fields=[
64+
('id', models.BigAutoField(primary_key=True, serialize=False)),
65+
('token', models.CharField(max_length=255, unique=True)),
66+
('expires', models.DateTimeField()),
67+
('scope', models.TextField(blank=True)),
68+
('created', models.DateTimeField(auto_now_add=True)),
69+
('updated', models.DateTimeField(auto_now=True)),
70+
('custom_field', models.CharField(max_length=255)),
71+
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
72+
('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)),
73+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)),
74+
],
75+
options={
76+
'abstract': False,
77+
},
78+
),
79+
migrations.CreateModel(
80+
name='BaseTestApplication',
81+
fields=[
82+
('id', models.BigAutoField(primary_key=True, serialize=False)),
83+
('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)),
84+
('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')),
85+
('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
86+
('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)),
87+
('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)),
88+
('name', models.CharField(blank=True, max_length=255)),
89+
('skip_authorization', models.BooleanField(default=False)),
90+
('created', models.DateTimeField(auto_now_add=True)),
91+
('updated', models.DateTimeField(auto_now=True)),
92+
('allowed_schemes', models.TextField(blank=True)),
93+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)),
94+
],
95+
options={
96+
'abstract': False,
97+
},
98+
),
99+
migrations.CreateModel(
100+
name='SampleRefreshToken',
101+
fields=[
102+
('id', models.BigAutoField(primary_key=True, serialize=False)),
103+
('token', models.CharField(max_length=255)),
104+
('created', models.DateTimeField(auto_now_add=True)),
105+
('updated', models.DateTimeField(auto_now=True)),
106+
('revoked', models.DateTimeField(null=True)),
107+
('custom_field', models.CharField(max_length=255)),
108+
('access_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)),
109+
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
110+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplerefreshtoken', to=settings.AUTH_USER_MODEL)),
111+
],
112+
options={
113+
'abstract': False,
114+
'unique_together': {('token', 'revoked')},
115+
},
116+
),
117+
]

Diff for: tests/migrations/__init__.py

Whitespace-only changes.

Diff for: tests/models.py

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.db import models
22

3+
from oauth2_provider.settings import oauth2_settings
34
from oauth2_provider.models import (
45
AbstractAccessToken, AbstractApplication,
56
AbstractGrant, AbstractRefreshToken
@@ -21,10 +22,19 @@ class SampleApplication(AbstractApplication):
2122

2223
class SampleAccessToken(AbstractAccessToken):
2324
custom_field = models.CharField(max_length=255)
25+
source_refresh_token = models.OneToOneField(
26+
# unique=True implied by the OneToOneField
27+
oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True,
28+
related_name="s_refreshed_access_token"
29+
)
2430

2531

2632
class SampleRefreshToken(AbstractRefreshToken):
2733
custom_field = models.CharField(max_length=255)
34+
access_token = models.OneToOneField(
35+
oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True,
36+
related_name="s_refresh_token"
37+
)
2838

2939

3040
class SampleGrant(AbstractGrant):

Diff for: tests/settings.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
DATABASES = {
66
"default": {
77
"ENGINE": "django.db.backends.sqlite3",
8-
"NAME": "example.sqlite",
8+
"NAME": ":memory:",
99
}
1010
}
1111

12+
AUTH_USER_MODEL = 'auth.User'
13+
OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"
14+
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken"
15+
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken"
16+
1217
ALLOWED_HOSTS = []
1318

1419
TIME_ZONE = "UTC"
@@ -74,6 +79,7 @@
7479
"django.contrib.sites",
7580
"django.contrib.staticfiles",
7681
"django.contrib.admin",
82+
"django.contrib.messages",
7783

7884
"oauth2_provider",
7985
"tests",

Diff for: tests/test_application_views.py

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from .models import SampleApplication
1010

11-
1211
Application = get_application_model()
1312
UserModel = get_user_model()
1413

Diff for: tests/test_oauth2_validators.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,8 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r
284284

285285
self.validator.save_bearer_token(token, self.request)
286286

287-
create_access_token_mock.assert_called_once()
288-
create_refresh_token_mock.asert_called_once()
287+
assert create_access_token_mock.call_count == 1
288+
assert create_refresh_token_mock.call_count == 1
289289

290290

291291
class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase):

0 commit comments

Comments
 (0)