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:
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.
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.
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:
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.