Asymmetric JWT support
This commit is contained in:
@@ -100,8 +100,6 @@ from lms.envs.common import (
|
||||
# to generating test databases will discover and try to create all tables
|
||||
# and this setting needs to be present
|
||||
OAUTH2_PROVIDER_APPLICATION_MODEL,
|
||||
DEFAULT_JWT_ISSUER,
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER,
|
||||
JWT_AUTH,
|
||||
|
||||
USERNAME_REGEX_PARTIAL,
|
||||
|
||||
@@ -883,14 +883,8 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = ENV_TOKENS.get(
|
||||
CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_URL)
|
||||
|
||||
#### JWT configuration ####
|
||||
DEFAULT_JWT_ISSUER = ENV_TOKENS.get('DEFAULT_JWT_ISSUER', DEFAULT_JWT_ISSUER)
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER = ENV_TOKENS.get(
|
||||
'RESTRICTED_APPLICATION_JWT_ISSUER',
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER
|
||||
)
|
||||
JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {}))
|
||||
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)
|
||||
JWT_AUTH.update(AUTH_TOKENS.get('JWT_AUTH', {}))
|
||||
|
||||
################# PROCTORING CONFIGURATION ##################
|
||||
|
||||
|
||||
@@ -3146,11 +3146,6 @@ LTI_USER_EMAIL_DOMAIN = 'lti.example.com'
|
||||
# The time value is in seconds.
|
||||
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()`
|
||||
JWT_PRIVATE_SIGNING_KEY = None
|
||||
JWT_EXPIRED_PRIVATE_SIGNING_KEYS = []
|
||||
|
||||
# Credit notifications settings
|
||||
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
|
||||
NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png"
|
||||
@@ -3158,20 +3153,7 @@ NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.pn
|
||||
|
||||
################################ Settings for JWTs ################################
|
||||
|
||||
DEFAULT_JWT_ISSUER = {
|
||||
'ISSUER': 'change-me',
|
||||
'SECRET_KEY': SECRET_KEY,
|
||||
'AUDIENCE': 'change-me',
|
||||
}
|
||||
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER = {
|
||||
'ISSUER': 'change-me',
|
||||
'SECRET_KEY': SECRET_KEY,
|
||||
'AUDIENCE': 'change-me',
|
||||
}
|
||||
|
||||
JWT_AUTH = {
|
||||
'JWT_ALGORITHM': 'HS256',
|
||||
'JWT_VERIFY_EXPIRATION': True,
|
||||
|
||||
'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'),
|
||||
@@ -3182,13 +3164,15 @@ JWT_AUTH = {
|
||||
'JWT_EXPIRATION': 30,
|
||||
'JWT_SUPPORTED_VERSION': '1.1.0',
|
||||
|
||||
'JWT_SECRET_KEY': DEFAULT_JWT_ISSUER['SECRET_KEY'],
|
||||
'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'],
|
||||
'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'],
|
||||
'JWT_ISSUERS': [
|
||||
DEFAULT_JWT_ISSUER,
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER,
|
||||
],
|
||||
'JWT_ALGORITHM': 'HS256',
|
||||
'JWT_SECRET_KEY': SECRET_KEY,
|
||||
|
||||
'JWT_SIGNING_ALGORITHM': 'RS512',
|
||||
'JWT_PRIVATE_SIGNING_JWK': None,
|
||||
'JWT_PUBLIC_SIGNING_JWK_SET': None,
|
||||
|
||||
'JWT_ISSUER': 'change-me',
|
||||
'JWT_AUDIENCE': 'change-me',
|
||||
}
|
||||
|
||||
################################ Settings for Microsites ################################
|
||||
|
||||
@@ -228,41 +228,32 @@ CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ORIGIN_WHITELIST = ()
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
|
||||
# JWT settings for devstack
|
||||
JWT_PRIVATE_SIGNING_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-----"""
|
||||
|
||||
###################### JWTs ######################
|
||||
JWT_AUTH.update({
|
||||
'JWT_SECRET_KEY': 'lms-secret',
|
||||
'JWT_ISSUER': 'http://127.0.0.1:8000/oauth2',
|
||||
'JWT_ISSUER': OAUTH_OIDC_ISSUER,
|
||||
'JWT_AUDIENCE': 'lms-key',
|
||||
|
||||
'JWT_SECRET_KEY': 'lms-secret',
|
||||
|
||||
'JWT_PRIVATE_SIGNING_JWK': (
|
||||
'{"e": "AQAB", "d": "RQ6k4NpRU3RB2lhwCbQ452W86bMMQiPsa7EJiFJUg-qBJthN0FMNQVbArtrCQ0xA1BdnQHThFiUnHcXfsTZUwmwvTu'
|
||||
'iqEGR_MI6aI7h5D8vRj_5x-pxOz-0MCB8TY8dcuK9FkljmgtYvV9flVzCk_uUb3ZJIBVyIW8En7n7nV7JXpS9zey1yVLld2AbRG6W5--Pgqr9J'
|
||||
'CI5-bLdc2otCLuen2sKyuUDHO5NIj30qGTaKUL-OW_PgVmxrwKwccF3w5uGNEvMQ-IcicosCOvzBwdIm1uhdm9rnHU1-fXz8VLRHNhGVv7z6mo'
|
||||
'ghjNI0_u4smhUkEsYeshPv7RQEWTdkOQ", "n": "smKFSYowG6nNUAdeqH1jQQnH1PmIHphzBmwJ5vRf1vu48BUI5VcVtUWIPqzRK_LDSlZYh'
|
||||
'9D0YFL0ZTxIrlb6Tn3Xz7pYvpIAeYuQv3_H5p8tbz7Fb8r63c1828wXPITVTv8f7oxx5W3lFFgpFAyYMmROC4Ee9qG5T38LFe8_oAuFCEntimW'
|
||||
'xN9F3P-FJQy43TL7wG54WodgiM0EgzkeLr5K6cDnyckWjTuZbWI-4ffcTgTZsL_Kq1owa_J2ngEfxMCObnzGy5ZLcTUomo4rZLjghVpq6KZxfS'
|
||||
'6I1Vz79ZsMVUWEdXOYePCKKsrQG20ogQEkmTf9FT_SouC6jPcHLXw", "q": "7KWj7l-ZkfCElyfvwsl7kiosvi-ppOO7Imsv90cribf88Dex'
|
||||
'cO67xdMPesjM9Nh5X209IT-TzbsOtVTXSQyEsy42NY72WETnd1_nAGLAmfxGdo8VV4ZDnRsA8N8POnWjRDwYlVBUEEeuT_MtMWzwIKU94bzkWV'
|
||||
'nHCY5vbhBYLeM", "p": "wPkfnjavNV1Hqb5Qqj2crBS9HQS6GDQIZ7WF9hlBb2ofDNe2K2dunddFqCOdvLXr7ydRcK51ZwSeHjcjgD1aJkHA'
|
||||
'9i1zqyboxgd0uAbxVDo6ohnlVqYLtap2tXXcavKm4C9MTpob_rk6FBfEuq4uSsuxFvCER4yG3CYBBa4gZVU", "kid": "devstack_key", "'
|
||||
'kty": "RSA"}'
|
||||
),
|
||||
'JWT_PUBLIC_SIGNING_JWK_SET': (
|
||||
'{"keys": [{"kid": "devstack_key", "e": "AQAB", "kty": "RSA", "n": "smKFSYowG6nNUAdeqH1jQQnH1PmIHphzBmwJ5vRf1vu'
|
||||
'48BUI5VcVtUWIPqzRK_LDSlZYh9D0YFL0ZTxIrlb6Tn3Xz7pYvpIAeYuQv3_H5p8tbz7Fb8r63c1828wXPITVTv8f7oxx5W3lFFgpFAyYMmROC'
|
||||
'4Ee9qG5T38LFe8_oAuFCEntimWxN9F3P-FJQy43TL7wG54WodgiM0EgzkeLr5K6cDnyckWjTuZbWI-4ffcTgTZsL_Kq1owa_J2ngEfxMCObnzG'
|
||||
'y5ZLcTUomo4rZLjghVpq6KZxfS6I1Vz79ZsMVUWEdXOYePCKKsrQG20ogQEkmTf9FT_SouC6jPcHLXw"}]}'
|
||||
),
|
||||
})
|
||||
|
||||
#####################################################################
|
||||
|
||||
@@ -27,18 +27,8 @@ CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150'
|
||||
|
||||
OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL)
|
||||
|
||||
DEFAULT_JWT_ISSUER = {
|
||||
'ISSUER': OAUTH_OIDC_ISSUER,
|
||||
'SECRET_KEY': 'lms-secret',
|
||||
'AUDIENCE': 'lms-key',
|
||||
}
|
||||
JWT_AUTH.update({
|
||||
'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'],
|
||||
'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'],
|
||||
'JWT_ISSUERS': [
|
||||
DEFAULT_JWT_ISSUER,
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER,
|
||||
],
|
||||
'JWT_ISSUER': OAUTH_OIDC_ISSUER,
|
||||
})
|
||||
|
||||
FEATURES.update({
|
||||
|
||||
@@ -272,19 +272,6 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
|
||||
# don't cache courses for testing
|
||||
OIDC_COURSE_HANDLER_CACHE_TIMEOUT = 0
|
||||
|
||||
########################### Settings for JWTs ##################################
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER = {
|
||||
'ISSUER': 'restricted-app',
|
||||
'SECRET_KEY': 'restricted-secret',
|
||||
'AUDIENCE': 'restricted-app',
|
||||
}
|
||||
JWT_AUTH.update({
|
||||
'JWT_ISSUERS': [
|
||||
DEFAULT_JWT_ISSUER,
|
||||
RESTRICTED_APPLICATION_JWT_ISSUER,
|
||||
],
|
||||
})
|
||||
|
||||
########################### External REST APIs #################################
|
||||
FEATURES['ENABLE_MOBILE_REST_API'] = True
|
||||
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
|
||||
|
||||
@@ -72,13 +72,11 @@ unprotected microservices.
|
||||
make the new keys available to a microservice only after they
|
||||
have been updated to enforce OAuth Scopes.
|
||||
|
||||
* edx_rest_framework_extensions.settings_ supports having a list of
|
||||
JWT_ISSUERS instead of just a single one.
|
||||
|
||||
* The `edx-platform settings`_ will be updated to have a list of
|
||||
JWT_ISSUERS instead of a single JWT_ISSUER in its settings (example_).
|
||||
A separate settings field will keep track of which is the new issuer
|
||||
key that is to be used for signing tokens for Restricted Application.
|
||||
* The `edx-platform settings`_ will be updated to support a new signing
|
||||
key. Since this transition to using a new key will happen as a staged
|
||||
rollout, we will take this opportunity to have the new signing key be
|
||||
an asymmetric key, rather than the current (not as secure) shared
|
||||
symmetric key.
|
||||
|
||||
* oauth_dispatch.views.AccessTokenView.dispatch_ will be updated to
|
||||
pass the new JWT key to JwtBuilder_, but only if
|
||||
@@ -90,12 +88,10 @@ unprotected microservices.
|
||||
JWT tokens for Restricted Applications, but ONLY if:
|
||||
|
||||
* the token_type in the request equals *"jwt"* and
|
||||
* a `feature toggle (switch)`_ named "oauth2.unexpired_restricted_applications"
|
||||
* a `feature toggle (switch)`_ named "oauth2.enforce_jwt_scopes"
|
||||
is enabled.
|
||||
|
||||
.. _edx_rest_framework_extensions.settings: https://github.com/edx/edx-drf-extensions/blob/1db9f5e3e5130a1e0f43af2035489b3ed916d245/edx_rest_framework_extensions/settings.py#L73
|
||||
.. _edx-platform settings: https://github.com/edx/edx-platform/blob/master/lms/envs/docs/README.rst
|
||||
.. _example: https://github.com/edx/edx-drf-extensions/blob/1db9f5e3e5130a1e0f43af2035489b3ed916d245/test_settings.py#L51
|
||||
.. _JwtBuilder: https://github.com/edx/edx-platform/blob/d3d64970c36f36a96d684571ec5b48ed645618d8/openedx/core/lib/token_utils.py#L15
|
||||
.. _oauth_dispatch.views.AccessTokenView.dispatch: https://github.com/edx/edx-platform/blob/d21a09828072504bc97a2e05883c1241e3a35da9/openedx/core/djangoapps/oauth_dispatch/views.py#L100
|
||||
.. _oauth_dispatch.validators: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
8. Use Asymmetric JWTs
|
||||
----------------------
|
||||
|
||||
Status
|
||||
------
|
||||
|
||||
Accepted
|
||||
|
||||
Context
|
||||
-------
|
||||
|
||||
The edX OAuth Provider (via this OAuth Dispatch Django app) builds and returns JSON Web Tokens (JWTs)
|
||||
when an OAuth client requests an access token with "token_type=jwt" in the request. See `Use JWT as
|
||||
OAuth2 Tokens; Remove OpenID Connect`_.
|
||||
|
||||
We use a shared secret ("symmetric" cryptographic key) to "sign" the JWT with an HMAC (a keyed-hash
|
||||
message authentication code). This means the secret used by the OAuth Provider to create JWTs is not
|
||||
really a secret since all OAuth Clients need to know the value of the secret in order to verify the
|
||||
contents of the JWT.
|
||||
|
||||
The JWT is currently not encrypted, only signed. So any client can always read the contents of the JWT.
|
||||
But to verify that the JWT was created by the OAuth Provider, the client should first verify the HMAC
|
||||
sent along with the JWT. Since the secret is "symmetric" any OAuth Client that is privy to the secret
|
||||
could also have just as easily created the JWT (thus spoofing the OAuth Provider).
|
||||
|
||||
.. _`Use JWT as OAuth2 Tokens; Remove OpenID Connect`: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0003-use-jwt-as-oauth-tokens-remove-openid-connect.rst
|
||||
|
||||
Additionally, for clients that still use Open ID Connect, their `ID Tokens are HMACed with their own
|
||||
client_secret`_ (privately shared with the OAuth Provider). Although this somewhat mitigates the issue
|
||||
above since each OAuth Client can no longer create tokens verifiable by other Clients, it does not
|
||||
allow a Client to forward a verifiable token to other Clients.
|
||||
|
||||
.. _ID Tokens are HMACed with their own client_secret: https://github.com/edx/edx-oauth2-provider/blob/7e59e30ae0bfd9eac4d05469768d79c50a90aeb7/edx_oauth2_provider/views.py#L155-L163
|
||||
|
||||
Looking forward, we want to support Single Page Apps (a.k.a., Microfronteds), where users can seamlessly
|
||||
traverse from one microfronted to another and access APIs on various backends. This *Single Sign On*
|
||||
capability cannot be achieved unless verifiable tokens can be forwarded from one service to another.
|
||||
|
||||
Decisions
|
||||
---------
|
||||
|
||||
Asymmetric JWTs
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
We will introduce identified "asymmetric" cryptographic keys for signing JWTs. The OAuth Provider will
|
||||
be the only service configured with the aymmetric keypair, including its Private and Public key portions.
|
||||
All other OAuth Clients will be configured with only the Public key portion of the asymmetric key pair.
|
||||
|
||||
"kid" Key Identifier
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
In order to support key rotation in a forward compatible manner, we will identify the asymmetric keys,
|
||||
using the `JSON Web Key (JWK)`_ standard's `"kid" (Key ID)`_ parameter. When a `JSON Web Signature (JWS)`_
|
||||
is created to sign a JWT, its `"kid" header parameter`_ specifies which key was used to secure the JWS.
|
||||
The code examples below show this in action.
|
||||
|
||||
.. _JSON Web Key (JWK): https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36
|
||||
.. _`"kid" (Key ID)`: https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36#section-4.5
|
||||
.. _JSON Web Signature (JWS): https://tools.ietf.org/html/rfc7515
|
||||
.. _`"kid" header parameter`: https://tools.ietf.org/html/rfc7515#section-4.1.4
|
||||
|
||||
Remove JWT_ISSUERS
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
edx_rest_framework_extensions.settings_ supports having a list of **JWT_ISSUERS** instead of just a single
|
||||
one. This support for configuring multiple issuers is present across many services. However, this does not
|
||||
conform to the `JWT standard`_, where the `issuer`_ is intended to identify the entity that generates and
|
||||
signs the JWT. In our case, that should be the single Auth service only.
|
||||
|
||||
If different values for the issuer_ claim are needed for multi-tenancy purposes, those should be specified
|
||||
using `site configuration`_ variants instead of adding complexity with multiple issuers.
|
||||
|
||||
Additionally, **JWT_ISSUERS** is not intended to be used for key rotation. Rather, the set of active signing
|
||||
keys should be specified as a `JSON Web Key Set (JWK Set)`_ instead. Thus, there would only be a single
|
||||
issuer, but with (the potential of) multiple signing keys stored in a JWT Set.
|
||||
|
||||
.. _edx_rest_framework_extensions.settings: https://github.com/edx/edx-drf-extensions/blob/1db9f5e3e5130a1e0f43af2035489b3ed916d245/edx_rest_framework_extensions/settings.py#L73
|
||||
.. _JWT standard: https://tools.ietf.org/html/rfc7519
|
||||
.. _issuer: https://tools.ietf.org/html/rfc7519#section-4.1.1
|
||||
.. _JSON Web Key Set (JWK Set): https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36#section-5
|
||||
.. _site configuration: https://github.com/edx/edx-platform/blob/af841336c7e39d634c238cd8a11c5a3a661aa9e2/openedx/core/djangoapps/site_configuration/__init__.py
|
||||
|
||||
Example Code
|
||||
------------
|
||||
|
||||
KeyPair Generation
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Here is code for generating a keypair::
|
||||
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from jwkest import jwk
|
||||
|
||||
rsa_key = RSA.generate(2048)
|
||||
rsa_jwk = jwk.RSAKey(kid="your_key_id", key=rsa_key)
|
||||
|
||||
To serialize the **public key** in a `JSON Web Key Set (JWK Set)`_::
|
||||
|
||||
public_keys = jwk.KEYS()
|
||||
public_keys.append(rsa_jwk)
|
||||
serialized_public_keys_json = public_keys.dump_jwks()
|
||||
|
||||
and its sample output::
|
||||
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kid": "your_key_id",
|
||||
"e": "strawberry",
|
||||
"kty": "RSA",
|
||||
"n": "something"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
To serialize the **keypair** as a JWK::
|
||||
|
||||
serialized_keypair = rsa_jwk.serialize(private=True)
|
||||
serialized_keypair_json = json.dumps(serialized_keypair)
|
||||
|
||||
and its sample output::
|
||||
|
||||
{
|
||||
"e": "strawberry",
|
||||
"d": "apple",
|
||||
"n": "banana",
|
||||
"q": "pear",
|
||||
"p": "plum",
|
||||
"kid": "your_key_id",
|
||||
"kty": "RSA"
|
||||
}
|
||||
|
||||
Signing
|
||||
~~~~~~~
|
||||
|
||||
To deserialize the keypair from above::
|
||||
|
||||
private_keys = jwk.KEYS()
|
||||
serialized_keypair = json.loads(serialized_keypair_json)
|
||||
private_keys.add(serialized_keypair)
|
||||
|
||||
To create a signature::
|
||||
|
||||
from jwkest.jws import JWS
|
||||
jws = JWS("JWT payload", alg="RS512")
|
||||
signed_message = jws.sign_compact(keys=private_keys)
|
||||
|
||||
Note: we specify **RS512** above to identify *RSASSA-PKCS1-v1_5 using SHA-512* as
|
||||
the signature algorithm value as described in the `JSON Web Algorithms (JWA)`_ spec.
|
||||
|
||||
.. _JSON Web Algorithms (JWA): https://tools.ietf.org/html/rfc7518#section-3.3
|
||||
|
||||
Verify Signature
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
To verify the signature from above::
|
||||
|
||||
public_keys = jwk.KEYS()
|
||||
public_keys.load_jwks(serialized_public_keys_json)
|
||||
jws.verify_compact(signed_message, public_keys)
|
||||
|
||||
Key Rotation
|
||||
~~~~~~~~~~~~
|
||||
|
||||
When a new public key is added in the future, it should have a unique "kid"
|
||||
value and added to the public keys JWK set::
|
||||
|
||||
new_rsa_key = RSA.generate(2048)
|
||||
new_rsa_jwk = jwk.RSAKey(kid="new_id", key=new_rsa_key)
|
||||
public_keys.append(new_rsa_jwk)
|
||||
|
||||
When a JWS is created, it is signed with a certain "kid"-identified keypair. When it
|
||||
is later verified, the public key with the matching "kid" in the JWK set is used.
|
||||
|
||||
Consequences
|
||||
------------
|
||||
|
||||
* As described in the Context_, there are both security and feature (Single Sign On)
|
||||
benefits of using asymmetric JWTs.
|
||||
|
||||
* As we transition away from DOP and Open ID Connect (see past decisions), we continue
|
||||
to have multiple authentication implementations in the platform. Introducing
|
||||
asymmetric JWTs introduces yet another. The sooner we upgrade our dependent services
|
||||
and remove these other mechanisms, the better - in the meantime, we are increasing
|
||||
code complexity.
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
OAuth Dispatch test mixins
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from jwkest.jwk import KEYS
|
||||
from jwkest.jws import JWS
|
||||
import jwt
|
||||
from jwt.exceptions import ExpiredSignatureError
|
||||
|
||||
@@ -14,7 +15,7 @@ class AccessTokenMixin(object):
|
||||
""" Mixin for tests dealing with OAuth 2 access tokens. """
|
||||
|
||||
def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False, filters=None,
|
||||
jwt_issuer=settings.DEFAULT_JWT_ISSUER, should_be_restricted=None):
|
||||
should_be_asymmetric_key=False, should_be_restricted=None):
|
||||
"""
|
||||
Verify the specified JWT access token is valid, and belongs to the specified user.
|
||||
|
||||
@@ -22,27 +23,38 @@ class AccessTokenMixin(object):
|
||||
access_token (str): JWT
|
||||
user (User): User whose information is contained in the JWT payload.
|
||||
(optional) should_be_expired: indicates if the passed in JWT token is expected to be expired
|
||||
(optional) should_be_asymmetric_key: indicates if the JWT token should be signed with an
|
||||
asymmetric key.
|
||||
|
||||
Returns:
|
||||
dict: Decoded JWT payload
|
||||
"""
|
||||
scopes = scopes or []
|
||||
audience = jwt_issuer['AUDIENCE']
|
||||
issuer = jwt_issuer['ISSUER']
|
||||
secret_key = jwt_issuer['SECRET_KEY']
|
||||
audience = settings.JWT_AUTH['JWT_AUDIENCE']
|
||||
issuer = settings.JWT_AUTH['JWT_ISSUER']
|
||||
secret_key = settings.JWT_AUTH['JWT_SECRET_KEY']
|
||||
|
||||
def _decode_jwt(verify_expiration):
|
||||
"""
|
||||
Helper method to decode a JWT with the ability to
|
||||
verify the expiration of said token
|
||||
"""
|
||||
keys = KEYS()
|
||||
if should_be_asymmetric_key:
|
||||
keys.load_jwks(settings.JWT_AUTH['JWT_PUBLIC_SIGNING_JWK_SET'])
|
||||
else:
|
||||
keys.add({'key': secret_key, 'kty': 'oct'})
|
||||
|
||||
_ = JWS().verify_compact(access_token.encode('utf-8'), keys)
|
||||
|
||||
return jwt.decode(
|
||||
access_token,
|
||||
secret_key,
|
||||
algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']],
|
||||
audience=audience,
|
||||
issuer=issuer,
|
||||
verify_expiration=verify_expiration
|
||||
verify_expiration=verify_expiration,
|
||||
options={'verify_signature': False},
|
||||
)
|
||||
|
||||
# Note that if we expect the claims to have expired
|
||||
|
||||
@@ -9,8 +9,12 @@ import ddt
|
||||
import httpretty
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from mock import call, patch
|
||||
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from jwkest import jwk
|
||||
|
||||
from oauth2_provider import models as dot_models
|
||||
from organizations.tests.factories import OrganizationFactory
|
||||
|
||||
@@ -167,6 +171,20 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
|
||||
|
||||
return body
|
||||
|
||||
def _generate_key_pair(self):
|
||||
""" Generates an asymmetric key pair and returns the JWK of its public keys and keypair. """
|
||||
rsa_key = RSA.generate(2048)
|
||||
rsa_jwk = jwk.RSAKey(kid="key_id", key=rsa_key)
|
||||
|
||||
public_keys = jwk.KEYS()
|
||||
public_keys.append(rsa_jwk)
|
||||
serialized_public_keys_json = public_keys.dump_jwks()
|
||||
|
||||
serialized_keypair = rsa_jwk.serialize(private=True)
|
||||
serialized_keypair_json = json.dumps(serialized_keypair)
|
||||
|
||||
return serialized_public_keys_json, serialized_keypair_json
|
||||
|
||||
@ddt.data('dop_app', 'dot_app')
|
||||
def test_access_token_fields(self, client_attr):
|
||||
client = getattr(self, client_attr)
|
||||
@@ -242,33 +260,41 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
|
||||
mock_set_custom_metric.assert_has_calls(expected_calls, any_order=True)
|
||||
|
||||
@ddt.data(
|
||||
(False, True, settings.DEFAULT_JWT_ISSUER),
|
||||
(True, False, settings.RESTRICTED_APPLICATION_JWT_ISSUER),
|
||||
(False, True),
|
||||
(True, False),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_restricted_jwt_access_token(self, enforce_jwt_scopes_enabled, expiration_expected,
|
||||
jwt_issuer_expected):
|
||||
def test_restricted_jwt_access_token(self, enforce_jwt_scopes_enabled, expiration_expected):
|
||||
"""
|
||||
Verify that when requesting a JWT token from a restricted Application
|
||||
within the DOT subsystem, that our claims is marked as already expired
|
||||
(i.e. expiry set to Jan 1, 1970)
|
||||
"""
|
||||
with ENFORCE_JWT_SCOPES.override(enforce_jwt_scopes_enabled):
|
||||
response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertIn('expires_in', data)
|
||||
self.assertEqual(data['expires_in'] < 0, expiration_expected)
|
||||
self.assertEqual(data['token_type'], 'JWT')
|
||||
self.assert_valid_jwt_access_token(
|
||||
data['access_token'],
|
||||
self.user,
|
||||
data['scope'].split(' '),
|
||||
should_be_expired=expiration_expected,
|
||||
jwt_issuer=jwt_issuer_expected,
|
||||
should_be_restricted=True,
|
||||
)
|
||||
public_jwk_set, private_jwk = self._generate_key_pair()
|
||||
jwt_auth_settings = settings.JWT_AUTH
|
||||
jwt_auth_settings.update({
|
||||
'JWT_PRIVATE_SIGNING_JWK': private_jwk,
|
||||
'JWT_PUBLIC_SIGNING_JWK_SET': public_jwk_set,
|
||||
})
|
||||
with override_settings(JWT_AUTH=jwt_auth_settings):
|
||||
|
||||
response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertIn('expires_in', data)
|
||||
self.assertEqual(data['expires_in'] < 0, expiration_expected)
|
||||
self.assertEqual(data['token_type'], 'JWT')
|
||||
self.assert_valid_jwt_access_token(
|
||||
data['access_token'],
|
||||
self.user,
|
||||
data['scope'].split(' '),
|
||||
should_be_expired=expiration_expected,
|
||||
should_be_asymmetric_key=enforce_jwt_scopes_enabled,
|
||||
should_be_restricted=True,
|
||||
)
|
||||
|
||||
def test_restricted_access_token(self):
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,7 @@ django-oauth-toolkit as appropriate.
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.views.generic import View
|
||||
@@ -23,6 +24,8 @@ from . import adapters
|
||||
from .dot_overrides import views as dot_overrides_views
|
||||
from .toggles import ENFORCE_JWT_SCOPES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _DispatchingView(View):
|
||||
"""
|
||||
@@ -115,61 +118,58 @@ class AccessTokenView(RatelimitMixin, _DispatchingView):
|
||||
""" Builds the content of the response, including the JWT token. """
|
||||
client_id = self._get_client_id(request)
|
||||
adapter = self.get_adapter(request)
|
||||
expires_in, scopes, user = self._decompose_access_token_response(adapter, response)
|
||||
issuer, secret, audience, filters, is_client_restricted = self._get_client_specific_claims(
|
||||
client_id,
|
||||
adapter
|
||||
)
|
||||
is_client_restricted = adapter.is_client_restricted(client_id)
|
||||
|
||||
expires_in, scope, user = self._parse_access_token_response(adapter, response)
|
||||
jwt_builder = self._get_jwt_builder(user, is_client_restricted)
|
||||
|
||||
content = {
|
||||
'access_token': JwtBuilder(
|
||||
user,
|
||||
secret=secret,
|
||||
issuer=issuer,
|
||||
).build_token(
|
||||
scopes,
|
||||
'access_token': jwt_builder.build_token(
|
||||
scope.split(' '),
|
||||
expires_in,
|
||||
aud=audience,
|
||||
additional_claims={
|
||||
'filters': filters,
|
||||
'filters': adapter.get_authorization_filters(client_id),
|
||||
'is_restricted': is_client_restricted,
|
||||
},
|
||||
),
|
||||
'expires_in': expires_in,
|
||||
'scope': scope,
|
||||
'token_type': 'JWT',
|
||||
'scope': ' '.join(scopes),
|
||||
}
|
||||
return json.dumps(content)
|
||||
|
||||
def _decompose_access_token_response(self, adapter, response):
|
||||
""" Decomposes the access token in the request to an expiration date, scopes, and User. """
|
||||
def _parse_access_token_response(self, adapter, response):
|
||||
""" Parses the expires_in, scope, and user values of the response. """
|
||||
content = json.loads(response.content)
|
||||
access_token = content['access_token']
|
||||
scope = content['scope']
|
||||
scopes = scope.split(' ')
|
||||
user = adapter.get_access_token(access_token).user
|
||||
expires_in = content['expires_in']
|
||||
return expires_in, scopes, user
|
||||
scope = content['scope']
|
||||
user = adapter.get_access_token(access_token).user
|
||||
return expires_in, scope, user
|
||||
|
||||
def _get_jwt_builder(self, user, is_client_restricted):
|
||||
""" Creates and returns a JWTBuilder object for creating JWTs. """
|
||||
|
||||
def _get_client_specific_claims(self, client_id, adapter):
|
||||
""" Get claims that are specific to the client. """
|
||||
# If JWT scope enforcement is enabled, we need to sign tokens
|
||||
# given to restricted application with a separate secret which
|
||||
# given to restricted applications with a key that
|
||||
# other IDAs do not have access to. This prevents restricted
|
||||
# applications from getting access to API endpoints available
|
||||
# on other IDAs which have not yet been protected with the
|
||||
# scope-related DRF permission classes. Once all endpoints have
|
||||
# been protected we can remove this if/else and go back to using
|
||||
# a single secret.
|
||||
# been protected, we can enable all IDAs to use the same new
|
||||
# (asymmetric) key.
|
||||
# TODO: ARCH-162
|
||||
is_client_restricted = adapter.is_client_restricted(client_id)
|
||||
if ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted:
|
||||
issuer_setting = 'RESTRICTED_APPLICATION_JWT_ISSUER'
|
||||
else:
|
||||
issuer_setting = 'DEFAULT_JWT_ISSUER'
|
||||
use_asymmetric_key = ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted
|
||||
monitoring_utils.set_custom_metric('oauth_asymmetric_jwt', use_asymmetric_key)
|
||||
|
||||
jwt_issuer = getattr(settings, issuer_setting)
|
||||
filters = adapter.get_authorization_filters(client_id)
|
||||
return jwt_issuer['ISSUER'], jwt_issuer['SECRET_KEY'], jwt_issuer['AUDIENCE'], filters, is_client_restricted
|
||||
log.info("Using Asymmetric JWT: %s", use_asymmetric_key)
|
||||
|
||||
return JwtBuilder(
|
||||
user,
|
||||
asymmetric=use_asymmetric_key,
|
||||
secret=settings.JWT_AUTH['JWT_SECRET_KEY'],
|
||||
issuer=settings.JWT_AUTH['JWT_ISSUER'],
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationView(_DispatchingView):
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import json
|
||||
from time import time
|
||||
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from django.conf import settings
|
||||
from django.utils.functional import cached_property
|
||||
from jwkest.jwk import KEYS, RSAKey
|
||||
from jwkest import jwk
|
||||
from jwkest.jws import JWS
|
||||
|
||||
from student.models import UserProfile, anonymous_id_for_user
|
||||
@@ -105,11 +104,12 @@ class JwtBuilder(object):
|
||||
|
||||
def encode(self, payload):
|
||||
"""Encode the provided payload."""
|
||||
keys = KEYS()
|
||||
keys = jwk.KEYS()
|
||||
|
||||
if self.asymmetric:
|
||||
keys.add(RSAKey(key=RSA.importKey(settings.JWT_PRIVATE_SIGNING_KEY)))
|
||||
algorithm = 'RS512'
|
||||
serialized_keypair = json.loads(self.jwt_auth['JWT_PRIVATE_SIGNING_JWK'])
|
||||
keys.add(serialized_keypair)
|
||||
algorithm = self.jwt_auth['JWT_SIGNING_ALGORITHM']
|
||||
else:
|
||||
key = self.secret if self.secret else self.jwt_auth['JWT_SECRET_KEY']
|
||||
keys.add({'key': key, 'kty': 'oct'})
|
||||
|
||||
@@ -116,7 +116,7 @@ edx-completion==0.1.8
|
||||
edx-django-oauth2-provider==1.3.4
|
||||
edx-django-release-util==0.3.1
|
||||
edx-django-sites-extensions==2.3.1
|
||||
edx-drf-extensions==1.5.2
|
||||
edx-drf-extensions==1.5.4
|
||||
edx-enterprise==0.72.2
|
||||
edx-i18n-tools==0.4.6
|
||||
edx-milestones==0.1.13
|
||||
|
||||
Reference in New Issue
Block a user