Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ dist: xenial
language: python

python:
- "3.8"
- "3.7"
- "3.6"
- "3.5"
- "3.4"

cache:
Expand Down
25 changes: 25 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ The import string of the class (model) representing your access tokens. Overwrit
this value if you wrote your own implementation (subclass of
``oauth2_provider.models.AccessToken``).

ACCESS_TOKEN_GENERATOR
~~~~~~~~~~~~~~~~~~~~~~
Import path of a callable used to generate access tokens.
oauthlib.oauth2.tokens.random_token_generator is (normally) used if not provided.

ALLOWED_REDIRECT_URI_SCHEMES
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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

EXTRA_SERVER_KWARGS
~~~~~~~~~~~~~~~~~~~
A dictionary to be passed to oauthlib's Server class. Three options
are natively supported: token_expires_in, token_generator,
refresh_token_generator. There's no extra processing so callables (every one
of those three can be a callable) must be passed here directly and classes
must be instantiated (callables should accept request as their only argument).

GRANT_MODEL
~~~~~~~~~~~~~~~~~
The import string of the class (model) representing your grants. Overwrite
Expand All @@ -103,6 +116,9 @@ REFRESH_TOKEN_EXPIRE_SECONDS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The number of seconds before a refresh token gets removed from the database by
the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info.
NOTE: This value is completely ignored when validating refresh tokens.
If you don't change the validator code and don't run cleartokens all refresh
tokens will last until revoked or the end of time.

REFRESH_TOKEN_GRACE_PERIOD_SECONDS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -123,6 +139,15 @@ this value if you wrote your own implementation (subclass of
ROTATE_REFRESH_TOKEN
~~~~~~~~~~~~~~~~~~~~
When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token.
Known bugs: `False` currently has a side effect of immediately revoking both access and refresh token on refreshing.
See also: validator's rotate_refresh_token method can be overridden to make this variable
(could be usable with expiring refresh tokens, in particular, so that they are rotated
when close to expiration, theoretically).

REFRESH_TOKEN_GENERATOR
~~~~~~~~~~~~~~~~~~~~~~~~~~
See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens.
Defaults to access token generator if not provided.

REQUEST_APPROVAL_PROMPT
~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
20 changes: 15 additions & 5 deletions oauth2_provider/oauth2_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@

class OAuthLibCore(object):
"""
TODO: add docs
Wrapper for oauth Server providing django-specific interfaces.

Meant for things like extracting request data and converting
everything to formats more palatable for oauthlib's Server.
"""
def __init__(self, server=None):
"""
:params server: An instance of oauthlib.oauth2.Server class
"""
self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(oauth2_settings.OAUTH2_VALIDATOR_CLASS())
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
validator = validator_class()
server_kwargs = oauth2_settings.server_kwargs
self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(
validator, **server_kwargs
)

def _get_escaped_full_path(self, request):
"""
Expand Down Expand Up @@ -189,9 +197,11 @@ def extract_body(self, request):

def get_oauthlib_core():
"""
Utility function that take a request and returns an instance of
Utility function that returns an instance of
`oauth2_provider.backends.OAuthLibCore`
"""
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
server = oauth2_settings.OAUTH2_SERVER_CLASS(validator)
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
validator = validator_class()
server_kwargs = oauth2_settings.server_kwargs
server = oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs)
return oauth2_settings.OAUTH2_BACKEND_CLASS(server)
9 changes: 5 additions & 4 deletions oauth2_provider/oauth2_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,11 @@ def save_bearer_token(self, token, request, *args, **kwargs):
if "scope" not in token:
raise FatalClientError("Failed to renew access token: missing scope")

expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
# expires_in is passed to Server on initialization
# custom server class can have logic to override this
expires = timezone.now() + timedelta(seconds=token.get(
'expires_in', oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks unrelated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally related, this changeset is about the way to configure Server instance and since we have the settings variable already it makes total sense to pass it there too now that a way to pass variables is added.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it's required for a token generator (that can be unaware of DOT and its settings) to have access to this value -- it's normally passed as a request attribute (or its result when it's a callable).

))

if request.grant_type == "client_credentials":
request.user = None
Expand Down Expand Up @@ -558,9 +562,6 @@ def save_bearer_token(self, token, request, *args, **kwargs):
else:
self._create_access_token(expires, request, token)

# TODO: check out a more reliable way to communicate expire time to oauthlib
token["expires_in"] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS

def _create_access_token(self, expires, request, token, source_refresh_token=None):
return AccessToken.objects.create(
user=request.user,
Expand Down
30 changes: 30 additions & 0 deletions oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator",
"CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator",
"CLIENT_SECRET_GENERATOR_LENGTH": 128,
"ACCESS_TOKEN_GENERATOR": None,
"REFRESH_TOKEN_GENERATOR": None,
"EXTRA_SERVER_KWARGS": {},
"OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server",
"OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator",
"OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore",
Expand Down Expand Up @@ -82,6 +85,8 @@
IMPORT_STRINGS = (
"CLIENT_ID_GENERATOR_CLASS",
"CLIENT_SECRET_GENERATOR_CLASS",
"ACCESS_TOKEN_GENERATOR",
"REFRESH_TOKEN_GENERATOR",
"OAUTH2_SERVER_CLASS",
"OAUTH2_VALIDATOR_CLASS",
"OAUTH2_BACKEND_CLASS",
Expand Down Expand Up @@ -171,5 +176,30 @@ def validate_setting(self, attr, val):
if not val and attr in self.mandatory:
raise AttributeError("OAuth2Provider setting: %r is mandatory" % (attr))

@property
def server_kwargs(self):
"""
This is used to communicate settings to oauth server.

Takes relevant settings and format them accordingly.
There's also EXTRA_SERVER_KWARGS that can override every value
and is more flexible regarding keys and acceptable values
but doesn't have import string magic or any additional
processing, callables have to be assigned directly.
For the likes of signed_token_generator it means something like

{'token_generator': signed_token_generator(privkey, **kwargs)}
"""
kwargs = {
key: getattr(self, value)
for key, value in [
('token_expires_in', 'ACCESS_TOKEN_EXPIRE_SECONDS'),
('refresh_token_expires_in', 'REFRESH_TOKEN_EXPIRE_SECONDS'),
('token_generator', 'ACCESS_TOKEN_GENERATOR'),
('refresh_token_generator', 'REFRESH_TOKEN_GENERATOR'),
]
}
kwargs.update(self.EXTRA_SERVER_KWARGS)
return kwargs

oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY)
3 changes: 2 additions & 1 deletion oauth2_provider/views/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ def get_server(cls):
"""
server_class = cls.get_server_class()
validator_class = cls.get_validator_class()
return server_class(validator_class())
server_kwargs = oauth2_settings.server_kwargs
return server_class(validator_class(), **server_kwargs)

@classmethod
def get_oauthlib_core(cls):
Expand Down
117 changes: 117 additions & 0 deletions tests/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Generated by Django 2.2.6 on 2019-10-24 20:21

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import oauth2_provider.generators


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
]

operations = [
migrations.CreateModel(
name='SampleGrant',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('code', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('redirect_uri', models.CharField(max_length=255)),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('code_challenge', models.CharField(blank=True, default='', max_length=128)),
('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)),
('custom_field', models.CharField(max_length=255)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplegrant', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SampleApplication',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)),
('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')),
('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)),
('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)),
('name', models.CharField(blank=True, max_length=255)),
('skip_authorization', models.BooleanField(default=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('custom_field', models.CharField(max_length=255)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SampleAccessToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('custom_field', models.CharField(max_length=255)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('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)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='BaseTestApplication',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)),
('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')),
('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)),
('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)),
('name', models.CharField(blank=True, max_length=255)),
('skip_authorization', models.BooleanField(default=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('allowed_schemes', models.TextField(blank=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SampleRefreshToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.CharField(max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('revoked', models.DateTimeField(null=True)),
('custom_field', models.CharField(max_length=255)),
('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)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplerefreshtoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
'unique_together': {('token', 'revoked')},
},
),
]
Empty file added tests/migrations/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions tests/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db import models

from oauth2_provider.settings import oauth2_settings
from oauth2_provider.models import (
AbstractAccessToken, AbstractApplication,
AbstractGrant, AbstractRefreshToken
Expand All @@ -21,10 +22,19 @@ class SampleApplication(AbstractApplication):

class SampleAccessToken(AbstractAccessToken):
custom_field = models.CharField(max_length=255)
source_refresh_token = models.OneToOneField(
# unique=True implied by the OneToOneField
oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True,
related_name="s_refreshed_access_token"
)


class SampleRefreshToken(AbstractRefreshToken):
custom_field = models.CharField(max_length=255)
access_token = models.OneToOneField(
oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True,
related_name="s_refresh_token"
)


class SampleGrant(AbstractGrant):
Expand Down
8 changes: 7 additions & 1 deletion tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "example.sqlite",
"NAME": ":memory:",
}
}

AUTH_USER_MODEL = 'auth.User'
OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken"
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken"

ALLOWED_HOSTS = []

TIME_ZONE = "UTC"
Expand Down Expand Up @@ -74,6 +79,7 @@
"django.contrib.sites",
"django.contrib.staticfiles",
"django.contrib.admin",
"django.contrib.messages",

"oauth2_provider",
"tests",
Expand Down
1 change: 0 additions & 1 deletion tests/test_application_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from .models import SampleApplication


Application = get_application_model()
UserModel = get_user_model()

Expand Down
4 changes: 2 additions & 2 deletions tests/test_oauth2_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r

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

create_access_token_mock.assert_called_once()
create_refresh_token_mock.asert_called_once()
assert create_access_token_mock.call_count == 1
assert create_refresh_token_mock.call_count == 1


class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase):
Expand Down
Loading