tymondesigns / jwt-auth

🔐 JSON Web Token Authentication for Laravel & Lumen
https://jwt-auth.com
MIT License
11.3k stars 1.54k forks source link

JWT_TTL vs JWT_REFRESH_TTL #1863

Open bert-w opened 5 years ago

bert-w commented 5 years ago

Your environment

Q A
Bug? maybe
New Feature? maybe
Framework Laravel
Framework version 5.8
Package version ^1.0.0-rc.3
PHP version 7.3

What is the difference between JWT_TTL and JWT_REFRESH_TTL? It seems like the library is missing some access_token/refresh_token logic.

I cant figure out how these 2 values are different, because when I set JWT_TTL to 60 (which means it is valid for 1 minute) and set JWT_REFRESH_TTL to 2 weeks, i will not be able to refresh my token using POST /auth/refresh after 2 minutes because it gives me unauthorized.

How are these 2 values supposed to be implemented? I was originally expecting to get both a access_token and a refresh_token back from my POST auth/login request, but that is not the case.

clugg commented 5 years ago

Let's go on a quick trip through the library's code. I will be ignoring any tests throughout this, and since we are operating within normal context, I'll also be ignoring any blacklist-related code. You are welcome to skip to the bottom for a summary, this was more to log my own train of thought.

JWT_REFRESH_TTL is only referenced in one spot, setting the config key refresh_ttl:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/config/config.php#L123

The config key refresh_ttl is referenced in one other (relevant) spot:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Providers/AbstractServiceProvider.php#L303-L304

, as a parameter to setRefreshTTL on instance of Tymon\JWTAuth\Validators\PayloadValidator:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Validators/PayloadValidator.php#L121-L126

, which sets a value to a property called refreshTTL. If we look search for instances of that, we'll find two functions of note:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Claims/IssuedAt.php#L56-L61

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Validators/PayloadValidator.php#L95-L98

I am going to assume that $claims->validate('refresh', ...) in the second instance is actually calling validateRefresh from the first.

As such, we know that we are looking for calls to validateRefresh. If we look for this, we'll find only one that we haven't yet seen:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Validators/PayloadValidator.php#L47-L52

Based on some property refreshFlow, either refresh validation (validateRefresh) will occur, or standard validation (validatePayload).

If we search for refreshFlow we'll find it comes from a trait:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Validators/Validator.php#L18-L20

This trait has one method:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Support/RefreshFlow.php#L30-L35

If we search for setRefreshFlow, we will find that it is used twice in Tymon\JWTAuth\Manager:

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Manager.php#L99-L113

https://github.com/tymondesigns/jwt-auth/blob/6e419252cc592afc2ed4cd61be6f5e303a636cc5/src/Manager.php#L124-L139

We are now at methods you may recognise and call yourself: decode and refresh.

Unlike some implementations, instead of having two tokens, an auth token and a refresh token, this library lets the auth token to act as both. Using JWT_REFRESH_TTL, you are able to tell it that while the token may have passed expiry for standard auth use, you can still call refresh() with it to get a new (and valid) auth token. For example:

JWT_TTL=5
JWT_REFRESH_TTL=10

When a token is issued, it will be valid for standard authentication use for 5 minutes. Once those 5 minutes have passed, any auth checks will fail. However, until 10 minutes have passed, you can still call refresh with your expired token to get a new one.

The docs make this concept a little confusing (and misrepresent it). You can see in the sample AuthController in the docs that it is recommended to put everything except the login method behind the auth:api middleware. However, if you do this, refresh will also be behind the auth:api middleware. This is wrong and means that the token must not have expired before it can be refreshed, which completely disrespects the JWT_REFRESH_TTL value. As such, you should also put refresh in the exceptions:

$this->middleware('auth:api', ['except' => ['login', 'refresh']]);

I hope this clears things up.

abishekrsrikaanth commented 5 years ago

@clugg, A very clear answer with the code walkthrough. Since we are talking about refreshing tokens here, could you also help clarify my below question.

I use the defaults on the config set by this package and the token expires every 60 minutes, so the user has to login again to refresh the token. What is a good strategy so the user doesn't have to login frequently to refresh the token. I can call the refresh method if it expires, but am not sure if that is the correct way to do it. Could you advice on how I should deal with this?

clugg commented 5 years ago

Refreshing the token doesn't require logging in again (or shouldn't) - the idea is that you pass the existing token to receive a fresh one. Logging in would pass credentials in to receive a fresh one, not the same process. Personally I hit my refresh endpoint on an interval in the frontend code to keep the token up-to-date, but another option that I have seen is to hit the endpoint whenever you get a 401 back telling you that your token has expired.

yannick-milanetto commented 5 years ago

Thx @clugg it helped me a lot too. Too bad that it is not properly documented. Having one token used for both authentification and refresh is not that common, and would deserve a chapter in the doc. Not sure if I can help to update this doc, if so i'm more than happy to do it.

bert-w commented 5 years ago

@yannick-milanetto i agree, from my understanding the current single token implementation actually defeats the purpose of the whole refreshing flow, since you can just refresh your token at any time with literally the same JWT token. The whole idea behind a separate refresh token is that it is actually separate, and only to be sent when you are about to refresh your token.

Therefore you might just as well set your config to JWT_TTL === JWT_REFRESH_TTL. If you lose your JWT token, you lose your access token but in this case also the refresh token.

Using a token refresh means that your claims are updated again with current values and put into the JWT. However, everyone seems to be using this library as a Stateful JSON Web Token which basically means it only stores a user ID like 1, which the backend then matches to a database user record.

The counterpart being a Stateless JSON Web Token, which stores a whole user object and possibly permissions (resulting in a big JWT). The backend can simply decode the JWT and use that value as a given, without ever calling the database.

In these 2 JWT implementations, the refresh flow is of more importance in the Stateless version to make sure the token is refreshed like every 15 minutes and the most recent claims are assigned.


So in order to make these refresh tokens work properly, they need to be a separate value outside of the JWT, preferably long-lived and stored securely like in a browser's localStorage. Then you would only send them to the authentication server/route when you need to refresh your token.

As long as you put the refresh token inside the JWT (the exp claim), your access token is functionally identical to the refresh token.

Please correct me if I'm wrong.

yannick-milanetto commented 5 years ago

@bert-w Well, this was not my understanding, and my latest tests seem to confirm that. Based on clugg's answer, what I understood is that there is a Refresh workflow, but this workflow works by using a single token, call it as you want (access token, jwt token, id token, etc...) The way it works is the following : you set a TTL for the JWT token, let's say 15 minutes. You set the TTL for the refresh workflow, let's say 1 week (the default being 2 weeks if you don't override it).

You have then 3 scenarii:

My tests and implementation seems to confirm that, but I could have missed something.

FYI, the tricky part on my side was that the second scenario (token expired but refresh workflow not expired). You can't have your refresh endpoint's route under the auth:api middleware because then the middleware raise the TokenExpiredException and doesn't call next() to reach out your controller behind this route. So you end up having an exception without having the token refreshed. As mentioned by clugg, this means that you must NOT put your refresh endpoint's route under the auth:api middleware. It's important to be aware of that.

It was an issue for me because in my implementation of JWT workflow, I wanted to have the connected user returned as part of the login / refresh workflow (to save one additional http request from the frontend). But then, what happened is that when I refreshed the token, because my endpoint is not under auth:api middleware i could not get the connected user simply by calling Auth::user(). I ended up having 3 different endpoints to cover all the JWT scenarii:

This is the code i used for autologin in backend (NB: I'm under Lumen without Facades activated, so i used app('auth') but this is similar to Auth facade :

public function autoLogin(Request $request) {
        $token = $request->bearerToken();
        app('auth')->setToken($token);
        if (!app('auth')->check()) {
            $token = app('auth')->refresh();
            app('auth')->setToken($token);
        }
        app('auth')->byId(app('auth')->getClaim('sub'));
        return $this->respondWithToken($token);
}

And the code used on frontend if login / autologin are successful in order to call refresh endpoint just before the token get expired (NB: i'm using VueJS with Typescript in frontend):

async function refreshUser(
  state: ActionContext<UserState, ModuleState>,
): Promise<void> {
  try {
    const { data } = await userApi.refresh();
    authOK(state, data);
  }
}

function authOK(
  state: ActionContext<UserState, ModuleState>,
  { expires_in, user, token }: LoginResponse,
) {
    // store the connected user in vuex + the token in localStorage
    state.commit(types.AUTH_SUCCESS, { user, token });

    // refresh token check 1 minute before expiration
    if (expires_in) {
      setTimeout(() => {
        refreshUser(state);
      }, (expires_in - 1) * 60 * 1000);
    }
  }
}

Hope this helps.

clugg commented 5 years ago

@bert-w

stored securely like in a browser's localStorage

Please note that localStorage is not secure. If you are running any third-party scripts on your page, which is very likely unless you are writing all of your JS alone without any third-party libraries, then all of those scripts have the ability to access localStorage and, as such, can access your user credentials.

The idea of a having multiple tokens (access and refresh) is that you store the refresh token (long life) in a HttpOnly cookie so that it cannot be accessed by JS at all. When you hit a login endpoint with that existing refresh token cookie, it can return an access token (short life, e.g. 15 minutes). This is not perfectly stateless because you need to store refresh tokens against users in your data store, but for the majority of requests (any non-refresh ones), the access token can be used statelessly. This also has an additional benefit: you don't need to "blacklist" access tokens themselves because in the vast majority of cases, you can simply revoke the refresh token and let the access token expire (given its short life). This makes things such as "log out all devices" very easy, given a disclaimer of "this may take up to X minutes to apply" - X being your access token's TTL.

@yannick-milanetto 's explanation regarding the flow of this library perfectly matches my understanding.

bert-w commented 5 years ago

@clugg @yannick-milanetto

I dont see how a 15m access token and a 30m refresh token does any good for your app security, when both of them reside in the same JWT. They are bound to eachother, and the loss of the JWToken results in 30m access (assuming the attacker knows the POST /auth/refresh route).

The idea of a having multiple tokens (access and refresh) is that you store the refresh token (long life) in a HttpOnly cookie so that it cannot be accessed by JS at all.

Yes a HttpOnly cookie might be better; note that this must be set from PHP since it cant be set using Javascript. This demonstrates a good separation between script-accessible access token and non-script accessible refresh token, hence why the implementation of this package lacks.

clugg commented 5 years ago

@bert-w I absolutely agree. This package lacks a secure and practical implementation of refresh tokens. The current implementation doesn't make much sense - if you have the token, you have it forever if you know the refresh endpoint.

bert-w commented 4 years ago

@tymondesigns care to elaborate on this post?

xwiz commented 4 years ago

I'm going to be a bit different here considering how other system works --- I think JWT should spend more time on extra authentication steps and remove the refresh token feature. I'm more concerned about an attacker gaining any type of access to the token for the first time.

For example, if a token was obtained from a particular IP, and is suddenly being used from a different IP in different subnet, there should be a setting that allows instant invalidation of that token. You could argue that every developer should go and figure out how to implement that but I think that's the advantage of having packages like this. I don't believe in refresh tokens as I honestly don't know how they can be securely stored differently from the auth token just like @clugg pointed out. What is needed is a better secure authentication token implementation such that at the point of authorization/token validation, one can make sure the token is not under attack.

bert-w commented 4 years ago

@xwiz I guess you can implement your use case with an extra claim in the JWT with the users' IP address. However changing IPs is not something I regard as a possible danger. It can simply be caused by switching from Wifi to 4G or using a VPN or proxy. So depending on how strict you want your security policy, it might limit your end-users a lot.

However your statement about you not believing in refresh tokens is only partly explainable. From my understanding, the refresh token is something that you would only send once to the authorization server, and the access token is something you send every request to the resource server. If you think about a resource and auth server as a separate entity you can understand why the access-/refresh-token concept exists.

Sadly this concept is misimplemented in this library since it provides a single JWT that contains both tokens.

xwiz commented 4 years ago

@bert-w That's why I said it should be an optional setting. this feature is usually coupled with user agent, if your user agent and IP suddenly changes, most sensitive services you use will automatically log you out. Hence why I said it should be a feature, it's common enough to be a feature.

I might have missed the part about refresh tokens, my point about refresh tokens is simply that no matter how they are sent they are an alternative auth token with longer TTL, it's like having two auth tokens and using one sparingly, this can create the illusion of security but it isn't more secure. The real issue is how do we find out when a token has been compromised, most security token features also check the number of parallel sessions for that token, I am not sure that feature is present here. I'm more of the opinion that more reliable anti-compromisation security features be implemented than refresh tokens. I don't know how practically my API users can use the refresh token to secure their app.

bert-w commented 4 years ago

@xwiz I'll show you how trivial your problem is:

Go to the place in your code where the JWT is generated after logging in:

// SomeAuthController.php
return $this->respondWithToken(auth()->claims([
    'ip' => $request->ip(),
    'user_agent' => $request->header('User-Agent')
]);

Then simply create some middleware that checks these custom claims:

// SomeMiddleware.php
// Inject \Illuminate\Contracts\Auth\Factory $auth to $this->auth
$guard = $this->auth->guard();
$claimUserAgent = $guard->getPayload()->get('user_agent');
if($claimUserAgent !== $request->header('User-Agent')) {
    throw new AuthorizationException('Wrong User-Agent');
}
return $next($request);

Read https://stackoverflow.com/questions/3487991/why-does-oauth-v2-have-both-access-and-refresh-tokens to have your question answered why refresh-tokens != access-tokens.

Needless to say the default implementation doesn't apply to this package. Since here, the refresh-token is literally inside the JWT, which is the only thing that gets passed around. Which means access-token === refresh-token.

xwiz commented 4 years ago

@bert-w That's a different context, and I'm not saying there's no point in that. But let's be careful whether we're simply saying that a refresh token is the third part of the user's password. Normally in JWT scenario, you're expected to create tokens with username and password (the actual refresh token in this case), now if someone says, I don't want my users entering username and passwords all the time, and decides to store the refresh token (where? browser?) I mean if it's an app fine, but as an attacker I already understand access tokens may not be so useful.

You might think that your suggestion above solves the problem completely but I'm sure you know deep down it doesn't. You'll likely need a package as well that decodes the IP locations, and you actually to test if the IP is in the same subnet, or a forward IP from cloudflare, etc. There may be some point in every developer going off on their own tangent to solve that problem, but it's also OK to have it in a package where security experts can contribute. But maybe I'm thinking it all wrong, a lot of experts don't think these matter because they find it hard to guage how much additional protection it actually provides to a desperate attacker, but in my experience, majority of attacks in the wild web are random vulnerability scan attacks and these IP/location and agent checks can pretty much defeat a good number of them. Might be too much for a package that says it's just a JWT auth package, but you get the point, where do you draw the line, there's no official document on that.

bert-w commented 4 years ago

@xwiz this package is not going to provide all that for you. The JWT implementation and the claims that come along with it are provided, which is the blueprint you need for whichever restrictions you need to add. You basically admitted yourself that it is out of scope of this package since you keep adding requirements (ip + subnet + user agent + ip locator + forwarded ips).

If you read the original post, you can see that your first comment should actually be a new issue or feature request. I simply created this issue to point out that the current TymonJWT refresh logic is a facade which doesn't provide any extra protection in the current state.

I do think there is merit in properly implementing access/refreshtoken logic.

xwiz commented 4 years ago

Ah I see what you mean, sorry if I derailed your thread. I am sure I'm a bit rash in my judgment on refresh tokens, but it should definitely be a feature especially since there's already config related to it.

ssi-anik commented 4 years ago

Since this morning I had been debugging my code. Unfortunately, I had set JWT_TTL to 2 months, and the JWT_REFRESH_TTL is set to 14 days. Before reading @clugg 's comment, I went through the codebase. And I strongly disagree with his comment.

Because, when the token is decoded from the request and goes through the Payload validation https://github.com/tymondesigns/jwt-auth/blob/3af7ac13bf07154ed2c2b9270af5bbabde47c033/src/Payload.php#L44-L47 https://github.com/tymondesigns/jwt-auth/blob/b927137cd5bd4d2f5d48a1ca71bc85006b99dbae/src/Validators/PayloadValidator.php#L47-L52 https://github.com/tymondesigns/jwt-auth/blob/b927137cd5bd4d2f5d48a1ca71bc85006b99dbae/src/Validators/PayloadValidator.php#L81-L84 https://github.com/tymondesigns/jwt-auth/blob/b927137cd5bd4d2f5d48a1ca71bc85006b99dbae/src/Claims/Collection.php#L54-L67

Finally, if your token contains exp claim, then it'll throw the following exception even if you put the refresh endpoint behind the auth check. https://github.com/tymondesigns/jwt-auth/blob/b927137cd5bd4d2f5d48a1ca71bc85006b99dbae/src/Claims/Expiration.php#L28-L34


Thus I guess, there is no use case for JWT_REFRESH_TTL variable. Point me out if I'm wrong.

stale[bot] commented 3 years ago

Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward?

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.