
This post is about how to properly handle authentication ofmicrosoft authentication library (msal) based single page applications incypress e2e tests.
Why is it important?
In many cases modern single page applications are protected via authentication & authorization mechanisms like OAuth. Of course we want to test our applications properly to develop and deploy with the best possible confidence. What is better than writing some e2e tests (with Cypress)?
But wait... How do we handle authentication within the tests? Isn't there a redirect to the login page of the identity provider (IDP) triggered by the app? How do we pass user credentials? What about multi-factor-authentication?
A lot of questions need to be clarified, before we can actually start writing functional tests. Altough Cypress provides some good recipes for most common authentication protocols and providers and there are even somepackages out there, which abstract the login logic, authentication against the Azure AD can be very tricky.
This is, why the post will focus on MSAL based apps, which use the@azure/msal-browser under the hood and authenticate against the Azure AD. But before you think, this is not of interest for you, you can still follow the steps and adapt the approach to your scenario, no matter if you are using a different IDP or underlying auth package.
Let's get started 🛫
How not to do it 🚨
Let's first talk about two approaches, you shouldnot follow:
Login via UI:
Please don't! One of the most important rules in e2e testing - or testing in general - is to not test components, which are not under your control. You may be happy and your code will work for a while, but your tests will break sooner or later when Microsoft decides to change its login page.Disable Auth in E2E Tests:
Another popular approach is to disable authentication via environment variables and mock missing parts in e2e tests. I personally really dislike the approach und would also not recommend it to anyone. The problem is that you have to touch your production code and implement some switches here and there to make it work (auth guards, route protection, http interceptor, ...).
While I think it's never worth changing your productive code implementation just for test purposes, the main problem is, that those switches increase the complexity and so also the risk for configuration errors. We are just humans and make silly mistakes, isn't it? Imagine deploying your app to production without correct settings for AuthN and AuthZ. Also, the significance of your e2e tests decreases.
How to do it then? ✅
To make it short, the approach is the following. We will
- programatically request a access and id token
- transform the response and save it to the localstorage
- run some e2e tests
1. Acquire ID and Access Token
Usually modern SPAs are using OAuth either with Implicit or Authorization Code Flow (recommended in OAuth 2.1). If you are not yet familiar with the different flows,Auth0 comes with a great documentation.
However, both flows include some ping pong and redirect between SPA and IDP, so those are not fitting for our purpose.
Instead, we will use theResource Owner Password Flow, because it is much simpler as you only need to make one REST call to receive the token.
Please consider the following:
[...] the Resource Owner Password Flow should only be used when redirect-based flows (like the Authorization Code Flow) cannot be used.
(https://auth0.com/docs/authorization/flows/resource-owner-password-flow)
Generate a client secret
However, the flow expects the client to be trusted. Because a SPA is usually considered a public and therefore not a trusted client, we need to pass not only the user credentials (username, password), but also a additional client secret. So let's create one in the Azure Portal (official docs):
Open Azure Portal > Azure AD > App Registrations > your app reg > Certificates & Secrets
Please note, that you can only see the secret once, when you create it. Afterwards you won't be able to reveal the entire secret again. Ideally, you directly save the secret in a Key Vault and consume it from there if you need it.
Create a technical user
Before we can continue with the login, we need to have a technical user account.
Please don't use your own user account, especiallynot in CI environment.
If you don't have one yet, you can simply create one in the Azure Portal (official docs):
Open Azure Portal > Azure AD > Users > New User
If this button is disabled for you, you may need to request a test user from your Azure AD Admin.
Here are some important points on test users:
- MFA must be disabled for the test user
- The test user must not be a guest user
- You must login once via UI to grant consent for the requested scopes
- Test users should not be shared among different apps or even test types
- Do not check in user password and client secret into git
- Pro Tip: Use theAzure Graph API to dynamically add and remove app role assignments before and after e2e test execution
Test the Login
Now we can finally try the login:
curl-X POST"https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token"\-H"Content-Type: application/x-www-form-urlencoded"\--data-urlencode"grant_type=password"\--data-urlencode"client_id=<client-id>"\--data-urlencode"client_secret=<client-secret>"\--data-urlencode"scope=openid profile email <api-scope>"\--data-urlencode"username=<username>"\--data-urlencode"password=<password>"
If the request is sucessfull, you will see a response like this:
{"token_type":"Bearer","scope":"openid profile email <api-scope>","expires_in":3599,"ext_expires_in":3599,"access_token":"<access-token>","id_token":"<id-token>"}
If you like you can verify the content of the base64-encoded JWTs withJWT.io. Just paste the encoded token there.
Request Login via cy.request()
Of course we don't usecurl
within the e2e test. Instead we can usecy.request()
to perform the login via the REST API:
cy.request({url:authority+'/oauth2/v2.0/token',method:'POST',body:{grant_type:'password',client_id:clientId,client_secret:clientSecret,scope:['openid profile email'].concat(apiScopes).join(''),username:username,password:password,},form:true,}).then(response=>{// work with response.body});
2. Prepare the localstorage
We already did important groundwork so that we can acquire a JWT with Cypress. In a next step, we need to transform the api response properly and set it in the localstorage.
Let's analyze the target structure first. This is a localstorage snapshot of my example app:
We can see, that there are a few entries in the localstorage. If those are present and their entries are valid (e.g. token is not expired), the SPA won't trigger a redirect to the IDP, when it is opened (by Cypress) and we can directly run some tests in a authenticated context 🎉.
To not blow up the blogpost endlessly, we will just focus on the entry for access token. If you are interested in the whole story, I can recommend you theVideo of Joonas Westlin, where everything is explained in detail. Of course you can also directly jump to theend result, which I published onGitHub.
Recapture - our starting point
We already prepared the request to the API, which is now part of the login function:
// Get all necessary credentials out of Cypress environment// Vriables can easily be replaced depending on the stageconst{tenantId,clientId,clientSecret,apiScopes,username,password,}=Cypress.env();constauthority=`https://login.microsoftonline.com/${tenantId}`;constenvironment='login.windows.net';exportconstlogin=()=>{letchainable:Cypress.Chainable=cy.visit('/');chainable=chainable.request({url:authority+'/oauth2/v2.0/token',method:'POST',body:{grant_type:'password',client_id:clientId,client_secret:clientSecret,scope:['openid profile email'].concat(apiScopes).join(''),username:username,password:password,},form:true,});chainable.then((response)=>{// transforming the response and set localstorageinjectTokens(response.body);}).reload().then(()=>{returntokenResponse;});returnchainable;};
As you can see, we are working withCypress.Chainable
. This is in preparation for later, when we wrap thelogin
in a custom command.
Implement InjectTokens
At the end we need a function like this:
constinjectTokens=(tokenResponse:any)=>{// some logic in between// ...localStorage.setItem(accountKey,JSON.stringify(accountEntity));localStorage.setItem(idTokenKey,JSON.stringify(idTokenEntity));localStorage.setItem(accessTokenKey,JSON.stringify(accessTokenEntity));...};
As mentioned, we will focus on theaccessToken
only. But to accomplish this, we already cover a lot.
Let's begin with theaccessTokenKey
. As we can see from the localstorage snapshot (Link), the key consists of several concatenated attributes:
constaccessTokenKey=`${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes.join('')}`;
Maybe you noticed, that we did not yet come acrosshomeAccountId
and therealm
. Those attributes can be parsed from theidToken
:
// npm i jsonwebtoken -Dimport{decode,JwtPayload}from'jsonwebtoken';...constidToken:JwtPayload=decode(tokenResponse.id_token)asJwtPayload;constlocalAccountId=idToken.oid||idToken.sid;constrealm=idToken.tid;consthomeAccountId=`${localAccountId}.${realm}`;...
Ok, well. Now let's fill the value for theaccessTokenKey
:
constbuildAccessTokenEntity=(homeAccountId:string,accessToken:string,expiresIn:number,extExpiresIn:number,realm:string,scopes:string[])=>{constnow=Math.floor(Date.now()/1000);return{homeAccountId,credentialType:'AccessToken',secret:accessToken,cachedAt:now.toString(),expiresOn:(now+expiresIn).toString(),extendedExpiresOn:(now+extExpiresIn).toString(),environment,clientId,realm,target:scopes.map((s:string)=>s.toLowerCase()).join(''),// Scopes _must_ be lowercase or the token won't be found};};
Now put it all together:
constinjectTokens=(tokenResponse:any)=>{constidToken:JwtPayload=decode(tokenResponse.id_token)asJwtPayload;constlocalAccountId=idToken.oid||idToken.sid;constrealm=idToken.tid;consthomeAccountId=`${localAccountId}.${realm}`;constaccessTokenKey=`${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes.join('')}`;constaccessTokenEntity=buildAccessTokenEntity(homeAccountId,tokenResponse.access_token,tokenResponse.expires_in,tokenResponse.ext_expires_in,realm,apiScopes);localStorage.setItem(accessTokenKey,JSON.stringify(accessTokenEntity));};
This is already the whole concept. We are receiving thetokenResponse
as input and we need to put all the things together as expected and save it to the localstorage.
We now showcased the concept with theaccessToken
. To complete the login, we need to repeat the exercise withidToken
and theaccountKey
as well. You can find theend result here.
Wrap the login in a cypress custom command
As we need to run the login in every test, this is a perfect fit for a Cypress custom command. A custom command extends the existing API of Cypress and comes with the same capabilities as usual cy commands (cy.get(), ...) in terms of debuggability and retryability.
Register the custom command in/support/commands.ts
file:
import{login}from'./auth';Cypress.Commands.add('login',()=>{returnlogin().then((tokenResponse)=>{// ...});});
We could already use the login now, but as it is, the actual login request is performed every single test, which means one API call per test. On the other site, a JWT is usually valid for 1h.
So we can add a simplecaching mechanism
as a last step:
// auth.tsexportconstlogin=(cachedTokenResponse:any)=>{lettokenResponse:any=null;letchainable:Cypress.Chainable=cy.visit('/');if(!cachedTokenResponse){chainable=chainable.request({url:authority+'/oauth2/v2.0/token',method:'POST',body:{grant_type:'password',client_id:clientId,client_secret:clientSecret,scope:['openid profile email'].concat(apiScopes).join(''),username:username,password:password,},form:true,});}else{chainable=chainable.then(()=>{return{body:cachedTokenResponse,};});}chainable.then((response)=>{injectTokens(response.body);tokenResponse=response.body;}).reload().then(()=>{returntokenResponse;});returnchainable;};// commands.tsletcachedTokenExpiryTime=newDate().getTime();letcachedTokenResponse:any=null;Cypress.Commands.add('login',()=>{// Clear our cache if tokens are expiredif(cachedTokenExpiryTime<=newDate().getTime()){cachedTokenResponse=null;}returnlogin(cachedTokenResponse).then((tokenResponse)=>{cachedTokenResponse=tokenResponse;// Set expiry time to 50 minutes from nowcachedTokenExpiryTime=newDate().getTime()+50*60*1000;});});
With the last adaption, we added a inline caching mechanism. At the end, the token is only requested initially and is reused for 50 minutes, which should be enough for all e2e tests. If not, a new token is acquired automatically.
3. Write some tests
Finally we can write some tests and try out our newcy.login()
command. In our scenario, we have an angular app, which has a profile page. This page displays some data from the/me
endpoint of MS Graph API:
This is how we can test the page and its content:
describe('angular-msal-example',()=>{beforeEach(()=>{cy.login();});it('should display user data',()=>{cy.visit('/#/profile').fixture('user.json').then((expectedUser)=>cy.get('[data-cy="firstName"]').should('have.text',`First Name:${expectedUser.firstName}`).get('[data-cy="lastName"]').should('have.text',`Last Name:${expectedUser.lastName}`).get('[data-cy="email"]').should('have.text',`Email:${expectedUser.email}`).get('[data-cy="id"]').should('have.text',`Id:${expectedUser.id}`));});});
The expected user data is saved asfixture in/fixtures/users.json
Last but not least, let's see the tests turning green 🥇:
Summary
In this post, we learned several aspects of authentication handling in e2e tests with cypress:
- Hownot to implement authentication in e2e tests
- How to acquire id- and access-token via
Resource Owner Password
Flow - How msal works under the hood
- How to fake the localstorage for msal based SPAs
- How to wrap the login in a cypress custom command
- How to reuse the tokenResponse for multiple tests
Next steps 🚵
As always, software is never ready and this is just the beginning.
I can imagine the following activities:
- Integrate the tests in your CI-Pipeline.
- Make use of thecypress-localstorage-commands package to preserve the state between tests. Use this package with caution. It is intended by design to always clear the state after a single test.
- If you are working with a Nx monorepo, you can extract the custom command into a shared library and can reuse the function in different e2e-apps with ease.
- Adapt the code to your needs.
Happy Coding & happy testing 😊
GitHub Repository
I added my code in thisGitHub Repository. Please note, that I used aNx workspace for it.
You can find the angular-msal-example-apphere and the corresponding e2e-app / cypress testshere.
To run the e2e tests, use these commands:
npminstall
npx nx e2e angular-msal-example-e2e--watch
Credits 🤝
I would like to thank my buddyPhilip Riecks for proofreading my first ever blogpost. Make sure to checkout his content as well!
Here are some further links:
- UI testing Azure AD protected single page applications with Cypress by Joonas Westlin
- Azure AD Authentication in Cypress Tests by Tim Veletta (MSAL v1)
Top comments(4)

- LocationNuremberg, Germany
- WorkSoftware Engineer @ Schaeffler
- Joined
This is my first blog post ever. So, when you have feedback for, I am more than happy 😊
For further actions, you may consider blocking this person and/orreporting abuse