From eac1ce7bfdc01a3c036779808f4e00e42480adca Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Mon, 23 Jul 2018 16:16:44 -0400 Subject: [PATCH] Asymmetric JWT support --- cms/envs/common.py | 2 - lms/envs/aws.py | 8 +- lms/envs/common.py | 34 +--- lms/envs/devstack.py | 57 +++--- lms/envs/devstack_docker.py | 12 +- lms/envs/test.py | 13 -- .../0006-enforce-scopes-in-LMS-APIs.rst | 16 +- .../decisions/0008-use-asymmetric-jwts.rst | 185 ++++++++++++++++++ .../djangoapps/oauth_dispatch/tests/mixins.py | 24 ++- .../oauth_dispatch/tests/test_views.py | 64 ++++-- .../core/djangoapps/oauth_dispatch/views.py | 66 +++---- openedx/core/lib/token_utils.py | 10 +- requirements/edx/base.txt | 2 +- 13 files changed, 328 insertions(+), 165 deletions(-) create mode 100644 openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst diff --git a/cms/envs/common.py b/cms/envs/common.py index d2692515ce..612c434830 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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, diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 687ba4663a..fbd8e53fcb 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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 ################## diff --git a/lms/envs/common.py b/lms/envs/common.py index 22a25bed58..f0361c7532 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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 ################################ diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 7e674a5fac..c715f40d87 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -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"}]}' + ), }) ##################################################################### diff --git a/lms/envs/devstack_docker.py b/lms/envs/devstack_docker.py index 8ce464f8cb..0f67d6a6d3 100644 --- a/lms/envs/devstack_docker.py +++ b/lms/envs/devstack_docker.py @@ -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({ diff --git a/lms/envs/test.py b/lms/envs/test.py index ee8d0c21c3..392e6ddc3e 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -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 diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst index b5379d20b7..3135a05df5 100644 --- a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst +++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst @@ -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 diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst new file mode 100644 index 0000000000..f9e9bf19fc --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst @@ -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. diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py index a92421e057..e8dddd7ae6 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py @@ -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 diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py index 690a14416b..098980b460 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py @@ -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): """ diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index 3f0bb447e7..65df22d39b 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -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): diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index 9780912afc..706ce67bdb 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -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'}) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b12726c35f..b00c5b0a49 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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