diff --git a/.travis.yml b/.travis.yml
index 0accf76b..8e611f40 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -5,7 +5,7 @@ cache: pip
matrix:
fast_finish: true
include:
- - python: 3.7
+ - python: 3.8
env: TOXENV=flake8
# Django 1.11
diff --git a/Makefile b/Makefile
index 8951a057..c707f870 100644
--- a/Makefile
+++ b/Makefile
@@ -9,11 +9,11 @@ test:
@pytest tests
coverage:
- @pytest\
- --verbose\
- --cov graphql_jwt\
- --cov-config .coveragerc\
- --cov-report term\
+ @pytest \
+ --verbose \
+ --cov graphql_jwt \
+ --cov-config .coveragerc \
+ --cov-report term \
--cov-report xml
test-all:
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 415fc9cb..92708832 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -49,6 +49,49 @@ When a token is requested and ``jwt_cookie`` decorator is set, the response will
If the ``jwt_cookie`` decorator is set, consider adding `CSRF middleware `_ ``'django.middleware.csrf.CsrfViewMiddleware'`` to provide protection against `Cross Site Request Forgeries `_.
+A cookie-based authentication does not require sending the tokens as a mutation input argument.
+
+Delete Cookies
+~~~~~~~~~~~~~~
+
+In order to prevent XSS (cross-site scripting) attacks, cookies have the ``HttpOnly`` flag set, so you cannot delete them on the client-side. This package includes some mutations to delete the cookies on the server-side.
+
+Add mutations to the root schema::
+
+ import graphene
+ import graphql_jwt
+
+
+ class Mutation(graphene.ObjectType):
+ delete_token_cookie = graphql_jwt.DeleteJSONWebTokenCookie.Field()
+
+ # Long running refresh tokens
+ delete_refresh_token_cookie = \
+ graphql_jwt.refresh_token.DeleteRefreshTokenCookie.Field()
+
+
+ schema = graphene.Schema(mutation=Mutation)
+
+
+* ``deleteTokenCookie`` to delete the ``JWT`` cookie:
+
+ ::
+
+ mutation {
+ deleteTokenCookie {
+ deleted
+ }
+ }
+
+* ``deleteRefreshTokenCookie`` to delete ``JWT-refresh-token`` cookie for :doc:`long running refresh tokens`.
+
+ ::
+
+ mutation {
+ deleteRefreshTokenCookie {
+ deleted
+ }
+ }
Per-argument
------------
diff --git a/docs/customizing.rst b/docs/customizing.rst
index ebe2ecba..caba8391 100644
--- a/docs/customizing.rst
+++ b/docs/customizing.rst
@@ -23,6 +23,7 @@ Authenticate the user and obtain a **JSON Web Token** and the *user id*::
mutation TokenAuth($username: String!, $password: String!) {
tokenAuth(username: $username, password: $password) {
token
+ payload
user {
id
}
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index 44b9b5fb..7f8ef7c0 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -72,6 +72,8 @@ Queries
mutation TokenAuth($username: String!, $password: String!) {
tokenAuth(username: $username, password: $password) {
token
+ payload
+ refreshExpiresIn
}
}
diff --git a/docs/refresh_token.rst b/docs/refresh_token.rst
index 68dbd8a7..34686f4c 100644
--- a/docs/refresh_token.rst
+++ b/docs/refresh_token.rst
@@ -4,7 +4,7 @@ Refresh token
This package supports two refresh methods:
* `Single token refresh <#single-token-refresh>`__ (by default)
-* `Long running refresh tokens <#long-running-refresh-tokens>`__ (`django-graphql-jwt` ≥ v0.1.14)
+* `Long running refresh tokens <#long-running-refresh-tokens>`__
Single token refresh
--------------------
@@ -20,7 +20,7 @@ Settings
'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7),
}
-It means that you need to refresh every 5 mins and even you keep on refreshing token every 5 mins, you will still be logout in 7 days after the first token has been issued.
+It means that you need to refresh every 5 mins (``payload.exp``) and even you keep on refreshing token every 5 mins, you will still be logout in 7 days after the first token has been issued (``refreshExpiresIn``).
Queries
~~~~~~~
@@ -33,6 +33,7 @@ Queries
refreshToken(token: $token) {
token
payload
+ refreshExpiresIn
}
}
@@ -50,7 +51,7 @@ Queries
::
- exp = orig_iat + JWT_EXPIRATION_DELTA
+ exp = orig_iat + JWT_EXPIRATION_DELTA (payload.exp)
refreshToken (t): exp = t + JWT_EXPIRATION_DELTA
2. Signature expiration (login is required)
@@ -65,7 +66,7 @@ Queries
::
- when: t = orig_iat + JWT_REFRESH_EXPIRATION_DELTA
+ when: t = orig_iat + JWT_REFRESH_EXPIRATION_DELTA (refreshExpiresIn)
refreshToken (t): error!
Long running refresh tokens
@@ -93,7 +94,7 @@ Settings
'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7),
}
-It means that you need to refresh every 5 mins and you need to replace your refresh token in 7 days after it has been issued.
+It means that you need to refresh every 5 mins (``payload.exp``) and you need to replace your refresh token in 7 days after it has been issued (``refreshExpiresIn``).
Schema
~~~~~~
@@ -122,7 +123,9 @@ Queries
mutation TokenAuth($username: String!, $password: String!) {
tokenAuth(username: $username, password: $password) {
token
+ payload
refreshToken
+ refreshExpiresIn
}
}
@@ -134,8 +137,9 @@ Queries
mutation RefreshToken($refreshToken: String!) {
refreshToken(refreshToken: $refreshToken) {
token
- refreshToken
payload
+ refreshToken
+ refreshExpiresIn
}
}
diff --git a/docs/relay.rst b/docs/relay.rst
index b81e23ef..2b11df8c 100644
--- a/docs/relay.rst
+++ b/docs/relay.rst
@@ -16,10 +16,14 @@ Add mutations to the root schema::
token_auth = graphql_jwt.relay.ObtainJSONWebToken.Field()
verify_token = graphql_jwt.relay.Verify.Field()
refresh_token = graphql_jwt.relay.Refresh.Field()
+ delete_token_cookie = graphql_jwt.relay.DeleteJSONWebTokenCookie.Field()
# Long running refresh tokens
revoke_token = graphql_jwt.relay.Revoke.Field()
+ delete_refresh_token_cookie = \
+ graphql_jwt.refresh_token.relay.DeleteRefreshTokenCookie.Field()
+
schema = graphene.Schema(mutation=Mutation)
@@ -37,6 +41,8 @@ Relay mutations only accepts one argument named *input*.
mutation TokenAuth($username: String!, $password: String!) {
tokenAuth(input: {username: $username, password: $password}) {
token
+ payload
+ refreshExpiresIn
}
}
@@ -62,6 +68,7 @@ Single token refresh
refreshToken(input: {token: $token}) {
token
payload
+ refreshExpiresIn
}
}
@@ -76,8 +83,9 @@ Long running refresh tokens
mutation RefreshToken($refreshToken: String!) {
refreshToken(input: {refreshToken: $refreshToken}) {
token
- refreshToken
payload
+ refreshToken
+ refreshExpiresIn
}
}
@@ -92,6 +100,30 @@ Long running refresh tokens
}
+Cookies
+~~~~~~~
+
+* ``deleteTokenCookie`` to delete the ``JWT`` cookie:
+
+ ::
+
+ mutation {
+ deleteTokenCookie(input: {}) {
+ deleted
+ }
+ }
+
+* ``deleteRefreshTokenCookie`` to delete ``JWT-refresh-token`` cookie for :doc:`long running refresh tokens`.
+
+ ::
+
+ mutation {
+ deleteRefreshTokenCookie(input: {}) {
+ deleted
+ }
+ }
+
+
Customizing
-----------
@@ -117,6 +149,8 @@ Authenticate the user and obtain a **JSON Web Token** and the *user id*::
mutation TokenAuth($username: String!, $password: String!) {
tokenAuth(input: {username: $username, password: $password}) {
token
+ payload
+ refreshExpiresIn
user {
id
}
diff --git a/docs/settings.rst b/docs/settings.rst
index 4c569129..9083d8ac 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -160,11 +160,19 @@ JWT_REFRESH_TOKEN_MODEL
JWT_REFRESH_TOKEN_N_BYTES
~~~~~~~~~~~~~~~~~~~~~~~~~
- Refresh token number of bytes
+ Long running refresh token number of bytes
Default: ``20``
+JWT_REUSE_REFRESH_TOKENS
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Reuse the long running refreshed token instead of generating a new one
+
+ Default: ``False``
+
+
JWT_REFRESH_EXPIRED_HANDLER
~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -248,6 +256,7 @@ JWT_COOKIE_NAME
Default: ``'JWT'``
+
JWT_REFRESH_TOKEN_COOKIE_NAME
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -259,7 +268,42 @@ JWT_REFRESH_TOKEN_COOKIE_NAME
JWT_COOKIE_SECURE
~~~~~~~~~~~~~~~~~
- Whether to use a secure cookie for the JWT cookie. If this is set to True, the cookie will be marked as "secure", which means browsers may ensure that the cookie is only sent under an HTTPS connection.
+ Whether to use a secure cookie for the JWT cookie. If this is set to True, the cookie will be marked as "secure", which means browsers may ensure that the cookie is only sent under an HTTPS connection
+
+ Default: ``False``
+
+
+JWT_COOKIE_PATH
+~~~~~~~~~~~~~~~~~
+
+ Document location for the cookie
+
+ Default: ``'/'``
+
+
+JWT_COOKIE_DOMAIN
+~~~~~~~~~~~~~~~~~
+
+ Use domain if you want to set a cross-domain cookie
+
+ Default: ``None``
+
+
+JWT_HIDE_TOKEN_FIELDS
+~~~~~~~~~~~~~~~~~~~~~
+
+ For cookie-based authentications, remove the token fields from the GraphQL schema in order to prevent XSS exploitation
+
+ Default: ``False``
+
+
+CSRF
+----
+
+JWT_CSRF_ROTATION
+~~~~~~~~~~~~~~~~~
+
+ Rotate CSRF tokens each time a token or refresh token is issued
Default: ``False``
diff --git a/docs/signals.rst b/docs/signals.rst
index b4d58ab0..66e6505a 100644
--- a/docs/signals.rst
+++ b/docs/signals.rst
@@ -33,7 +33,8 @@ Sent when a long running refresh token has been rotated.
Arguments sent with this signal:
- sender: The class of the refresh_token that just rotated.
- request: The current HttpRequest instance.
- - refresh_token: The RefreshToken instance that just rotated.
+ - refresh_token: The old RefreshToken instance that just rotated.
+ - refresh_token_issued: The new RefreshToken instance issued.
refresh_token_revoked
diff --git a/graphql_jwt/__init__.py b/graphql_jwt/__init__.py
index 8926bd61..0e53044f 100644
--- a/graphql_jwt/__init__.py
+++ b/graphql_jwt/__init__.py
@@ -1,6 +1,7 @@
from . import relay
from .mutations import (
- JSONWebTokenMutation, ObtainJSONWebToken, Refresh, Revoke, Verify,
+ DeleteJSONWebTokenCookie, DeleteRefreshTokenCookie, JSONWebTokenMutation,
+ ObtainJSONWebToken, Refresh, Revoke, Verify,
)
__all__ = [
@@ -10,6 +11,8 @@
'Verify',
'Refresh',
'Revoke',
+ 'DeleteJSONWebTokenCookie',
+ 'DeleteRefreshTokenCookie',
]
__version__ = '0.3.0'
diff --git a/graphql_jwt/decorators.py b/graphql_jwt/decorators.py
index 2812e782..d556ac03 100644
--- a/graphql_jwt/decorators.py
+++ b/graphql_jwt/decorators.py
@@ -1,25 +1,30 @@
+from calendar import timegm
from datetime import datetime
from functools import wraps
from django.contrib.auth import authenticate, get_user_model
+from django.middleware.csrf import rotate_token
from django.utils.translation import gettext as _
from graphql.execution.base import ResolveInfo
from promise import Promise, is_thenable
from . import exceptions, signals
-from .refresh_token.shortcuts import refresh_token_lazy
+from .refresh_token.shortcuts import create_refresh_token, refresh_token_lazy
from .settings import jwt_settings
-from .shortcuts import get_token
+from .utils import delete_cookie, set_cookie
__all__ = [
'user_passes_test',
'login_required',
'staff_member_required',
+ 'superuser_required',
'permission_required',
'token_auth',
+ 'csrf_rotation',
'setup_jwt_cookie',
'jwt_cookie',
+ 'ensure_token',
]
@@ -62,22 +67,29 @@ def check_perms(user):
return user_passes_test(check_perms)
+def on_token_auth_resolve(values):
+ context, user, payload = values
+ payload.payload = jwt_settings.JWT_PAYLOAD_HANDLER(user, context)
+ payload.token = jwt_settings.JWT_ENCODE_HANDLER(payload.payload, context)
+
+ if jwt_settings.JWT_LONG_RUNNING_REFRESH_TOKEN:
+ if getattr(context, 'jwt_cookie', False):
+ context.jwt_refresh_token = create_refresh_token(user)
+ payload.refresh_token = context.jwt_refresh_token.get_token()
+ else:
+ payload.refresh_token = refresh_token_lazy(user)
+
+ return payload
+
+
def token_auth(f):
@wraps(f)
@setup_jwt_cookie
+ @csrf_rotation
+ @refresh_expiration
def wrapper(cls, root, info, password, **kwargs):
context = info.context
context._jwt_token_auth = True
-
- def on_resolve(values):
- user, payload = values
- payload.token = get_token(user, context)
-
- if jwt_settings.JWT_LONG_RUNNING_REFRESH_TOKEN:
- payload.refresh_token = refresh_token_lazy(user)
-
- return payload
-
username = kwargs.get(get_user_model().USERNAME_FIELD)
user = authenticate(
@@ -85,22 +97,51 @@ def on_resolve(values):
username=username,
password=password,
)
-
if user is None:
raise exceptions.JSONWebTokenError(
- _('Please enter valid credentials'))
+ _('Please enter valid credentials'),
+ )
if hasattr(context, 'user'):
context.user = user
result = f(cls, root, info, **kwargs)
- values = (user, result)
+ values = (context, user, result)
signals.token_issued.send(sender=cls, request=context, user=user)
if is_thenable(result):
- return Promise.resolve(values).then(on_resolve)
- return on_resolve(values)
+ return Promise.resolve(values).then(on_token_auth_resolve)
+ return on_token_auth_resolve(values)
+ return wrapper
+
+
+def refresh_expiration(f):
+ @wraps(f)
+ def wrapper(cls, *args, **kwargs):
+ def on_resolve(payload):
+ payload.refresh_expires_in = (
+ timegm(datetime.utcnow().utctimetuple()) +
+ jwt_settings.JWT_REFRESH_EXPIRATION_DELTA.total_seconds()
+ )
+ return payload
+
+ result = f(cls, *args, **kwargs)
+
+ if is_thenable(result):
+ return Promise.resolve(result).then(on_resolve)
+ return on_resolve(result)
+ return wrapper
+
+
+def csrf_rotation(f):
+ @wraps(f)
+ def wrapper(cls, root, info, *args, **kwargs):
+ result = f(cls, root, info, **kwargs)
+
+ if jwt_settings.JWT_CSRF_ROTATION:
+ rotate_token(info.context)
+ return result
return wrapper
@@ -124,24 +165,41 @@ def wrapped_view(request, *args, **kwargs):
if hasattr(request, 'jwt_token'):
expires = datetime.utcnow() + jwt_settings.JWT_EXPIRATION_DELTA
- response.set_cookie(
+ set_cookie(
+ response,
jwt_settings.JWT_COOKIE_NAME,
request.jwt_token,
expires=expires,
- httponly=True,
- secure=jwt_settings.JWT_COOKIE_SECURE,
)
if hasattr(request, 'jwt_refresh_token'):
refresh_token = request.jwt_refresh_token
expires = refresh_token.created +\
jwt_settings.JWT_REFRESH_EXPIRATION_DELTA
- response.set_cookie(
+ set_cookie(
+ response,
jwt_settings.JWT_REFRESH_TOKEN_COOKIE_NAME,
refresh_token.token,
expires=expires,
- httponly=True,
- secure=jwt_settings.JWT_COOKIE_SECURE,
)
+
+ if hasattr(request, 'delete_jwt_cookie'):
+ delete_cookie(response, jwt_settings.JWT_COOKIE_NAME)
+
+ if hasattr(request, 'delete_refresh_token_cookie'):
+ delete_cookie(response, jwt_settings.JWT_REFRESH_TOKEN_COOKIE_NAME)
+
return response
return wrapped_view
+
+
+def ensure_token(f):
+ @wraps(f)
+ def wrapper(cls, root, info, token=None, *args, **kwargs):
+ if token is None:
+ token = info.context.COOKIES.get(jwt_settings.JWT_COOKIE_NAME)
+
+ if token is None:
+ raise exceptions.JSONWebTokenError(_('Token is required'))
+ return f(cls, root, info, token, *args, **kwargs)
+ return wrapper
diff --git a/graphql_jwt/mixins.py b/graphql_jwt/mixins.py
index 0f7ef3d7..0b5c2c0e 100644
--- a/graphql_jwt/mixins.py
+++ b/graphql_jwt/mixins.py
@@ -4,19 +4,25 @@
from graphene.types.generic import GenericScalar
from . import exceptions, signals
-from .decorators import setup_jwt_cookie
+from .decorators import csrf_rotation, ensure_token, setup_jwt_cookie
from .refresh_token.mixins import RefreshTokenMixin
from .settings import jwt_settings
from .utils import get_payload, get_user_by_payload
class JSONWebTokenMixin:
- token = graphene.String()
+ payload = GenericScalar(required=True)
+ refresh_expires_in = graphene.Int(required=True)
@classmethod
def Field(cls, *args, **kwargs):
- if jwt_settings.JWT_LONG_RUNNING_REFRESH_TOKEN:
- cls._meta.fields['refresh_token'] = graphene.Field(graphene.String)
+ if not jwt_settings.JWT_HIDE_TOKEN_FIELDS:
+ cls._meta.fields['token'] =\
+ graphene.Field(graphene.String, required=True)
+
+ if jwt_settings.JWT_LONG_RUNNING_REFRESH_TOKEN:
+ cls._meta.fields['refresh_token'] =\
+ graphene.Field(graphene.String, required=True)
return super().Field(*args, **kwargs)
@@ -33,7 +39,12 @@ def __init_subclass_with_meta__(cls, name=None, **options):
class VerifyMixin:
- payload = GenericScalar()
+ payload = GenericScalar(required=True)
+
+ @classmethod
+ @ensure_token
+ def verify(cls, root, info, token, **kwargs):
+ return cls(payload=get_payload(token, info.context))
class ResolveMixin:
@@ -46,10 +57,12 @@ def resolve(cls, root, info, **kwargs):
class KeepAliveRefreshMixin:
class Fields:
- token = graphene.String(required=True)
+ token = graphene.String()
@classmethod
@setup_jwt_cookie
+ @csrf_rotation
+ @ensure_token
def refresh(cls, root, info, token, **kwargs):
context = info.context
payload = get_payload(token, context)
@@ -64,15 +77,34 @@ def refresh(cls, root, info, token, **kwargs):
payload = jwt_settings.JWT_PAYLOAD_HANDLER(user, context)
payload['origIat'] = orig_iat
+ refresh_expires_in = orig_iat +\
+ jwt_settings.JWT_REFRESH_EXPIRATION_DELTA.total_seconds()
token = jwt_settings.JWT_ENCODE_HANDLER(payload, context)
signals.token_refreshed.send(sender=cls, request=context, user=user)
- return cls(token=token, payload=payload)
+
+ return cls(
+ token=token,
+ payload=payload,
+ refresh_expires_in=refresh_expires_in,
+ )
class RefreshMixin((RefreshTokenMixin
if jwt_settings.JWT_LONG_RUNNING_REFRESH_TOKEN
else KeepAliveRefreshMixin),
JSONWebTokenMixin):
+ """RefreshMixin"""
- payload = GenericScalar()
+
+class DeleteJSONWebTokenCookieMixin:
+ deleted = graphene.Boolean(required=True)
+
+ @classmethod
+ def delete_cookie(cls, root, info, **kwargs):
+ context = info.context
+ context.delete_jwt_cookie = (
+ jwt_settings.JWT_COOKIE_NAME in context.COOKIES and
+ getattr(context, 'jwt_cookie', False)
+ )
+ return cls(deleted=context.delete_jwt_cookie)
diff --git a/graphql_jwt/mutations.py b/graphql_jwt/mutations.py
index cca37237..d26f99ae 100644
--- a/graphql_jwt/mutations.py
+++ b/graphql_jwt/mutations.py
@@ -4,8 +4,7 @@
from . import mixins
from .decorators import token_auth
-from .refresh_token.mutations import Revoke
-from .utils import get_payload
+from .refresh_token.mutations import DeleteRefreshTokenCookie, Revoke
__all__ = [
'JSONWebTokenMutation',
@@ -13,6 +12,7 @@
'Verify',
'Refresh',
'Revoke',
+ 'DeleteRefreshTokenCookie',
]
@@ -43,11 +43,11 @@ class ObtainJSONWebToken(mixins.ResolveMixin, JSONWebTokenMutation):
class Verify(mixins.VerifyMixin, graphene.Mutation):
class Arguments:
- token = graphene.String(required=True)
+ token = graphene.String()
@classmethod
- def mutate(cls, root, info, token, **kwargs):
- return cls(payload=get_payload(token, info.context))
+ def mutate(cls, *args, **kwargs):
+ return cls.verify(*args, **kwargs)
class Refresh(mixins.RefreshMixin, graphene.Mutation):
@@ -58,3 +58,12 @@ class Arguments(mixins.RefreshMixin.Fields):
@classmethod
def mutate(cls, *arg, **kwargs):
return cls.refresh(*arg, **kwargs)
+
+
+class DeleteJSONWebTokenCookie(
+ mixins.DeleteJSONWebTokenCookieMixin,
+ graphene.Mutation):
+
+ @classmethod
+ def mutate(cls, *args, **kwargs):
+ return cls.delete_cookie(*args, **kwargs)
diff --git a/graphql_jwt/refresh_token/decorators.py b/graphql_jwt/refresh_token/decorators.py
new file mode 100644
index 00000000..d1b69753
--- /dev/null
+++ b/graphql_jwt/refresh_token/decorators.py
@@ -0,0 +1,21 @@
+from functools import wraps
+
+from django.utils.translation import gettext as _
+
+from .. import exceptions
+from ..settings import jwt_settings
+
+
+def ensure_refresh_token(f):
+ @wraps(f)
+ def wrapper(cls, root, info, refresh_token=None, *args, **kwargs):
+ if refresh_token is None:
+ refresh_token = info.context.COOKIES.get(
+ jwt_settings.JWT_REFRESH_TOKEN_COOKIE_NAME,
+ )
+ if refresh_token is None:
+ raise exceptions.JSONWebTokenError(
+ _('Refresh token is required'),
+ )
+ return f(cls, root, info, refresh_token, *args, **kwargs)
+ return wrapper
diff --git a/graphql_jwt/refresh_token/mixins.py b/graphql_jwt/refresh_token/mixins.py
index 44bf5d06..a51fdd28 100644
--- a/graphql_jwt/refresh_token/mixins.py
+++ b/graphql_jwt/refresh_token/mixins.py
@@ -5,48 +5,83 @@
import graphene
from .. import exceptions
-from ..decorators import setup_jwt_cookie
+from ..decorators import csrf_rotation, refresh_expiration, setup_jwt_cookie
from ..settings import jwt_settings
+from . import signals
+from .decorators import ensure_refresh_token
from .shortcuts import (
create_refresh_token, get_refresh_token, refresh_token_lazy,
)
-class RefreshTokenMixin(object):
+class RefreshTokenMixin:
class Fields:
- refresh_token = graphene.String(required=True)
+ refresh_token = graphene.String()
@classmethod
@setup_jwt_cookie
+ @csrf_rotation
+ @refresh_expiration
+ @ensure_refresh_token
def refresh(cls, root, info, refresh_token, **kwargs):
context = info.context
- refresh_token = get_refresh_token(refresh_token, context)
+ old_refresh_token = get_refresh_token(refresh_token, context)
- if refresh_token.is_expired(context):
+ if old_refresh_token.is_expired(context):
raise exceptions.JSONWebTokenError(_('Refresh token is expired'))
- payload = jwt_settings.JWT_PAYLOAD_HANDLER(refresh_token.user, context)
+ payload = jwt_settings.JWT_PAYLOAD_HANDLER(
+ old_refresh_token.user,
+ context,
+ )
token = jwt_settings.JWT_ENCODE_HANDLER(payload, context)
- refresh_token.rotate(context)
if getattr(context, 'jwt_cookie', False):
context.jwt_refresh_token = create_refresh_token(
- refresh_token.user,
+ old_refresh_token.user,
+ old_refresh_token,
)
- refreshed_token = context.jwt_refresh_token.get_token()
+ new_refresh_token = context.jwt_refresh_token.get_token()
else:
- refreshed_token = refresh_token_lazy(refresh_token.user)
+ new_refresh_token = refresh_token_lazy(
+ old_refresh_token.user,
+ old_refresh_token,
+ )
- return cls(token=token, payload=payload, refresh_token=refreshed_token)
+ signals.refresh_token_rotated.send(
+ sender=cls,
+ request=context,
+ refresh_token=old_refresh_token,
+ refresh_token_issued=new_refresh_token,
+ )
+ return cls(
+ token=token,
+ payload=payload,
+ refresh_token=new_refresh_token,
+ )
-class RevokeMixin(object):
- revoked = graphene.Int()
+class RevokeMixin:
+ revoked = graphene.Int(required=True)
@classmethod
+ @ensure_refresh_token
def revoke(cls, root, info, refresh_token, **kwargs):
context = info.context
- refresh_token = get_refresh_token(refresh_token, context)
- refresh_token.revoke(context)
- return cls(revoked=timegm(refresh_token.revoked.timetuple()))
+ refresh_token_obj = get_refresh_token(refresh_token, context)
+ refresh_token_obj.revoke(context)
+ return cls(revoked=timegm(refresh_token_obj.revoked.timetuple()))
+
+
+class DeleteRefreshTokenCookieMixin:
+ deleted = graphene.Boolean(required=True)
+
+ @classmethod
+ def delete_cookie(cls, root, info, **kwargs):
+ context = info.context
+ context.delete_refresh_token_cookie = (
+ jwt_settings.JWT_REFRESH_TOKEN_COOKIE_NAME in context.COOKIES and
+ getattr(context, 'jwt_cookie', False)
+ )
+ return cls(deleted=context.delete_refresh_token_cookie)
diff --git a/graphql_jwt/refresh_token/models.py b/graphql_jwt/refresh_token/models.py
index fb298376..0e3427bb 100644
--- a/graphql_jwt/refresh_token/models.py
+++ b/graphql_jwt/refresh_token/models.py
@@ -64,12 +64,10 @@ def revoke(self, request=None):
refresh_token=self,
)
- def rotate(self, request=None):
- signals.refresh_token_rotated.send(
- sender=AbstractRefreshToken,
- request=request,
- refresh_token=self,
- )
+ def reuse(self, request=None):
+ self.token = ''
+ self.created = timezone.now()
+ self.save(update_fields=['token', 'created'])
class RefreshToken(AbstractRefreshToken):
diff --git a/graphql_jwt/refresh_token/mutations.py b/graphql_jwt/refresh_token/mutations.py
index 061c62f8..635c3765 100644
--- a/graphql_jwt/refresh_token/mutations.py
+++ b/graphql_jwt/refresh_token/mutations.py
@@ -6,8 +6,17 @@
class Revoke(mixins.RevokeMixin, graphene.Mutation):
class Arguments:
- refresh_token = graphene.String(required=True)
+ refresh_token = graphene.String()
@classmethod
def mutate(cls, *args, **kwargs):
return cls.revoke(*args, **kwargs)
+
+
+class DeleteRefreshTokenCookie(
+ mixins.DeleteRefreshTokenCookieMixin,
+ graphene.Mutation):
+
+ @classmethod
+ def mutate(cls, *args, **kwargs):
+ return cls.delete_cookie(*args, **kwargs)
diff --git a/graphql_jwt/refresh_token/relay.py b/graphql_jwt/refresh_token/relay.py
index cfb63471..dee19d38 100644
--- a/graphql_jwt/refresh_token/relay.py
+++ b/graphql_jwt/refresh_token/relay.py
@@ -6,8 +6,17 @@
class Revoke(mixins.RevokeMixin, graphene.ClientIDMutation):
class Input:
- refresh_token = graphene.String(required=True)
+ refresh_token = graphene.String()
@classmethod
def mutate_and_get_payload(cls, *args, **kwargs):
return cls.revoke(*args, **kwargs)
+
+
+class DeleteRefreshTokenCookie(
+ mixins.DeleteRefreshTokenCookieMixin,
+ graphene.ClientIDMutation):
+
+ @classmethod
+ def mutate_and_get_payload(cls, *args, **kwargs):
+ return cls.delete_cookie(*args, **kwargs)
diff --git a/graphql_jwt/refresh_token/shortcuts.py b/graphql_jwt/refresh_token/shortcuts.py
index 9eec6941..729174bc 100644
--- a/graphql_jwt/refresh_token/shortcuts.py
+++ b/graphql_jwt/refresh_token/shortcuts.py
@@ -20,8 +20,15 @@ def get_refresh_token(token, context=None):
raise JSONWebTokenError(_('Invalid refresh token'))
-def create_refresh_token(user):
+def create_refresh_token(user, refresh_token=None):
+ if refresh_token is not None and jwt_settings.JWT_REUSE_REFRESH_TOKENS:
+ refresh_token.reuse()
+ return refresh_token
return get_refresh_token_model().objects.create(user=user)
-refresh_token_lazy = lazy(lambda u: create_refresh_token(u).get_token(), str)
+refresh_token_lazy = lazy(
+ lambda user, refresh_token=None:
+ create_refresh_token(user, refresh_token).get_token(),
+ str,
+)
diff --git a/graphql_jwt/refresh_token/signals.py b/graphql_jwt/refresh_token/signals.py
index e9b38f8c..49376105 100644
--- a/graphql_jwt/refresh_token/signals.py
+++ b/graphql_jwt/refresh_token/signals.py
@@ -1,4 +1,6 @@
from django.dispatch import Signal
refresh_token_revoked = Signal(providing_args=['request', 'refresh_token'])
-refresh_token_rotated = Signal(providing_args=['request', 'refresh_token'])
+refresh_token_rotated = Signal(
+ providing_args=['request', 'refresh_token', 'refresh_token_issued'],
+)
diff --git a/graphql_jwt/relay.py b/graphql_jwt/relay.py
index 84f3ddc7..5af25e6f 100644
--- a/graphql_jwt/relay.py
+++ b/graphql_jwt/relay.py
@@ -4,8 +4,7 @@
from . import mixins
from .decorators import token_auth
-from .refresh_token.relay import Revoke
-from .utils import get_payload
+from .refresh_token.relay import DeleteRefreshTokenCookie, Revoke
__all__ = [
'JSONWebTokenMutation',
@@ -13,6 +12,7 @@
'Verify',
'Refresh',
'Revoke',
+ 'DeleteRefreshTokenCookie',
]
@@ -44,11 +44,11 @@ class ObtainJSONWebToken(mixins.ResolveMixin, JSONWebTokenMutation):
class Verify(mixins.VerifyMixin, graphene.ClientIDMutation):
class Input:
- token = graphene.String(required=True)
+ token = graphene.String()
@classmethod
- def mutate_and_get_payload(cls, root, info, token, **kwargs):
- return cls(payload=get_payload(token, info.context))
+ def mutate_and_get_payload(cls, *args, **kwargs):
+ return cls.verify(*args, **kwargs)
class Refresh(mixins.RefreshMixin, graphene.ClientIDMutation):
@@ -59,3 +59,12 @@ class Input(mixins.RefreshMixin.Fields):
@classmethod
def mutate_and_get_payload(cls, *args, **kwargs):
return cls.refresh(*args, **kwargs)
+
+
+class DeleteJSONWebTokenCookie(
+ mixins.DeleteJSONWebTokenCookieMixin,
+ graphene.ClientIDMutation):
+
+ @classmethod
+ def mutate_and_get_payload(cls, *args, **kwargs):
+ return cls.delete_cookie(*args, **kwargs)
diff --git a/graphql_jwt/settings.py b/graphql_jwt/settings.py
index 5590f2b7..69f2c6e5 100644
--- a/graphql_jwt/settings.py
+++ b/graphql_jwt/settings.py
@@ -19,6 +19,7 @@
'JWT_LONG_RUNNING_REFRESH_TOKEN': False,
'JWT_REFRESH_TOKEN_MODEL': 'refresh_token.RefreshToken',
'JWT_REFRESH_TOKEN_N_BYTES': 20,
+ 'JWT_REUSE_REFRESH_TOKENS': False,
'JWT_AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'JWT_AUTH_HEADER_PREFIX': 'JWT',
'JWT_ALLOW_ARGUMENT': False,
@@ -36,9 +37,13 @@
'graphql_jwt.refresh_token.utils.get_refresh_token_by_model',
'JWT_ALLOW_ANY_HANDLER': 'graphql_jwt.middleware.allow_any',
'JWT_ALLOW_ANY_CLASSES': (),
+ 'JWT_CSRF_ROTATION': False,
+ 'JWT_HIDE_TOKEN_FIELDS': False,
'JWT_COOKIE_NAME': 'JWT',
'JWT_REFRESH_TOKEN_COOKIE_NAME': 'JWT-refresh-token',
'JWT_COOKIE_SECURE': False,
+ 'JWT_COOKIE_PATH': '/',
+ 'JWT_COOKIE_DOMAIN': None,
}
IMPORT_STRINGS = (
diff --git a/graphql_jwt/utils.py b/graphql_jwt/utils.py
index 1abf4c0a..53a7806d 100644
--- a/graphql_jwt/utils.py
+++ b/graphql_jwt/utils.py
@@ -117,3 +117,23 @@ def get_user_by_payload(payload):
def refresh_has_expired(orig_iat, context=None):
exp = orig_iat + jwt_settings.JWT_REFRESH_EXPIRATION_DELTA.total_seconds()
return timegm(datetime.utcnow().utctimetuple()) > exp
+
+
+def set_cookie(response, key, value, expires):
+ response.set_cookie(
+ key,
+ value,
+ expires=expires,
+ httponly=True,
+ secure=jwt_settings.JWT_COOKIE_SECURE,
+ path=jwt_settings.JWT_COOKIE_PATH,
+ domain=jwt_settings.JWT_COOKIE_DOMAIN,
+ )
+
+
+def delete_cookie(response, key):
+ response.delete_cookie(
+ key,
+ path=jwt_settings.JWT_COOKIE_PATH,
+ domain=jwt_settings.JWT_COOKIE_DOMAIN,
+ )
diff --git a/tests/mixins.py b/tests/mixins.py
index 656b998f..16e7a230 100644
--- a/tests/mixins.py
+++ b/tests/mixins.py
@@ -1,21 +1,26 @@
+from graphql_jwt.settings import jwt_settings
from graphql_jwt.shortcuts import get_token
-from graphql_jwt.utils import get_payload
+from graphql_jwt.signals import token_issued, token_refreshed
-from .context_managers import back_to_the_future, refresh_expired
+from .context_managers import back_to_the_future, catch_signal, refresh_expired
from .decorators import override_jwt_settings
class TokenAuthMixin:
def test_token_auth(self):
- response = self.execute({
- self.user.USERNAME_FIELD: self.user.get_username(),
- 'password': 'dolphins',
- })
+ with catch_signal(token_issued) as token_issued_handler:
+ response = self.execute({
+ self.user.USERNAME_FIELD: self.user.get_username(),
+ 'password': 'dolphins',
+ })
- payload = get_payload(response.data['tokenAuth']['token'])
+ data = response.data['tokenAuth']
- self.assertUsernameIn(payload)
+ self.assertEqual(token_issued_handler.call_count, 1)
+
+ self.assertIsNone(response.errors)
+ self.assertUsernameIn(data['payload'])
def test_token_auth_invalid_credentials(self):
response = self.execute({
@@ -35,6 +40,7 @@ def test_verify(self):
payload = response.data['verifyToken']['payload']
+ self.assertIsNone(response.errors)
self.assertUsernameIn(payload)
def test_verify_invalid_token(self):
@@ -48,20 +54,29 @@ def test_verify_invalid_token(self):
class RefreshMixin:
def test_refresh(self):
- with back_to_the_future(seconds=1):
+ with catch_signal(token_refreshed) as \
+ token_refreshed_handler, back_to_the_future(seconds=1):
+
response = self.execute({
'token': self.token,
})
data = response.data['refreshToken']
token = data['token']
- payload = get_payload(token)
+ payload = data['payload']
+ self.assertEqual(token_refreshed_handler.call_count, 1)
+
+ self.assertIsNone(response.errors)
self.assertNotEqual(token, self.token)
self.assertUsernameIn(data['payload'])
self.assertEqual(payload['origIat'], self.payload['origIat'])
self.assertGreater(payload['exp'], self.payload['exp'])
+ def test_missing_token(self):
+ response = self.execute({})
+ self.assertIsNotNone(response.errors)
+
def test_refresh_expired(self):
with refresh_expired():
response = self.execute({
@@ -78,3 +93,47 @@ def test_refresh_error(self):
})
self.assertIsNotNone(response.errors)
+
+
+class CookieTokenAuthMixin:
+
+ def test_token_auth(self):
+ response = self.execute({
+ self.user.USERNAME_FIELD: self.user.get_username(),
+ 'password': 'dolphins',
+ })
+
+ data = response.data['tokenAuth']
+ token = response.cookies.get(jwt_settings.JWT_COOKIE_NAME).value
+
+ self.assertIsNone(response.errors)
+ self.assertEqual(token, data['token'])
+ self.assertUsernameIn(data['payload'])
+
+
+class CookieRefreshMixin:
+
+ def test_refresh(self):
+ self.set_cookie()
+
+ with back_to_the_future(seconds=1):
+ response = self.execute()
+
+ data = response.data['refreshToken']
+ token = data['token']
+
+ self.assertIsNone(response.errors)
+ self.assertNotEqual(token, self.token)
+ self.assertUsernameIn(data['payload'])
+
+
+class DeleteCookieMixin:
+
+ def test_delete_cookie(self):
+ self.set_cookie()
+
+ response = self.execute()
+ data = response.data['deleteCookie']
+
+ self.assertIsNone(response.errors)
+ self.assertTrue(data['deleted'])
diff --git a/tests/refresh_token/mixins.py b/tests/refresh_token/mixins.py
index bb1bb11c..a83beadc 100644
--- a/tests/refresh_token/mixins.py
+++ b/tests/refresh_token/mixins.py
@@ -1,9 +1,15 @@
import graphene
+from graphql_jwt.refresh_token.signals import (
+ refresh_token_revoked, refresh_token_rotated,
+)
+from graphql_jwt.settings import jwt_settings
from graphql_jwt.shortcuts import create_refresh_token, get_refresh_token
-from graphql_jwt.utils import get_payload
+from graphql_jwt.signals import token_issued
-from ..context_managers import back_to_the_future, refresh_expired
+from ..context_managers import (
+ back_to_the_future, catch_signal, refresh_expired,
+)
from ..decorators import override_jwt_settings
@@ -22,16 +28,19 @@ class TokenAuthMixin(RefreshTokenMutationMixin):
@override_jwt_settings(JWT_LONG_RUNNING_REFRESH_TOKEN=True)
def test_token_auth(self):
- response = self.execute({
- self.user.USERNAME_FIELD: self.user.get_username(),
- 'password': 'dolphins',
- })
+ with catch_signal(token_issued) as token_issued_handler:
+ response = self.execute({
+ self.user.USERNAME_FIELD: self.user.get_username(),
+ 'password': 'dolphins',
+ })
data = response.data['tokenAuth']
- payload = get_payload(data['token'])
refresh_token = get_refresh_token(data['refreshToken'])
- self.assertUsernameIn(payload)
+ self.assertEqual(token_issued_handler.call_count, 1)
+
+ self.assertIsNone(response.errors)
+ self.assertUsernameIn(data['payload'])
self.assertEqual(refresh_token.user, self.user)
@@ -45,7 +54,9 @@ def setUp(self):
class RefreshMixin(RefreshTokenMutationMixin, RefreshTokenMixin):
def test_refresh_token(self):
- with back_to_the_future(seconds=1):
+ with catch_signal(refresh_token_rotated) as \
+ refresh_token_rotated_handler, back_to_the_future(seconds=1):
+
response = self.execute({
'refreshToken': self.refresh_token.token,
})
@@ -53,7 +64,10 @@ def test_refresh_token(self):
data = response.data['refreshToken']
token = data['token']
refresh_token = get_refresh_token(data['refreshToken'])
- payload = get_payload(token)
+ payload = data['payload']
+
+ self.assertIsNone(response.errors)
+ self.assertEqual(refresh_token_rotated_handler.call_count, 1)
self.assertUsernameIn(payload)
self.assertNotEqual(token, self.token)
@@ -63,6 +77,31 @@ def test_refresh_token(self):
self.assertEqual(refresh_token.user, self.user)
self.assertGreater(refresh_token.created, self.refresh_token.created)
+ @override_jwt_settings(JWT_REUSE_REFRESH_TOKENS=True)
+ def test_reuse_refresh_token(self):
+ with catch_signal(refresh_token_rotated) as \
+ refresh_token_rotated_handler, back_to_the_future(seconds=1):
+
+ response = self.execute({
+ 'refreshToken': self.refresh_token.token,
+ })
+
+ data = response.data['refreshToken']
+ token = data['token']
+ refresh_token = get_refresh_token(data['refreshToken'])
+ payload = data['payload']
+
+ self.assertIsNone(response.errors)
+ self.assertEqual(refresh_token_rotated_handler.call_count, 1)
+
+ self.assertUsernameIn(payload)
+ self.assertNotEqual(token, self.token)
+ self.assertNotEqual(refresh_token.token, self.refresh_token.token)
+
+ def test_missing_refresh_token(self):
+ response = self.execute({})
+ self.assertIsNotNone(response.errors)
+
def test_refresh_token_expired(self):
with refresh_expired():
response = self.execute({
@@ -75,11 +114,70 @@ def test_refresh_token_expired(self):
class RevokeMixin(RefreshTokenMixin):
def test_revoke(self):
- response = self.execute({
- 'refreshToken': self.refresh_token.token,
- })
+ with catch_signal(refresh_token_revoked) as \
+ refresh_token_revoked_handler:
- self.refresh_token.refresh_from_db()
+ response = self.execute({
+ 'refreshToken': self.refresh_token.token,
+ })
+
+ self.assertIsNone(response.errors)
+ self.assertEqual(refresh_token_revoked_handler.call_count, 1)
+ self.refresh_token.refresh_from_db()
self.assertIsNotNone(self.refresh_token.revoked)
self.assertIsNotNone(response.data['revokeToken']['revoked'])
+
+
+class CookieTokenAuthMixin(RefreshTokenMutationMixin):
+
+ @override_jwt_settings(JWT_LONG_RUNNING_REFRESH_TOKEN=True)
+ def test_token_auth(self):
+ with catch_signal(token_issued) as token_issued_handler:
+ response = self.execute({
+ self.user.USERNAME_FIELD: self.user.get_username(),
+ 'password': 'dolphins',
+ })
+
+ data = response.data['tokenAuth']
+ token = response.cookies.get(
+ jwt_settings.JWT_REFRESH_TOKEN_COOKIE_NAME,
+ ).value
+
+ self.assertEqual(token_issued_handler.call_count, 1)
+
+ self.assertIsNone(response.errors)
+ self.assertEqual(token, response.data['tokenAuth']['refreshToken'])
+ self.assertUsernameIn(data['payload'])
+
+
+class CookieRefreshMixin(RefreshTokenMutationMixin):
+
+ def test_refresh_token(self):
+ self.set_refresh_token_cookie()
+
+ with catch_signal(refresh_token_rotated) as \
+ refresh_token_rotated_handler, back_to_the_future(seconds=1):
+
+ response = self.execute()
+
+ data = response.data['refreshToken']
+ token = data['token']
+
+ self.assertIsNone(response.errors)
+ self.assertEqual(refresh_token_rotated_handler.call_count, 1)
+
+ self.assertNotEqual(token, self.token)
+ self.assertUsernameIn(data['payload'])
+
+
+class DeleteCookieMixin:
+
+ def test_delete_cookie(self):
+ self.set_refresh_token_cookie()
+
+ response = self.execute()
+ data = response.data['deleteCookie']
+
+ self.assertIsNone(response.errors)
+ self.assertTrue(data['deleted'])
diff --git a/tests/refresh_token/mutations.py b/tests/refresh_token/mutations.py
new file mode 100644
index 00000000..a283e635
--- /dev/null
+++ b/tests/refresh_token/mutations.py
@@ -0,0 +1,8 @@
+import graphql_jwt
+from graphql_jwt.refresh_token.mixins import RefreshTokenMixin
+
+
+class Refresh(RefreshTokenMixin, graphql_jwt.Refresh):
+
+ class Arguments(RefreshTokenMixin.Fields):
+ """Refresh Arguments"""
diff --git a/tests/refresh_token/relay.py b/tests/refresh_token/relay.py
new file mode 100644
index 00000000..8a8ccef4
--- /dev/null
+++ b/tests/refresh_token/relay.py
@@ -0,0 +1,8 @@
+import graphql_jwt
+from graphql_jwt.refresh_token.mixins import RefreshTokenMixin
+
+
+class Refresh(RefreshTokenMixin, graphql_jwt.relay.Refresh):
+
+ class Input(RefreshTokenMixin.Fields):
+ """Refresh Input"""
diff --git a/tests/refresh_token/test_models.py b/tests/refresh_token/test_models.py
index c1174796..5f98c364 100644
--- a/tests/refresh_token/test_models.py
+++ b/tests/refresh_token/test_models.py
@@ -1,5 +1,5 @@
-from graphql_jwt.refresh_token import signals
from graphql_jwt.refresh_token.models import AbstractRefreshToken
+from graphql_jwt.refresh_token.signals import refresh_token_revoked
from graphql_jwt.settings import jwt_settings
from graphql_jwt.shortcuts import create_refresh_token
@@ -39,25 +39,25 @@ def test_is_expired(self):
self.assertTrue(self.refresh_token.is_expired())
def test_revoke(self):
- with catch_signal(signals.refresh_token_revoked) as handler:
+ with catch_signal(refresh_token_revoked) as \
+ refresh_token_revoked_handler:
+
self.refresh_token.revoke()
self.assertIsNotNone(self.refresh_token.revoked)
- handler.assert_called_once_with(
+ refresh_token_revoked_handler.assert_called_once_with(
sender=AbstractRefreshToken,
- signal=signals.refresh_token_revoked,
+ signal=refresh_token_revoked,
request=None,
refresh_token=self.refresh_token,
)
- def test_rotate(self):
- with catch_signal(signals.refresh_token_rotated) as handler:
- self.refresh_token.rotate()
+ def test_reuse(self):
+ token = self.refresh_token.token
+ created = self.refresh_token.created
- handler.assert_called_once_with(
- sender=AbstractRefreshToken,
- signal=signals.refresh_token_rotated,
- request=None,
- refresh_token=self.refresh_token,
- )
+ self.refresh_token.reuse()
+
+ self.assertNotEqual(self.refresh_token.token, token)
+ self.assertGreater(self.refresh_token.created, created)
diff --git a/tests/refresh_token/test_mutations.py b/tests/refresh_token/test_mutations.py
index 790517c0..d491fccb 100644
--- a/tests/refresh_token/test_mutations.py
+++ b/tests/refresh_token/test_mutations.py
@@ -1,10 +1,11 @@
import graphene
import graphql_jwt
-from graphql_jwt.refresh_token.mixins import RefreshTokenMixin
from ..testcases import SchemaTestCase
from . import mixins
+from .mutations import Refresh
+from .testcases import CookieTestCase
class TokenAuthTests(mixins.TokenAuthMixin, SchemaTestCase):
@@ -12,7 +13,9 @@ class TokenAuthTests(mixins.TokenAuthMixin, SchemaTestCase):
mutation TokenAuth($username: String!, $password: String!) {
tokenAuth(username: $username, password: $password) {
token
+ payload
refreshToken
+ refreshExpiresIn
}
}'''
@@ -21,19 +24,14 @@ class TokenAuthTests(mixins.TokenAuthMixin, SchemaTestCase):
}
-class Refresh(RefreshTokenMixin, graphql_jwt.Refresh):
-
- class Arguments(RefreshTokenMixin.Fields):
- """Refresh Arguments"""
-
-
class RefreshTests(mixins.RefreshMixin, SchemaTestCase):
query = '''
- mutation RefreshToken($refreshToken: String!) {
+ mutation RefreshToken($refreshToken: String) {
refreshToken(refreshToken: $refreshToken) {
token
- refreshToken
payload
+ refreshToken
+ refreshExpiresIn
}
}'''
@@ -52,3 +50,47 @@ class RevokeTests(mixins.RevokeMixin, SchemaTestCase):
class Mutation(graphene.ObjectType):
revoke_token = graphql_jwt.Revoke.Field()
+
+
+class CookieTokenAuthTests(mixins.CookieTokenAuthMixin, CookieTestCase):
+ query = '''
+ mutation TokenAuth($username: String!, $password: String!) {
+ tokenAuth(username: $username, password: $password) {
+ token
+ payload
+ refreshToken
+ refreshExpiresIn
+ }
+ }'''
+
+ refresh_token_mutations = {
+ 'token_auth': graphql_jwt.ObtainJSONWebToken,
+ }
+
+
+class CookieRefreshTests(mixins.CookieRefreshMixin, CookieTestCase):
+ query = '''
+ mutation {
+ refreshToken {
+ token
+ payload
+ refreshToken
+ refreshExpiresIn
+ }
+ }'''
+
+ refresh_token_mutations = {
+ 'refresh_token': Refresh,
+ }
+
+
+class DeleteCookieTests(mixins.DeleteCookieMixin, CookieTestCase):
+ query = '''
+ mutation {
+ deleteCookie {
+ deleted
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ delete_cookie = graphql_jwt.DeleteRefreshTokenCookie.Field()
diff --git a/tests/refresh_token/test_relay.py b/tests/refresh_token/test_relay.py
index 170b1bda..65dfd9e1 100644
--- a/tests/refresh_token/test_relay.py
+++ b/tests/refresh_token/test_relay.py
@@ -1,10 +1,11 @@
import graphene
import graphql_jwt
-from graphql_jwt.refresh_token.mixins import RefreshTokenMixin
from ..testcases import RelaySchemaTestCase
from . import mixins
+from .relay import Refresh
+from .testcases import RelayCookieTestCase
class TokenAuthTests(mixins.TokenAuthMixin, RelaySchemaTestCase):
@@ -12,7 +13,9 @@ class TokenAuthTests(mixins.TokenAuthMixin, RelaySchemaTestCase):
mutation TokenAuth($input: ObtainJSONWebTokenInput!) {
tokenAuth(input: $input) {
token
+ payload
refreshToken
+ refreshExpiresIn
clientMutationId
}
}'''
@@ -22,19 +25,14 @@ class TokenAuthTests(mixins.TokenAuthMixin, RelaySchemaTestCase):
}
-class Refresh(RefreshTokenMixin, graphql_jwt.relay.Refresh):
-
- class Input(RefreshTokenMixin.Fields):
- """Refresh Input"""
-
-
class RefreshTokenTests(mixins.RefreshMixin, RelaySchemaTestCase):
query = '''
mutation RefreshToken($input: RefreshInput!) {
refreshToken(input: $input) {
token
- refreshToken
payload
+ refreshToken
+ refreshExpiresIn
clientMutationId
}
}'''
@@ -55,3 +53,48 @@ class RevokeTokenTests(mixins.RevokeMixin, RelaySchemaTestCase):
class Mutation(graphene.ObjectType):
revoke_token = graphql_jwt.relay.Revoke.Field()
+
+
+class CookieTokenAuthTests(mixins.CookieTokenAuthMixin, RelayCookieTestCase):
+ query = '''
+ mutation TokenAuth($input: ObtainJSONWebTokenInput!) {
+ tokenAuth(input: $input) {
+ token
+ payload
+ refreshToken
+ refreshExpiresIn
+ clientMutationId
+ }
+ }'''
+
+ refresh_token_mutations = {
+ 'token_auth': graphql_jwt.relay.ObtainJSONWebToken,
+ }
+
+
+class CookieRefreshTests(mixins.CookieRefreshMixin, RelayCookieTestCase):
+ query = '''
+ mutation {
+ refreshToken(input: {}) {
+ token
+ payload
+ refreshToken
+ refreshExpiresIn
+ }
+ }'''
+
+ refresh_token_mutations = {
+ 'refresh_token': Refresh,
+ }
+
+
+class DeleteCookieTests(mixins.DeleteCookieMixin, RelayCookieTestCase):
+ query = '''
+ mutation {
+ deleteCookie(input: {}) {
+ deleted
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ delete_cookie = graphql_jwt.relay.DeleteRefreshTokenCookie.Field()
diff --git a/tests/refresh_token/testcases.py b/tests/refresh_token/testcases.py
new file mode 100644
index 00000000..475cd349
--- /dev/null
+++ b/tests/refresh_token/testcases.py
@@ -0,0 +1,25 @@
+from graphql_jwt.settings import jwt_settings
+from graphql_jwt.shortcuts import create_refresh_token
+
+from .. import testcases
+
+
+class CookieClient(testcases.CookieClient):
+
+ def set_refresh_token_cookie(self, token):
+ self.cookies[jwt_settings.JWT_REFRESH_TOKEN_COOKIE_NAME] = token
+
+
+class CookieTestCase(testcases.CookieTestCase):
+ client_class = CookieClient
+
+ def setUp(self):
+ super().setUp()
+ self.refresh_token = create_refresh_token(self.user)
+
+ def set_refresh_token_cookie(self):
+ self.client.set_refresh_token_cookie(self.refresh_token.token)
+
+
+class RelayCookieTestCase(testcases.RelaySchemaTestCase, CookieTestCase):
+ """CookieTestCase"""
diff --git a/tests/test_cookie.py b/tests/test_cookie.py
deleted file mode 100644
index ca15f86d..00000000
--- a/tests/test_cookie.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import graphene
-
-import graphql_jwt
-from graphql_jwt.settings import jwt_settings
-from graphql_jwt.utils import get_payload
-
-from .testcases import CookieGraphQLViewTestCase
-
-
-class TokenAuthTests(CookieGraphQLViewTestCase):
- query = '''
- mutation TokenAuth($username: String!, $password: String!) {
- tokenAuth(username: $username, password: $password) {
- token
- }
- }'''
-
- class Mutation(graphene.ObjectType):
- token_auth = graphql_jwt.ObtainJSONWebToken.Field()
-
- def test_token_auth(self):
- response = self.execute({
- self.user.USERNAME_FIELD: self.user.get_username(),
- 'password': 'dolphins',
- })
-
- token = response.cookies.get(jwt_settings.JWT_COOKIE_NAME).value
- payload = get_payload(token)
-
- self.assertEqual(token, response.data['tokenAuth']['token'])
- self.assertUsernameIn(payload)
-
-
-class ViewerTests(CookieGraphQLViewTestCase):
- query = '''
- {
- viewer
- }'''
-
- class Query(graphene.ObjectType):
- viewer = graphene.String()
-
- def resolve_viewer(self, info):
- return info.context.user.get_username()
-
- def test_viewer(self):
- self.authenticate()
- response = self.execute()
-
- self.assertEqual(self.user.get_username(), response.data['viewer'])
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index bfc87d54..24e6cfa7 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -4,136 +4,126 @@
from graphql_jwt import decorators, exceptions
+from .decorators import override_jwt_settings
from .testcases import TestCase
class UserPassesTests(TestCase):
def test_user_passes_test(self):
+ result = decorators.user_passes_test(
+ lambda u: u.pk == self.user.pk,
+ )(lambda info: None)(self.info(self.user))
- @decorators.user_passes_test(lambda u: u.pk == self.user.pk)
- def wrapped(info):
- """Decorated function"""
-
- result = wrapped(self.info(self.user))
self.assertIsNone(result)
def test_permission_denied(self):
-
- @decorators.user_passes_test(lambda u: u.pk == self.user.pk + 1)
- def wrapped(info):
- """Decorated function"""
+ func = decorators.user_passes_test(
+ lambda u: u.pk == self.user.pk + 1,
+ )(lambda info: None)
with self.assertRaises(exceptions.PermissionDenied):
- wrapped(self.info(self.user))
+ func(self.info(self.user))
class LoginRequiredTests(TestCase):
def test_login_required(self):
+ result = decorators.login_required(
+ lambda info: None,
+ )(self.info(self.user))
- @decorators.login_required
- def wrapped(info):
- """Decorated function"""
-
- result = wrapped(self.info(self.user))
self.assertIsNone(result)
def test_permission_denied(self):
-
- @decorators.login_required
- def wrapped(info):
- """Decorated function"""
+ func = decorators.login_required(lambda info: None)
with self.assertRaises(exceptions.PermissionDenied):
- wrapped(self.info(AnonymousUser()))
+ func(self.info(AnonymousUser()))
class StaffMemberRequiredTests(TestCase):
def test_staff_member_required(self):
-
- @decorators.staff_member_required
- def wrapped(info):
- """Decorated function"""
-
self.user.is_staff = True
- result = wrapped(self.info(self.user))
+
+ result = decorators.staff_member_required(
+ lambda info: None,
+ )(self.info(self.user))
self.assertIsNone(result)
def test_permission_denied(self):
-
- @decorators.staff_member_required
- def wrapped(info):
- """Decorated function"""
+ func = decorators.staff_member_required(lambda info: None)
with self.assertRaises(exceptions.PermissionDenied):
- wrapped(self.info(self.user))
+ func(self.info(self.user))
class SuperuserRequiredTests(TestCase):
def test_superuser_required(self):
-
- @decorators.superuser_required
- def wrapped(info):
- """Decorated function"""
-
self.user.is_superuser = True
- result = wrapped(self.info(self.user))
+
+ result = decorators.superuser_required(
+ lambda info: None,
+ )(self.info(self.user))
self.assertIsNone(result)
def test_permission_denied(self):
-
- @decorators.superuser_required
- def wrapped(info):
- """Decorated function"""
+ func = decorators.superuser_required(lambda info: None)
with self.assertRaises(exceptions.PermissionDenied):
- wrapped(self.info(self.user))
+ func(self.info(self.user))
class PermissionRequiredTests(TestCase):
def test_permission_required(self):
-
- @decorators.permission_required('auth.add_user')
- def wrapped(info):
- """Decorated function"""
-
perm = Permission.objects.get(codename='add_user')
self.user.user_permissions.add(perm)
- result = wrapped(self.info(self.user))
+ result = decorators.permission_required('auth.add_user')(
+ lambda info: None,
+ )(self.info(self.user))
+
self.assertIsNone(result)
def test_permission_denied(self):
-
- @decorators.permission_required(['auth.add_user', 'auth.change_user'])
- def wrapped(info):
- """Decorated function"""
+ func = decorators.permission_required(
+ ['auth.add_user', 'auth.change_user'],
+ )(lambda info: None)
with self.assertRaises(exceptions.PermissionDenied):
- wrapped(self.info(self.user))
+ func(self.info(self.user))
class TokenAuthTests(TestCase):
def test_is_thenable(self):
-
- @decorators.token_auth
- def wrapped(cls, root, info, **kwargs):
- return Promise()
-
info_mock = self.info(AnonymousUser())
-
- result = wrapped(
+ func = decorators.token_auth(
+ lambda cls, root, info, **kwargs: Promise(),
+ )
+ result = func(
self,
None,
info_mock,
password='dolphins',
- username=self.user.get_username())
+ username=self.user.get_username(),
+ )
self.assertTrue(is_thenable(result))
+
+
+class CSRFRotationTests(TestCase):
+
+ @override_jwt_settings(JWT_CSRF_ROTATION=True)
+ def test_csrf_rotation(self):
+ info_mock = self.info(AnonymousUser())
+ decorators.csrf_rotation(
+ lambda cls, root, info, *args, **kwargs: None,
+ )(self, None, info_mock)
+
+ self.assertTrue(info_mock.context.csrf_cookie_needs_reset)
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 2f04332a..c4c050fb 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -68,7 +68,6 @@ def test_invalid_token(self):
next_mock.assert_not_called()
- @override_jwt_settings(JWT_ALLOW_ANY_HANDLER=lambda *args: False)
@mock.patch('graphql_jwt.middleware.authenticate')
def test_already_authenticated(self, authenticate_mock):
headers = {
@@ -138,9 +137,7 @@ def test_authenticate(self):
user = self.middleware.cached_authentication[tuple(info_mock.path)]
self.assertEqual(user, self.user)
- @override_jwt_settings(
- JWT_ALLOW_ARGUMENT=True,
- JWT_ALLOW_ANY_HANDLER=lambda *args, **kwargs: False)
+ @override_jwt_settings(JWT_ALLOW_ARGUMENT=True)
def test_authenticate_parent(self):
next_mock = mock.Mock()
info_mock = self.info(AnonymousUser())
@@ -152,9 +149,7 @@ def test_authenticate_parent(self):
next_mock.assert_called_with(None, info_mock)
self.assertEqual(info_mock.context.user, self.user)
- @override_jwt_settings(
- JWT_ALLOW_ARGUMENT=True,
- JWT_ALLOW_ANY_HANDLER=lambda *args, **kwargs: False)
+ @override_jwt_settings(JWT_ALLOW_ARGUMENT=True)
def test_clear_authentication(self):
next_mock = mock.Mock()
info_mock = self.info(self.user)
@@ -164,9 +159,7 @@ def test_clear_authentication(self):
next_mock.assert_called_with(None, info_mock)
self.assertIsInstance(info_mock.context.user, AnonymousUser)
- @override_jwt_settings(
- JWT_ALLOW_ARGUMENT=True,
- JWT_ALLOW_ANY_HANDLER=lambda *args, **kwargs: False)
+ @override_jwt_settings(JWT_ALLOW_ARGUMENT=True)
def test_clear_session_authentication(self):
next_mock = mock.Mock()
info_mock = self.info(self.user)
@@ -177,9 +170,7 @@ def test_clear_session_authentication(self):
next_mock.assert_called_with(None, info_mock)
self.assertIsInstance(info_mock.context.user, AnonymousUser)
- @override_jwt_settings(
- JWT_ALLOW_ARGUMENT=True,
- JWT_ALLOW_ANY_HANDLER=lambda *args, **kwargs: False)
+ @override_jwt_settings(JWT_ALLOW_ARGUMENT=True)
def test_context_has_not_attr_user(self):
next_mock = mock.Mock()
info_mock = self.info()
diff --git a/tests/test_mutations.py b/tests/test_mutations.py
index 3ccc712a..db3afca9 100644
--- a/tests/test_mutations.py
+++ b/tests/test_mutations.py
@@ -3,7 +3,7 @@
import graphql_jwt
from . import mixins
-from .testcases import SchemaTestCase
+from .testcases import CookieTestCase, SchemaTestCase
class TokenAuthTests(mixins.TokenAuthMixin, SchemaTestCase):
@@ -11,6 +11,8 @@ class TokenAuthTests(mixins.TokenAuthMixin, SchemaTestCase):
mutation TokenAuth($username: String!, $password: String!) {
tokenAuth(username: $username, password: $password) {
token
+ payload
+ refreshExpiresIn
}
}'''
@@ -32,12 +34,53 @@ class Mutation(graphene.ObjectType):
class RefreshTests(mixins.RefreshMixin, SchemaTestCase):
query = '''
- mutation RefreshToken($token: String!) {
+ mutation RefreshToken($token: String) {
refreshToken(token: $token) {
token
payload
+ refreshExpiresIn
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ refresh_token = graphql_jwt.Refresh.Field()
+
+
+class CookieTokenAuthTests(mixins.CookieTokenAuthMixin, CookieTestCase):
+ query = '''
+ mutation TokenAuth($username: String!, $password: String!) {
+ tokenAuth(username: $username, password: $password) {
+ token
+ payload
+ refreshExpiresIn
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ token_auth = graphql_jwt.ObtainJSONWebToken.Field()
+
+
+class CookieRefreshTests(mixins.CookieRefreshMixin, CookieTestCase):
+ query = '''
+ mutation {
+ refreshToken {
+ token
+ payload
+ refreshExpiresIn
}
}'''
class Mutation(graphene.ObjectType):
refresh_token = graphql_jwt.Refresh.Field()
+
+
+class DeleteCookieTests(mixins.DeleteCookieMixin, CookieTestCase):
+ query = '''
+ mutation {
+ deleteCookie {
+ deleted
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ delete_cookie = graphql_jwt.DeleteJSONWebTokenCookie.Field()
diff --git a/tests/test_relay.py b/tests/test_relay.py
index 9f582562..cadd6b9d 100644
--- a/tests/test_relay.py
+++ b/tests/test_relay.py
@@ -3,7 +3,7 @@
import graphql_jwt
from . import mixins
-from .testcases import RelaySchemaTestCase
+from .testcases import RelayCookieTestCase, RelaySchemaTestCase
class TokenAuthTests(mixins.TokenAuthMixin, RelaySchemaTestCase):
@@ -11,6 +11,8 @@ class TokenAuthTests(mixins.TokenAuthMixin, RelaySchemaTestCase):
mutation TokenAuth($input: ObtainJSONWebTokenInput!) {
tokenAuth(input: $input) {
token
+ payload
+ refreshExpiresIn
clientMutationId
}
}'''
@@ -38,9 +40,51 @@ class RefreshTests(mixins.RefreshMixin, RelaySchemaTestCase):
refreshToken(input: $input) {
token
payload
+ refreshExpiresIn
clientMutationId
}
}'''
class Mutation(graphene.ObjectType):
refresh_token = graphql_jwt.relay.Refresh.Field()
+
+
+class CookieTokenAuthTests(mixins.CookieTokenAuthMixin, RelayCookieTestCase):
+ query = '''
+ mutation TokenAuth($input: ObtainJSONWebTokenInput!) {
+ tokenAuth(input: $input) {
+ token
+ payload
+ refreshExpiresIn
+ clientMutationId
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ token_auth = graphql_jwt.relay.ObtainJSONWebToken.Field()
+
+
+class CookieRefreshTests(mixins.CookieRefreshMixin, RelayCookieTestCase):
+ query = '''
+ mutation {
+ refreshToken(input: {}) {
+ token
+ payload
+ refreshExpiresIn
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ refresh_token = graphql_jwt.relay.Refresh.Field()
+
+
+class DeleteCookieTests(mixins.DeleteCookieMixin, RelayCookieTestCase):
+ query = '''
+ mutation {
+ deleteCookie(input: {}) {
+ deleted
+ }
+ }'''
+
+ class Mutation(graphene.ObjectType):
+ delete_cookie = graphql_jwt.relay.DeleteJSONWebTokenCookie.Field()
diff --git a/tests/testcases.py b/tests/testcases.py
index 24983e2b..246f9bba 100644
--- a/tests/testcases.py
+++ b/tests/testcases.py
@@ -62,13 +62,13 @@ def execute(self, variables=None):
return super().execute({'input': variables})
-class CookieGraphQLViewClient(JSONWebTokenClient):
+class CookieClient(JSONWebTokenClient):
def post(self, path, data, **kwargs):
kwargs.setdefault('content_type', 'application/json')
return self.generic('POST', path, json.dumps(data), **kwargs)
- def authenticate(self, token):
+ def set_cookie(self, token):
self.cookies[jwt_settings.JWT_COOKIE_NAME] = token
def execute(self, query, variables=None, **extra):
@@ -79,12 +79,18 @@ def execute(self, query, variables=None, **extra):
view = GraphQLView(schema=self._schema)
request = self.post('/', data=data, **extra)
response = jwt_cookie(view.dispatch)(request)
- response.data = self._parse_json(response)['data']
+ content = self._parse_json(response)
+ response.data = content.get('data')
+ response.errors = content.get('errors')
return response
-class CookieGraphQLViewTestCase(SchemaTestCase):
- client_class = CookieGraphQLViewClient
+class CookieTestCase(SchemaTestCase):
+ client_class = CookieClient
- def authenticate(self):
- self.client.authenticate(self.token)
+ def set_cookie(self):
+ self.client.set_cookie(self.token)
+
+
+class RelayCookieTestCase(RelaySchemaTestCase, CookieTestCase):
+ """RelayCookieTestCase"""