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
This commit is contained in:
@@ -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
|
||||
from django.conf import settings
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
import httpretty
|
||||
from Crypto.PublicKey import RSA
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
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],
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user