diff --git a/README.rst b/README.rst index 1ce19a1..6365247 100644 --- a/README.rst +++ b/README.rst @@ -223,20 +223,24 @@ You can either set this in your .ini-file, or pass/override them directly to the The follow options applies to the cookie-based authentication policy: -+----------------+---------------------------+---------------+--------------------------------------------+ -| Parameter | ini-file entry | Default | Description | -+================+===========================+===============+============================================+ -| cookie_name | jwt.cookie_name | Authorization | Key used to identify the cookie. | -+----------------+---------------------------+---------------+--------------------------------------------+ -| cookie_path | jwt.cookie_path | None | Path for cookie. | -+----------------+---------------------------+---------------+--------------------------------------------+ -| https_only | jwt.https_only_cookie | True | Whether or not the token should only be | -| | | | sent through a secure HTTPS transport | -+----------------+---------------------------+---------------+--------------------------------------------+ -| reissue_time | jwt.cookie_reissue_time | None | Number of seconds (or a datetime.timedelta | -| | | | instance) before a cookie (and the token | -| | | | within it) is reissued | -+----------------+---------------------------+---------------+--------------------------------------------+ ++------------------+---------------------------+---------------+--------------------------------------------+ +| Parameter | ini-file entry | Default | Description | ++==================+===========================+===============+============================================+ +| cookie_name | jwt.cookie_name | Authorization | Key used to identify the cookie. | ++------------------+---------------------------+---------------+--------------------------------------------+ +| cookie_path | jwt.cookie_path | None | Path for cookie. | ++------------------+---------------------------+---------------+--------------------------------------------+ +| https_only | jwt.https_only_cookie | True | Whether or not the token should only be | +| | | | sent through a secure HTTPS transport | ++------------------+---------------------------+---------------+--------------------------------------------+ +| reissue_time | jwt.cookie_reissue_time | None | Number of seconds (or a datetime.timedelta | +| | | | instance) before a cookie (and the token | +| | | | within it) is reissued | ++------------------+---------------------------+---------------+--------------------------------------------+ +| reissue_callback | n/a | None | A callback function to be called when | +| | | | re-issuing cookies; see | +| | | | `Using a reissue callback`_ | ++------------------+---------------------------+---------------+--------------------------------------------+ Pyramid JWT example use cases ============================= @@ -390,8 +394,10 @@ through the ACL and then tie them into pyramids security framework. Creating a JWT within a cookie ------------------------------ -Since cookie-based authentication is already standardized within Pyramid by the -``remember()`` and ``forget()`` calls, you should simply use them: +While cookie-based authentication is standardized within Pyramid by the +``remember()`` and ``forget()`` calls, in order to accomodate more exotic use +cases, we're passing a constructed JWT token to the ``remember()`` call instead +of passing the pricipal and claims directly: .. code-block:: python @@ -406,15 +412,21 @@ Since cookie-based authentication is already standardized within Pyramid by the password = request.POST['password'] user = authenticate(login, password) # From the previous snippet if user: - headers = remember( + token = request.create_jwt_token( user['userid'], roles=user['roles'], userName=user['user_name'] ) + headers = remember(token) return Response(headers=headers, body="OK") # Or maybe redirect somewhere else return Response(status=403) # Or redirect back to login -Please note that since the JWT cookies will be stored inside the cookies, +The benefit of passing a constructed JWT token to ``remember()`` is that +we can pass a token constructed by a third party, i.e. we can use an +authentication endpoint that can return JWT tokens signed with an assymetric +cryptographic algorithm. + +Please note that since the JWT token will be stored inside the cookies, there's no need for your app to explicitly include it on the response body. The browser (or whatever consuming this response) is responsible to keep that cookie for as long as it's valid, and re-send it on the following requests. @@ -422,6 +434,38 @@ cookie for as long as it's valid, and re-send it on the following requests. Also note that there's no need to decode the cookie manually. The Policy handles that through the existing ``request.jwt_claims``. +Using a reissue callback +------------------------ + +The JWT cookie policy accepts a callback to be used when the cookie needs to be +re-issued. The callback signature is: + +.. code-block:: python + + def reissue_callback(request, principal, **claims): + pass + +and should return a constructed JWT token. When not provided a callback the +default behaviour is equivalent to the following: + +.. code-block:: python + + def reissue_callback(request, principal, **claims): + return request.create_jwt_token(principal, **claims) + +Providing a reissue callback is useful in two cases: + +1. refreshing the extra claims from a database or a third party and + re-encoding them in the generated token; this is useful when the claims + can change and you want the changes to be reflected when the cookie is + re-issued + +2. re-issuing a cookie that is not generated locally and there is an equivalent + re-issuing mechanism availabile over the authenticating API. + +If you want to prevent the refresh from going ahead you can return a falsey +value in the callback. This will stop the reissue from going ahead. + How is this secure? ------------------- diff --git a/src/pyramid_jwt/__init__.py b/src/pyramid_jwt/__init__.py index c0b633f..40bc5dd 100644 --- a/src/pyramid_jwt/__init__.py +++ b/src/pyramid_jwt/__init__.py @@ -96,6 +96,7 @@ def set_jwt_cookie_authentication_policy( https_only=True, reissue_time=None, cookie_path=None, + reissue_callback=None, ): settings = config.get_settings() cookie_name = cookie_name or settings.get("jwt.cookie_name") @@ -124,6 +125,7 @@ def set_jwt_cookie_authentication_policy( https_only=https_only, reissue_time=reissue_time, cookie_path=cookie_path, + reissue_callback=reissue_callback, ) _configure(config, auth_policy) diff --git a/src/pyramid_jwt/policy.py b/src/pyramid_jwt/policy.py index 214d586..8c9d2ca 100644 --- a/src/pyramid_jwt/policy.py +++ b/src/pyramid_jwt/policy.py @@ -172,6 +172,7 @@ def __init__( https_only=True, reissue_time=None, cookie_path=None, + reissue_callback=None, ): super(JWTCookieAuthenticationPolicy, self).__init__( private_key, @@ -195,6 +196,13 @@ def __init__( reissue_time = reissue_time.total_seconds() self.reissue_time = reissue_time + def _default_reissue_callback(request, principal, **claims): + return self.create_token( + principal, self.expiration, self.audience, **claims + ) + + self.reissue_callback = reissue_callback or _default_reissue_callback + self.cookie_profile = CookieProfile( cookie_name=self.cookie_name, secure=self.https_only, @@ -236,15 +244,13 @@ def _get_cookies(self, request, value, max_age=None, domains=None): headers = profile.get_headers(value, **kw) return headers - def remember(self, request, principal, **kw): - token = self.create_token(principal, self.expiration, self.audience, **kw) - + def remember(self, request, token, **kw): if hasattr(request, "_jwt_cookie_reissued"): request._jwt_cookie_reissue_revoked = True - domains = kw.get("domains") - - return self._get_cookies(request, token, self.max_age, domains=domains) + return self._get_cookies( + request, token, self.max_age, domains=kw.get("domains") + ) def forget(self, request): request._jwt_cookie_reissue_revoked = True @@ -282,15 +288,17 @@ def _handle_reissue(self, request, claims): # Token not yet eligible for reissuing return - extra_claims = dict( - filter(lambda item: item[0] not in self.jwt_std_claims, claims.items()) - ) - headers = self.remember(request, principal, **extra_claims) + try: + token = self.reissue_callback(request, principal, **claims) + except Exception as e: + raise ReissueError("Callback raised exception") from e def reissue_jwt_cookie(request, response): if not hasattr(request, "_jwt_cookie_reissue_revoked"): for k, v in headers: response.headerlist.append((k, v)) - request.add_response_callback(reissue_jwt_cookie) - request._jwt_cookie_reissued = True + if token: + headers = self.remember(request, token) + request.add_response_callback(reissue_jwt_cookie) + request._jwt_cookie_reissued = True diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 76460cd..c000bca 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -25,7 +25,8 @@ def test_interface(): def test_cookie(dummy_request, principal): policy = JWTCookieAuthenticationPolicy("secret") - cookie = policy.remember(dummy_request, principal).pop() + token = policy.create_token(principal) + cookie = policy.remember(dummy_request, token).pop() assert len(cookie) == 2 @@ -36,7 +37,8 @@ def test_cookie(dummy_request, principal): def test_cookie_name(dummy_request, principal): policy = JWTCookieAuthenticationPolicy("secret", cookie_name="auth") - _, cookie = policy.remember(dummy_request, principal).pop() + token = policy.create_token(principal) + _, cookie = policy.remember(dummy_request, token).pop() name, value = cookie.split("=", 1) assert name == "auth" @@ -45,7 +47,8 @@ def test_cookie_name(dummy_request, principal): def test_secure_cookie(): policy = JWTCookieAuthenticationPolicy("secret", https_only=True) dummy_request = Request.blank("/") - _, cookie = policy.remember(dummy_request, str(uuid.uuid4())).pop() + token = policy.create_token(str(uuid.uuid4())) + _, cookie = policy.remember(dummy_request, token).pop() assert "; secure;" in cookie assert "; HttpOnly" in cookie @@ -53,7 +56,8 @@ def test_secure_cookie(): def test_insecure_cookie(dummy_request, principal): policy = JWTCookieAuthenticationPolicy("secret", https_only=False) - _, cookie = policy.remember(dummy_request, principal).pop() + token = policy.create_token(principal) + _, cookie = policy.remember(dummy_request, token).pop() assert "; secure;" not in cookie assert "; HttpOnly" in cookie @@ -62,7 +66,8 @@ def test_insecure_cookie(dummy_request, principal): def test_cookie_decode(dummy_request, principal): policy = JWTCookieAuthenticationPolicy("secret", https_only=False) - header, cookie = policy.remember(dummy_request, principal).pop() + token = policy.create_token(principal) + header, cookie = policy.remember(dummy_request, token).pop() name, value = cookie.split("=", 1) value, _ = value.split(";", 1) @@ -85,7 +90,8 @@ def test_cookie_max_age(dummy_request, principal): @pytest.mark.freeze_time def test_expired_token(dummy_request, principal, freezer): policy = JWTCookieAuthenticationPolicy("secret", cookie_name="auth", expiration=1) - _, cookie = policy.remember(dummy_request, principal).pop() + token = policy.create_token(principal) + _, cookie = policy.remember(dummy_request, token).pop() name, value = cookie.split("=", 1) freezer.tick(delta=2) diff --git a/tests/test_integration.py b/tests/test_integration.py index a45cdbb..8d8dffa 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -14,7 +14,7 @@ def login_view(request): def login_cookie_view(request): - headers = remember(request, 1) + headers = remember(request, request.create_jwt_token(1)) return Response(status=200, headers=headers, body="OK")