Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SPA and http-only cookies #52

Closed
BartoGabriel opened this issue May 7, 2020 · 14 comments
Closed

SPA and http-only cookies #52

BartoGabriel opened this issue May 7, 2020 · 14 comments

Comments

@BartoGabriel
Copy link
Contributor

I have a SPA application and it makes calls to an API. I had some doubts based on the examples.

Can the token be refreshed silently, using a SPA and http-only cookies?
Why does the SPA example use cookies shared with the client? In the event that it can be silently refreshed using http-only, I see no reason to expose the token and refresh token to js attacks.

@ottokruse
Copy link
Collaborator

Can the token be refreshed silently, using a SPA and http-only cookies?

Silently as in without the user noticing? Don't think so, because with http-only cookies, you cannot access them in JS, so you need the browser to explicitly visit another URL: the token refresh URL, where the server (Lambda@Edge) will read the JWT and do the refresh. The user will notice this redirect, even though the user is redirected back immediately.

@ottokruse
Copy link
Collaborator

BTW if you deploy the solution in static site mode, all cookies will be HTTP only by default.

@ottokruse
Copy link
Collaborator

@BartoGabriel
Copy link
Contributor Author

Thank you very much for your prompt response.

I was doing some tests. I hope I am not mistaken in the conclusions.
Although it is as you say, the JS does not have the capacity to renew token when it expires. But there is one interesting property of the “fetch” function (JS) can be used, which is to set the “redirect” property to “follow “(by default browsers take it this way). https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch

This means that JS makes an API call, the lambda function checks if it is about to expire, if so, it forwards it to "/ refreshauth", where the lambda function silently updates the token from behind and returns 307 to URL of the original API (with the new cookie), due to the redirection settings of the fetch function it follows the new URL. Then the browser calls the redirect URL again, and finally the fecth function returns (with the original URL).
Sorry if the explanation is poor or not well understood. I don't have good english.

To test this, I made a simple application, where a SPA is loaded and every 5 seconds it calls an API and updates a textarea with the result code and a little part of the JSON. As I show you in the following image, the application called the API, it updated the token, and for the spa application this was transparent. There was also no unwanted refresh page:
imagen

I think this is a way to leave the token a little more protected in a SPA application.
In the event that the SPA needs the JWT payload, a lambda function could be made that only returns the payload without revealing any extra information.

What do you think of this solution? Are there any considerations that I'm not considering?

@BartoGabriel
Copy link
Contributor Author

On the other hand, looking at the following line of code, I think there is a small bug:
https://github.com/aws-samples/cloudfront-authorization-at-edge/blob/master/src/lambda-edge/check-auth/index.ts#L32

I understand that what is intended is to update the token 5 minutes before it expires. If so, 60 * 5 should be added, not subtracted. Or in any case subtract the variable exp - (60 * 5). In this way, either compare it for a future time, or compare it for a token with less expiration time.

@ottokruse
Copy link
Collaborator

60 * 5 should be added, not subtracted

Indeed, nice catch! Wanna send a PR for it and grab the street creds?

About your solution regarding refreshes. I can follow your general reasoning I think. But isn't a caveat that you would need to send all fetches to the refresh URL, and pass the actual URL you want to fetch to as a query param or so, so the refreshAuth function can return a redirect to it?

While that will work, it is quite demanding on the front-end code I guess.

@BartoGabriel
Copy link
Contributor Author

Sorry, I think you don't explain me correctly. I'm going to explain it in other words and add a new option. I believe that these 2 options could be implemented to make the token refresh from SPA, without exposing the tokens.
In both cases, in the authentication server it uses the “hybrid flow”, with a client secret (since it is only known by the server in the lambdas functions), and with offline access in the scope (to be able to refresh the token calling from lambdas functions). And Always the server is the one that updates the tokens.

Option 1

I will explain my previous solution again in other words, since my level of English did not help me.
To help my reasoning, I am going to use a different example from the one I mentioned earlier (POST method instead of GET method): A user has a form with a field that POSTs the URL “/api/machine”. Sending the name of a new machine. The post method in the API creates a machine and returns code 202 with the data of the created machine.

Assumption 1 - User has a token that expires in 20 minutes

  1. The user makes a post to the URL “/api/machine”, with the data of the machine.
  2. The lambda (check-auth) function verifies the token, as this is correct, it continues its course towards the API.
  3. The user receives an HTTP code “202”, with the information of the created equipment.

imagen

Assumption 2 - The user has an expired or expired token in less than 5 minutes.

  1. The user makes a post to the URL “/api/machine”, with the data of the machine.
  2. The lambda function (check-auth) verifies the token, as this is expired or soon to expire, and the user has a refresh token, redirect it to the lambda function that is responsible for renewing the token (refresh-auth).
  3. The browser receives a 307 (redirected to the lambda function). Because it is a redirect, the JS fetch function in the code if configured with the "redirect" attribute in "follow" (default value), calls the redirect without the JS code knowing.

imagen

  1. The browser redirects the post to the new address, which is serviced by the lambda refresh-auth function. The function is responsible for calling the identity server, and with the client id, the secret client and the refresh token, it requests a new token. If everything goes well you get new tokens (this is all from the lamba function). And again it sends a redirect (307) response to the original URL (“/ api / machine”), but with the new cookies.
  2. Again, the browser receives a 307 (redirected to the original / api / machine address).. Because it is a redirect, the JS fetch function in the code if configured with the "redirect" attribute in "follow" (default value), calls the redirect without the JS code knowing.

imagen

  1. The lambda function (check-auth) verifies the token, as this is correct, it continues its course towards the API.
  2. The user receives an HTTP code “202”, with the information of the created equipment.

imagen

imagen

Conclusion:

In both cases, the result for JS was the same:

imagen

Some considerations:

  • Regarding the overhead you point out, these redirects only happen when the tokens expire (for example, if it were every 20 minutes, 2 redirects would happen every 20 minutes, I don't see it as a problem). It would be great to be able to do it behind the scenes without JS suffering these redirects, but for that the lambda function edge should update the token, call the api, and change the cookies (request and response event), I am new to lambda Edge, but I understand that this cannot be done.
  • Is important to note that in JS the fetch functions must be configured so that the redirects automatically follow. In the case that they have another configuration, this would not work. With which it can be a great disadvantage, since it would be good for developers to forget about this. Or maybe the api returns redirect and the JS needs to see them, and this would be a problem.
  • The refresh-auth function must tolerate all HTTP methods (not just the get).

Option 2

Develop a new lambda function in a new URL, for example "/check-session". This function will verify that the token is correct and has not expired. With which you will have 3 ways:

  • Token valid and not expired: Only returns Ok (202)
  • Token valid, but expired or soon to expire: The function is responsible for calling the identity server, and with the client id, the secret client and the refresh token, requests a new token. If everything is correct, restart Ok (202), but with the new cookies.
  • Token invalid or renewal fails: Authentication error 404. This must be handled by JS. Here there is no point in doing a redirect, since the api is only called from JS once it is authenticated.

Then from JS, you must do what is currently done (call this URL every 5 minutes):
https://github.com/aws-samples/cloudfront-authorization-at-edge/blob/master/src/cfn-custom-resources/react-app/src/App.js#L38

But instead of calling the authentication server, "/check-session" is called.

You can also add it to return the payload of the jwt, instead of returning an empty 202.

Conclusion:

I think this is the best option. It does not imply many changes, it is transparent to the JS, and the tokens are well secured in only http cookies.

@BartoGabriel
Copy link
Contributor Author

On the other hand, in a while I will do PR with the small correction.

@ottokruse
Copy link
Collaborator

Option 2 is actually already supported now I believe (maybe you are saying that). You could do a background XHR GET to index.html or any other page every 5 minutes, and that would trigger checkAuth, and token refresh if needed.

Good suggestion! Maybe we should write a bit about it in README.md

@BartoGabriel
Copy link
Contributor Author

Thank you so much for your patience. I think it is important that this is found in the documentation.

One last point: Although the token is refresh, either in the form calling the identity server every 5 minutes, or doing XHR GET to index.html every 5 minutes. I think that the check-auth function is not correct, because it can give that it is called in a time window that the token has not been updated yet, and JS will receive a redirect without knowing how to handle it.
Example:
imagen

I think that the API calls should only validate that the token is valid or if it is invalid return 404. It is not something that should be changed in the project, but I think it should be kept in the documentation.

ottokruse pushed a commit that referenced this issue May 12, 2020
* #52: Time frame corrected to refresh token soon to expire.

* tsconfig.tsbuildinfo file added to .gitignore
@deepku2
Copy link

deepku2 commented May 21, 2021

Storing tokens in non HttpOnly Cookie is a security vulnerability. No enterprise would allow it. We went through security review and this approach was rejected by InfoSec. Same vulnerability exists for Amplify implementation where they are storing tokens in non HTTPOnly cookies.
Also RefreshToken should be stored at specific path (/refresh-token)

@ottokruse
Copy link
Collaborator

ottokruse commented May 28, 2021

This solution here supports storing cookies as HttpOnly, and the linked blog post also goes into this.

It is all about trade offs !

Storing refreshToken at a specific path, if you want that, is possible too (ny the same mechanism). But it would need a small code change, because check auth lambda, which is attached to the default behavior, now checks to see if there is a refresh token before forwarding to the refresh tokens endpoint. That would not work, if check auth can't see the refresh token - so then it would send you to Cognito to sign-in again

Ultimately, the solution here is a sample, that you can go on from, intended to show how you could do it, and speed you up. By all means change it to fit your security requirements,

@apollo7483
Copy link

Sorry for opening this issue again, I am using this template to deploy a react app for employees to use in a company. Using non http-only cookies seems not best security practice. But in my case, the website is used by trusted employees. The website is not exposed on the internet to used by untrusted users, so this is not an issue. Am I right ? I am sending this jwt to an api gateway with cognito authorizer enabled that located outside of this cloudfront.

@ottokruse
Copy link
Collaborator

Making cookies http-only is a way to protect against malicious JavaScript. If somehow malicious JavaScript got deployed on your site, it could e.g. steal the credentials from logged in users.

But if your SPA needs to access the JWTs also because it uses them in client side fetches, then you have no choice but to make the cookies NOT httpOnly.

A middle ground is to just make the refreshToken httpOnly with cookie settings such as:

{
        idToken: "Path=/; Secure; SameSite=Lax",
        accessToken: "Path=/; Secure; SameSite=Lax",
        refreshToken: `Path=/refreshauth; Secure; HttpOnly; SameSite=Lax`,
        nonce: "Path=/; Secure; HttpOnly; SameSite=Lax",
}

But then, if your JWTs expire, you must make some request to CloudFront so that Lambda@Edge triggers and refreshes your cookies (because you can't do it client side anymore, as you can't access the refresh token there). Such as: #190 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants