Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Openid Connect Core support #545

Closed
wants to merge 48 commits into from
Closed
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
b03f4b3
Add OpenID connect hybrid grant type
wiliamsouza Oct 29, 2017
6af92ad
Add OpenID connect algorithm type to Application model
wiliamsouza Oct 29, 2017
ec33a1a
Add OpenID connect id token model
wiliamsouza Oct 29, 2017
60ca67e
Add nonce Authorization as required by OpenID connect Implicit Flow
wiliamsouza Oct 29, 2017
75aeaca
Add body to create_authorization_response to pass nonce and future Op…
wiliamsouza Oct 29, 2017
41d3362
Add OpenID connect ID token creation and validation methods and scopes
wiliamsouza Oct 29, 2017
68729e8
Add OpenID connect response types
wiliamsouza Oct 29, 2017
0ae398b
Add OpenID connect authorization code flow test
wiliamsouza Oct 29, 2017
5c3d245
Add OpenID connect implicit flow tests
wiliamsouza Oct 29, 2017
0583bad
Add validate_user_match method to OAuth2Validator
wiliamsouza Oct 29, 2017
4ef5321
Add RSA_PRIVATE_KEY setting with blank value
wiliamsouza Oct 29, 2017
e043524
Update tox
wiliamsouza Oct 29, 2017
6ac4795
Add get_jwt_bearer_token to OAuth2Validator
wiliamsouza Dec 19, 2017
a77fb12
Add validate_jwt_bearer_token to OAuth2Validator
wiliamsouza Dec 19, 2017
22596d4
Change OAuth2Validator.validate_id_token default return value to Fals…
wiliamsouza Dec 19, 2017
7358dd8
Change to use .encode to avoid py2.7 tox test error
wiliamsouza Jan 15, 2018
1e91d84
Add OpenID connect hybrid flow tests
wiliamsouza Jan 15, 2018
86630e7
Change to use .encode to avoid py2.7 tox test error
wiliamsouza Jan 16, 2018
2319e73
Add RSA_PRIVATE_KEY to the list of settings that cannot be empt
wiliamsouza Jan 18, 2018
0444220
Add support for oidc connect discovery
allisson Mar 14, 2018
73b8318
Use double quotes for strings
allisson Mar 14, 2018
e3515f6
Rename migrations to avoid name and order conflict
wiliamsouza Jan 16, 2019
d6bf785
Remove commando to install OAuthLib from master and removed jwcrypto …
wiliamsouza Jan 16, 2019
64761cc
Remove python 2 compatible code
wiliamsouza Jan 16, 2019
2557e42
Change errors access_denied/unauthorized_client/consent_required/logi…
wiliamsouza Jan 16, 2019
b0ba544
Change iss claim value to come from settings
wiliamsouza Jan 16, 2019
3bb351a
Change to use openid connect code server class
wiliamsouza Jan 16, 2019
33e193a
Change test to include missing state
wiliamsouza Jan 16, 2019
31254d5
Add id_token relation to AbstractAccessToken
wiliamsouza Jan 29, 2019
2d87515
Add claims property to AbstractIDToken
wiliamsouza Jan 29, 2019
69df9ee
Change OAuth2Validator._create_access_token to save id_token to acces…
wiliamsouza Jan 29, 2019
961bb55
Add userinfo endpoint
wiliamsouza Jan 29, 2019
cafb0dc
Update migrations and remove oauthlib duplication
wiliamsouza Apr 10, 2019
8b79da7
Remove old generated migrations
wiliamsouza Apr 16, 2019
48b7813
Add new migrations
wiliamsouza Apr 16, 2019
2211288
Fix tests
wiliamsouza Apr 16, 2019
4eb46d1
Add nonce to hybrid tests
wiliamsouza Jul 13, 2019
249b21f
Remove jwcrypto duplication from tox
wiliamsouza Jul 13, 2019
4e1f07b
Add missing new attributes to test migration
fvlima Mar 1, 2020
df1a154
Rebase fixing conflicts and tests
wiliamsouza Mar 2, 2020
5b05ce0
Remove auto generate message
wiliamsouza Mar 2, 2020
368e03a
Merge branch 'master' into openid-connect
auvipy Mar 3, 2020
3b2b2c1
Merge branch 'master' into openid-connect
auvipy Mar 23, 2020
ef161c0
Fix flake8 issues
fvlima Apr 26, 2020
723d37a
Fix test doc deps
fvlima Apr 26, 2020
e531284
Add project settings to be ignored in coverage
fvlima Apr 27, 2020
5680ca4
Merge pull request #9 from fvlima/coverage-omit-settings
auvipy May 8, 2020
6e71b66
Merge pull request #8 from fvlima/fixes-flake-issues
auvipy May 8, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ __pycache__
pip-log.txt

# Unit test / coverage reports
.cache
.pytest_cache
.coverage
.tox
.pytest_cache/
Expand Down
14 changes: 12 additions & 2 deletions oauth2_provider/admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from django.contrib import admin

from .models import (
get_access_token_model, get_application_model,
get_grant_model, get_refresh_token_model
get_access_token_model,
get_application_model,
get_grant_model,
get_refresh_token_model,
get_id_token_model,
)


Expand All @@ -26,6 +29,11 @@ class AccessTokenAdmin(admin.ModelAdmin):
raw_id_fields = ("user", "source_refresh_token")


class IDTokenAdmin(admin.ModelAdmin):
list_display = ("token", "user", "application", "expires")
raw_id_fields = ("user", )


class RefreshTokenAdmin(admin.ModelAdmin):
list_display = ("token", "user", "application")
raw_id_fields = ("user", "access_token")
Expand All @@ -34,9 +42,11 @@ class RefreshTokenAdmin(admin.ModelAdmin):
Application = get_application_model()
Grant = get_grant_model()
AccessToken = get_access_token_model()
IDToken = get_id_token_model()
RefreshToken = get_refresh_token_model()

admin.site.register(Application, ApplicationAdmin)
admin.site.register(Grant, GrantAdmin)
admin.site.register(AccessToken, AccessTokenAdmin)
admin.site.register(IDToken, IDTokenAdmin)
admin.site.register(RefreshToken, RefreshTokenAdmin)
1 change: 1 addition & 0 deletions oauth2_provider/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class AllowForm(forms.Form):
allow = forms.BooleanField(required=False)
redirect_uri = forms.CharField(widget=forms.HiddenInput())
scope = forms.CharField(widget=forms.HiddenInput())
nonce = forms.CharField(required=False, widget=forms.HiddenInput())
client_id = forms.CharField(widget=forms.HiddenInput())
state = forms.CharField(required=False, widget=forms.HiddenInput())
response_type = forms.CharField(widget=forms.HiddenInput())
Expand Down
2 changes: 0 additions & 2 deletions oauth2_provider/migrations/0002_auto_20190406_1805.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# Generated by Django 2.2 on 2019-04-06 18:05

from django.db import migrations, models


Expand Down
21 changes: 21 additions & 0 deletions oauth2_provider/migrations/0003_auto_20190413_2007.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('oauth2_provider', '0002_auto_20190406_1805'),
]

operations = [
migrations.AddField(
model_name='application',
name='algorithm',
field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5),
),
migrations.AlterField(
model_name='application',
name='authorization_grant_type',
field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32),
),
]
31 changes: 31 additions & 0 deletions oauth2_provider/migrations/0004_idtoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth2_provider', '0003_auto_20190413_2007'),
]

operations = [
migrations.CreateModel(
name='IDToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.TextField(unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL',
},
),
]
18 changes: 18 additions & 0 deletions oauth2_provider/migrations/0005_accesstoken_id_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('oauth2_provider', '0004_idtoken'),
]

operations = [
migrations.AddField(
model_name='accesstoken',
name='id_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL),
),
]
115 changes: 115 additions & 0 deletions oauth2_provider/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import logging
import json
from datetime import timedelta
from urllib.parse import parse_qsl, urlparse

from jwcrypto import jwk, jwt

from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
Expand Down Expand Up @@ -50,11 +53,20 @@ class AbstractApplication(models.Model):
GRANT_IMPLICIT = "implicit"
GRANT_PASSWORD = "password"
GRANT_CLIENT_CREDENTIALS = "client-credentials"
GRANT_OPENID_HYBRID = "openid-hybrid"
GRANT_TYPES = (
(GRANT_AUTHORIZATION_CODE, _("Authorization code")),
(GRANT_IMPLICIT, _("Implicit")),
(GRANT_PASSWORD, _("Resource owner password-based")),
(GRANT_CLIENT_CREDENTIALS, _("Client credentials")),
(GRANT_OPENID_HYBRID, _("OpenID connect hybrid")),
)

RS256_ALGORITHM = "RS256"
HS256_ALGORITHM = "HS256"
ALGORITHM_TYPES = (
(RS256_ALGORITHM, _("RSA with SHA-2 256")),
(HS256_ALGORITHM, _("HMAC with SHA-2 256")),
)

id = models.BigAutoField(primary_key=True)
Expand Down Expand Up @@ -82,6 +94,7 @@ class AbstractApplication(models.Model):

created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=RS256_ALGORITHM)
wiliamsouza marked this conversation as resolved.
Show resolved Hide resolved

class Meta:
abstract = True
Expand Down Expand Up @@ -282,6 +295,10 @@ class AbstractAccessToken(models.Model):
related_name="refreshed_access_token"
)
token = models.CharField(max_length=255, unique=True, )
id_token = models.OneToOneField(
oauth2_settings.ID_TOKEN_MODEL, on_delete=models.CASCADE, blank=True, null=True,
related_name="access_token"
)
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True,
)
Expand Down Expand Up @@ -415,6 +432,99 @@ class Meta(AbstractRefreshToken.Meta):
swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL"


class AbstractIDToken(models.Model):
"""
An IDToken instance represents the actual token to
access user's resources, as in :openid:`2`.

Fields:

* :attr:`user` The Django user representing resources' owner
* :attr:`token` ID token
* :attr:`application` Application instance
* :attr:`expires` Date and time of token expiration, in DateTime format
* :attr:`scope` Allowed scopes
"""
id = models.BigAutoField(primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True,
related_name="%(app_label)s_%(class)s"
)
token = models.TextField(unique=True)

Choose a reason for hiding this comment

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

Testing locally using MySQL the migration created by this errors with MySQL err 1170, "BLOB/TEXT column 'token' used in key specification without a key length".

Reference: https://dev.mysql.com/doc/refman/8.0/en/server-error-reference.html#error_er_blob_key_without_length

Given that this is not the primary key and (if my understanding is correct) typically a value generated by the application, perhaps we could not use a database index to ensure uniqueness? Happy to make a PR if so.

Copy link
Contributor

Choose a reason for hiding this comment

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

sure plz

Choose a reason for hiding this comment

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

application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True,
)
expires = models.DateTimeField()
scope = models.TextField(blank=True)

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

def is_valid(self, scopes=None):
"""
Checks if the access token is valid.

:param scopes: An iterable containing the scopes to check or None
"""
return not self.is_expired() and self.allow_scopes(scopes)

def is_expired(self):
"""
Check token expiration with timezone awareness
"""
if not self.expires:
return True

return timezone.now() >= self.expires

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

:param scopes: An iterable containing the scopes to check
"""
if not scopes:
return True

provided_scopes = set(self.scope.split())
resource_scopes = set(scopes)

return resource_scopes.issubset(provided_scopes)

def revoke(self):
"""
Convenience method to uniform tokens' interface, for now
simply remove this token from the database in order to revoke it.
"""
self.delete()

@property
def scopes(self):
"""
Returns a dictionary of allowed scope names (as keys) with their descriptions (as values)
"""
all_scopes = get_scopes_backend().get_all_scopes()
token_scopes = self.scope.split()
return {name: desc for name, desc in all_scopes.items() if name in token_scopes}

@property
def claims(self):
key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8"))
jwt_token = jwt.JWT(key=key, jwt=self.token)
return json.loads(jwt_token.claims)

def __str__(self):
return self.token

class Meta:
abstract = True


class IDToken(AbstractIDToken):
class Meta(AbstractIDToken.Meta):
swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL"


def get_application_model():
""" Return the Application model that is active in this project. """
return apps.get_model(oauth2_settings.APPLICATION_MODEL)
Expand All @@ -430,6 +540,11 @@ def get_access_token_model():
return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL)


def get_id_token_model():
""" Return the AccessToken model that is active in this project. """
return apps.get_model(oauth2_settings.ID_TOKEN_MODEL)


def get_refresh_token_model():
""" Return the RefreshToken model that is active in this project. """
return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL)
Expand Down
11 changes: 6 additions & 5 deletions oauth2_provider/oauth2_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,16 @@ def validate_authorization_request(self, request):
except oauth2.OAuth2Error as error:
raise OAuthToolkitError(error=error)

def create_authorization_response(self, request, scopes, credentials, allow):
def create_authorization_response(self, uri, request, scopes, credentials, body, allow):
"""
A wrapper method that calls create_authorization_response on `server_class`
instance.

:param request: The current django.http.HttpRequest object
:param scopes: A list of provided scopes
:param credentials: Authorization credentials dictionary containing
`client_id`, `state`, `redirect_uri`, `response_type`
`client_id`, `state`, `redirect_uri` and `response_type`
:param body: Other body parameters not used in credentials dictionary
:param allow: True if the user authorize the client, otherwise False
"""
try:
Expand All @@ -122,10 +123,10 @@ def create_authorization_response(self, request, scopes, credentials, allow):
credentials["user"] = request.user

headers, body, status = self.server.create_authorization_response(
uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials)
uri = headers.get("Location", None)
uri=uri, scopes=scopes, credentials=credentials, body=body)
redirect_uri = headers.get("Location", None)

return uri, headers, body, status
return redirect_uri, headers, body, status

except oauth2.FatalClientError as error:
raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"])
Expand Down