diff --git a/package-lock.json b/package-lock.json index e30dce65e0..1a245de25a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31364,6 +31364,10 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/msal-browser-popup-coop": { + "resolved": "samples/msal-browser-samples/popup-coop", + "link": true + }, "node_modules/msal-browser-testing-sample": { "resolved": "samples/msal-browser-samples/TestingSample", "link": true diff --git a/samples/msal-browser-samples/popup-coop/app/auth.js b/samples/msal-browser-samples/popup-coop/app/auth.js new file mode 100644 index 0000000000..c21fee1024 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/auth.js @@ -0,0 +1,124 @@ +// Browser check variables +// If you support IE, our recommendation is that you sign-in using Redirect APIs +// If you as a developer are testing using Edge InPrivate mode, please add "isEdge" to the if check +const ua = window.navigator.userAgent; +const msie = ua.indexOf("MSIE "); +const msie11 = ua.indexOf("Trident/"); +const msedge = ua.indexOf("Edge/"); +const isIE = msie > 0 || msie11 > 0; +const isEdge = msedge > 0; + +let signInType; +let accountId = ""; + +// Create the main myMSALObj instance +// configuration parameters are located at authConfig.js +const myMSALObj = new msal.PublicClientApplication(msalConfig); + +myMSALObj.initialize().then(() => { + // Redirect: once login is successful and redirects with tokens, call Graph API + myMSALObj.handleRedirectPromise().then(handleResponse).catch(err => { + console.error(err); + }); +}) + +function handleResponse(resp) { + if (resp !== null) { + accountId = resp.account.homeAccountId; + myMSALObj.setActiveAccount(resp.account); + showWelcomeMessage(resp.account); + } else { + // need to call getAccount here? + const currentAccounts = myMSALObj.getAllAccounts(); + if (!currentAccounts || currentAccounts.length < 1) { + return; + } else if (currentAccounts.length > 1) { + // Add choose account code here + } else if (currentAccounts.length === 1) { + const activeAccount = currentAccounts[0]; + myMSALObj.setActiveAccount(activeAccount); + accountId = activeAccount.homeAccountId; + showWelcomeMessage(activeAccount); + } + } +} + +async function signIn(method) { + signInType = isIE ? "redirect" : method; + if (signInType === "popup") { + return myMSALObj.loginPopup({ + ...loginRequest, + redirectUri: "http://localhost:30662" + }).then(handleResponse).catch(function (error) { + console.log(error); + }); + } else if (signInType === "redirect") { + return myMSALObj.loginRedirect(loginRequest) + } +} + +function signOut(interactionType) { + const logoutRequest = { + account: myMSALObj.getAccountByHomeId(accountId) + }; + + if (interactionType === "popup") { + myMSALObj.logoutPopup(logoutRequest).then(() => { + window.location.reload(); + }); + } else { + myMSALObj.logoutRedirect(logoutRequest); + } +} + +async function getTokenPopup(request, account) { + request.redirectUri = "http://localhost:30662" + return await myMSALObj + .acquireTokenSilent(request) + .catch(async (error) => { + console.log("silent token acquisition fails."); + if (error instanceof msal.InteractionRequiredAuthError) { + console.log("acquiring token using popup"); + return myMSALObj.acquireTokenPopup(request).catch((error) => { + console.error(error); + }); + } else { + console.error(error); + } + }); +} + +// This function can be removed if you do not need to support IE +async function getTokenRedirect(request, account) { + return await myMSALObj.acquireTokenSilent(request).catch(async (error) => { + console.log("silent token acquisition fails."); + if (error instanceof msal.InteractionRequiredAuthError) { + // fallback to interaction when silent call fails + console.log("acquiring token using redirect"); + myMSALObj.acquireTokenRedirect(request); + } else { + console.error(error); + } + }); +} + +function openPopupLmso(){ + // const popupWindow = window.open("http://localhost:30662", "popup", "width=600,height=600"); + const popupWindow = window.open("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", "popup", "width=600,height=600"); + monitorLmsoPopup(popupWindow); +} + +function monitorLmsoPopup(popupWindow){ + console.log("PopupHandler.monitorPopupLmso - monitoring popup"); + const intervalId = setInterval(() => { + // Window is closed + if (popupWindow.closed) { + console.log( + "PopupHandler.monitorPopupLmso - window closed" + ); + clearInterval(intervalId); + } + }, 100000); + popupWindow.location = "http://localhost:30662"; + popupWindow.close(); +}; diff --git a/samples/msal-browser-samples/popup-coop/app/authConfig.js b/samples/msal-browser-samples/popup-coop/app/authConfig.js new file mode 100644 index 0000000000..07fecb7d68 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/authConfig.js @@ -0,0 +1,69 @@ +// Config object to be passed to Msal on creation +const msalConfig = { + auth: { + clientId: "8015f5e0-0370-427c-9b0d-d834189ffdd0", + authority: + "https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47", + }, + cache: { + cacheLocation: "sessionStorage", // This configures where your cache will be stored + storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge + }, + system: { + loggerOptions: { + logLevel: msal.LogLevel.Trace, + loggerCallback: (level, message, containsPii) => { + if (containsPii) { + return; + } + switch (level) { + case msal.LogLevel.Error: + console.error(message); + return; + case msal.LogLevel.Info: + console.info(message); + return; + case msal.LogLevel.Verbose: + console.debug(message); + return; + case msal.LogLevel.Warning: + console.warn(message); + return; + default: + console.log(message); + return; + } + }, + }, + pollIntervalMilliseconds: 0 + }, + telemetry: { + application: { + appName: "MSAL Browser V2 Default Sample", + appVersion: "1.0.0", + }, + }, +}; + +// Add here scopes for id token to be used at MS Identity Platform endpoints. +const loginRequest = { + scopes: ["User.Read"], +}; + +// Add here the endpoints for MS Graph API services you would like to use. +const graphConfig = { + graphMeEndpoint: "https://graph.microsoft.com/v1.0/me", + graphMailEndpoint: "https://graph.microsoft.com/v1.0/me/messages", +}; + +// Add here scopes for access token to be used at MS Graph API endpoints. +const tokenRequest = { + scopes: ["Mail.Read"], + forceRefresh: false, // Set this to "true" to skip a cached token and go to the server to get a new token +}; + +const silentRequest = { + scopes: ["openid", "profile", "User.Read", "Mail.Read"], +}; + +const logoutRequest = {}; diff --git a/samples/msal-browser-samples/popup-coop/app/graph.js b/samples/msal-browser-samples/popup-coop/app/graph.js new file mode 100644 index 0000000000..0c5f6ba224 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/graph.js @@ -0,0 +1,64 @@ +// Helper function to call MS Graph API endpoint +// using authorization bearer token scheme +function callMSGraph(endpoint, accessToken, callback) { + const headers = new Headers(); + const bearer = `Bearer ${accessToken}`; + + headers.append("Authorization", bearer); + + const options = { + method: "GET", + headers: headers + }; + + console.log('request made to Graph API at: ' + new Date().toString()); + + fetch(endpoint, options) + .then(response => response.json()) + .then(response => callback(response, endpoint)) + .catch(error => console.log(error)); +} + +async function seeProfile() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await getTokenPopup(loginRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMeEndpoint, response.accessToken, updateUI); + profileButton.style.display = 'none'; + } +} + +async function readMail() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await getTokenPopup(tokenRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMailEndpoint, response.accessToken, updateUI); + mailButton.style.display = 'none'; + } +} + +async function seeProfileRedirect() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await getTokenRedirect(loginRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMeEndpoint, response.accessToken, updateUI); + profileButton.style.display = 'none'; + } +} + +async function readMailRedirect() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await getTokenRedirect(tokenRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMailEndpoint, response.accessToken, updateUI); + mailButton.style.display = 'none'; + } +} diff --git a/samples/msal-browser-samples/popup-coop/app/index.html b/samples/msal-browser-samples/popup-coop/app/index.html new file mode 100644 index 0000000000..9e97b7b949 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/index.html @@ -0,0 +1,82 @@ + + + + + + + Quickstart | MSAL.JS Vanilla JavaScript SPA + + + + + + + + + + +
+
Vanilla JavaScript SPA calling MS Graph API with MSAL.JS
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/msal-browser-samples/popup-coop/app/redirect.html b/samples/msal-browser-samples/popup-coop/app/redirect.html new file mode 100644 index 0000000000..dcc8b0ed7d --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/redirect.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/samples/msal-browser-samples/popup-coop/app/ui.js b/samples/msal-browser-samples/popup-coop/app/ui.js new file mode 100644 index 0000000000..e248605008 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/ui.js @@ -0,0 +1,70 @@ +// 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"); +const cardDiv = document.getElementById("card-div"); +const mailButton = document.getElementById("readMail"); +const profileButton = document.getElementById("seeProfile"); +const profileDiv = document.getElementById("profile-div"); + +function showWelcomeMessage(account) { + // Reconfiguring DOM elements + cardDiv.style.display = 'initial'; + welcomeDiv.innerHTML = `Welcome ${account.username}`; + signInButton.setAttribute('class', "btn btn-success dropdown-toggle"); + signInButton.innerHTML = "Sign Out"; + popupButton.setAttribute('onClick', "signOut(this.id)"); + popupButton.innerHTML = "Sign Out with Popup"; + redirectButton.setAttribute('onClick', "signOut(this.id)"); + redirectButton.innerHTML = "Sign Out with Redirect"; +} + +function updateUI(data, endpoint) { + console.log('Graph API responded at: ' + new Date().toString()); + + if (endpoint === graphConfig.graphMeEndpoint) { + const title = document.createElement('p'); + title.innerHTML = "Title: " + data.jobTitle; + const email = document.createElement('p'); + email.innerHTML = "Mail: " + data.mail; + const phone = document.createElement('p'); + phone.innerHTML = "Phone: " + data.businessPhones[0]; + const address = document.createElement('p'); + address.innerHTML = "Location: " + data.officeLocation; + profileDiv.appendChild(title); + profileDiv.appendChild(email); + profileDiv.appendChild(phone); + profileDiv.appendChild(address); + } else if (endpoint === graphConfig.graphMailEndpoint) { + if (data.value.length < 1) { + alert("Your mailbox is empty!") + } else { + const tabList = document.getElementById("list-tab"); + const tabContent = document.getElementById("nav-tabContent"); + + data.value.map((d, i) => { + // Keeping it simple + if (i < 10) { + const listItem = document.createElement("a"); + listItem.setAttribute("class", "list-group-item list-group-item-action") + listItem.setAttribute("id", "list" + i + "list") + listItem.setAttribute("data-toggle", "list") + listItem.setAttribute("href", "#list" + i) + listItem.setAttribute("role", "tab") + listItem.setAttribute("aria-controls", i) + listItem.innerHTML = d.subject; + tabList.appendChild(listItem) + + const contentItem = document.createElement("div"); + contentItem.setAttribute("class", "tab-pane fade") + contentItem.setAttribute("id", "list" + i) + contentItem.setAttribute("role", "tabpanel") + contentItem.setAttribute("aria-labelledby", "list" + i + "list") + contentItem.innerHTML = " from: " + d.from.emailAddress.address + "

" + d.bodyPreview + "..."; + tabContent.appendChild(contentItem); + } + }); + } + } +} diff --git a/samples/msal-browser-samples/popup-coop/jest.config.js b/samples/msal-browser-samples/popup-coop/jest.config.js new file mode 100644 index 0000000000..ddf4593104 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/jest.config.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); + +const APP_DIR = path.join(__dirname, 'app'); + +// if a sample is specified, only run tests for that sample +const sampleFolders = process.argv.find(arg => arg.startsWith('--sample=')) + ? [path.join(APP_DIR, process.argv.find(arg => arg.startsWith('--sample='))?.split('=')[1])] + : fs.readdirSync(APP_DIR, { withFileTypes: true }) + .filter(file => file.isDirectory() && file.name !== "node_modules" && fs.existsSync(path.resolve(APP_DIR, file.name, "jest.config.js"))) + .map(file => path.join(APP_DIR, file.name)); + +module.exports = { + projects: sampleFolders, + testTimeout: 30000 +}; diff --git a/samples/msal-browser-samples/popup-coop/package.json b/samples/msal-browser-samples/popup-coop/package.json new file mode 100644 index 0000000000..446af6f7de --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/package.json @@ -0,0 +1,19 @@ +{ + "name": "msal-browser-popup-coop", + "version": "1.0.0", + "license": "MIT", + "private": true, + "main": "server.js", + "scripts": { + "start": "node server.js", + "test": "npx playwright test" + }, + "dependencies": { + "@azure/msal-node": "^2.1.0", + "express": "^4.19.2", + "path": "^0.11.14" + }, + "devDependencies": { + "@playwright/test": "^1.30.0" + } +} diff --git a/samples/msal-browser-samples/popup-coop/server.js b/samples/msal-browser-samples/popup-coop/server.js new file mode 100644 index 0000000000..3f481516f5 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/server.js @@ -0,0 +1,90 @@ +/* +* Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. +* See LICENSE in the source repository root for complete license information. +*/ +const express = require('express'); +const morgan = require('morgan'); +const fs = require('fs'); +const path = require('path'); +const argv = require("yargs") + .usage("Usage: $0 -p [PORT] -https") + .alias("p", "port") + .alias("h", "https") + .describe("port", "(Optional) Port Number - default is 30662") + .describe("https", "(Optional) Serve over https") + .strict() + .argv; + + +const DEFAULT_PORT = 30662; +const APP_DIR = __dirname + `/app`; + +//initialize express. +const app = express(); + +// Initialize variables. +let port = DEFAULT_PORT; // -p {PORT} || 30662; +if (argv.p) { + port = argv.p; +} + +let logHttpRequests = true; + +// Set the front-end folder to serve public assets. +app.use("/lib", express.static(path.join(__dirname, "../../../lib/msal-browser/lib"))); + +// app.use(express.static('app/')); + +// app.use(express.static('app/', { +// setHeaders: (res) => { +// res.set('Cross-Origin-Opener-Policy', 'same-origin'); +// } +// })); + +app.use(express.static('app/', { + setHeaders: (res) => { + res.set('Cross-Origin-Opener-Policy', 'same-origin-allow-popups'); + } +})); + +if (logHttpRequests) { + // Configure morgan module to log all requests. + app.use(morgan('dev')); +} + +// set up a route for redirect.html. When using popup and silent APIs, +// we recommend setting the redirectUri to a blank page or a page that does not implement MSAL. +app.get("/redirect", function (req, res) { + res.sendFile(path.join(__dirname + "/redirect.html")); +}); + +// Set up a route for index.html. +app.get('*', function (req, res) { + res.sendFile(path.join(__dirname + '/index.html')); +}); + +// Start the server. +if (argv.https) { + const https = require('https'); + + /** + * Secrets should never be hardcoded. The dotenv npm package can be used to store secrets or certificates + * in a .env file (located in project's root directory) that should be included in .gitignore to prevent + * accidental uploads of the secrets. + * + * Certificates can also be read-in from files via NodeJS's fs module. However, they should never be + * stored in the project's directory. Production apps should fetch certificates from + * Azure KeyVault (https://azure.microsoft.com/products/key-vault), or other secure key vaults. + * + * Please see "Certificates and Secrets" (https://learn.microsoft.com/azure/active-directory/develop/security-best-practices-for-app-registration#certificates-and-secrets) + * for more information. + */ + const privateKey = fs.readFileSync('/certs/key.pem', 'utf8'); + const certificate = fs.readFileSync('/certs/cert.pem', 'utf8'); + const credentials = { key: privateKey, cert: certificate }; + const httpsServer = https.createServer(credentials, app); + httpsServer.listen(port); +} else { + app.listen(port); +} +console.log(`Listening on port ${port}...`); diff --git a/samples/msal-browser-samples/popup-coop/tsconfig.base.json b/samples/msal-browser-samples/popup-coop/tsconfig.base.json new file mode 100644 index 0000000000..d519609f49 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "suppressImplicitAnyIndexErrors": true, + "sourceMap": true, + "declaration": true, + "experimentalDecorators": true, + "importHelpers": true, + "noImplicitAny": true + }, + "compileOnSave": false, + "buildOnSave": false +} diff --git a/samples/msal-browser-samples/popup-coop/tsconfig.json b/samples/msal-browser-samples/popup-coop/tsconfig.json new file mode 100644 index 0000000000..5e6695a0fa --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "moduleResolution": "node", + "module": "commonjs", + "target": "es5", + "lib": [ + "es2015", + "dom", + "es2015.promise" + ], + "allowUnusedLabels": false, + "noImplicitReturns": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": [ + "node", + "jest" + ] + }, + "include": [ + "./app/**/test/*.spec.ts", + "./app/testUtils.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file