diff --git a/package-lock.json b/package-lock.json index 2ec655cc25..ebb905d10a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55799,6 +55799,7 @@ } }, "samples/msal-browser-samples/COOP": { + "name": "msal-browser-popup-coop", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -55808,7 +55809,127 @@ "path": "^0.11.14" }, "devDependencies": { - "@playwright/test": "^1.30.0" + "@playwright/test": "^1.31.1", + "@types/jest": "^29.5.0", + "@types/node": "^24.10.0", + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "@vercel/webpack-asset-relocator-loader": "1.7.3", + "autoprefixer": "^10.4.13", + "css-loader": "^6.0.0", + "e2e-test-utils": "file:../../e2eTestUtils", + "electron": "22.3.25", + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.0", + "fork-ts-checker-webpack-plugin": "^7.2.1", + "jest": "^29.5.0", + "node-loader": "^2.0.0", + "postcss": "^8.4.31", + "postcss-loader": "^4.2.0", + "sass": "^1.55.0", + "sass-loader": "^10.1.1", + "style-loader": "^3.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.2.2", + "ts-node": "^10.0.0", + "typescript": "~4.5.4" + } + }, + "samples/msal-browser-samples/COOP/node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "samples/msal-browser-samples/COOP/node_modules/@vercel/webpack-asset-relocator-loader": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@vercel/webpack-asset-relocator-loader/-/webpack-asset-relocator-loader-1.7.3.tgz", + "integrity": "sha512-vizrI18v8Lcb1PmNNUBz7yxPxxXoOeuaVEjTG9MjvDrphjiSxFZrRJ5tIghk+qdLFRCXI5HBCshgobftbmrC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.10.0" + } + }, + "samples/msal-browser-samples/COOP/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "samples/msal-browser-samples/COOP/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "samples/msal-browser-samples/COOP/node_modules/path": { @@ -55816,6 +55937,71 @@ "resolved": "https://registry.npmjs.org/path/-/path-0.11.14.tgz", "integrity": "sha512-CzEXTDgcEfa0yqMe+DJCSbEB5YCv4JZoic5xulBNFF2ifIMjNrTWbNSPNhgKfSo0MjneGIx9RLy4pCFuZPaMSQ==" }, + "samples/msal-browser-samples/COOP/node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "samples/msal-browser-samples/COOP/node_modules/typescript": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "samples/msal-browser-samples/COOP/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "samples/msal-browser-samples/ExpressSample": { "name": "express-sample", "version": "1.0.0", diff --git a/samples/msal-browser-samples/COOP/app/auth.js b/samples/msal-browser-samples/COOP/app/auth.js index bd99f1bd08..bc9465699d 100644 --- a/samples/msal-browser-samples/COOP/app/auth.js +++ b/samples/msal-browser-samples/COOP/app/auth.js @@ -39,7 +39,7 @@ function handleResponse(resp) { const successDiv = document.getElementById("successAuthCode"); if (successDiv) { successDiv.innerHTML = ` -
+
✅ Authentication Successful!

User: ${resp.account.name || resp.account.username}

ID Token: ${resp.idToken.substring(0, 30)}...

@@ -64,14 +64,18 @@ function handleResponse(resp) { } } -function logoutPopup(interactionType) { +function signOut(interactionType) { const logoutRequest = { - account: myMSALObj.getAccountByHomeId(accountId) + account: myMSALObj.getAccount({accountId}) }; - myMSALObj.logoutPopup(logoutRequest).then(() => { - window.location.reload(); - }); + if (interactionType === "popup") { + myMSALObj.logoutPopup(logoutRequest).then(() => { + window.location.reload(); + }); + } else { + myMSALObj.logoutRedirect(logoutRequest); + } } async function loginPopup(request, account) { diff --git a/samples/msal-browser-samples/COOP/app/index.html b/samples/msal-browser-samples/COOP/app/index.html index 8f1b28a1a3..e1613788ae 100644 --- a/samples/msal-browser-samples/COOP/app/index.html +++ b/samples/msal-browser-samples/COOP/app/index.html @@ -59,8 +59,8 @@
MSAL.js COOP sample


- - + +

diff --git a/samples/msal-browser-samples/COOP/app/ui.js b/samples/msal-browser-samples/COOP/app/ui.js index 9de6b2416a..90e83fd5d7 100644 --- a/samples/msal-browser-samples/COOP/app/ui.js +++ b/samples/msal-browser-samples/COOP/app/ui.js @@ -1,5 +1,4 @@ // Select DOM elements to work with -const welcomeDiv = document.getElementById("WelcomeMessage"); const signInButton = document.getElementById("SignIn"); const popupButton = document.getElementById("popup"); const redirectButton = document.getElementById("redirect"); @@ -7,7 +6,6 @@ const cardDiv = document.getElementById("card-div"); function showWelcomeMessage(account) { // Reconfiguring DOM elements - //welcomeDiv.innerHTML = `Welcome ${account.username}`; signInButton.setAttribute('class', "btn btn-success dropdown-toggle"); signInButton.innerHTML = "Sign Out"; popupButton.setAttribute('onClick', "signOut(this.id)"); diff --git a/samples/msal-browser-samples/COOP/package.json b/samples/msal-browser-samples/COOP/package.json index 931f08c88a..50a9c3864e 100644 --- a/samples/msal-browser-samples/COOP/package.json +++ b/samples/msal-browser-samples/COOP/package.json @@ -18,6 +18,31 @@ "path": "^0.11.14" }, "devDependencies": { - "@playwright/test": "^1.30.0" + "e2e-test-utils": "file:../../e2eTestUtils", + "@playwright/test": "^1.31.1", + "@types/node": "^24.10.0", + "@types/jest": "^29.5.0", + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "@vercel/webpack-asset-relocator-loader": "1.7.3", + "autoprefixer": "^10.4.13", + "css-loader": "^6.0.0", + "electron": "22.3.25", + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.0", + "fork-ts-checker-webpack-plugin": "^7.2.1", + "jest": "^29.5.0", + "node-loader": "^2.0.0", + "postcss": "^8.4.31", + "postcss-loader": "^4.2.0", + "sass": "^1.55.0", + "sass-loader": "^10.1.1", + "style-loader": "^3.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.2.2", + "ts-node": "^10.0.0", + "typescript": "~4.5.4" } } diff --git a/samples/msal-browser-samples/COOP/playwright.config.ts b/samples/msal-browser-samples/COOP/playwright.config.ts new file mode 100644 index 0000000000..81beb49b34 --- /dev/null +++ b/samples/msal-browser-samples/COOP/playwright.config.ts @@ -0,0 +1,96 @@ +import { PlaywrightTestConfig, devices } from "@playwright/test"; +import { RETRY_TIMES } from "e2e-test-utils"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: "./test", + maxFailures: 2, + /* Run tests in files in parallel */ + //fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + //forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: RETRY_TIMES, + /* Opt out of parallel tests on CI. */ + //workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: "https://localhost:30662", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + headless: false, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + // { + // name: "firefox", + // use: { ...devices["Desktop Firefox"] }, + // }, + + // { + // name: "webkit", + // use: { ...devices["Desktop Safari"] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + timeout: 50000, + globalTimeout: 5400000, + + /* Run your local dev servers before starting the tests */ + webServer: [ + { + command: "npm run start:https", + url: "https://localhost:30662", + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + }, + { + command: "npm run start:server:https", + url: "https://localhost:30663", + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + }, + ], +}; + +export default config; diff --git a/samples/msal-browser-samples/COOP/sts/ui.js b/samples/msal-browser-samples/COOP/sts/ui.js index ad220cbb87..3d5d5752f1 100644 --- a/samples/msal-browser-samples/COOP/sts/ui.js +++ b/samples/msal-browser-samples/COOP/sts/ui.js @@ -2,7 +2,17 @@ window.name = "STS Window"; const channel = new BroadcastChannel('sts-channel'); function performAuthentication() { + console.log("STS: Performing authentication (simulated)"); + console.log("STS: Adding 3 second delay..."); + + // Add 3 second delay + setTimeout(() => { + continueAuthentication(); + }, 2000); +} + +function continueAuthentication() { console.log("STS: window.opener", window.opener); console.log("STS: window.location.search", window.location.search); diff --git a/samples/msal-browser-samples/COOP/test/home.spec.ts b/samples/msal-browser-samples/COOP/test/home.spec.ts new file mode 100644 index 0000000000..8b84f7fdb9 --- /dev/null +++ b/samples/msal-browser-samples/COOP/test/home.spec.ts @@ -0,0 +1,194 @@ +import { chromium, Browser, Page, test, expect, Frame } from "@playwright/test"; +import { ScreenShotElectron } from "e2e-test-utils"; + +const LOCAL_SCREENSHOT_FOLDER = `${__dirname}/screenshots`; + +let browser: Browser; +let browserPage: Page; + +test.beforeEach(async () => { + browser = await chromium.launch({ + args: ["--allow-insecure-localhost"], + }); + browserPage = await browser.newPage(); + await browserPage.goto("/"); +}); + +test.afterEach(async () => { + await browserPage.close(); +}); + +test.afterAll(async () => { + await browser.close(); +}); + +test("Home page - Popup and Sso Silent buttons are loaded on home-page", async () => { + const testName = "homePageLoad"; + console.log(`${LOCAL_SCREENSHOT_FOLDER}/${testName}`); + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + await screenshot.takeScreenshot(browserPage, "Page loaded"); + + const popupButton = await browserPage.waitForSelector("#loginPopup"); + const ssoButton = await browserPage.waitForSelector("#sso"); + + expect(popupButton).not.toBeNull(); + expect(ssoButton).not.toBeNull(); +}); + +test("Popup Login Flow - Successful authentication and token acquisition", async () => { + const testName = "popupLoginFlow"; + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + + await screenshot.takeScreenshot(browserPage, "App loaded"); + + // Click the popup login button + const loginButton = await browserPage.waitForSelector("#loginPopup"); + const newPopupWindowPromise = new Promise((resolve) => + browserPage.once("popup", resolve) + ); + await loginButton.click(); + await screenshot.takeScreenshot(browserPage, "Login button clicked"); + await browserPage.waitForTimeout(1000); + + const popupPage = await newPopupWindowPromise; + if (!popupPage) { + throw new Error("Popup window was not opened"); + } + const popupWindowClosed = popupPage?.waitForEvent("close"); + + await screenshot.takeScreenshot(popupPage, "Popup opened"); + + // Wait for popup to close (indicates successful authentication) and return to app to verify login success + await popupWindowClosed; + + expect(popupPage.isClosed()).toBeTruthy(); + await browserPage.waitForSelector("#successAuthCode", { timeout: 3000 }); + await browserPage.waitForSelector("#successMsg", { timeout: 3000 }); + + await screenshot.takeScreenshot( + browserPage, + "Login successful - Welcome message displayed" + ); + + // Verify account info is displayed + const successMessage = await browserPage.textContent("#successAuthCode"); + console.log("Welcome message:", successMessage); + expect(successMessage).toContain("Authentication Successful"); + expect(successMessage).toContain("Test User"); +}); + +test("ssoSilent Token Acquisition", async () => { + const testName = "ssoSilentTokenAcquisition"; + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + + await screenshot.takeScreenshot(browserPage, "Initial page load"); + + //add iframe listener + const silentIframe = new Promise((resolve) => { + browserPage.once("frameattached", (frame) => { + resolve(frame); + console.log("Frame attached:", frame.url()); + }); + }); + + // Click the SSO silent button + const ssoButton = await browserPage.waitForSelector("#sso"); + await ssoButton.click(); + await screenshot.takeScreenshot(browserPage, "SSO button clicked"); + + //wait for the iframe to be detected + const frame = await silentIframe; + + if (!frame) { + throw new Error("Silent iframe was not opened"); + } + // Verify the iframe exists + expect(frame).not.toBeNull(); + console.log("Silent iframe frame object:", frame.url()); + expect(frame.url()).toContain("/authorize"); + + await browserPage.waitForSelector("#successAuthCode", { timeout: 3000 }); + await browserPage.waitForSelector("#successMsg", { timeout: 3000 }); + + await screenshot.takeScreenshot( + browserPage, + "Silent token acquisition completed" + ); + + // Verify account info is displayed + const successMessage = await browserPage.textContent("#successAuthCode"); + console.log("Welcome message:", successMessage); + expect(successMessage).toContain("Authentication Successful"); + expect(successMessage).toContain("Test User"); +}); + +test("COOP Header Validation - Verify Cross-Origin-Opener-Policy is set", async () => { + const testName = "coopHeaderValidation"; + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + + await screenshot.takeScreenshot(browserPage, "Initial page load"); + + // Check for COOP header in the response + const response = await browserPage.goto("/", { waitUntil: "networkidle" }); + const coopHeader = response?.headers()["cross-origin-opener-policy"]; + + await screenshot.takeScreenshot( + browserPage, + "Page loaded with COOP header check" + ); + + console.log("App COOP Header value:", coopHeader); + + // Verify COOP header is present and has expected value + expect(coopHeader).toBeUndefined(); + + // Click the popup login button + const loginButton = await browserPage.waitForSelector("#loginPopup"); + const newPopupWindowPromise = new Promise((resolve) => + browserPage.once("popup", resolve) + ); + await loginButton.click(); + await screenshot.takeScreenshot(browserPage, "Login button clicked"); + + const popupPage = await newPopupWindowPromise; + if (!popupPage) { + throw new Error("Popup window was not opened"); + } + + // Wait for popup to navigate and get the response to check COOP header + const popupResponse = await popupPage.waitForResponse( + (response) => response.url().includes("localhost:30663"), + { timeout: 10000 } + ); + + const popupCoopHeader = + popupResponse.headers()["cross-origin-opener-policy"]; + console.log("Popup COOP Header value:", popupCoopHeader); + + // Verify popup COOP header + expect(popupCoopHeader).toBeTruthy(); + expect(popupCoopHeader).toContain("same-origin"); + + const popupWindowClosed = popupPage?.waitForEvent("close"); + await screenshot.takeScreenshot(popupPage, "Popup opened"); + + // Wait for popup to close (indicates successful authentication) and return to app to verify login success + await popupWindowClosed; + + expect(popupPage.isClosed()).toBeTruthy(); + await browserPage.waitForSelector("#successAuthCode", { timeout: 3000 }); + await browserPage.waitForSelector("#successMsg", { timeout: 3000 }); + + await screenshot.takeScreenshot( + browserPage, + "Login successful - Welcome message displayed" + ); +});