diff --git a/samples/msal-core-samples/VanillaJSTestApp/app/adfs/Readme.md b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/Readme.md new file mode 100644 index 0000000000..9dc9056f60 --- /dev/null +++ b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/Readme.md @@ -0,0 +1,7 @@ +# ADFS Support + +MSAL.js supports connecting to Azure AD, which signs in managed-users (users managed in Azure AD) or federated users (users managed by another identity provider such as ADFS). MSAL.js does not differentiate between these two types of users. As far as it’s concerned, it talks to Azure AD. The authority that you would pass in this case is the normal Azure AD Authority: `https://login.microsoftonline.com/{Enter_the_Tenant_Info_Here}` + +MSAL.js also supports directly connecting to ADFS 2019, which is OpenID Connect compliant and has support for scopes and PKCE. This support requires that a service pack [KB 4490481](https://support.microsoft.com/en-us/help/4490481/windows-10-update-kb4490481) is applied to Windows Server. When connecting directly to ADFS, the authority you'll want to use to build your application will be of form `https://mysite.contoso.com/adfs/` + +Currently, there are no plans to support a direct connection to ADFS 16 or ADFS v2. ADFS 16 does not support scopes, and ADFS v2 is not OIDC compliant. diff --git a/samples/msal-core-samples/VanillaJSTestApp/app/adfs/auth.js b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/auth.js new file mode 100644 index 0000000000..13d6323c85 --- /dev/null +++ b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/auth.js @@ -0,0 +1,93 @@ +// 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; + +// Create the main myMSALObj instance +// configuration parameters are located at authConfig.js +const myMSALObj = new Msal.UserAgentApplication(msalConfig); + +// Register Callbacks for Redirect flow +myMSALObj.handleRedirectCallback(authRedirectCallBack); + +function authRedirectCallBack(error, response) { + if (error) { + console.log(error); + } else { + if (response.tokenType === "id_token" && myMSALObj.getAccount() && !myMSALObj.isCallback(window.location.hash)) { + console.log('id_token acquired at: ' + new Date().toString()); + showWelcomeMessage(myMSALObj.getAccount()); + } else if (response.tokenType === "access_token") { + console.log('access_token acquired at: ' + new Date().toString()); + updateUI(response); + accessTokenButtonPopup.style.display = 'none'; + accessTokenButtonRedirect.style.display = 'none'; + } else { + console.log("token type is:" + response.tokenType); + } + } +} + +// Redirect: once login is successful and redirects with tokens, call Graph API +if (myMSALObj.getAccount() && !myMSALObj.isCallback(window.location.hash)) { + // avoid duplicate code execution on page load in case of iframe and Popup window. + showWelcomeMessage(myMSALObj.getAccount()); +} + +function signIn(method) { + signInType = isIE ? "loginRedirect" : method; + if (signInType === "loginPopup") { + myMSALObj.loginPopup(loginRequest) + .then(loginResponse => { + console.log(loginResponse); + if (myMSALObj.getAccount()) { + showWelcomeMessage(myMSALObj.getAccount()); + } + }).catch(function (error) { + console.log(error); + }); + } else if (signInType === "loginRedirect") { + myMSALObj.loginRedirect(loginRequest) + } +} + +function signOut() { + myMSALObj.logout(); +} + +function getAccessTokenPopup() { + if (myMSALObj.getAccount()) { + myMSALObj.acquireTokenPopup(loginRequest).then(response => { + updateUI(response); + accessTokenButtonPopup.style.display = 'none'; + accessTokenButtonRedirect.style.display = 'none'; + }).catch(error => { + console.log(error); + }); + } +} + +function getAccessTokenRedirect() { + if (myMSALObj.getAccount()) { + myMSALObj.acquireTokenRedirect(loginRequest); + } +} + +function getAccessTokenSilent() { + if (myMSALObj.getAccount()) { + myMSALObj.acquireTokenSilent(loginRequest).then(response => { + updateUI(response); + accessTokenButtonPopup.style.display = 'none'; + accessTokenButtonRedirect.style.display = 'none'; + }).catch(error => { + console.log(error); + }) + } +} \ No newline at end of file diff --git a/samples/msal-core-samples/VanillaJSTestApp/app/adfs/authConfig.js b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/authConfig.js new file mode 100644 index 0000000000..99d20b6c77 --- /dev/null +++ b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/authConfig.js @@ -0,0 +1,18 @@ +// Config object to be passed to Msal on creation +const msalConfig = { + auth: { + clientId: "57448aa1-9515-4176-a106-5cb9be8550e1", + authority: "https://fs.msidlab8.com/adfs/", + knownAuthorities: ["fs.msidlab8.com"] + }, + cache: { + cacheLocation: "localStorage", // This configures where your cache will be stored + storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge + } +}; + +// Add here scopes for id token to be used at MS Identity Platform endpoints. +const loginRequest = { + scopes: ["openid", "profile"], + forceRefresh: false // Set this to "true" to skip a cached token and go to the server to get a new token +}; diff --git a/samples/msal-core-samples/VanillaJSTestApp/app/adfs/index.html b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/index.html new file mode 100644 index 0000000000..abef0cd5fe --- /dev/null +++ b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/index.html @@ -0,0 +1,73 @@ + + + + + + Quickstart | MSAL.JS with ADFS 2019 Vanilla JavaScript SPA + + + + + + + + + + +
+
Vanilla JavaScript SPA calling MS Graph API with MSAL.JS
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/samples/msal-core-samples/VanillaJSTestApp/app/adfs/test/browser.spec.ts b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/test/browser.spec.ts new file mode 100644 index 0000000000..0bb29df6c8 --- /dev/null +++ b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/test/browser.spec.ts @@ -0,0 +1,231 @@ +import * as Mocha from "mocha"; +import puppeteer from "puppeteer"; +import { expect } from "chai"; +import fs from "fs"; +import { LabClient, ILabApiParams } from "../../../e2eTests/LabClient"; + +const SCREENSHOT_BASE_FOLDER_NAME = `${__dirname}/screenshots`; +let SCREENSHOT_NUM = 0; +let username = ""; +let accountPwd = ""; + +// Set App Info +const clientId = "57448aa1-9515-4176-a106-5cb9be8550e1"; +const authority = "https://fs.msidlab8.com/adfs/" +const scopes = ["openid"] +const idTokenCacheKey = "msal." + clientId + ".idtoken" +const clientInfoCacheKey = "msal." + clientId + ".client.info" + +function setupScreenshotDir() { + if (!fs.existsSync(`${SCREENSHOT_BASE_FOLDER_NAME}`)) { + fs.mkdirSync(SCREENSHOT_BASE_FOLDER_NAME); + } +} + +async function setupCredentials() { + const testCreds = new LabClient(); + const userParams: ILabApiParams = { + envName: "onprem", + userType: "onprem", + federationProvider: "adfsv2019" + }; + const envResponse = await testCreds.getUserVarsByCloudEnvironment(userParams); + const testEnv = envResponse[0]; + if (testEnv.upn) { + username = testEnv.upn; + } + + const testPwdSecret = await testCreds.getSecret(testEnv.labName); + + accountPwd = testPwdSecret.value; +} + +async function takeScreenshot(page: puppeteer.Page, testName: string, screenshotName: string): Promise { + const screenshotFolderName = `${SCREENSHOT_BASE_FOLDER_NAME}/${testName}` + if (!fs.existsSync(`${screenshotFolderName}`)) { + fs.mkdirSync(screenshotFolderName); + } + await page.screenshot({ path: `${screenshotFolderName}/${++SCREENSHOT_NUM}_${screenshotName}.png` }); +} + +async function enterCredentials(page: puppeteer.Page, testName: string): Promise { + await takeScreenshot(page, testName, "SignInPage"); + await page.type("#userNameInput", username); + await page.type("#passwordInput", accountPwd); + await page.click("#submitButton"); +} + +async function loginRedirect(page: puppeteer.Page, testName: string): Promise { + // Home Page + await takeScreenshot(page, testName, `samplePageInit`); + // Click Sign In + await page.click("#SignIn"); + await takeScreenshot(page, testName, `signInClicked`); + // Click Sign In With Redirect + await page.click("#loginRedirect"); + await page.waitForSelector("#loginArea"); + // Enter credentials + await enterCredentials(page, testName); + // Wait for return to page + await page.waitForNavigation({ waitUntil: "networkidle0"}); + await page.waitForSelector("#getAccessTokenRedirect"); + await takeScreenshot(page, testName, `samplePageLoggedIn`); +} + +async function loginPopup(page: puppeteer.Page, testName: string): Promise { + // Home Page + await takeScreenshot(page, testName, `samplePageInit`); + // Click Sign In + await page.click("#SignIn"); + await takeScreenshot(page, testName, `signInClicked`); + // Click Sign In With Popup + const newPopupWindowPromise = new Promise(resolve => page.once('popup', resolve)); + await page.click("#loginPopup"); + const popupPage = await newPopupWindowPromise; + const popupWindowClosed = new Promise(resolve => popupPage.once("close", resolve)); + await popupPage.waitForSelector("#loginArea"); + + // Enter credentials + await enterCredentials(popupPage, testName); + // Wait until popup window closes and see that we are logged in + await popupWindowClosed; + await page.waitForSelector("#getAccessTokenPopup"); + await takeScreenshot(page, testName, `samplePageLoggedIn`); +} + +async function validateAccessTokens(page: puppeteer.Page, localStorage: Storage) { + let accessTokensFound = 0 + let accessTokenMatch: boolean; + + Object.keys(localStorage).forEach(async (key) => { + if (key.includes("authority")) { + let cacheKey = JSON.parse(key); + let cachedScopeList = cacheKey.scopes.split(" "); + + accessTokenMatch = cacheKey.authority === authority.toLowerCase() && + cacheKey.clientId.toLowerCase() === clientId.toLowerCase() && + scopes.every(scope => cachedScopeList.includes(scope)); + + if (accessTokenMatch) { + accessTokensFound += 1; + await page.evaluate((key) => window.localStorage.removeItem(key)) + } + } + }); + + return accessTokensFound; +} + +describe("Browser tests", function () { + this.timeout(8000); + this.retries(1); + + let browser: puppeteer.Browser; + before(async () => { + setupScreenshotDir(); + setupCredentials(); + browser = await puppeteer.launch({ + headless: true, + ignoreDefaultArgs: ['--no-sandbox', '–disable-setuid-sandbox'] + }); + }); + + let context: puppeteer.BrowserContext; + let page: puppeteer.Page; + + after(async () => { + await context.close(); + await browser.close(); + }); + + describe("Test Login functions", async () => { + beforeEach(async () => { + SCREENSHOT_NUM = 0; + context = await browser.createIncognitoBrowserContext(); + page = await context.newPage(); + await page.goto('http://localhost:30662/'); + }); + + afterEach(async () => { + await page.close(); + }); + + it("Performs loginRedirect", async () => { + const testName = "redirectBaseCase"; + await loginRedirect(page, testName); + + const localStorage = await page.evaluate(() => Object.assign({}, window.localStorage)); + + expect(Object.keys(localStorage)).to.contain(idTokenCacheKey); + expect(Object.keys(localStorage)).to.contain(clientInfoCacheKey); + }); + + it("Performs loginPopup", async () => { + const testName = "popupBaseCase"; + await loginPopup(page, testName); + + const localStorage = await page.evaluate(() => Object.assign({}, window.localStorage)); + expect(Object.keys(localStorage)).to.contain(idTokenCacheKey); + expect(Object.keys(localStorage)).to.contain(clientInfoCacheKey); + }); + }); + + describe("Test AcquireToken functions", async () => { + const testName = "acquireTokenBaseCase"; + + before(async () => { + SCREENSHOT_NUM = 0; + context = await browser.createIncognitoBrowserContext(); + page = await context.newPage(); + await page.goto('http://localhost:30662/'); + await loginPopup(page, testName); + }); + + after(async () => { + await page.close(); + }); + + afterEach(async () => { + await page.reload(); + }); + + it("Test acquireTokenRedirect", async () => { + await page.click("#getAccessTokenRedirect"); + await page.waitForSelector("#access-token-info"); + await takeScreenshot(page, testName, "accessTokenAcquiredRedirect"); + + const localStorage = await page.evaluate(() => Object.assign({}, window.localStorage)); + expect(Object.keys(localStorage)).to.contain(idTokenCacheKey); + expect(Object.keys(localStorage)).to.contain(clientInfoCacheKey); + + const accessTokensFound = await validateAccessTokens(page, localStorage); + expect(accessTokensFound).to.equal(1); + }); + + it("Test acquireTokenPopup", async () => { + await page.click("#getAccessTokenPopup"); + await page.waitForSelector("#access-token-info"); + await takeScreenshot(page, testName, "accessTokenAcquiredPopup"); + + const localStorage = await page.evaluate(() => Object.assign({}, window.localStorage)); + expect(Object.keys(localStorage)).to.contain(idTokenCacheKey); + expect(Object.keys(localStorage)).to.contain(clientInfoCacheKey); + + const accessTokensFound = await validateAccessTokens(page, localStorage); + expect(accessTokensFound).to.equal(1); + }); + + it("Test acquireTokenSilent", async () => { + await page.click("#getAccessTokenSilent"); + await page.waitForSelector("#access-token-info"); + await takeScreenshot(page, testName, "accessTokenAcquiredSilently"); + + const localStorage = await page.evaluate(() => Object.assign({}, window.localStorage)); + expect(Object.keys(localStorage)).to.contain(idTokenCacheKey); + expect(Object.keys(localStorage)).to.contain(clientInfoCacheKey); + + const accessTokensFound = await validateAccessTokens(page, localStorage); + expect(accessTokensFound).to.equal(1); + }); + }); +}); \ No newline at end of file diff --git a/samples/msal-core-samples/VanillaJSTestApp/app/adfs/ui.js b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/ui.js new file mode 100644 index 0000000000..a6af5fa69f --- /dev/null +++ b/samples/msal-core-samples/VanillaJSTestApp/app/adfs/ui.js @@ -0,0 +1,33 @@ +// Select DOM elements to work with +const welcomeDiv = document.getElementById("WelcomeMessage"); +const signInButton = document.getElementById("SignIn"); +const cardDiv = document.getElementById("card-div"); +const accessTokenButtonRedirect = document.getElementById("getAccessTokenRedirect"); +const accessTokenButtonPopup = document.getElementById("getAccessTokenPopup"); +const accessTokenButtonSilent = document.getElementById("getAccessTokenSilent"); +const profileDiv = document.getElementById("profile-div"); + +function showWelcomeMessage(account) { + // Reconfiguring DOM elements + cardDiv.style.display = 'initial'; + welcomeDiv.innerHTML = `Welcome ${account.name}`; + signInButton.nextElementSibling.style.display = 'none'; + signInButton.setAttribute("onclick", "signOut();"); + signInButton.setAttribute('class', "btn btn-success") + signInButton.innerHTML = "Sign Out"; +} + +function updateUI(response) { + const oldAccessTokenDiv = document.getElementById('access-token-info'); + if (oldAccessTokenDiv) { + oldAccessTokenDiv.remove(); + } + const accessTokenDiv = document.createElement('div'); + accessTokenDiv.id = "access-token-info"; + profileDiv.appendChild(accessTokenDiv); + + const scopes = document.createElement('p'); + scopes.innerHTML = "Access Token Acquired for Scopes: " + response.scopes; + + accessTokenDiv.appendChild(scopes); +} \ No newline at end of file diff --git a/samples/msal-core-samples/VanillaJSTestApp/e2eTests/LabClient.ts b/samples/msal-core-samples/VanillaJSTestApp/e2eTests/LabClient.ts index 4baa72b9ae..f59da2ee82 100644 --- a/samples/msal-core-samples/VanillaJSTestApp/e2eTests/LabClient.ts +++ b/samples/msal-core-samples/VanillaJSTestApp/e2eTests/LabClient.ts @@ -5,7 +5,8 @@ const labApiUri = "https://msidlab.com/api" export interface ILabApiParams { envName?: string, userType?: string, - b2cProvider?: string + b2cProvider?: string, + federationProvider?: string }; export class LabClient { @@ -54,6 +55,9 @@ export class LabClient { if (apiParams.b2cProvider) { queryParams.push(`b2cprovider=${apiParams.b2cProvider}`); } + if (apiParams.federationProvider) { + queryParams.push(`federationprovider=${apiParams.federationProvider}`); + } if (queryParams.length <= 0) { throw "Must provide at least one param to getUserVarsByCloudEnvironment";