diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0009-jwt-in-session-cookie.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0009-jwt-in-session-cookie.rst new file mode 100644 index 0000000000..e6174ddf03 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0009-jwt-in-session-cookie.rst @@ -0,0 +1,205 @@ +9. Transport JWT in HTTP Cookies +-------------------------------- + +Status +------ + +Accepted + +Context +------- + +For background, please see: + +* `Use JWT as OAuth2 Tokens`_, where we decided to use JSON Web Tokens (JWTs) as OAuth2 access tokens, thereby + embedding user identification information in access tokens. + +* `Use Asymmetric JWTs`_, where we decided to sign JWTs with public-private keypairs, thereby enabling less trusted + 3rd parties to receive and verify JWTs (with published signing public keys). + + +These earlier decisions have focused on the authentication needs of backend services for their connections and API +requests. Those services use traditional OAuth2 grant types (Credentials and Authorization Code) and obtain JWTs for +making API requests - as described in `Use JWT as OAuth2 Tokens`_. + +Moving forward, we need a simple and easy-to-use authentication mechanism for frontend applications as well. As +described in `Decoupled Frontend Architecture`_, each individual `microfrontend`_ supports its own use case. As a +user interacts with the overall application, the user's experience may lead them through multiple microfrontends, +each accessing APIs on various backends. Stateless authentication (via self-contained JWTs) would allow scalable +interactions between microfrontends and microservices. + +Note: User authentication for open edX mobile apps is outside the scope of this decision record. As a brief note, we +believe any decisions in this record will neither affect the current authentication mechanisms used for mobile +apps nor impact forward compatibility when/if mobile apps are consolidated to use a similar (if not the same) +authentication mechanism as outlined here for web apps. + +.. _Use JWT as OAuth2 Tokens: 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 +.. _Use Asymmetric JWTs: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst +.. _Decoupled Frontend Architecture: https://openedx.atlassian.net/wiki/spaces/FEDX/pages/790692200/Decoupled+Frontend+Architecture +.. _microfrontend: https://micro-frontends.org/ + +Decisions +--------- + +Login -> Cookie -> API +^^^^^^^^^^^^^^^^^^^^^^ + +#. **Single Login Microfrontend and Microservice.** There will be only a single microfrontend and a corresponding + single (backend) microservice (currently LMS) from which users can login to the edX system. This will isolate any + login-related vulnerabilities (i.e., frontend applications that gain access to users' passwords) and + login-related protections (i.e., password validation policies) to single points in the system. + +#. **"Two JWT Cookies".** Upon successful login, the backend login service will create and sign a JWT to identify the + newly logged in user. The JWT will be divided into the following 2 HTTP cookies (inspired by `Lightrail's + design`_), included in the login response, and stored in the user's browser cookie jar: + + * **"JWT Header/Payload Cookie"** + * Contains only the header and payload portions of the JWT. + * Disable HTTPOnly_ so the microfrontend can access user/role data in the JWT payload. + + * **"JWT Signature Cookie"** + * Contains only the public key signature portion of the JWT. + * Enable HTTPOnly_ so the signature is unavailable to JS code. See `JWT Cookie Security`_ below. + +#. **Automatically recombine and extract the JWT from Cookies on API calls.** + * We will create a new middleware that will reconstitute the divided JWT from its two cookies and store the + recombined JWT in a temporary cookie specified by JWT_AUTH_COOKIE_. + * The `Django Rest Framework JWT`_ library we use makes use of the JWT_AUTH_COOKIE_ configuration setting. + When set, the JSONWebTokenAuthentication_ class `automatically extracts the JWT from the cookie`_. Since all + open edX REST endpoints that support JWT-based authentication derive from this base class, their authentication + checks will make use of the JWTs provided in the JWT-related cookies. + +.. _`Lightrail's design`: https://medium.com/lightrail/getting-token-authentication-right-in-a-stateless-single-page-application-57d0c6474e3 +.. _Django Rest Framework JWT: https://getblimp.github.io/django-rest-framework-jwt/ +.. _JWT_AUTH_COOKIE: https://github.com/GetBlimp/django-rest-framework-jwt/blob/master/docs/index.md#jwt_auth_cookie +.. _JSONWebTokenAuthentication: https://github.com/GetBlimp/django-rest-framework-jwt/blob/0a0bd402ec21fd6b9a5f715d114411836fbb2923/rest_framework_jwt/authentication.py#L71 +.. _automatically extracts the JWT from the cookie: https://github.com/GetBlimp/django-rest-framework-jwt/blob/0a0bd402ec21fd6b9a5f715d114411836fbb2923/rest_framework_jwt/authentication.py#L86-L87 + + +JWT Cookie Lifetime +^^^^^^^^^^^^^^^^^^^ + +#. **Cookie and JWT expiration.** Both the HTTP cookies and the JWT have expiration times. + + * For simplicity and consistency, the cookies and their containing JWT will expire at the same time. There's + no need to have these be different values. + + * Given this, JWT cookies will always have expiration values, unlike `current open edX session cookies that may + have no expiration`_. + + * A configuration setting, JWT_AUTH_COOKIE_EXPIRATION, will specify the expiration duration for JWTs and their + containing cookie. + +#. **Revocation with short-lived JWTs** Given the tradeoff between long-lived JWTs versus immediacy of revocation, we + need to configure an appropriate expiration value for JWT cookies. In a future world with an API gateway, we *may* + have longer lived JWTs with a *stateful* check against a centralized `JWT blacklist`_ and each JWT uniquely + identified by a `JWT ID (jti)`_. In the meantime, we will err on the side of security and have short-lived JWTs. + +#. **Refresh JWT Cookies.** When a JWT expires, we do not want to ask the user to login again while their browser + session remains alive. A microfrontend will detect JWT expiration upon receiving a 401 response from an API + endpoint, or preemptively recognize an imminent expiration. To automatically refresh the JWT cookie, the + microfrontend will call a new endpoint ("refresh") that returns a new JWT Cookie to keep the user's session alive. + + * To support this, the login endpoint will include 3 related cookies in its response: + + * **Two JWT Cookies** (as described above), with a *domain* setting so that it is forwarded to any microservice + in the system. + * **JWT Refresh Cookie**, with a *domain* setting so that it is sent to the login service only. + +#. **Remove JWT Cookie on Logout.** When the user logs out, we will remove all JWT-related cookies in the response, + which will remove them from the user's browser cookie jar. Thus, the user will be logged out of all the + microfrontends. + +.. _`current open edX session cookies that may have no expiration`: https://github.com/edx/edx-platform/blob/92030ea15216a6641c83dd7bb38a9b65112bf31a/common/djangoapps/student/cookies.py#L25-L27 +.. _JWT blacklist: https://auth0.com/blog/blacklist-json-web-token-api-keys/ +.. _`JWT ID (jti)`: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#jtiDef + + +JWT Cookie Content +^^^^^^^^^^^^^^^^^^ + +#. **Minimize JWT size.** According to the `HTTP Cookie RFC standard`_, HTTP cookies `up to 4096 bytes`_ should be + supported by a browser. `Modern browsers have treated this requirement as a maximum`_ - and hence do not support + more than 4096 bytes. Our current JWT size is about 970 bytes (varying with size of user identifiers, like user's + name, etc). (Side note: Signing a JWT with a 2048 byte asymmetric key increases the JWT's size by 325 bytes.) + + To minimize the JWT's size from the start, we should eliminate any unnecessary data that is `currently embedded + in the JWT`_. For example: + + * *aud* - should remove this since we do not make use of the audience field. + * *preferred_username* - should be renamed simply to *username*. + * *administrator* - can keep for now, but may eventually be replaced as *role* data - when we design + authorization. + +.. _HTTP Cookie RFC standard: https://tools.ietf.org/html/rfc6265 +.. _up to 4096 bytes: https://tools.ietf.org/html/rfc6265#section-6.1 +.. _Modern browsers have treated this requirement as a maximum: http://browsercookielimits.squawky.net/ +.. _currently embedded in the JWT: https://github.com/edx/edx-platform/blob/92030ea15216a6641c83dd7bb38a9b65112bf31a/openedx/core/lib/token_utils.py#L13 + + +JWT Cookie Security +^^^^^^^^^^^^^^^^^^^ + +#. **Enable CSRF Protection.** Storing JWTs in HTTP cookies is potentially vulnerable to CSRF attacks. + See `JWT Cookie Storage Security`_. To protect against this: + + * Enable the HttpOnly_ flag on the **"JWT Signature Cookie"**, so Javascript code cannot misuse the JWT. + * Enable the Secure_ flag on the cookie, so it will not be sent (and thus leaked) through an unencrypted channel. + * Enable `Django's CSRF middleware`_ for every response. + * Ensure all GET requests are side-effect free. + + * Note: The `same-origin policy`_ protects against CSRF attacks on GET requests since the rogue website cannot + access the response from the GET request. + * However, even though the rogue website cannot access the response, the GET request is still processed on the + server before returning the response. So we need to ensure there are no unwanted side-effects on the server. + +#. **CORS and withCredentials.** `Cross-origin resource sharing (CORS)`_ will need to be configured so that all allowed + microfrontends can access the necessary backend microservices. In addition, microfrontends will need to set the + withCredentials_ attribute so that the JWT Cookie gets sent when API calls are made. + + Note: We cannot selectively choose which cookies are sent so all edX-issued cookies will be sent with these API + calls. Apparently, we already send all edX cookies on API requests today, so this will not cause a significant + performance issue. + + +.. _JWT Cookie Storage Security: https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage#so-whats-the-difference +.. _HttpOnly: https://www.owasp.org/index.php/HttpOnly +.. _Secure: https://www.owasp.org/index.php/SecureFlag +.. _`Django's CSRF middleware`: https://docs.djangoproject.com/en/1.11/ref/csrf/ +.. _same-origin policy: https://en.wikipedia.org/wiki/Same-origin_policy +.. _Cross-origin resource sharing (CORS): https://en.wikipedia.org/wiki/Cross-origin_resource_sharing +.. _withCredentials: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials + + +Consequences +------------ + +#. Since session cookies have a limited size of `at least 4096 bytes`_, we will need to monitor its size increase + over time and implement a warning before it exceeds the size. Having this hard limit requires us to be judicious + of what data is included in the JWT. A bloated JWT is not necessarily a benefit to overall web performance. + + Separating the JWT into two, specifically its large signature, mitigates this issue significantly. + +#. Rejected Alternative: Instead of storing JWTs in cookies, microfrontends could store them in HTML5 Web Storage. + However, that is vulnerable to XSS attacks as described in `JWT sessionStorage and localStorage Security`_. + +#. Since the **"JWT Header/Payload Cookie"** is accessible to the microfrontend JS code, it allows the microfrontend + to get user information directly and immediately from the cookie. + + We rejected the following alternatives for accessing this user information: + + #. Add an extra round trip to get the user-data from a backend API, and then cache it in HTML5 Storage. + #. Continue to use and expand the current `JS-accessible user-info cookie`_, which contains user-data. + #. Have the server populate the initial DOM with this data, but this would only work for server-generated HTML. + +.. _JWT sessionStorage and localStorage Security: https://stormpath. com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage#so-whats-the-difference +.. _JS-accessible user-info cookie: https://github.com/edx/edx-platform/blob/70d1ca474012b89e4c7184d25499eb87b3135409/common/djangoapps/student/cookies.py#L151 + +References +---------- + +* https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage +* https://dzone.com/articles/cookies-vs-tokens-the-definitive-guide +* http://www.redotheweb.com/2015/11/09/api-security.html +* http://flask-jwt-extended.readthedocs.io/en/latest/tokens_in_cookies.html +* https://medium.com/lightrail/getting-token-authentication-right-in-a-stateless-single-page-application-57d0c6474e3