Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Test MSAL based SPAs with Cypress
kauppfbi
kauppfbi

Posted on

     

Test MSAL based SPAs with Cypress

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:

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

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

  1. programatically request a access and id token
  2. transform the response and save it to the localstorage
  3. 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
Create a client secret
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>"
Enter fullscreen modeExit fullscreen mode

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>"}
Enter fullscreen modeExit fullscreen mode

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});
Enter fullscreen modeExit fullscreen mode

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:
Structure of Localstorage

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;};
Enter fullscreen modeExit fullscreen mode

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));...};
Enter fullscreen modeExit fullscreen mode

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('')}`;
Enter fullscreen modeExit fullscreen mode

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}`;...
Enter fullscreen modeExit fullscreen mode

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};};
Enter fullscreen modeExit fullscreen mode

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));};
Enter fullscreen modeExit fullscreen mode

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)=>{// ...});});
Enter fullscreen modeExit fullscreen mode

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;});});
Enter fullscreen modeExit fullscreen mode

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/meendpoint of MS Graph API:
Example App

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}`));});});
Enter fullscreen modeExit fullscreen mode

The expected user data is saved asfixture in/fixtures/users.json

Last but not least, let's see the tests turning green 🥇:
Cypress Tests

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 viaResource 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

Enter fullscreen modeExit fullscreen mode




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:

Top comments(4)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
garaxer profile image
Gary B
Mountain Biking and Javascript.
  • Joined

Very Nice, thank you very much

CollapseExpand
 
prathammantri profile image
Prathamesh Mantri
  • Joined

That's such a great explanation! Thank you for writing the article!
It helped a lot

CollapseExpand
 
kauppfbi_96 profile image
kauppfbi
My name is Fabian Kaupp, and I am a passionate Full Stack Engineer who loves building and improving user-centric and data-driven software solutions.
  • Location
    Nuremberg, Germany
  • Work
    Software Engineer @ Schaeffler
  • Joined

This is my first blog post ever. So, when you have feedback for, I am more than happy 😊

CollapseExpand
 
artsiom profile image
artemkapset
  • Joined

Did you try to use this approach in CI/CD? Is it possible since test user must be logged in via UI at least once?

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

My name is Fabian Kaupp, and I am a passionate Full Stack Engineer who loves building and improving user-centric and data-driven software solutions.
  • Location
    Nuremberg, Germany
  • Work
    Software Engineer @ Schaeffler
  • Joined

Trending onDEV CommunityHot

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp