3

I have two apps, the server-side app which is written in Laravel and the client-side app, written in VueJS. The vue app consumes the api provided by the laravel app.

The auth flow:

The user attempts to log in, the server sends two tokens to the client, a) access_token and b) refresh_token upon successful login. The server also sends the refresh token in the form of an httpOnly cookie to the client so that when the access token is expired, it can be refreshed using the refresh token from the cookie.

The problem:

When the user logs in, in the response, the server sends the following Set-Cookie header:

Set-Cookie: refresh_token=tokenvalue; expires=Mon, 04-Nov-2019 09:13:28 GMT; Max-Age=604800; path=/v1/refresh; domain=http://app.test; httponly; samesite=none

This means that I expect the cookie to be sent to the server whenever there is a request to the /v1/refresh endpoint. However, the cookie is not present in the request. (I've logged $request->cookie('refresh_token') in controller but it logs null).

This whole token refreshing mechanism is handled in a vuex action:

export function refreshToken({commit}, payload) {
    return new Promise((resolve, reject) => {
        // axios.defaults.withCredentials = true;

        // here, payload() function just converts the url to:
        // "http://app.test/v1/refresh"

        axios.post(payload('/refresh'), {}, {
            withCredentials: true, transformRequest: [(data, headers) => {
                delete headers.common.Authorization;
                return data;
            }]
        }).then(response => {
            let token = response.data.access_token;
            localStorage.setItem('token', token);
            commit('refreshSuccess', token);
            resolve(token);
        }).catch(err => reject(err));
    });
}

As you can see, I've set the withCredentials config to true. I am also sending the Access-Control-Allow-Credentials: true from the server. Here is my cors middleware:

public function handle($request, Closure $next)
    {
        $whiteList = ['http://localhost:8080'];
        if (isset($request->server()['HTTP_ORIGIN'])) {
            $origin = $request->server()['HTTP_ORIGIN'];
            if (in_array($origin, $whiteList)) {
                header('Access-Control-Allow-Origin: ' . $request->server()['HTTP_ORIGIN']);
                header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS');
                header('Access-Control-Allow-Headers: Origin, Content-Type, Authorization');
                header('Access-Control-Allow-Credentials: true');
                header('Access-Control-Expose-Headers: Content-Disposition');
            }
        }
        return $next($request);
    }

I don't know what have I done wrong. My PHP version is: 7.3.5. Here are the request headers of /v1/refresh endpoint:

Accept: application/json, text/plain, */*
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,bn;q=0.8
Connection: keep-alive
Content-Length: 15
Content-Type: application/x-www-form-urlencoded
Host: app.test
Origin: http://localhost:8080
Referer: http://localhost:8080/products
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.70 Safari/537.36

...and the response headers:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Origin, Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS
Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Expose-Headers: Content-Disposition
Cache-Control: no-cache, private
Connection: keep-alive
Content-Type: application/json
Date: Mon, 28 Oct 2019 09:40:31 GMT
Server: nginx/1.15.5
Transfer-Encoding: chunked
X-Powered-By: PHP/7.3.5
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59

I don't know the inner-workings of browser's cookie storing mechanism, I also don't know if an httpOnly cookie can be found in the filesystem, but in despair, to know whether the browser is indeed saving the cookie, I googled and found that cookies are stored in ~/Library/Application Support/Google/Chrome/Default/Cookies file, which is an SQLite file. I opened that file and searched for my cookie , but it wasn't there either (maybe httpOnly cookies are stored somewhere else?).

Now, my question is, how do I retrieve the cookie from the client-side app?

Tanmay
  • 3,009
  • 9
  • 53
  • 83
  • What is exactly your HOST? Is it `localhost:8080` or `app.test`? – nmfzone Oct 28 '19 at 12:43
  • Thanks for the response. `localhost:8080` is where my vue app is served from. `app.test` is where my laravel app is served from. They are two different apps/domains/origins. This is a very typical set up for a SPA that is completely isolated from the laravel app. – Tanmay Oct 28 '19 at 13:05
  • So, the vue app and api has different host, right? Of course it would not work, since your laravel set the cookie to `app.test`, not `localhost:8080`. Take a look at the domain `path=/v1/refresh; domain=http://app.test; httponly; samesite=none`. – nmfzone Oct 28 '19 at 13:14

1 Answers1

4

Since your Vue App and Laravel (API) has different HOST, it will not working.

You can re-check your server response:

Set-Cookie: refresh_token=tokenvalue; expires=Mon, 04-Nov-2019 09:13:28 GMT; Max-Age=604800; path=/v1/refresh; domain=http://app.test; httponly; samesite=none

It sets the cookie to http://app.test, not http://localhost:8080. So, there is no refresh_token cookie set in your http://localhost:8080.

The very typical solution is:

  1. You need to use subdomain, and let your cookie set to the domain=.app.test (whole domain). I mean, you need to make sure Laravel and Vue under the same domain.

  2. You don't need to get the refresh_token from cookie again in your Laravel app. First, you just need to save your refresh_token you get from API, to the either localStorage or cookie at your Vue App. Then, just send your refresh_token via forms (form-data). Finally, get your refresh_token via $request->get('refresh_token').

Here is the example, just to illustrate what i mean for the second solution.

Let's assume (typically) the http://app.test/api/login would response:

{
    "token_type": "Bearer",
    "expires_in": 31622399,
    "access_token": "xxx",
    "refresh_token": "xxx"
}
import Cookies from 'js-cookie'

async login() {
    const { data } = await axios.post('http://app.test/api/login', {
        email: 'hi@app.test',
        password: 'secret',
    })

    const refreshToken = data.refresh_token

    Cookies.set('refresh_token', refreshToken)
},
async refreshToken() {
    const refreshToken = Cookies.get('refresh_token')

    const response = await axios.post('http://app.test/api/refresh-token', {
        refresh_token: refreshToken,
    })
}
nmfzone
  • 2,755
  • 1
  • 19
  • 32
  • Changed domain to `localhost:8080`, cleared cache, cookies, and everything. Tried again. Still, the cookie could not be retrieved. – Tanmay Oct 28 '19 at 13:28
  • What do you mean by `changed domain to localhost:8080` ? You can't set cookie from Laravel `app.test` to `http://localhost:8080`. [Here is why](https://stackoverflow.com/questions/6761415/how-to-set-a-cookie-for-another-domain). – nmfzone Oct 28 '19 at 13:35
  • I meant I changed the *value* of the `domain=` from `app.test` to `localhost:8080` as you've mentioned. – Tanmay Oct 28 '19 at 13:43
  • I am working on your first point. But your second point where you are suggesting to send the refresh token via form, it can't be done. Because this cookie is httpOnly cookie. It can't be retrieved from `$request->get()` – Tanmay Oct 28 '19 at 13:43
  • For the second point, that's the problem. You don't need to set `refresh_token` as cookie again in Laravel. You just need to save your `refresh_token` you get on authenticate to the localStorage or cookie in your Vue App. – nmfzone Oct 28 '19 at 13:51
  • I am not setting the refresh_token *again*. All I am trying to do is set the token to clients cookie, and retrieve the token (which is supposed to be sent automatically once it is set). – Tanmay Oct 28 '19 at 14:01
  • Do you know how can I assign my vue app to the subdomain of `app.test`? I am using laravel valet. – Tanmay Oct 28 '19 at 14:03
  • @Tanmay No, I don't know how to setup subdomain in local machine, even with valet. I would just suggest my second solution. That's the typical use case (AFAIK). But, just do what you want. – nmfzone Oct 28 '19 at 14:06
  • The second solution suggests storing the refresh token to localStorage. But under no circumstances should I store the refresh token in localStorage. That would defeat the whole purpose of a **refresh token**. Refresh token should be stored as httpOnly cookie so that javascript can't access it. The whole point of the refresh token is to prevent javascript from accessing it. – Tanmay Oct 28 '19 at 14:12
  • I said, `localStorage` or `cookie`. It depends on what's your Vue App types. If it's **SSR**, you can set it to the `httpOnly` cookie. But, if it's **SPA**, of course you can't set `httpOnly` cookie. If you don't mean to save it to cookie or localStorage in SPA, so you can use vuex (or just use simple class to hold it), just the same as Auth0 used [here](https://github.com/auth0/auth0-spa-js/blob/master/src/cache.ts) – nmfzone Oct 28 '19 at 14:30
  • AFAIK, if you're using cookie in your API, I believe it wouldn't work if your API got accessed via Android (or similar, like cURL). – nmfzone Oct 28 '19 at 14:33
  • Btw, I was forget something. Where do you store your `access_token`? – nmfzone Oct 28 '19 at 14:57
  • I would create a different endpoint for android, let's say `v1/app-refresh` where instead of grabing the token from `$request->cookie()` I would grab it from `$request->token` – Tanmay Oct 28 '19 at 15:06
  • I am storing accesd_token in localStorage. It only has 1 minute life time. If it gets stolen, it would already be expired by the time attacker makes another request. – Tanmay Oct 28 '19 at 15:07
  • haha, that's what i've guessed. You store `access_token` in localStorage, so what's the difference? ;)) Ok, just do as you please. – nmfzone Oct 28 '19 at 15:09
  • I had the very same question in my mind when I was naively implementing the auth. Then one day I asked myself what exactly is the purpose of a refresh token. So here is the difference: if someone steals the `access_token` from the localStorage, he cannot do anything with it. But if someone steals the `refresh_token`, then he has 7 days, which is enough to destroy the system. – Tanmay Oct 28 '19 at 15:24
  • Are you sure attackers can't do anything in 1 minute? ;)) I even can access all your API just in seconds, with simple script. – nmfzone Oct 28 '19 at 15:33
  • That's the point, I can change the access token's life time to 1 second. – Tanmay Oct 28 '19 at 15:45
  • Then, just change `refresh_token` to 1 second – nmfzone Oct 28 '19 at 15:50
  • It seems to me that you keep insisting that access_token and refresh_token are same thing. They aren't. [Refresh tokens cannot have short lifetime](https://auth0.com/learn/refresh-tokens/) – Tanmay Oct 28 '19 at 16:47
  • Yes, it just the same, from the aspect of security. Sorry, I can't find that text. Where is it? I can only find `refresh tokens have the potential for a long lifetime`. That's it. – nmfzone Oct 28 '19 at 17:17
  • Thanks for your opinion. But I refuse to accept that people who've invented the refresh token, invented it for no reason. If both access and refresh token have the same purpose, why was refresh token inventend in the first place? – Tanmay Oct 29 '19 at 00:25
  • Since this conversation is far from question you're asking, I think It's enough. I'll just give you some references [1](http://www.redotheweb.com/2015/11/09/api-security.html) [2](https://auth0.com/docs/security/store-tokens). Thanks for your opinion. Just don't forget to accept the answer if it's answering your *original* question. Have a good day! – nmfzone Oct 29 '19 at 03:32
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/201529/discussion-between-nmfzone-and-tanmay). – nmfzone Oct 29 '19 at 07:36
  • localStorage is too unsafe for a refresh token (long-lived access; attackers can keep giving themselves access). – amucunguzi Jan 12 '21 at 19:25
  • @amucunguzi From the aspect of security, localStorage is always unsafe, even for access token. It's just an alternative, read [here](https://auth0.com/docs/tokens/token-storage#browser-local-storage-scenarios). – nmfzone Jan 13 '21 at 02:10