Commit 2b4817b1 by Clinton Blackburn

Added OpenID Connect discovery endpoint

Although we are phasing out our support of OIDC, this particular feature will allow us to eliminate many of the settings we
share across services. Instead of reading various endpoints and secret keys from settings or hardcoded values, services
with the proper authentication backend can simply read (and cache) the information from this endpoint.

ECOM-3629
parent 0df079a9
......@@ -848,8 +848,8 @@ CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_U
#### JWT configuration ####
JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {}))
PUBLIC_RSA_KEY = ENV_TOKENS.get('PUBLIC_RSA_KEY', PUBLIC_RSA_KEY)
PRIVATE_RSA_KEY = ENV_TOKENS.get('PRIVATE_RSA_KEY', PRIVATE_RSA_KEY)
JWT_PRIVATE_SIGNING_KEY = ENV_TOKENS.get('JWT_PRIVATE_SIGNING_KEY', JWT_PRIVATE_SIGNING_KEY)
JWT_EXPIRED_PRIVATE_SIGNING_KEYS = ENV_TOKENS.get('JWT_EXPIRED_PRIVATE_SIGNING_KEYS', JWT_EXPIRED_PRIVATE_SIGNING_KEYS)
################# PROCTORING CONFIGURATION ##################
......
......@@ -2975,8 +2975,8 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = 15 * 60
# For help generating a key pair import and run `openedx.core.lib.rsa_key_utils.generate_rsa_key_pair()`
PUBLIC_RSA_KEY = None
PRIVATE_RSA_KEY = None
JWT_PRIVATE_SIGNING_KEY = None
JWT_EXPIRED_PRIVATE_SIGNING_KEYS = []
# Credit notifications settings
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
......
......@@ -225,18 +225,7 @@ CORS_ORIGIN_WHITELIST = ()
CORS_ORIGIN_ALLOW_ALL = True
# JWT settings for devstack
PUBLIC_RSA_KEY = """\
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApCujf5oZBGK4MafMRGY9
+zdRRI9YDm1r+81coDCysSrwkhTkFIwP2dmS6lYvJuQ5wifuQa3WFv1Kh9Nr2XRJ
1m9OL3/JpmMyTi/YuwD7tIf65tab1SOSRYkoxOKRuuvZuXQG9nWbXrGDncnwuWxf
eymwWaIrAhALUS5+nDa7dauj8VngsWauMrEA/MWShEzsR53wGKlciEZA1r/AfQ55
XS42GvBobhhy9SeZ3B6LHiaAEywpwFmKPssuoHSNhbPa49LW3gXJ6CsFGRDcBFKd
xJ/l8O847Q7kg1lvckpLsKyu5167NK9Qj1X/O3SwVBL3cxx1HpQ6+q3SGLZ4ngow
hwIDAQAB
-----END PUBLIC KEY-----"""
PRIVATE_RSA_KEY = """\
JWT_PRIVATE_SIGNING_KEY = """\
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkK6N/mhkEYrgx
p8xEZj37N1FEj1gObWv7zVygMLKxKvCSFOQUjA/Z2ZLqVi8m5DnCJ+5BrdYW/UqH
......
......@@ -3,25 +3,31 @@ Tests for Blocks Views
"""
import json
import unittest
import ddt
import httpretty
from Crypto.PublicKey import RSA
from django.conf import settings
from django.test import RequestFactory, TestCase
from django.core.urlresolvers import reverse
import httpretty
from django.test import RequestFactory, TestCase, override_settings
from oauth2_provider import models as dot_models
from provider import constants
import unittest
from student.tests.factories import UserFactory
from third_party_auth.tests.utils import ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinGoogle
from .constants import DUMMY_REDIRECT_URL
from . import mixins
from .constants import DUMMY_REDIRECT_URL
from .. import adapters
from .. import models
if settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"):
# NOTE (CCB): We use this feature flag in a roundabout way to determine if the oauth_dispatch app is installed
# in the current service--LMS or Studio. Normally we would check if settings.ROOT_URLCONF == 'lms.urls'; however,
# simply importing the views will results in an error due to the requisite apps not being installed (in Studio). Thus,
# we are left with this hack, of checking the feature flag which will never be True for Studio.
OAUTH_PROVIDER_ENABLED = settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER')
if OAUTH_PROVIDER_ENABLED:
from .. import views
......@@ -62,7 +68,7 @@ class AccessTokenLoginMixin(object):
self.assertEqual(self.login_with_access_token(access_token=access_token).status_code, 401)
@unittest.skipUnless(settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"), "OAuth2 not enabled")
@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled')
class _DispatchingViewTestCase(TestCase):
"""
Base class for tests that exercise DispatchingViews.
......@@ -117,6 +123,7 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
"""
Test class for AccessTokenView
"""
def setUp(self):
super(TestAccessTokenView, self).setUp()
self.url = reverse('access_token')
......@@ -235,6 +242,7 @@ class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAut
"""
Test class for AccessTokenExchangeView
"""
def setUp(self):
self.url = reverse('exchange_access_token', kwargs={'backend': 'google-oauth2'})
self.view_class = views.AccessTokenExchangeView
......@@ -385,7 +393,7 @@ class TestAuthorizationView(_DispatchingViewTestCase):
return response.redirect_chain[-1][0]
@unittest.skipUnless(settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"), "OAuth2 not enabled")
@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled')
class TestViewDispatch(TestCase):
"""
Test that the DispatchingView dispatches the right way.
......@@ -479,6 +487,7 @@ class TestRevokeTokenView(AccessTokenLoginMixin, _DispatchingViewTestCase): # p
"""
Test class for RevokeTokenView
"""
def setUp(self):
self.revoke_token_url = reverse('revoke_token')
self.access_token_url = reverse('access_token')
......@@ -554,3 +563,104 @@ class TestRevokeTokenView(AccessTokenLoginMixin, _DispatchingViewTestCase): # p
Tests invalidation/revoke of user access token for django-oauth-toolkit
"""
self.verify_revoke_token(self.access_token)
@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled')
class JwksViewTests(TestCase):
def test_serialize_rsa_key(self):
key = """\
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkK6N/mhkEYrgx
p8xEZj37N1FEj1gObWv7zVygMLKxKvCSFOQUjA/Z2ZLqVi8m5DnCJ+5BrdYW/UqH
02vZdEnWb04vf8mmYzJOL9i7APu0h/rm1pvVI5JFiSjE4pG669m5dAb2dZtesYOd
yfC5bF97KbBZoisCEAtRLn6cNrt1q6PxWeCxZq4ysQD8xZKETOxHnfAYqVyIRkDW
v8B9DnldLjYa8GhuGHL1J5ncHoseJoATLCnAWYo+yy6gdI2Fs9rj0tbeBcnoKwUZ
ENwEUp3En+Xw7zjtDuSDWW9ySkuwrK7nXrs0r1CPVf87dLBUEvdzHHUelDr6rdIY
tnieCjCHAgMBAAECggEBAJvTiAdQPzq4cVlAilTKLz7KTOsknFJlbj+9t5OdZZ9g
wKQIDE2sfEcti5O+Zlcl/eTaff39gN6lYR73gMEQ7h0J3U6cnsy+DzvDkpY94qyC
/ZYqUhPHBcnW3Mm0vNqNj0XGae15yBXjrKgSy9lUknSXJ3qMwQHeNL/DwA2KrfiL
g0iVjk32dvSSHWcBh0M+Qy1WyZU0cf9VWzx+Q1YLj9eUCHteStVubB610XV3JUZt
UTWiUCffpo2okHsTBuKPVXK/5BL+BpGplcxRSlnSbMaI611kN3iKlO8KGISXHBz7
nOPdkfZC9poEXt5SshtINuGGCCc8hDxpg1otYqCLaYECgYEA1MSCPs3pBkEagchV
g0rxYmDUC8QkeIOBuZFjhkdoUgZ6rFntyRZd1NbCUi3YBbV1YC12ZGohqWUWom1S
AtNbQ2ZTbqEnDKWbNvLBRwkdp/9cKBce85lCCD6+U2o2Ha8C0+hKeLBn8un1y0zY
1AQTqLAz9ItNr0aDPb89cs5voWcCgYEAxYdC8vR3t8iYMUnK6LWYDrKSt7YiorvF
qXIMANcXQrnO0ptC0B56qrUCgKHNrtPi5bGpNBJ0oKMfbmGfwX+ca8sCUlLvq/O8
S2WZwSJuaHH4lEBi8ErtY++8F4B4l3ENCT84Hyy5jiMpbpkHEnh/1GNcvvmyI8ud
3jzovCNZ4+ECgYEA0r+Oz0zAOzyzV8gqw7Cw5iRJBRqUkXaZQUj8jt4eO9lFG4C8
IolwCclrk2Drb8Qsbka51X62twZ1ZA/qwve9l0Y88ADaIBHNa6EKxyUFZglvrBoy
w1GT8XzMou06iy52G5YkZeU+IYOSvnvw7hjXrChUXi65lRrAFqJd6GEIe5MCgYA/
0LxDa9HFsWvh+JoyZoCytuSJr7Eu7AUnAi54kwTzzL3R8tE6Fa7BuesODbg6tD/I
v4YPyaqePzUnXyjSxdyOQq8EU8EUx5Dctv1elTYgTjnmA4szYLGjKM+WtC3Bl4eD
pkYGZFeqYRfAoHXVdNKvlk5fcKIpyF2/b+Qs7CrdYQKBgQCc/t+JxC9OpI+LhQtB
tEtwvklxuaBtoEEKJ76P9vrK1semHQ34M1XyNmvPCXUyKEI38MWtgCCXcdmg5syO
PBXdDINx+wKlW7LPgaiRL0Mi9G2aBpdFNI99CWVgCr88xqgSE24KsOxViMwmi0XB
Ld/IRK0DgpGP5EJRwpKsDYe/UQ==
-----END PRIVATE KEY-----"""
# pylint: disable=line-too-long
expected = {
'kty': 'RSA',
'use': 'sig',
'alg': 'RS512',
'n': 'pCujf5oZBGK4MafMRGY9-zdRRI9YDm1r-81coDCysSrwkhTkFIwP2dmS6lYvJuQ5wifuQa3WFv1Kh9Nr2XRJ1m9OL3_JpmMyTi_YuwD7tIf65tab1SOSRYkoxOKRuuvZuXQG9nWbXrGDncnwuWxfeymwWaIrAhALUS5-nDa7dauj8VngsWauMrEA_MWShEzsR53wGKlciEZA1r_AfQ55XS42GvBobhhy9SeZ3B6LHiaAEywpwFmKPssuoHSNhbPa49LW3gXJ6CsFGRDcBFKdxJ_l8O847Q7kg1lvckpLsKyu5167NK9Qj1X_O3SwVBL3cxx1HpQ6-q3SGLZ4ngowhw',
'e': 'AQAB',
'kid': '6e80b9d2e5075ae8bb5d1dd762ebc62e'
}
self.assertEqual(views.JwksView.serialize_rsa_key(key), expected)
def test_get(self):
JWT_PRIVATE_SIGNING_KEY = RSA.generate(2048).exportKey('PEM')
JWT_EXPIRED_PRIVATE_SIGNING_KEYS = [RSA.generate(2048).exportKey('PEM'), RSA.generate(2048).exportKey('PEM')]
secret_keys = [JWT_PRIVATE_SIGNING_KEY] + JWT_EXPIRED_PRIVATE_SIGNING_KEYS
with override_settings(JWT_PRIVATE_SIGNING_KEY=JWT_PRIVATE_SIGNING_KEY,
JWT_EXPIRED_PRIVATE_SIGNING_KEYS=JWT_EXPIRED_PRIVATE_SIGNING_KEYS):
response = self.client.get(reverse('jwks'))
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
expected = {
'keys': [views.JwksView.serialize_rsa_key(key) for key in secret_keys],
}
self.assertEqual(actual, expected)
@override_settings(JWT_PRIVATE_SIGNING_KEY=None, JWT_EXPIRED_PRIVATE_SIGNING_KEYS=[])
def test_get_without_keys(self):
""" The view should return an empty list if no keys are configured. """
response = self.client.get(reverse('jwks'))
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
self.assertEqual(actual, {'keys': []})
@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled')
class ProviderInfoViewTests(TestCase):
DOMAIN = 'testserver.fake'
def build_url(self, path):
return 'http://{domain}{path}'.format(domain=self.DOMAIN, path=path)
def test_get(self):
issuer = 'test-issuer'
self.client = self.client_class(SERVER_NAME=self.DOMAIN)
expected = {
'issuer': issuer,
'authorization_endpoint': self.build_url(reverse('authorize')),
'token_endpoint': self.build_url(reverse('access_token')),
'end_session_endpoint': self.build_url(reverse('logout')),
'token_endpoint_auth_methods_supported': ['client_secret_post'],
'access_token_signing_alg_values_supported': ['RS512', 'HS256'],
'scopes_supported': ['openid', 'profile', 'email'],
'claims_supported': ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'],
'jwks_uri': self.build_url(reverse('jwks')),
}
with override_settings(JWT_AUTH={'JWT_ISSUER': issuer}):
response = self.client.get(reverse('openid-config'))
self.assertEqual(response.status_code, 200)
actual = json.loads(response.content)
self.assertEqual(actual, expected)
......@@ -13,7 +13,9 @@ urlpatterns = patterns(
'',
url(r'^authorize/?$', csrf_exempt(views.AuthorizationView.as_view()), name='authorize'),
url(r'^access_token/?$', csrf_exempt(views.AccessTokenView.as_view()), name='access_token'),
url(r'^revoke_token/?$', csrf_exempt(views.RevokeTokenView.as_view()), name="revoke_token"),
url(r'^revoke_token/?$', csrf_exempt(views.RevokeTokenView.as_view()), name='revoke_token'),
url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='openid-config'),
url(r'^jwks\.json$', views.JwksView.as_view(), name='jwks')
)
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
......
......@@ -5,15 +5,20 @@ django-oauth-toolkit as appropriate.
from __future__ import unicode_literals
import hashlib
import json
from Crypto.PublicKey import RSA
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import JsonResponse
from django.views.generic import View
from edx_oauth2_provider import views as dop_views # django-oauth2-provider views
from jwkest.jwk import RSAKey
from oauth2_provider import models as dot_models, views as dot_views # django-oauth-toolkit
from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views
from openedx.core.lib.token_utils import JwtBuilder
from . import adapters
......@@ -132,3 +137,45 @@ class RevokeTokenView(_DispatchingView):
Dispatch to the RevokeTokenView of django-oauth-toolkit
"""
dot_view = dot_views.RevokeTokenView
class ProviderInfoView(View):
def get(self, request, *args, **kwargs):
data = {
'issuer': settings.JWT_AUTH['JWT_ISSUER'],
'authorization_endpoint': request.build_absolute_uri(reverse('authorize')),
'token_endpoint': request.build_absolute_uri(reverse('access_token')),
'end_session_endpoint': request.build_absolute_uri(reverse('logout')),
'token_endpoint_auth_methods_supported': ['client_secret_post'],
# NOTE (CCB): This is not part of the OpenID Connect standard. It is added here since we
# use JWS for our access tokens.
'access_token_signing_alg_values_supported': ['RS512', 'HS256'],
'scopes_supported': ['openid', 'profile', 'email'],
'claims_supported': ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'],
'jwks_uri': request.build_absolute_uri(reverse('jwks')),
}
response = JsonResponse(data)
return response
class JwksView(View):
@staticmethod
def serialize_rsa_key(key):
kid = hashlib.md5(key.encode('utf-8')).hexdigest()
key = RSAKey(kid=kid, key=RSA.importKey(key), use='sig', alg='RS512')
return key.serialize(private=False)
def get(self, request, *args, **kwargs):
secret_keys = []
if settings.JWT_PRIVATE_SIGNING_KEY:
secret_keys.append(settings.JWT_PRIVATE_SIGNING_KEY)
# NOTE: We provide the expired keys in case there are unexpired access tokens
# that need to have their signatures verified.
if settings.JWT_EXPIRED_PRIVATE_SIGNING_KEYS:
secret_keys += settings.JWT_EXPIRED_PRIVATE_SIGNING_KEYS
return JsonResponse({
'keys': [self.serialize_rsa_key(key) for key in secret_keys if key],
})
"""Utilities for working with ID tokens."""
import json
from time import time
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from Cryptodome.PublicKey import RSA
from django.conf import settings
from django.utils.functional import cached_property
import jwt
from jwkest.jwk import KEYS, RSAKey
from jwkest.jws import JWS
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from student.models import UserProfile, anonymous_id_for_user
......@@ -27,6 +28,7 @@ class JwtBuilder(object):
asymmetric (Boolean): Whether the JWT should be signed with this app's private key.
secret (string): Overrides configured JWT secret (signing) key. Unused if an asymmetric signature is requested.
"""
def __init__(self, user, asymmetric=False, secret=None):
self.user = user
self.asymmetric = asymmetric
......@@ -50,6 +52,7 @@ class JwtBuilder(object):
now = int(time())
expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION']
payload = {
# TODO Consider getting rid of this claim since we don't use it.
'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'],
'exp': now + expires_in,
'iat': now,
......@@ -100,11 +103,16 @@ class JwtBuilder(object):
def encode(self, payload):
"""Encode the provided payload."""
keys = KEYS()
if self.asymmetric:
secret = load_pem_private_key(settings.PRIVATE_RSA_KEY, None, default_backend())
keys.add(RSAKey(key=RSA.importKey(settings.JWT_PRIVATE_SIGNING_KEY)))
algorithm = 'RS512'
else:
secret = self.secret if self.secret else self.jwt_auth['JWT_SECRET_KEY']
key = self.secret if self.secret else self.jwt_auth['JWT_SECRET_KEY']
keys.add({'key': key, 'kty': 'oct'})
algorithm = self.jwt_auth['JWT_ALGORITHM']
return jwt.encode(payload, secret, algorithm=algorithm)
data = json.dumps(payload)
jws = JWS(data, alg=algorithm)
return jws.sign_compact(keys=keys)
......@@ -84,6 +84,8 @@ polib==1.0.3
pycrypto>=2.6
pygments==2.0.1
pygraphviz==1.1
pyjwkest==1.3.2
# TODO Replace PyJWT usage with pyjwkest
PyJWT==1.4.0
pymongo==2.9.1
python-memcached==1.48
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment