Using Cypress Intercept to Fix a Cross-Domain Test
The full example for this scenario can be found at https://github.com/scolton99/cypress-sso-cookies
At work, we have been implementing Cypress testing so that we can automate what once was the very drawn-out process of manually testing our highly customized identity management software. It’s certainly not the typical use case for Cypress — we’re not testing a software that is entirely our own and we don’t have a way of running the software locally (on our own laptops), so we’re only using Cypress to test our deployed dev, QA, and production instances of the application.
Like most other applications, our identity management software is behind SSO, so we wanted to use Cypress to test a user’s ability to log in via SSO. This comes with extra added complexity: Cypress is on shaky historical ground with regard to tests that require visiting more than one domain.
Still, for a while, Cypress was working well to test our SSO login process. The test that we wrote was only a few lines of code and seemed to work with mostly out-of-the-box settings. Nothing particularly tricky was required on the Cypress end to make it work. That all changed when we added MFA to our login process.
Adding MFA added another redirect to our login process and this time, it required going out to an external site rather than a domain within our organization. As it turns out, this would break our test. One of the internal SSO domains set a cookie properly, then expected it to be there when the user returned from the third-party MFA service. Because Cypress tests run in an iframe, however, the rules that determine which cookies are sent are different than at the top-level of a browser window.
The cookies being set by our SSO provider were very basic — no SameSite
directive, and no Secure
directive. The rules determining which cookies send with a request can be found in RFC6265§5.6.3. Let’s go through them one-by-one and see why these cookies don’t send in a Cypress test context:
- Either the cookie has no domain set and the domain of the request exactly matches the domain that set the cookie OR the request domain matches or is a subdomain of the domain set on the cookie.
✅ We’re good here. The cookies are only expected to be visible to the same domain that set them. - The request’s path matches or is a subpath of the cookie’s path.
✅ All set here as well. The cookies paths are set to “/”, so they match all paths. - If the cookie is marked
Secure
, the request must use HTTPS.
✅ All requests in the login flow use HTTPS (of course). - If the cookie is marked
HttpOnly
, the request must be an HTTP request (i.e., the cookie is not accessible within scripts).
✅ All of the requests are HTTP requests — we’re not trying to access these cookies from within a script. (The cookies aren’tHttpOnly
anyway.) - If the cookie’s
SameSite
flag is not “None” and this is a cross-site request (which includes redirections from other domains), ALL of the subsequent bullet points must be true.
✅ We are in the process of being redirected from an outside domain, so this counts as a cross-site request.SameSite
was not set on these cookies, so per new web standards, it defaults to “Lax”. - This must be an HTTP(S) request (not a JavaScript request for a cookie value).
✅ These are HTTP requests, not requests for the cookie within a script. - The
SameSite
flag must be “Lax” or “Default”.
✅ As stated above, theSameSite
value for these cookies was not specified and thus defaulted to “Lax”. - The request must use HTTPS.
✅ We are still, of course, using HTTPS. - The request must come from a top-level browsing context (i.e., not a frame).
❌ Here lies the source of our woes. We’re running this from within a frame, so these cookies will not be sent.
So we’ve identified why the cookies aren’t sending, so how do we go about solving the problem? Cypress has an ingenious method of doing so.
Cypress Interception
Let’s create a project to mock-up this issue. We’ll create two small NodeJS servers to represent our MFA host and our SSO host. Create a new project via npm init
and then run the following two commands to get the dependencies:
npm install -s express cookie-parser pug
npm install --save-dev cypress
It accepts a request and shows the SSO login page. Then, a form on that page submits and the server redirects to the MFA server and sets a cookie. When MFA redirects back to the SSO site, it will expect to see the cookie again and if it does, it will say “Welcome back!”. If not, it will say “Who are you?”.
There are two files needed for the (very rudimentary) SSO UI:
This will go in views/sso.pug
:
And this will go in public/css/sso.css
:
All this does is create a rudimentary login form. We’re not going to check the values submitted for the username and password (which is good, because both are missing the name
attribute and won’t be sent to the server anyway).
Now, we need the MFA server:
This server accepts a request and checks to be sure that there is a query parameter called return
, then sets up a page to redirect the user back to that page after a few seconds.
Our MFA server also depends on a very tiny and rudimentary UI. The view should go in views/mfa.pug
:
And the script to be run on this page belongs in public/js/mfa.js
and is very small:
We almost have everything we need. Here’s a small index.js
to tie everything together:
You’ll need to generate yourself an SSL certificate with a private key. You can store the certificate at the root of the project as cert.crt
and the key as cert.key
. Then, open Cypress for the first time by running npx cypress open
and then close it. In the generated cypress.json
, add the line "chromeWebSecurity": false
in order for our tests to work.
Since our servers also need to be on different domains in order to have this be a true simulation of the issue, you’ll want to edit your hosts file to include the following lines:
127.0.0.1 sso.local.domain
127.0.0.1 mfa.external.domain
Once that’s done, run node index.js
and you should see the following output:
MFA listening...
SSO listening...
Now, open up a new web browser and go to https://sso.local.domain:8443/
. It should prompt you with this login page:
Go ahead an type anything for the username and password, then click “Log in”. With any luck, you’ll land on a screen that says “Welcome back!” after several seconds of waiting.
Now let’s write a Cypress test for this. Create cypress/integration/sso.spec.js
:
All this test does is mimic exactly what we just did in the browser, but it will be using an iframe to host the site, so we’ll see the problem that we expect:
Let’s use Cypress Interception to fix this issue. Before the SSO server’s request to set the cookie can reach the browser, we will have Cypress intercept it and alter the cookie so that it matches the settings we need it to have (namely, SameSite="None"
and Secure
.
Add the following function at the top of the sso.spec.js
file:
And add this test to the existing test suite:
Now, when Cypress sees a POST request to https://sso.local.domain:8443/
, it will intercept that request and pass information about the request being made to the cookieFixer
function. That will include all of the request headers and query string parameters, among other things. We don’t care about the request — only the response — so we’ll allow the request to continue unaltered by calling req.continue
. This function takes a callback which is passed information about the response, and it’s a modifiable object!
We can alter the headers on the response object to change them before they reach the browser. In this case, we want to take the first value of the Set-Cookie
header (it’s a multi-valued header but in this case we know there will be exactly one value) and add ; SameSite=None; Secure
to the end to set these flags on our cookie.
This guarantees that our cookie will be sent to the SSO server once again when we are redirected there:
Voilà! We can now test a complete log-in flow using SSO using this same technique. All in all, not that much needed to be added to our test to make this possible.