playwright icon indicating copy to clipboard operation
playwright copied to clipboard

[Question] Reusing authentication state does not work with MSAL and Azure B2C

Open svdHero opened this issue 3 years ago • 8 comments
trafficstars

Context:

  • Playwright Version: 1.25.1
  • Operating System: Ubuntu 20.04 Linux or Windows 10
  • Node.js version: 16.15.0
  • Browser: Chrome

The Problem:

I have automated the login to my web application under test using Playwright. That works great when I do a login before each test like described here. However, execution of the whole test suite takes a long time and I would like to reduce total test time by reusing login state as described here.

However, when I do that, the web application under test totally ignores the storage state and shows the user as not being authenticated. That happens, although the called functions for login are exactly the same as in the beforeEach function. To be more precise, I have this test code:

// navbar.spec.js
import { test, expect, Page } from '@playwright/test'
import { closeCookieConsentDialog, logInViaSignInButton } from './common'
import { dnpConfig } from './dnp-config'
import { EXTERNAL_USER_LOGIN_STATE, MY_APP_URL } from './global-foo'

test.beforeEach(async ({ page }) => {
  await page.goto(MY_APP_URL)
  await expect(page).toHaveURL(MY_APP_URL)
  await closeCookieConsentDialog(page)
})

async function checkLoginStatusInNavBar(page: Page){
    await expect(page.locator(`nav.navbar :text-is('${dnpConfig.externalB2cTestUser.givenName}')`)).toBeVisible()
    await expect(page.locator("nav.navbar div.navbar-dropdown")).toBeVisible()
}

test.describe("This succeeds", () => {
    test.beforeEach(async ({ page }) => {
        await logInViaSignInButton(page, dnpConfig.externalB2cTestUser)
        await expect(page).toHaveURL(MY_APP_URL)
    })

    test("Drop-down menu with user name is displayed in nav bar", async ({ page }) => {
        await checkLoginStatusInNavBar(page)
    })
})

test.describe("This fails", () => {
    test.use({ storageState: EXTERNAL_USER_LOGIN_STATE })

    test("Drop-down menu with user name is displayed in nav bar", async ({ page }) => {
        await checkLoginStatusInNavBar(page)
    })
})

and this global setup code:

// global-setup.js
import { Browser, chromium, expect, FullConfig } from "@playwright/test"
import { closeCookieConsentDialog, logInViaSignInButton } from "./common"
import { DnpUser, dnpConfig } from "./dnp-config"

export const MY_APP_URL = "https://foo.bar.com"
export const EXTERNAL_USER_LOGIN_STATE = "external-user-login-state.json"

async function createAndStoreLoginState(browser: Browser, user: DnpUser, storageFile: string) {
    console.info(`Creating storage state ${storageFile}...`)
    const page = await browser.newPage({locale: "en-US", viewport: { width: 1920, height: 1080 }})
    await page.goto(MY_APP_URL)
    await closeCookieConsentDialog(page)
    await logInViaSignInButton(page, user)
    await expect(page).toHaveURL(MY_APP_URL)
    await page.context().storageState({ path: storageFile })
    await page.close()
    console.info(`Storage state ${storageFile} created successfully.`)
}

async function globalSetup(config: FullConfig) {
    const browser = await chromium.launch()
    await createAndStoreLoginState(browser, dnpConfig.externalB2cTestUser, EXTERNAL_USER_LOGIN_STATE)
    await browser.close()
}

export default globalSetup

What's wrong here? My application under test is an SPA that uses MSAL for authentication against Azure B2C.

When I add console.log(JSON.stringify(await context.storageState())) to my test function, I can see that the storage is restored correctly. However, my app does not seem to notice it.

Does anybody have experience using Playwright for MSAL / Azure B2C applications? I would highly appreciate any help I can get.

svdHero avatar Sep 14 '22 10:09 svdHero

I'm having a similar issue with our non-Azure SSO. context.cookies has the SSO cookie, but the SSO login page/prompt still appears. Re-using the initial context and creating pages from it works fine.

With 1.25.0 and Chromium on OS X. (Looks like 1.25.2 fixes an issue with Firefox, but it isn't in Maven Central. Is there a similar regression with Chromium?)

jtgasper3 avatar Sep 14 '22 23:09 jtgasper3

Just crossing out possibilities: does your application login need sessionStorage? cookies and localStorage are cached with your code, but storageState does not cache sessionStorage. https://playwright.dev/docs/auth#session-storage

Here is one tip to cross out global-setup issues:

Create a new test eg. "Debuglogin.spec.ts" purely for debugging purposes. This test logs in and saves state.json. -Disable global-setup for a while. -Disable beforeEach during the test as well.

test.describe('Login and save json', () => {
    test("that login can work", async ({ page, context }) => {
        // Do your login stuff hardcoded here...

        // Save cookies and localstorage to a file, which we can use later in the tests to be logged in automatically
        await context.storageState({ path: 'state.json' });
        console.log('Cookies and localStorage should now be saved in state.json file for further cache usage...');
    });
    // Test suite ends
});

Then

  1. Make sure state.json is empty of contents
  2. Run the "Debuglogin.spec.ts" or whatever name
  3. Check that state.json has contents
  4. Run your test headed navbar.ts and see if the problem persists
//sample.test.ts
test.describe("Login Tests", () => {
test.use({ storageState: "state.json" });
  test(`TEST 1`, async ({ page }) => {
    //browser launches in authenticated state
  });

  test(`Test 2`, async ({ page}) => {
    //browser launches in unauthenticated state
  });
});
It won't solve the issue, but then you'll know 2 things:
-that the "sample.test.ts" code is not the problem
-it is possible, in your application, to reuse authenticated state with many tests

Wilhop avatar Sep 15 '22 08:09 Wilhop

@Wilhop thank your for that hint. I will check about the session storage thing.

How is your suggestion about crossing out global-setup issues different from the code snippets I have posted? Isn't that exactly what I'm doing? Like I wrote, I've already managed to confirm that state.json contains the expected data and that it is also loaded correctly when using test.use({ storageState: "state.json" }). It's just that my app (or MSAL) is not picking up the correct authentication state.

So I actually can cross out global-setup. Unless I misunderstood you and overlooked something.

svdHero avatar Sep 15 '22 13:09 svdHero

Yes, the goal in my example is exactly the same as what you're doing. Why I'm suggesting it is because, for some unknown reason, our cached Azure login does not work when we use global-setup as per instructions. I never got it to work.

Wilhop avatar Sep 15 '22 13:09 Wilhop

It's working fine for me. And you were right. The problem is with the session storage. I've manually checked with the F12 dev tools in Chrome. MSAL stores all authentication information in session storage and not in local storage.

When I change

export const msalConfig: Configuration = {
    auth: {
        clientId: process.env.REACT_APP_CLIENT_ID as string,
        authority: b2cConfiguration.policies.signUpSignIn.authority,
        knownAuthorities: [b2cConfiguration.authorityDomain],
        redirectUri: process.env.REACT_APP_REDIRECT_URI as string,
        navigateToLoginRequestUrl: true,
        postLogoutRedirectUri: process.env.REACT_APP_POST_LOGOUT_REDIRECT_URI as string
    },
    cache: {
        cacheLocation: "sessionStorage", // This configures where your cache will be stored
        storeAuthStateInCookie: isEdge // Set this to "true" if you are having issues on Edge
    }
}

to

export const msalConfig: Configuration = {
    auth: {
        clientId: process.env.REACT_APP_CLIENT_ID as string,
        authority: b2cConfiguration.policies.signUpSignIn.authority,
        knownAuthorities: [b2cConfiguration.authorityDomain],
        redirectUri: process.env.REACT_APP_REDIRECT_URI as string,
        navigateToLoginRequestUrl: true,
        postLogoutRedirectUri: process.env.REACT_APP_POST_LOGOUT_REDIRECT_URI as string
    },
    cache: {
        cacheLocation: "localStorage", // This configures where your cache will be stored
        storeAuthStateInCookie: isEdge // Set this to "true" if you are having issues on Edge
    }
}

in my application under test, everything works fine and the app (i.e. MSAL) is picking up the authentication state correctly.

Now I only have to find out why Microsoft has chosen session storage over local storage as a default value for the MSAL cache. What are the pros and cons here? I don't like the idea that I have to change my production code just to reduce test runtime.

svdHero avatar Sep 15 '22 14:09 svdHero

I had to create 2 separate ways to store all needed cache

  1. cookies and localStorage are saved with: storageState
  2. sessionStorage is saved by applying https://playwright.dev/docs/auth#session-storage

Hope you manage to get a working version!

Wilhop avatar Sep 15 '22 14:09 Wilhop

@Wilhop thank you for the push in the right direction. I will look into storing session state.

svdHero avatar Sep 20 '22 07:09 svdHero

@Wilhop Ah maybe one follow-up question: How would I load the session storage on a per test basis? I am looking for a code snippet like test.use() that would only load the session storage within a test.describe() scope, but not globally for all tests. The explanation at https://playwright.dev/docs/auth#session-storage seems to imply that the init script is run for all tests.

Right now I am thinking about a simple test.beforeEach like so:

test.beforeEach(async ({ page, context }) => {
    const sessionStorage = process.env.SESSION_STORAGE;
    await context.addInitScript(storage => {
    if (window.location.hostname === 'example.com') {
        const entries = JSON.parse(storage);
        for (const [key, value] of Object.entries(entries)) {
            window.sessionStorage.setItem(key, value);
        }
    }
    }, sessionStorage);
});

Is that the right thing to do? Would the init script at this point still be run? I wouldn't assume that, because the page has been been created already.

svdHero avatar Sep 20 '22 08:09 svdHero

@Wilhop may I humbly ask for guidance again? Any thoughts on my question above? I would be very grateful to know what the best practice is here.

svdHero avatar Oct 05 '22 08:10 svdHero

Why was this issue closed?

Thank you for your involvement. This issue was closed due to limited engagement (upvotes/activity), lack of recent activity, and insufficient actionability. To maintain a manageable database, we prioritize issues based on these factors.

If you disagree with this closure, please open a new issue and reference this one. More support or clarity on its necessity may prompt a review. Your understanding and cooperation are appreciated.

pavelfeldman avatar Nov 16 '23 04:11 pavelfeldman