Cypress - application Single Sign-on (SSO) authorization on a different super domain

Cypress - application Single Sign-on (SSO) authorization on a different super domain
December 17, 2020
Those who work with the Cypress testing tool know that Cypress is the perfect modern tool for E2E UI testing, but it still has some limitations, some of which are set by Cypress developers. One of the limitations is being limited to only one super domain per test.

Note: During finalization of this article, Cypress version 6.0 was released. All code snippets in this article are meant for versions 5.1 – 5.6

In one of our project’s applications, login is implemented externally via 2-factor SSO gateway (Single Sign-On), where the user is redirected.

Normally in Cypress, if we try to access this application, our test will fail because we, as users, are automatically redirected to the SSO gateway on a different domain.

There are two ways you can implement a login in a test:

  1. UI login
  2. Network requests

A login with network requests is suggested and even used by the Cypress team, but it is not always possible – for example, if the SSO gateway is configured externally and you have no control over it.

UI Login

First let’s look at how we can implement SSO login via UI login.

You will need to make some changes in the Cypress configuration file (cypress.json):

{% c-block language="js" %}
{
   "baseUrl": `${applicationUrl}`,
   "experimentalNetworkStubbing": true,
   "chromeWebSecurity": false
}
{% c-block-end %}

At first, you need to set ‘chromeWebSecurity’ to false which will tell Cypress to stop blocking cross-origin visits.

In our case, with this change the test will succeed, but the browser will not render the login page because of the SSO gateway content security policy. The browser console will display this error message:

Refused to frame ‘${ssoGatewayUrl}' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'".

To fix this restriction, we need to set ‘experimentalNetworkStubbing’ to true. With this new feature, Cypress will listen to all application requests (xhr, fetch etc.) and it will also enable the use of a new Cypress command “cy.route2()”, which we can use to intercept the application requests.

Note: For Cypress 6.0, command “cy.route2()” was moved from experimental features into standard features. In version 6.0, you do not need to set ‘experimentalNetworkStubbing’ to true and the command is named “cy.intercept()”. The version 6.0 changelog can be found here.

In the test, we intercept requests pointed to the SSO gateway.

{% c-block language="js" %}
describe('SSO resolve', () => {
   it('login', () => {
       cy.route2(`${ssoGatewayUrl}/**`).as('sso')
       cy.visit('/')
   });
})
{% c-block-end %}

Now the login page will render normally, and you can perform the login workflow.

TIP: If your login workflow has the 2nd step as OTP token login (token normally obtained from mobile authenticator application), you can use the Cypress plugin ‘cypress-otp’ which can be found here. But be aware that it is not an official plugin- it is created by the community. With this plugin we can create an OTP token via a custom command with a secret_key.  

Network requests

For those who want a faster login sequence in your tests (for example in “before()” or “beforeEach()” hooks), we can implement an SSO login with network requests. The exact implementation will depend mostly upon application login workflow, so you need to debug this workflow yourself in the web browser (for example, look at requests for each login step in the browser developers network tool).

In our case, implementation was a little bit harder because the login form in UI contains stateId as a part of the action attribute (in URL for redirection).

You can use the normal Cypress command cy.request() for sending network requests, but for cleaner test files I would recommend creating custom commands.

Our workflow case:

  1. Login with username and password
  2. Get redirected URL with stateId from OTP login form action attribute
  3. Login with OTP

Custom commands can be placed in the commands.js file in the support folder or you can create your own file and then import it to the support/commands.js file.

First let’s create a custom command for username/password login.

{% c-block language="js" %}
Cypress.Commands.add('ssoUserLogin', () => {
   return new Cypress.Promise((resolve, reject) => {
       Cypress.log()

       const ssoUserLogin = async () => {
           const params = new URLSearchParams();
           params.append('username', `${username}`);
           params.append('password', `${password}`);
           params.append('login-form-type', 'pwd');

           const response = await fetch(`${ssoUserLoginUrl}`, {
               method: 'POST',
               body: params
           }).then(res => {
               if (!res.ok) {
                   reject(res.statusText)
               }
               return res
           })
           return response
       }

       ssoUserLogin().then(resolve)
   })
})
{% c-block-end %}

As you can see, we created Cypress.Promise and use the async/await method. This command will not resolve until we receive a response from the request. If the request fails, we reject this promise and return the request details for debugging purposes in the Cypress browser console.

The command Cypress.log() will display custom commands in the Cypress UI.

Next we create a custom command for retrieving a redirected URL with a stateId from the OTP login form.

{% c-block language="js" %}
Cypress.Commands.add('ssoState', () => {
   return new Cypress.Promise((resolve, reject) => {
       Cypress.log()

       const ssoState = async () => {
           const response = await fetch(`${ssoOtpLoginFormUrl}`).then(res => {
               if (!res.ok) {
                   reject(res.statusText)
               }

               return res.text()
           }).then((text) => {
               const html = document.createElement('html');
               html.innerHTML = text;
               const node = html.getElementsByTagName('form');
               Cypress.env('actionUrl',node[0].attributes.action.value)
           })
           return response
       }
       ssoState().then(resolve)
   })
})
{% c-block-end %}

We parse the URL from the html element attribute and save it as a Cypress variable.

Finally, we create a final custom command for OTP login.

{% c-block language="js" %}
Cypress.Commands.add('ssoOTPLogin', () => {
   return new Cypress.Promise((resolve, reject) => {
       Cypress.log()

       const ssoOTPLogin = async () => {
           const otpGenerator = require("cypress-otp")
           const otp = otpGenerator(`${secret_key}`).toString()
           const params = new URLSearchParams();
           const stateId = Cypress.env('actionUrl')
           params.append('otp', otp);
           params.append('operation', 'verify');

           const response = await fetch(`${ssoOtpLoginUrl}${stateId}`, {
               method: 'POST',
               body: params
           }).then(res => {
               if (!res.ok) {
                   reject(res.statusText)
               }
               return res
           })
           return response
       }

       ssoOTPLogin().then(resolve)
   })
})
{% c-block-end %}

For more convenience, you can create an additional custom command to merge the previously created custom commands into one.

{% c-block language="js" %}
Cypress.Commands.add("login", () => {
   cy.ssoUserLogin()
   cy.ssoState()
   cy.ssoOTPLogin()
})
{% c-block-end %}

Do not use Cypress.Promise in this case because you cannot place Cypress.Promise inside another Cypress.Promise. Cypress.Promise is not a Promise. :)

Now we have a working login workflow via network requests but wait… it works only in Electron. In Chrome, this login workflow will fail and the user will be again redirected to the SSO gateway.

The reason is that these requests receive cookies in response headers (to preserve the login) which should be set in the browser, but Chrome has the enabled feature SameSiteByDefaultCookies (from Chrome 84).  

This feature will block setting up cookies in the browser if the ‘Set-Cookie’ response header does not include ‘SameSite=none’ for cross-origin usage.

Luckily, we can disable this feature for Chrome via flag before the browser starts.

This setting will be placed in the plugins/index.js file inside the module.exports function.

{% c-block language="js" %}
module.exports = (on, config) => {
 on('before:browser:launch', (browser = {}, launchOptions) => {
   console.log(launchOptions.args) // print all current args

   if (browser.family === 'chromium' && browser.name !== 'electron') {
     launchOptions.args.push('--disable-features=SameSiteByDefaultCookies')
     // whatever you return here becomes the launchOptions
     return launchOptions
   }
 })
}
{% c-block-end %}

If you want to know more about testing, you can review my previous blog post here, follow our social media, or contact me directly.

Share:
Martin is a skillful tester, quick-learning DevOps, and reliable teammate. He has a deep knowledge of HTML and CSS, JavaScript, Cypress, and Robot framework. He is experienced in automated testing and since recently our brave performance testing guru. He is also a great rock'n'roll dancer and energy drink lover.

Other articles by same author

Article collaborators

SABO Newsletter icon

SABO NEWSLETTER

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

About SABO Mobile IT

We focus on developing specialized software for our customers in the automotive, supplier, medical and high-tech industries in Germany and other European countries. We connect systems, data and users and generate added value for our customers with products that are intuitive to use.
Learn more about sabo