Decision: Transport JWT in HTTP Cookies
This commit is contained in:
committed by
Robert Raposa
parent
68beb6f665
commit
1376409351
@@ -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
|
||||
Reference in New Issue
Block a user