Djangorestframework-simplejwt: Sliding Token Clarification

Created on 29 Aug 2019  路  12Comments  路  Source: jazzband/djangorestframework-simplejwt

Hi, I'm trying to use sliding tokens, but I think I need a bit of clarification. From what I understand, when I generate a sliding token, it can be used for authentication until it's expiration claim expires. After the expiration claim expires, I can still refresh the token until the refresh expiration claim expires.

The problem I'm running into is that I can only refresh the token while the auth expiration claim is valid. I'm not sure if this is intended or if I'm doing something wrong, but this makes it seem like there's no point to the refresh expiration claim.

My config is:

SIMPLE_JWT = {
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.SlidingToken',),
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=1),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

I can obtain a token:

Request:
> POST /api/users/token/ HTTP/1.1
> Host: localhost:8000
> User-Agent: insomnia/6.6.2
> Content-Type: application/json
> Accept: */*
> Content-Length: 44

| {
|   "username": "test",
|   "password": "test"
| }

< HTTP/1.1 200 OK
< Date: Thu, 29 Aug 2019 16:56:17 GMT
< Server: WSGIServer/0.2 CPython/3.7.4
< Content-Type: application/json
< Vary: Accept
< Allow: POST, OPTIONS
< X-Frame-Options: SAMEORIGIN
< Content-Length: 252

| {
|  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoic2xpZGluZyIsImV4cCI6MTU2NzA5NzgzNywianRpIjoiOWZkZDFkYTlkODI2NDAwNzkwNjRiYjM4Nzc5Y2FkMmQiLCJyZWZyZXNoX2V4cCI6MTU2NzE4NDE3NywidXNlcl9pZCI6Mn0.1eb5oFz6wwyjBI1wQrl5tPkkUogXWmWBBTWJn_xJC2k"
| }

I can use that token for authentication:

> GET /api/post/1 HTTP/1.1
> Host: localhost:8000
> User-Agent: insomnia/6.6.2
> Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoic2xpZGluZyIsImV4cCI6MTU2NzA5NzgzNywianRpIjoiOWZkZDFkYTlkODI2NDAwNzkwNjRiYjM4Nzc5Y2FkMmQiLCJyZWZyZXNoX2V4cCI6MTU2NzE4NDE3NywidXNlcl9pZCI6Mn0.1eb5oFz6wwyjBI1wQrl5tPkkUogXWmWBBTWJn_xJC2k
> Accept: */*

< HTTP/1.1 200 OK
< Date: Thu, 29 Aug 2019 16:57:06 GMT
< Server: WSGIServer/0.2 CPython/3.7.4
< Content-Type: application/json
< Vary: Accept
< Allow: GET, POST, HEAD, OPTIONS
< X-Frame-Options: SAMEORIGIN
< Content-Length: 140

| [correct response data...]

After SLIDING_TOKEN_LIFETIME (1 minute), trying to make authenticated requests gives a 401, which I would expect:

> GET /api/post/1 HTTP/1.1
> Host: localhost:8000
> User-Agent: insomnia/6.6.2
> Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoic2xpZGluZyIsImV4cCI6MTU2NzA5NzgzNywianRpIjoiOWZkZDFkYTlkODI2NDAwNzkwNjRiYjM4Nzc5Y2FkMmQiLCJyZWZyZXNoX2V4cCI6MTU2NzE4NDE3NywidXNlcl9pZCI6Mn0.1eb5oFz6wwyjBI1wQrl5tPkkUogXWmWBBTWJn_xJC2k
> Accept: */*

< HTTP/1.1 401 Unauthorized
< Date: Thu, 29 Aug 2019 17:00:27 GMT
< Server: WSGIServer/0.2 CPython/3.7.4
< Content-Type: application/json
< WWW-Authenticate: Bearer realm="api"
< Vary: Accept
< Allow: GET, POST, HEAD, OPTIONS
< X-Frame-Options: SAMEORIGIN
< Content-Length: 185

| {
|   "detail": "Given token not valid for any token type",
|   "code": "token_not_valid",
|   "messages": [
|     {
|       "token_class": "SlidingToken",
|       "token_type": "sliding",
|       "message": "Token is invalid or expired"
|     }
|   ]
| }

But when I try to refresh the token within SLIDING_TOKEN_REFRESH_LIFETIME:

> POST /api/users/token/refresh/ HTTP/1.1
> Host: localhost:8000
> User-Agent: insomnia/6.6.2
> Content-Type: application/json
> Accept: */*
> Content-Length: 256

| {
|   "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoic2xpZGluZyIsImV4cCI6MTU2NzA5NzgzNywianRpIjoiOWZkZDFkYTlkODI2NDAwNzkwNjRiYjM4Nzc5Y2FkMmQiLCJyZWZyZXNoX2V4cCI6MTU2NzE4NDE3NywidXNlcl9pZCI6Mn0.1eb5oFz6wwyjBI1wQrl5tPkkUogXWmWBBTWJn_xJC2k"
| }

< HTTP/1.1 401 Unauthorized
< Date: Thu, 29 Aug 2019 17:02:53 GMT
< Server: WSGIServer/0.2 CPython/3.7.4
< Content-Type: application/json
< WWW-Authenticate: Bearer realm="api"
< Vary: Accept
< Allow: POST, OPTIONS
< X-Frame-Options: SAMEORIGIN
< Content-Length: 65

| {
|   "detail": "Token is invalid or expired",
|   "code": "token_not_valid"
| }

But if I try refreshing a token within SLIDING_TOKEN_LIFETIME, it works fine:

> POST /api/users/token/refresh/ HTTP/1.1
> Host: localhost:8000
> User-Agent: insomnia/6.6.2
> Content-Type: application/json
> Accept: */*
> Content-Length: 256

| {
|   "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoic2xpZGluZyIsImV4cCI6MTU2NzA5ODM1NSwianRpIjoiNTg5ODhiMGFkMWE1NGU3MWFkOGU2NTMxZGI4ZTNhMTAiLCJyZWZyZXNoX2V4cCI6MTU2NzE4NDY5NSwidXNlcl9pZCI6Mn0.BW-qxTVupUlBogT4O-s_ySKjEdfPkl_zX7vw-d903iA"
| }

< HTTP/1.1 200 OK
< Date: Thu, 29 Aug 2019 17:05:06 GMT
< Server: WSGIServer/0.2 CPython/3.7.4
< Content-Type: application/json
< Vary: Accept
< Allow: POST, OPTIONS
< X-Frame-Options: SAMEORIGIN
< Content-Length: 252

| {
|   "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoic2xpZGluZyIsImV4cCI6MTU2NzA5ODM2NiwianRpIjoiNTg5ODhiMGFkMWE1NGU3MWFkOGU2NTMxZGI4ZTNhMTAiLCJyZWZyZXNoX2V4cCI6MTU2NzE4NDY5NSwidXNlcl9pZCI6Mn0.nnBpI7FDiFZOa-f4fGQC5omtff2g6WFg5NKVlE6k0CM"
| }

So am I doing something wrong? What is SLIDING_TOKEN_REFRESH_LIFETIME for? The way I expected to use a sliding token would be to retrieve a token on login, and use it for requests. Once a request returns a 401 telling me the token expired, I refresh it to obtain a new token, and repeat the process.

Thanks! Aside from refreshing sliding tokens, this library has been super easy to use :)

Most helpful comment

I think I'm having the same problem...
If I understand the problem correctly, in the time after exp and before refresh_exp, we should be able to refresh the token with post('/api/tokens/refresh/', {'token': token}). But instead, we're getting 401 errors.

So I dug into the source a little to see if I could see what's going on.

TL;DR

A low-level verification function is checking if exp has passed (which it has) instead of refresh_exp (which hasn't). Because exp has passed, the functions are throwing the token out.

What Goes Wrong

Here's a nice, long stack-trace getting to the bottom of what's happening...
If you're interested like I was, grab a coffee and read on.

We'll start at the TokenRefreshSlidingSerializer, called with the refresh api, which looks like this:

class TokenRefreshSlidingSerializer(serializers.Serializer):
    token = serializers.CharField()

    def validate(self, attrs):
        token = SlidingToken(attrs['token'])

        # Check that the timestamp in the "refresh_exp" claim has not
        # passed
        token.check_exp(api_settings.SLIDING_TOKEN_REFRESH_EXP_CLAIM)

        # Update the "exp" claim
        token.set_exp()

        return {'token': str(token)}

The token gets rejected at the token = SlidingToken(attrs['token']) line, so I checked the SlidingToken.__init__ function, next:

class SlidingToken(BlacklistMixin, Token):
    token_type = 'sliding'
    lifetime = api_settings.SLIDING_TOKEN_LIFETIME

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if self.token is None:
            # Set sliding refresh expiration claim if new token
            self.set_exp(
                api_settings.SLIDING_TOKEN_REFRESH_EXP_CLAIM,
                from_time=self.current_time,
                lifetime=api_settings.SLIDING_TOKEN_REFRESH_LIFETIME,
            )

Killed at the super(), so next stop, Token.__init__.

def __init__(self, token=None, verify=True):
        """
        !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error
        message if the given token is invalid, expired, or otherwise not safe
        to use.
        """
        if self.token_type is None or self.lifetime is None:
            raise TokenError(_('Cannot create token with no type or lifetime'))

        self.token = token
        self.current_time = aware_utcnow()

        # Set up token
        if token is not None:
            # An encoded token was provided
            from .state import token_backend

            # Decode token
            try:
                self.payload = token_backend.decode(token, verify=verify)
            except TokenBackendError:
                raise TokenError(_('Token is invalid or expired'))

            if verify:
                self.verify()
        else:
            # New token.  Skip all the verification steps.
            self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type}

            # Set "exp" claim with default value
            self.set_exp(from_time=self.current_time, lifetime=self.lifetime)

            # Set "jti" claim
            self.set_jti()

self.payload = token_backend.decode(token, verify=verify) is the line where this dies. And what happens in there?

def decode(self, token, verify=True):
        """
        Performs a validation of the given token and returns its payload
        dictionary.

        Raises a `TokenBackendError` if the token is malformed, if its
        signature check fails, or if its 'exp' claim indicates it has expired.
        """
        try:
            return jwt.decode(token, self.verifying_key, algorithms=[self.algorithm], verify=verify)
        except InvalidTokenError:
            raise TokenBackendError(_('Token is invalid or expired'))

Yup that's right, it's only checking the exp key, not the refresh_exp key.

Just to be thorough, let's see what's going on in the jwt library.
Because verify=True, jwt.decode goes ahead and calls an internal function called self._validate_claims, which finally calls self._validate_exp, which, of course, gives us a nice ExpiredSignatureError.

A Brute-Force Solution

To fix this, one could change the SlidingToken initialization in the TokenRefreshSlidingSerializer from

token = SlidingToken(attrs['token'])

to

token = SlidingToken(attrs['token'], verify=False)

which I successfully tested, but I don't know the library well enough to understand the ramifications of that kind of action.

I hope this helps someone, somewhere?

All 12 comments

@IvanFon were you able to solve this?

No, unfortunately. I ended up using separate auth and refresh tokens, which works fine.

It鈥檚 not a huge hassle to use two separate tokens, but in my case it would be slightly more convenient to use a single sliding token. Having separate tokens wouldn鈥檛 improve my security, both tokens are stored in memory/localStorage. If an attacker gets access to one, they have access to both.

On October 17, 2019 6:06:04 a.m. EDT, BlackXnt notifications@github.com wrote:

@IvanFon were you able to solve this?

--
You are receiving this because you were mentioned.
Reply to this email directly or view it on GitHub:
https://github.com/davesque/django-rest-framework-simplejwt/issues/154#issuecomment-543103887

I think I'm having the same problem...
If I understand the problem correctly, in the time after exp and before refresh_exp, we should be able to refresh the token with post('/api/tokens/refresh/', {'token': token}). But instead, we're getting 401 errors.

So I dug into the source a little to see if I could see what's going on.

TL;DR

A low-level verification function is checking if exp has passed (which it has) instead of refresh_exp (which hasn't). Because exp has passed, the functions are throwing the token out.

What Goes Wrong

Here's a nice, long stack-trace getting to the bottom of what's happening...
If you're interested like I was, grab a coffee and read on.

We'll start at the TokenRefreshSlidingSerializer, called with the refresh api, which looks like this:

class TokenRefreshSlidingSerializer(serializers.Serializer):
    token = serializers.CharField()

    def validate(self, attrs):
        token = SlidingToken(attrs['token'])

        # Check that the timestamp in the "refresh_exp" claim has not
        # passed
        token.check_exp(api_settings.SLIDING_TOKEN_REFRESH_EXP_CLAIM)

        # Update the "exp" claim
        token.set_exp()

        return {'token': str(token)}

The token gets rejected at the token = SlidingToken(attrs['token']) line, so I checked the SlidingToken.__init__ function, next:

class SlidingToken(BlacklistMixin, Token):
    token_type = 'sliding'
    lifetime = api_settings.SLIDING_TOKEN_LIFETIME

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if self.token is None:
            # Set sliding refresh expiration claim if new token
            self.set_exp(
                api_settings.SLIDING_TOKEN_REFRESH_EXP_CLAIM,
                from_time=self.current_time,
                lifetime=api_settings.SLIDING_TOKEN_REFRESH_LIFETIME,
            )

Killed at the super(), so next stop, Token.__init__.

def __init__(self, token=None, verify=True):
        """
        !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error
        message if the given token is invalid, expired, or otherwise not safe
        to use.
        """
        if self.token_type is None or self.lifetime is None:
            raise TokenError(_('Cannot create token with no type or lifetime'))

        self.token = token
        self.current_time = aware_utcnow()

        # Set up token
        if token is not None:
            # An encoded token was provided
            from .state import token_backend

            # Decode token
            try:
                self.payload = token_backend.decode(token, verify=verify)
            except TokenBackendError:
                raise TokenError(_('Token is invalid or expired'))

            if verify:
                self.verify()
        else:
            # New token.  Skip all the verification steps.
            self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type}

            # Set "exp" claim with default value
            self.set_exp(from_time=self.current_time, lifetime=self.lifetime)

            # Set "jti" claim
            self.set_jti()

self.payload = token_backend.decode(token, verify=verify) is the line where this dies. And what happens in there?

def decode(self, token, verify=True):
        """
        Performs a validation of the given token and returns its payload
        dictionary.

        Raises a `TokenBackendError` if the token is malformed, if its
        signature check fails, or if its 'exp' claim indicates it has expired.
        """
        try:
            return jwt.decode(token, self.verifying_key, algorithms=[self.algorithm], verify=verify)
        except InvalidTokenError:
            raise TokenBackendError(_('Token is invalid or expired'))

Yup that's right, it's only checking the exp key, not the refresh_exp key.

Just to be thorough, let's see what's going on in the jwt library.
Because verify=True, jwt.decode goes ahead and calls an internal function called self._validate_claims, which finally calls self._validate_exp, which, of course, gives us a nice ExpiredSignatureError.

A Brute-Force Solution

To fix this, one could change the SlidingToken initialization in the TokenRefreshSlidingSerializer from

token = SlidingToken(attrs['token'])

to

token = SlidingToken(attrs['token'], verify=False)

which I successfully tested, but I don't know the library well enough to understand the ramifications of that kind of action.

I hope this helps someone, somewhere?

Yeah, I included the whole sliding token thing to provide some kind of backwards compatibility with users of the legacy django-rest-framework-jwt library. But I honestly just don't like the idea and I kinda want to get rid of it. I need to look into how easy it would be to do this.

An alternative, as suggested @IvanFon is to use pair token. Dont forget to set 'ROTATE_REFRESH_TOKENS' to True, if you decide to go for the pair token approach. I forgot this flag existed which made me believe sliding tokens was the only alternative.

Just wanted to add that I've also stumbled upon this issue. Thanks @decepulis for digging into the issue and finding a workaround in the meantime.

I bumped in to this issue aswell since I wanted a fault proof system where only one valid token was present for a user. When using a pair, the access token is still valid for a certain time even though the refresh has been blacklisted. One additional step to add is that when overriding the serializer, you should also add token.check_blacklist() and token.blacklist() to make old tokens invalid and token.set_jti() for creating a new token.

I think I'm having the same problem...
If I understand the problem correctly, in the time after exp and before refresh_exp, we should be able to refresh the token with post('/api/tokens/refresh/', {'token': token}). But instead, we're getting 401 errors.

So I dug into the source a little to see if I could see what's going on.

TL;DR

A low-level verification function is checking if exp has passed (which it has) instead of refresh_exp (which hasn't). Because exp has passed, the functions are throwing the token out.

What Goes Wrong

Here's a nice, long stack-trace getting to the bottom of what's happening...
If you're interested like I was, grab a coffee and read on.

We'll start at the TokenRefreshSlidingSerializer, called with the refresh api, which looks like this:

class TokenRefreshSlidingSerializer(serializers.Serializer):
    token = serializers.CharField()

    def validate(self, attrs):
        token = SlidingToken(attrs['token'])

        # Check that the timestamp in the "refresh_exp" claim has not
        # passed
        token.check_exp(api_settings.SLIDING_TOKEN_REFRESH_EXP_CLAIM)

        # Update the "exp" claim
        token.set_exp()

        return {'token': str(token)}

The token gets rejected at the token = SlidingToken(attrs['token']) line, so I checked the SlidingToken.__init__ function, next:

class SlidingToken(BlacklistMixin, Token):
    token_type = 'sliding'
    lifetime = api_settings.SLIDING_TOKEN_LIFETIME

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if self.token is None:
            # Set sliding refresh expiration claim if new token
            self.set_exp(
                api_settings.SLIDING_TOKEN_REFRESH_EXP_CLAIM,
                from_time=self.current_time,
                lifetime=api_settings.SLIDING_TOKEN_REFRESH_LIFETIME,
            )

Killed at the super(), so next stop, Token.__init__.

def __init__(self, token=None, verify=True):
        """
        !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error
        message if the given token is invalid, expired, or otherwise not safe
        to use.
        """
        if self.token_type is None or self.lifetime is None:
            raise TokenError(_('Cannot create token with no type or lifetime'))

        self.token = token
        self.current_time = aware_utcnow()

        # Set up token
        if token is not None:
            # An encoded token was provided
            from .state import token_backend

            # Decode token
            try:
                self.payload = token_backend.decode(token, verify=verify)
            except TokenBackendError:
                raise TokenError(_('Token is invalid or expired'))

            if verify:
                self.verify()
        else:
            # New token.  Skip all the verification steps.
            self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type}

            # Set "exp" claim with default value
            self.set_exp(from_time=self.current_time, lifetime=self.lifetime)

            # Set "jti" claim
            self.set_jti()

self.payload = token_backend.decode(token, verify=verify) is the line where this dies. And what happens in there?

def decode(self, token, verify=True):
        """
        Performs a validation of the given token and returns its payload
        dictionary.

        Raises a `TokenBackendError` if the token is malformed, if its
        signature check fails, or if its 'exp' claim indicates it has expired.
        """
        try:
            return jwt.decode(token, self.verifying_key, algorithms=[self.algorithm], verify=verify)
        except InvalidTokenError:
            raise TokenBackendError(_('Token is invalid or expired'))

Yup that's right, it's only checking the exp key, not the refresh_exp key.

Just to be thorough, let's see what's going on in the jwt library.
Because verify=True, jwt.decode goes ahead and calls an internal function called self._validate_claims, which finally calls self._validate_exp, which, of course, gives us a nice ExpiredSignatureError.

A Brute-Force Solution

To fix this, one could change the SlidingToken initialization in the TokenRefreshSlidingSerializer from

token = SlidingToken(attrs['token'])

to

token = SlidingToken(attrs['token'], verify=False)

which I successfully tested, but I don't know the library well enough to understand the ramifications of that kind of action.

I hope this helps someone, somewhere?

I hope this helps someone, somewhere?

Have a security error in this solution. With verify=False on SlidingToken, the signature validate is don't verified, an is possible change the payload. Is possible make a test of this on debbuger of https://jwt.io/, changing the payload, with wrong key, and submit to refresh.

@Kaspary You're probably using PyJWT 2.0.0. Ref #349 Once it's merged, it should solve your problem. Until then, downgrade to PyJWT 1.7.1. Sorry for the inconvenience!

one doubt, how do i create a sliding Token??

Please don't use sliding tokens. They're confusing. (They came from migrating from drf-jwt). We may be deprecating is soon as migration from drf-jwt is much lower than before.

ok thanks @Andrew-Chen-Wang

Was this page helpful?
0 / 5 - 0 ratings