Auth0-spa-js: Cannot automate login in cypress since 1.12 using cookie injection

Created on 18 Sep 2020  路  17Comments  路  Source: auth0/auth0-spa-js

Describe the problem

In order to automate test to a frontend application i'm currently working on, I'm using cypress on a react app.

As it's not possible to navigate to the auth0 login page, I've been using a command that generates the user token using the API POST /oauth/token, and then inject the token in the cookies and redirect the front end as it would occur after a successful login using the state param.

But since version 1.12, the behavior has changed and the auth0 sdk does not seem to accept this way to inject authentication.

Here is the code I'm using to authenticate the browser automation (This code still works using 1.11):

Cypress.Commands.add("login", ({ username, password }, appState = { targetUrl: "/" }) => {
  cy.log(`Log in as ${username}`)
  cy.request({
    method: "POST",
    url: Cypress.env("AUTH0_GET_TOKEN_URL"),
    body: {
      grant_type: "password",
      username: username,
      password,
      audience: Cypress.env("AUTH0_AUDIENCE"),
      scope: "openid profile email",
      client_id: Cypress.env("AUTH0_CLIENTID"),
      client_secret: Cypress.env("AUTH0_CLIENT_SECRET"),
    },
  }).then(({ body }) => {
    const { access_token, expires_in, id_token } = body

    cy.server()
    cy.route({
      url: Cypress.env("AUTH0_GET_TOKEN_URL"),
      method: "POST",
      response: {
        access_token,
        id_token,
        scope: "openid profile email",
        expires_in,
        token_type: "Bearer",
      },
    })

    const stateId = "test"
    cy.setCookie(
      `a0.spajs.txs.${stateId}`,
      encodeURIComponent(
        JSON.stringify({
          appState,
          scope: "openid profile email",
          audience: Cypress.env("AUTH0_AUDIENCE"),
          redirect_uri: "http://localhost:3000",
        })
      )
    )
    cy.visit(`/?code=test-code&state=${stateId}`)
  })
})

Maybe it's a bad thing to do, but it seemed fine to me to do it that way, if you have any other option available I would be glad to hear about how I should do it differently.

What was the expected behavior?

I was expecting that the state handling would be the same after the version upgrade.

Reproduction

  • Allow password authentication on your tenant
  • Generate a token using auth0 api (POST https://{your-tenant}.auth0.com/oauth/token)
  • insert a cookie at a0.spajs.txs.${stateId} as the login page would do
  • mock the auth0 api with the generated token
  • redirect your app to the login callback with the parameters /?code=any-code&state=${stateId}

Can the behavior be reproduced using the SPA SDK Playground?

  • I'm not sure about how to test it with the playground

Environment

Please provide the following:

  • Version of auth0-spa-js used: 1.12.1
  • Which browsers have you tested in? any
  • Which framework are you using, if applicable (Angular, React, etc): react
  • Other modules/plugins/libraries that might be involved: cypress
bug report

Most helpful comment

Hey @eliasplatekdkt

If I'm understanding correctly the solution using the localStorage is way more complex, because before using the localStorage some cookies checks are performed

apologies, I just used that as an example of setting localstorage - you don't need to do the cookie stuff

I've put a working example here https://github.com/adamjmcgrath/cypress-spa-example

I think it's simpler than your solution because it doesn't rely on cy.request/cy.server
It has the disadvantage of having to change cacheLocation in cypress mode
But it works with the latest version of spa js and IMO relies less on our internals

All 17 comments

@eliasplatekdkt thanks for raising. In v1.12 we moved from using cookies for the transaction store to session storage, so if you're relying on cookie behaviour your tests will need to be updated.

It does get slightly easier though, in that we no longer include the state value in the key name, so that can be dropped. Otherwise, the data stored inside session storage should be exactly the same as it was for cookies.

Let me know if you're able to get this working as before.

Hi Steve, thanks for pointing me in the right direction, I've made some changes to my code in order to use the session storage.
Now the transaction manager can access the data to get the token.


    const { access_token, expires_in, id_token } = body

    cy.server()
    cy.route({
      url: Cypress.env("AUTH0_GET_TOKEN_URL"),
      method: "POST",
      response: {
        access_token,
        id_token,
        scope: "openid profile email",
        expires_in,
        token_type: "Bearer",
      },
    })
    cy.window().then((win) => {
      win.sessionStorage.setItem(
        "a0.spajs.txs",
        JSON.stringify({
          nonce: "how can i set a valid nonce here ?",
          code_verifier: "code_verifierIn",
          appState: appState,
          scope: "openid profile email",
          audience: Cypress.env("AUTH0_AUDIENCE"),
          redirect_uri: "http://localhost:3000",
        })
      )
    })
    cy.visit("/?code=test-code&state=state")

But I still have a problem that I don't know how to solve, when I'm calling the POST /oauth/token, the id_token field doesn't contain a nonce, but the validation requires it to be present, and I don't know how to make it appear using the API, I don't see any doc about it, the library calls the token endpoint and receives the nonce in it.

Do you have any idea how I can get a nonce in my API call ?

Normally would specify the nonce in the call to /authorize with a scope of openid and response type id_token token, which would cause nonce to appear in the resulting ID token. However, I'm not sure how this would have changed for you with the changes you've made (although, I can't see the whole changeset). Can you try sending a nonce value through in your initial call to the token endpoint?

I'm not using the /authorize endpoint. I'm using the same code as in my initial post, except that I set the transaction object in the session storage instead of the cookie.

I wrote this code based on what is still recommended on the official documentation: https://auth0.com/blog/end-to-end-testing-with-cypress-and-auth0/

The limitation of cypress is that I cannot navigate to another domain than the domain under test. So I don't see a way to automate the login without using POST /oauth/token withgrant_type: password.

The initial solution would still work if the library does not try to validate the nonce when it is not defined in the id_token, would that be an acceptable solution ?

@stevehobbsdev I've been playing a bit with the library, and it can be fixed by removing the pre-check on the nonce in handleRedirectCalback:

    // Transaction should have a `code_verifier` to do PKCE and a `nonce` for CSRF protection
    if (!transaction || !transaction.code_verifier || !transaction.nonce) {
      throw new Error('Invalid state');
    }

This check can be removed safely I think because the nonce is revalidated after getting the token in verifyIdToken:

  if (options.nonce) {
    if (!decoded.claims.nonce) {
      throw new Error(
        'Nonce (nonce) claim must be a string present in the ID token'
      );
    }
    if (decoded.claims.nonce !== options.nonce) {
      throw new Error(
        `Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${decoded.claims.nonce}"`
      );
    }
  }

If the transaction test only checks for the code_verifier before calling the POST /oauth/token, then you ensure that the code verifier is provided, and when you verify the id_token the nonce is validated as before (it is even declared as an optional parameter).

From my point of view it seems enough to fix it like this, I can provide a PR if it's okay for you

@eliasplatekdkt Good spot, this actually looks like a regression as we only started checking nonce there recently. Although I can't understand why your tests are failing just by changing to session storage, as this should have also failed when you were using cookies.

@adamjmcgrath what are your thoughts here? I understand we should only be checking nonce if one was provided in the original authentication request but this check effectively makes it mandatory.

The login action started failing after upgrading to 1.12.0, I didn't change anything except upgrading the library. BTW here is the configuration I'm using:

export const auth0Config: Auth0ClientOptions = {
  domain: REACT_APP_AUTH0_DOMAIN!,
  client_id: REACT_APP_AUTH0_CLIENTID!,
  redirect_uri: window.location.origin + "/callback",
  audience: REACT_APP_AUTH0_AUDIENCE,
  useRefreshTokens: true,
}

I cannot use my code anymore because I was setting the transaction data in the cookie as it was originally working. After the upgrade, the transaction data is defined in the session storage as you told me in your comment, so updating the transaction setup in the storage is working fine, here is the complete version of the login command:

Cypress.Commands.add("login", ({ username, password }, appState = { targetUrl: "/" }) => {
  cy.log(`Log in as ${username}`)
  // Call the Auth0 API to get a token for the provided username with
  // with the grant_type password to https://{your-tenant}.auth0.com/oauth/token
  cy.request({
    method: "POST",
    url: Cypress.env("AUTH0_GET_TOKEN_URL"),
    body: {
      grant_type: "password",
      username,
      password,
      audience: Cypress.env("AUTH0_AUDIENCE"),
      scope: "openid profile email",
      client_id: Cypress.env("AUTH0_CLIENTID"),
      client_secret: Cypress.env("AUTH0_CLIENT_SECRET"),
    },
  }).then(({ body }) => {
    // body contains the result of the call to Auth0 API
    const { access_token, expires_in, id_token } = body

    // Mocking the call to the Auth0 API that will be fired
    // by the handleRedirectCallback action in the library,
    // and injecting the token from the previous call
    cy.server()
    cy.route({
      url: Cypress.env("AUTH0_GET_TOKEN_URL"),
      method: "POST",
      response: {
        access_token,
        id_token,
        scope: "openid profile email",
        expires_in,
        token_type: "Bearer",
      },
    })
    // Injecting the transaction object that will be used by the library
    // to setup the call to the Auth0 API (it's supposed to contain the
    // code_verifier from the code query param be it won't be used)
    cy.window().then((win) => {
      win.sessionStorage.setItem(
        "a0.spajs.txs",
        JSON.stringify({
          code_verifier: "any-code",
          appState,
          scope: "openid profile email",
          audience: Cypress.env("AUTH0_AUDIENCE"),
          redirect_uri: "http://localhost:3000",
        })
      )
    })
    cy.visit("/?code=any-code&state=any-state")
  })
})

Although he difference with the library is that I'm using the grant_type: "password" option to the POST /oauth/token instead of grant_type: "authorize", so the id_token that I'm returning back to the lib doesn't contain a nonce.

@adamjmcgrath what are your thoughts here? I understand we should only be checking nonce if one was provided in the original authentication request but this check effectively makes it mandatory.

True, you don't need the code_verifier either, if your code hasn't been created with a code_challenge - but we added that check to prevent people logging in to an app with a code they didn't create from the login process.

I'm ok with removing the nonce check if we have to, let's double check with security first though.

@eliasplatekdkt - could you avoid this and just get an access token via the api, then store it in localstorage and use cachelocation: 'localstorage' in cypress mode?

It relies less on our internals, so would be less prone to being broken.
eg:

export const auth0Config: Auth0ClientOptions = {
  domain: REACT_APP_AUTH0_DOMAIN!,
  client_id: REACT_APP_AUTH0_CLIENTID!,
  redirect_uri: window.location.origin + "/callback",
  audience: REACT_APP_AUTH0_AUDIENCE,
  useRefreshTokens: true,
  cacheLocation: window.Cypress ? 'localstorage' : 'memory'
}

and

cy.window().then((win) => {
      win.localStorage.setItem(
        `@@auth0spajs@@::${client_id}::${audience}::${scope}`,
        JSON.stringify({
          access_token,
          ...
        })
      )
    })

similar to: https://sandrino.dev/blog/writing-cypress-e2e-tests-with-auth0#jamstack-and-single-page-applications

@adamjmcgrath thanks for the link I didn't know about this way of logging in.

If I'm understanding correctly the solution using the localStorage is way more complex, because before using the localStorage some cookies checks are performed, and the implemented solution requires:

  • to build a login url using the Auth0Client
  • navigate to the login page using puppeteer
  • extract the cookies and the redirect uri after successful login
  • inject the cookies and redirect to the app with the redirect uri

Even if this solution seems more interesting because I wouldn't even need to change the app code, the setup is really more complex and doesn't seem to be recommended by the official documentation that says that we shouldn't test the login page, and it relies on internals that can change and break the code the same way that I'm struggling today with the cookie management :)

It doesn't seem possible to me to only inject the token in the localStorage, I've tried to log in with my app using for cacheLocation: "localstorage", I can see that a key is added with the format you gave me. There is also a cookie named auth0.is.authenticated=true. Some hidden cookie seem to reactivate the connection, even after clearing the cookies and local storage.

So I tried to inject the cookie and the localStorage key, but the library still tries to redirect to the login page, this time I can't see why.

I'm not sure that using memory or localstorage will change the matter here, because in both solutions there is a need to rely on some internals of the library, with that said, if moving the nonce validation further in the code doesn't occur security issues, it seems a more benefical solution to me.

Hey @eliasplatekdkt

If I'm understanding correctly the solution using the localStorage is way more complex, because before using the localStorage some cookies checks are performed

apologies, I just used that as an example of setting localstorage - you don't need to do the cookie stuff

I've put a working example here https://github.com/adamjmcgrath/cypress-spa-example

I think it's simpler than your solution because it doesn't rely on cy.request/cy.server
It has the disadvantage of having to change cacheLocation in cypress mode
But it works with the latest version of spa js and IMO relies less on our internals

@adamjmcgrath this is a very elegant solution and it works seamlessly. I tried the same implementation but some fields were missing which is why it didn't work. Thanks a lot for your time !

I think that would be interesting to update the documentation at https://auth0.com/blog/end-to-end-testing-with-cypress-and-auth0/ which explains quite a different approach, and can be misleading for people searching for cypress + auth0, the localStorage approach requires less setup in cypress. I'm going with this solution in my project.

Been debugging this for a few hours now. Glad someone found a working solution. Thank you!

The implementation seems to work fine for logging in initially but my Cypress suite seems to get stuck whenever a redirect occurs within the app. Spent several hours trying to debug but couldn't really find anything helpful.

For now, just reverting back to v1.11.0.

@eliasplatekdkt @adamjmcgrath Thank you so much guys ! It helps me a lot to auto login users on my SPA ! 馃挭

Glad you got it working @vinzcelavi!

One thing that tripped me up was I had useRefreshTokens set to true, so the auth0 library was adding offline_access to my scopes, which changes the localstorage key.

Thanks for sharing that @StephenCleary

If anyone else is having trouble setting up https://github.com/auth0/auth0-spa-js/issues/581#issuecomment-697323567 - It's usually because the localStorage key for the credentials you're putting in aren't the same as the key SPA JS is looking for (eg you unexpectedly had offline_access in your scopes)

I added a suggestion and snippet of code that might help you debug situations like this here https://github.com/adamjmcgrath/cypress-spa-example/issues/2#issuecomment-765583180

Was this page helpful?
0 / 5 - 0 ratings