Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 62 additions & 18 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=============================
Expand Down Expand Up @@ -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

Expand All @@ -406,22 +412,60 @@ 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.

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?
-------------------

Expand Down
2 changes: 2 additions & 0 deletions src/pyramid_jwt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 20 additions & 12 deletions src/pyramid_jwt/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ def __init__(
https_only=True,
reissue_time=None,
cookie_path=None,
reissue_callback=None,
):
super(JWTCookieAuthenticationPolicy, self).__init__(
private_key,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
18 changes: 12 additions & 6 deletions tests/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -45,15 +47,17 @@ 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


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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down