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 with Implicit Grant - handling token refresh #1218

Open
jiristanglica opened this issue Jul 12, 2018 · 36 comments
Open

SPA with Implicit Grant - handling token refresh #1218

jiristanglica opened this issue Jul 12, 2018 · 36 comments
Labels
Auth Related to Auth components/category Cognito Related to cognito issues feature-request Request a new feature Service Team Issues asked to the Service Team Vue Related to Vue Framework issues

Comments

@jiristanglica
Copy link

jiristanglica commented Jul 12, 2018

Do you want to request a feature or report a bug?

A bug/feature request together perhaps. This has to do with both Amplify and Cognito.

What is the current behavior?

When using Cognito + Amplify with a SPA javascript app (Vue app in my case), the only proper way to implement authentication is to use the Implicit Grant (reasoning for example here). Using the Implicit Grant, Amplify is unable to automatically refresh the tokens after they expire.
The sources (like the one linked above) recommend to use a silent authentication or silent refresh to renew the tokens. The problem is, that Amplify doesn't provide any way to do this - and Congnito doesn't support it either (there is no prompt=none support as described here). This makes it impossible to design the SPA properly and provide a nice user experience.

What is the expected behavior?

I would expect that there is a way to renew the tokens when using Implicit Grant somehow.

I realize that this is probably more Congnito issue than Amplify issue, but since you guys probably work quite close together, I hope you could maybe provide some tips how to make Amplify properly usable with a SPA app. Thank you!

There is a discussion about the same thing in another repo, without a solution: amazon-archives/amazon-cognito-auth-js#92

@elorzafe elorzafe added the Auth Related to Auth components/category label Jul 31, 2018
@nihakue
Copy link

nihakue commented Jul 31, 2018

(Putting this here to avoid duplication. I have the same problem and describe my setup below):

Hey, it seems as though Amplify only handles credential refresh if we are using the 'code' / Auth flow. What is the guidance for automatically refreshing tokens if we are using 'token' / implicit flow, and have no refresh token? At the moment, Amplify just falls back to making unauthenticated requests. I would love to be able to use implicit flow and not have to worry about my session expiring.

Ideally I would like to be able to do this silently without interrupting the user's workflows /refreshing the page. Is that something that is possible? I've found it very difficult to answer these questions, but would be happy to contribute to documentation if I could come up with a sanctioned solution.

For reference, my setup is:

  • Cognito User pool with federated identities (Using a company internal SAML federated idp)
  • Using Cognito Federated Identity Pool with User pool set as IdP
  • Using @aws-amplify/auth@1.0.2 with oauth setup with the following call when Auth.currentAuthenticatedUser() throws:
const url = `https://${domain}/oauth2/authorize?client_id=${clientId}&redirect_uri=${redirectSignIn}&response_type=${responseType}&prompt=none`;
  window.location.assign(url);
  • Using @aws-amplify/api@1.0.2 to call API Gateway + Lambda (not using custom headers, since API gateway is using AWS_IAM authentication instead of User Pool)

I'm seeing that after my session expires, amplify tries to refresh my access token using the refresh token, but there isn't one since I'm using token / implicit flow. Failing that, it seems to give up and use guest user instead, which my identity pool doesn't (and won't) support.

Thanks for your help!

@nikkon226
Copy link

I am also having this same issue. Will a dev take a look at this please?

@jiristanglica
Copy link
Author

A followup to my original post:

After spending quite some time on the issue together with my colleagues and investigating different options, we came to a conclusion that this is simply not possible with Cognito. It is not an Amplify issue but rather Cognito as is. There is currently no way to perform a silent refresh when using the implicit flow. The reasons are following:

  • Cognito does not support the prompt=none (specs here) query parameter on the /authorize endpoint
  • Cognito blocks iframes
  • Cognito is not OIDC compliant (see the second reply here)

So... a bummer. I wish somebody would come here and bashed me to the ground that I'm wrong and stupid and there is actually a way, but I'm afraid that won't happen.

@rajwilkhu
Copy link

I have the same issue as well. This is a real blocker for using Cognito with implicit flow. Has any one solved silent refresh using cognito?

@stormit-vn
Copy link

stormit-vn commented Oct 5, 2018

I'm also seeking for a resolution to handle refresh token for implicit flow since we are working on the SPA. Using authorization code flow can retrieve refresh token but it doesn't good because of security concern. In this example, you will see the response_type=code is required, but it should only be used for the mobile app.

Many examples using authenticateUser API, as the result, a refresh token will be stored at the client site (on the browser) - it doesn't a best practice, does it? The authenicateUser API will be received the same response with the authorization code. How about if we implement a storage from the backend and apply authorization code (or using common authenticateUser API)? Do we have any disadvantage or any security concern we need to take care?

@maziarz
Copy link

maziarz commented Oct 16, 2018

This is unfortunately still not possible. I spend some days last year to resolve this problem, but ended up with a cookie-based solution utilising passport.js with nuxt.js. Not exactly ideal, but there was simply no other way around implicit grant with Cognito without having to re-login the user.

@claurin
Copy link

claurin commented Nov 13, 2018

It seems implicit flow is only possible with Cognito user pools, not identity pools.

@claurin
Copy link

claurin commented Nov 13, 2018

This SDK helps.

@jamesoflol
Copy link

jamesoflol commented Jan 23, 2019

I have long wondered, what is so bad about using auth code grant with an SPA? The only difference, from what I can see, is that the refresh token is stored in the user's browser. Obviously, if someone gets access to that refresh token, then they can impersonate the user for the duration of the refresh token. That refresh can then be revoked (globalSignout).

And by comparison, the alternative is that you're using auth code grant and storing/handling the refresh token in your own backend. In which case, you still have to provide your user with a cookie or token to store in their browser, which could be used to access your backend for the expiration-duration of that cookie or token.

What's the difference? Why is the first scenario any worse?

@maziarz
Copy link

maziarz commented Jan 23, 2019

I have longer wondered, what is so bad about using auth code grant with an SPA? The only difference, from what I can see, is that the refresh token is stored in the user's browser. Obviously, if someone gets access to that refresh token, then they can impersonate the user for the duration of the refresh token. That refresh can then be revoked (globalSignout).

And by comparison, the alternative is that you're using auth code grant and storing/handling the refresh token in your own backend. In which case, you still have to provide your user with a cookie or token to store in their browser, which could be used to access your backend for the expiration-duration of that cookie or token.

What's the difference? Why is the first scenario any worse?

Its not really that bad, since client_secret can be hidden and the refreshToken ttl can go all the way down to 1day. As far as i can see there is a lack of PKCE support in the aws-amplify SDK for Authorization Code Grant, unless HostedUI is utilised, which is not an option in my use-case.

@vanpra1
Copy link

vanpra1 commented Feb 18, 2019

I am having a similar scenario and a similar issue. Has anyone solved this one yet?

@ryang-bgl
Copy link

@vanpra1 The current parsing url to get authentication function is bounded with the use of hosted UI. In my case, I don't use the hosted UI. So I use the following codes to parse the URL and create the Auth user session.

const oauth = {
// Domain name
domain : '[your domain].auth.[region].amazoncognito.com',
// Authorized scopes
scope : ['email'],
// Callback URL
redirectSignIn : 'http://localhost:8000/',
// Sign out URL
redirectSignOut : 'http://localhost:8000/',
// 'code' for Authorization code grant,
// 'token' for Implicit grant
responseType: 'code',
// optional, for Cognito hosted ui specified options
options: {
// Indicates if the data collection is enabled to support Cognito advanced security features. By default, this flag is set to true.
AdvancedSecurityDataCollectionFlag : true
}
}

Auth.configure({
  Auth: {
    oauth: oauth
  },
});

const currentUrl = window.location.href;
(Auth as any)._cognitoAuthClient.parseCognitoWebResponse(currentUrl);

I am using typescript, the _cognitoAuthClient is a private field of AuthClass. I didn't find an easy way of creating CognitoAuth from amazon-cognito-auth-js. So I ended up above codes to ignore the type checking.

With code grant, it automatically hit the token endpoint to get access token, id token.

@powerful23 does the above codes look reasonable to you?

@nikkon226
Copy link

This is what I'm currently testing. Not sure if it's perfect or handles everything, but it seems to work. It's part of a Vue SPA using vue-router. This is called on every router page change. There are commented out lines that I used when figuring out everything.

return new Promise(function(resolve, reject) {
  Auth.currentAuthenticatedUser()
    .then((user) => {
      // console.log({message: 'validated', user: JSON.parse(JSON.stringify(user))})
      if (user.getSignInUserSession() != null) {
        commit('SET_CURRENT_USER', user.getSignInUserSession())
        resolve(user)
      }
      user.getSession(function(err, data) {
        if (err) {
          // console.log({message: "error getting session", error: err})
          reject(err)
        }
        commit('SET_CURRENT_USER', data)
        resolve(user)
      })
    })
    .catch((err) => {
      reject(err)
    })
}).catch(() => {
  // let stateDup = JSON.parse(JSON.stringify(state))
  // console.log({message: 'error validating', error: err, state: stateDup})
  return Auth.currentCredentials()
    .then((credentials) => {
      // console.log({message: 'credentials found', creds: credentials})
      if (credentials.needsRefresh()) {
        credentials.refresh(state.currentUser.refreshToken, (ret) => {
          // console.log({message: 'refreshing access token', returnInfo: ret})
          return Auth.currentAuthenticatedUser().then((user) => {
            // console.log({message: 'found user after fetched credentials', user: JSON.parse(JSON.stringify(user))})
            commit('SET_CURRENT_USER', user.getSignInUserSession())
            return user
          })
        })
      }
      return Auth.currentAuthenticatedUser().then((user) => {
        // console.log({message: 'found user after fetched credentials', user: JSON.parse(JSON.stringify(user))})
        commit('SET_CURRENT_USER', user.getSignInUserSession())
        return user
      })
    })
    .catch(() => {
      // console.log({message: 'refreshing error informaton', error: err})
      commit('SET_CURRENT_USER', null)
      return false
    })

@elorzafe elorzafe added investigating This issue is being investigated and removed investigating This issue is being investigated labels Mar 1, 2019
@jordanranz jordanranz added feature-request Request a new feature Service Team Issues asked to the Service Team and removed investigating This issue is being investigated labels Apr 26, 2019
@jordanranz
Copy link
Contributor

This is still not supported. Marking as a feature request for syncing visibility with the Cognito team.

@stale
Copy link

stale bot commented Jun 15, 2019

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

@harsimranb
Copy link

It seems implicit flow is only possible with Cognito user pools, not identity pools.

@claurin Can you elaborate on this? It seems to me this issue is affecting user pools.

@crsepulv
Copy link

crsepulv commented Nov 3, 2019

as I read here is not safe to store refresh tokens on an SPA because all of the application code and storage is easily accessible.

What if I use code grant and delete the RefreshToken from the local storage and store it on the server? then, when the app whant to refresh the tokens, first it retrieve the refresh token, use it to refresh the other tokens, and then erase again from local storage? is not 100% safe, but no one could copy/paste the refresh token form localStorage.

@ericclemmons ericclemmons added this to the UI Components Refactor milestone Feb 15, 2020
@dazinator
Copy link

dazinator commented Jun 5, 2020

as I read here is not safe to store refresh tokens on an SPA because all of the application code and storage is easily accessible.

What if I use code grant and delete the RefreshToken from the local storage and store it on the server? then, when the app whant to refresh the tokens, first it retrieve the refresh token, use it to refresh the other tokens, and then erase again from local storage? is not 100% safe, but no one could copy/paste the refresh token form localStorage.

Why not have the server return a secure cookie containing the refresh token. The spa need not then worry about submitting a refresh token, the cookie will be submitted by the browser automatically, just adapt your server side code to check for it..?

@niklr
Copy link

niklr commented Jul 9, 2020

This is not really an issue related to amplify-js but rather Cognito. Quite a bummer that this has not been resolved within 2 years...

@cScarlson
Copy link

Wow... Reported in 2018 and it's now 2021. Not a good look for even a small company.

I would call this a BUG

I have never ever seen an Implicit Grant Flow that:

  • Does not implement prompt=none
  • Implements X-Frame-Options: DENY
  • Provides zero possibilities for Silent Refresh
  • Provides zero options for refresh whatsoever

Perhaps Amazon is planning to deliver my new token by Drone, which looks like will happen faster than resolving this pathetically embarrassing BUG.

@justjoeyuk
Copy link

Yeah, this is a bit of a complete joke really. Imagine investing so much time into learning Cognito and all it's horrible flaws - just to get everything working... but not be able to silently refresh tokens on the Client. Seriously AWS, what is this.

@agileurbanite
Copy link

hey folks, we are actively working on this issue with the cognito team and will we will have a release as soon as the cognito service updates are out. We understand your frustration but please understand that we are actively working on this issue.

@X-TiMe
Copy link

X-TiMe commented Jun 11, 2021

@agileurbanite do you know when a fix will be released ? in days/weeks/months ?

If you need someone to make some tests I can offer my help as i'm working on it. My Stack is C# .Net Blazor

@aws-amplify aws-amplify deleted a comment from ebisbe Jun 12, 2021
@Sam152
Copy link

Sam152 commented Jun 17, 2021

Also finding that secure key management is a blocker to using Cognito, which otherwise would serve my use case quite well. Interested to understand a timeline and what specific features are planned to address this.

@mrdavidhanson
Copy link

We are also hoping AWS can make some progress on this. We were hoping to use Blazor with Cognito but we're not having much luck whereas getting up and running with identity solutions from other providers is a breeze. This issue alone may push us towards embracing another cloud provider for our next project (despite our preference to continue working with AWS). Hopefully some progress can be made (quickly).

@lucashenning
Copy link

+1 our team is hoping for a timely solution. This issue makes it impossible to build a nice SPA UX with Cognito.

@agileurbanite @aws-amplify-ops do you happen to have an update on this?

@inventivejon
Copy link

any news?

@justin-ad
Copy link

Looks like @agileurbanite is no longer with AWS... does @aws-amplify-ops have someone else working on this item?

@Frozenlock
Copy link

hey folks, we are actively working on this issue with the cognito team and will we will have a release as soon as the cognito service updates are out. We understand your frustration but please understand that we are actively working on this issue.

More than a year later... any news?

@justjoeyuk
Copy link

hey folks, we are actively working on this issue with the cognito team and will we will have a release as soon as the cognito service updates are out. We understand your frustration but please understand that we are actively working on this issue.

More than a year later... any news?

Don't pressure them! They've already invested 4 years of time and sweat to solve this issue for us ❤️

@eff-kay
Copy link

eff-kay commented Dec 12, 2022

hey folks, we are actively working on this issue with the cognito team and will we will have a release as soon as the cognito service updates are out. We understand your frustration but please understand that we are actively working on this issue.

More than a year later... any news?

Don't pressure them! They've already invested 4 years of time and sweat to solve this issue for us ❤️

hey, guys spent the last 5 days trying to identify this, and ended up here. For people who faced the bugs, which other auth provider would you recommend. I don't want to spend too much time, going through the libraries to have to figure out what the issue is, which is what I did in this case.

@speller
Copy link

speller commented Nov 28, 2023

... And still no resolution... Now I understand why people are switching to alternative solutions from Cognito. Important issues are not solved for years.

@tjmcewan
Copy link

We switched to Supabase. (not affiliated, just a fan.)

@speller
Copy link

speller commented Nov 28, 2023

FusionAuth is also an option (not affiliated as well but they're actively developing and listening to users' feedback)

@ramosbugs
Copy link

ramosbugs commented Nov 29, 2023

TL;DR Use the authorization code flow with a stateless backend to store long-lived refresh tokens safely in HttpOnly cookies.


As OP mentioned, it's really not possible to build a decent UX on top of Amazon Cognito using the implicit flow due to the lack of refresh tokens. Note that this behavior isn't a Cognito-specific limitation; the OAuth2 spec prohibits the implicit flow from returning refresh tokens:

The authorization server MUST NOT issue a refresh token.

As a result, the implicit flow causes users to be logged out whenever the short-lived access token expires. This leads to a similarly frustrating user experience as the AWS Console. Note that even if Cognito supported the OpenID Connect parameter prompt=none, an app would have to redirect the user away from the SPA or use a popup to request a new access token each time the previous one expires. Both of these are pretty suboptimal UX. EDIT: The OpenID Connect Session Management 1.0 spec defines an iframe-based mechanism for requesting new tokens via prompt=none, but Cognito doesn't implement that spec.

Aside from the UX issues, the IETF's Best Current Practice (BCP) for browser-based OAuth2 apps requires the use of the authorization code flow.

There are a couple of secure, viable alternatives that work well, but Amplify doesn't make them particularly easy.

Both of these options require a (stateless) token-mediating backend API in order to keep refresh tokens inaccessible to client-side JavaScript code. This is an important defense in depth measure so that even if an app is vulnerable to cross-site scripting (XSS), an attacker won't be able to steal users' long-lived refresh tokens.

Important

Storing refresh tokens in local/session storage or client-accessible cookies means that JS code has access to them, and that includes any JS code an attacker is able to introduce via XSS. If refresh tokens are instead stored in HttpOnly cookies, they'll be safe from XSS. However, this does require a small token-mediating backend API to set the cookie after login and to use the refresh token to fetch a new access token whenever the previous access token expires. It's a classic tradeoff between security and (manageable) complexity.

Unfortunately, all of Amplify's built-in token storage mechanisms lack defense-in-depth against XSS. This is particularly troubling since Cognito doesn't rotate refresh tokens after use, a requirement in the BCP.

If you don't want to follow current security best practices, consider using Amplify's built-in Authenticator. If you want to follow the recommendations of application security experts and protect refresh tokens from XSS exfiltration, consider the options below.

Option 1: OAuth2 Authorization Code Flow w/ Token-Mediating Backend

Advantages

  • Supports federated logins (e.g., Google, OpenID Connect, SAML, etc.).
  • Supports custom scopes that an API Gateway authorizer can use to control access to API endpoints. Note that custom scopes control access to user pool clients and probably only make sense if you have multiple OAuth2 clients (e.g., a driver app and a rider app) accessing the same API Gateway. To control access based on user roles/attributes, either a Lambda authorizer is needed, or API Gateway can pass ID token claims via the integration $context, which the backend API handler can use to determine whether to allow the request.

Disadvantages

  • User login requires a browser redirect to the authorization endpoint. This can add seconds of latency to the login flow, and the entire SPA will need to load again after the redirect.
  • Local (non-federated) logins must use the Cognito hosted UI, which is less customizable than having a login form built into the app itself.

The full solution with important security details is described in the BCP, but briefly, it entails the following steps:

  1. The app frontend redirects the browser to the token-mediating backend API instead of the Cognito authorization endpoint.
  2. The backend API redirects the browser to the Cognito authorization endpoint.
  3. The user logs in via the Cognito hosted UI or yet another redirect to a federated identity provider like Google.
  4. Cognito redirects the browser to the backend API and provides it with an authorization code.
  5. The backend API makes a machine-to-machine request to the Cognito token endpoint to exchange the authorization code for an access token, refresh token, and ID token.
  6. The backend API stores the tokens in HttpOnly cookies and redirects the browser back to the frontend.
  7. The frontend uses the Fetch API to make a request to the backend API to get the access token and ID token from the cookie. This is needed to avoid exposing the access token in the redirect URL back to the frontend, which is one of the security issues associated with the implicit flow.

When an access token expires:

  1. The frontend makes a Fetch API request to the backend to ask for a new access token.
  2. The backend makes a machine-to-machine request to Cognito's token endpoint to exchange the refresh token for a new access token. Recall that the refresh token is stored in an HttpOnly cookie, which the browser includes in this backend request.
  3. The backend returns the new access token to the frontend in the API response.

In this solution, the registered Cognito user pool client is the backend API, not the frontend app. It's a confidential client that should use a client secret when communicating with the Cognito token endpoint. The allowed callback URL registered for the Cognito client should correspond to the backend API.

When implementing this solution, be sure to pay attention to the BCP for details around CSRF protection, PKCE, etc.

Option 2: User Pool API (non-OAuth2) w/ Token-Mediating Backend

Advantages

  • No browser redirects required (better UX and performance); all API requests can use the browser Fetch API.
  • Fully-customizable login form built into the app itself.

Disadvantages

  • Only supports local (non-federated) Cognito user pool logins. Social logins and OpenID Connect/SAML federation are unsupported.
  • Only the aws.cognito.signin.user.admin scope is supported. As mentioned above, this is probably fine unless you have multiple apps, since role-based access control should be done via user attributes, not OAuth2 scopes.

This solution uses Cognito's InitiateAuth and related API endpoints instead of OAuth2. Unlike the OAuth2 flow, no browser redirects are needed; everything happens using the browser Fetch API, and the user enters their credentials directly into the app frontend. The backend API essentially proxies the Cognito user pool API calls so that it can set an HttpOnly cookie rather than exposing the refresh token to the frontend, which calling InitiateAuth/RespondToAuthChallenge directly would do. It uses Cognito's USER_SRP_AUTH auth flow so that the user's plaintext password never has to leave the browser.

The steps needed are similar to Amplify's handleUserSRPAuthFlow implementation, except making requests to the backend API instead of directly to the Cognito user pool API. The steps are as follows:

  1. The frontend calls Amplify's getAuthenticationHelper function, which implements the cryptography used in Cognito's SRP auth flow.
  2. The frontend sends a POST request to the backend API with the username/email (depending on the Cognito user pool configuration) and the SRP_A parameter (authenticationHelper.A.toString(16)).
  3. The backend calls Cognito's InitiateAuth API with the USER_SRP_AUTH auth flow and the parameters provided by the frontend, along with the Cognito secret hash computed using the user pool client secret.
  4. Cognito responds to the backend with a PASSWORD_VERIFIER auth challenge.
  5. The backend responds to the frontend with Cognito's challenge parameters (SALT, SECRET_BLOCK, SRP_B).
  6. The frontend computes the password claim signature by calling Amplify's authenticationHelper.getPasswordAuthenticationKey and getSignatureString functions. See Amplify's handlePasswordVerifierChallenge implementation for details.
  7. The frontend sends another POST request to the backend API with the SRP challenge response (USERNAME, PASSWORD_CLAIM_SECRET_BLOCK, TIMESTAMP, and PASSWORD_CLAIM_SIGNATURE).
  8. The backend calls Cognito's RespondToAuthChallenge API with the values provided by the frontend.
  9. Cognito responds with an access token, refresh token, and ID token.
  10. The backend API stores the refresh token in an HttpOnly cookie and responds to the frontend with the access token and ID token.

When an access token expires:

  1. The frontend makes a POST request to the backend API. The browser includes the HttpOnly cookie in the request.
  2. The backend calls Cognito's InitiateAuth API with the REFRESH_TOKEN_AUTH auth flow and the REFRESH_TOKEN and SECRET_HASH auth parameters.
  3. Cognito responds with a new access token and ID token.
  4. The backend responds to the frontend with the access token and ID token.

Both of these solutions take a fair amount of effort to implement, but if there's interest, I may open source an implementation. Please let me know if this would be useful, and which solution is more relevant!

@Sam152
Copy link

Sam152 commented Nov 29, 2023

Thanks for this detailed write up of the options and considerations that go into deploying cognito.

I am currently using some variation of option 2 but I have considered implementing option 1 soon, but with a stateful back-end that could issue cognito JWTs in exchange for a longer lived HTTP only session cookie.

I think the value proposition of cognito is quite slim if you are just proxying calls from a back-end, at that point you may as well have implemented your own auth on top of an established library or product.

Using the federation and social login features seem like one of the only reasons you'd use cognito, and thus you'd think option 1 would be generally more valuable. Still a shame you have to essentially manage your own proxy to make cognito useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Auth Related to Auth components/category Cognito Related to cognito issues feature-request Request a new feature Service Team Issues asked to the Service Team Vue Related to Vue Framework issues
Projects
None yet
Development

No branches or pull requests