From 310c33cda9fc8ffbd80bb5cff9e3ef756b2fc496 Mon Sep 17 00:00:00 2001 From: roicarrera <120183761+roicarrera@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:44:50 +0100 Subject: [PATCH] Cypress login with MFA (#1070) --- CHANGELOG.md | 1 + .../quickstarters/pages/e2e-cypress.adoc | 14 +- e2e-cypress/Jenkinsfile.template | 4 +- .../files/cypress-acceptance.config.ts | 3 + .../files/cypress-installation.config.ts | 3 + .../files/cypress-integration.config.ts | 3 + e2e-cypress/files/cypress.config.ts | 3 + e2e-cypress/files/package.json | 1 + e2e-cypress/files/support/commands.ts | 7 +- e2e-cypress/files/support/e2e.ts | 70 +-------- e2e-cypress/files/support/login-functions.ts | 120 +++++++++++++++ .../files/support/msalv2-login_obsolete.ts | 138 ------------------ .../tests/acceptance/acceptance.spec.cy.ts | 5 +- 13 files changed, 159 insertions(+), 213 deletions(-) create mode 100644 e2e-cypress/files/support/login-functions.ts delete mode 100644 e2e-cypress/files/support/msalv2-login_obsolete.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fba13b8..748d46444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Add microsoft-edge to nodejs agents for using with cypress ([#1063](https://github.com/opendevstack/ods-quickstarters/pull/1063)) - Replaced centos8 repository for AlmaLinux 8 due to deprecation ([#1063](https://github.com/opendevstack/ods-quickstarters/pull/1063)) - Improvements in the reporter for cypress ([#1042](https://github.com/opendevstack/ods-quickstarters/issues/1042)) +- Added new function to cypress to log into applications using MFA ([#1070](https://github.com/opendevstack/ods-quickstarters/pull/1070)) ### Added diff --git a/docs/modules/quickstarters/pages/e2e-cypress.adoc b/docs/modules/quickstarters/pages/e2e-cypress.adoc index 6ed365ffe..e2b8f65bb 100644 --- a/docs/modules/quickstarters/pages/e2e-cypress.adoc +++ b/docs/modules/quickstarters/pages/e2e-cypress.adoc @@ -99,9 +99,21 @@ RUN chgrp -R 0 $HOME && \ USER 1001 ---- +== Configuring Microsoft Account with Google Authenticator + +To use the login with MFA function, the following requirements have to be met: + +1. A testing account must be created with access to the application under test. + +2. The account must have MFA enabled. + +3. The MFA method used must be OTP (One Time Password). + +4. The secret key generated during the setup process must be used for the OTP generation. In the case of microsoft, it can be retrieved by clicking the "Can't scan image" button during the setup process. + == Cypress Cloud -To use Cypress Cloud within the Cypress QuickSarter, follow these steps: +To use Cypress Cloud within the Cypress QuickStarter, follow these steps: 1. **Create a project in Cypress Cloud.** Access Cypress Cloud by following this link (https://cloud.cypress.io/), and create a project. This project will be used to store your Cypress tests and results. diff --git a/e2e-cypress/Jenkinsfile.template b/e2e-cypress/Jenkinsfile.template index d592732b3..dd5e5df3f 100644 --- a/e2e-cypress/Jenkinsfile.template +++ b/e2e-cypress/Jenkinsfile.template @@ -56,6 +56,7 @@ def stageTest(def context) { // azureClientId = sh(returnStdout: true, script:"oc get secret azure -o jsonpath='{.data.AZURE_CLIENT_ID}' | base64 -d") // azureClientSecret = sh(returnStdout: true, script:"oc get secret azure -o jsonpath='{.data.AZURE_CLIENT_SECRET}' | base64 -d") // azureTenantId = sh(returnStdout: true, script:"oc get configmaps azure -o jsonpath='{.data.AZURE_TENANT}'") // config map values are not base64 encoded + // authenticatorOTPSecret = sh(returnStdout: true, script:"oc get secret azure -o jsonpath='{.data.OTP_SECRET}' | base64 -d") withEnv(["TAGVERSION=${context.tagversion}", "NEXUS_HOST=${context.nexusHost}", @@ -65,7 +66,8 @@ def stageTest(def context) { // "CYPRESS_CLIENT_ID=${azureClientId}", // "CYPRESS_CLIENT_SECRET=${azureClientSecret}", // "CYPRESS_USERNAME=${cypressUser}", - // "CYPRESS_PASSWORD=${cypressPassword}" + // "CYPRESS_PASSWORD=${cypressPassword}", + // "OTP_SECRET=${authenticatorOTPSecret}", "COMMIT_INFO_SHA=${context.gitCommit}", "BUILD_NUMBER=${context.buildNumber}", ]) { diff --git a/e2e-cypress/files/cypress-acceptance.config.ts b/e2e-cypress/files/cypress-acceptance.config.ts index 034f50bf1..8dbceffb6 100644 --- a/e2e-cypress/files/cypress-acceptance.config.ts +++ b/e2e-cypress/files/cypress-acceptance.config.ts @@ -20,4 +20,7 @@ export default defineConfig({ return (await import('./plugins/index')).default(on, config); }, }, + // env: { + // otp_secret: process.env.OTP_SECRET + // }, }) diff --git a/e2e-cypress/files/cypress-installation.config.ts b/e2e-cypress/files/cypress-installation.config.ts index d7d447e9a..b49600bd9 100644 --- a/e2e-cypress/files/cypress-installation.config.ts +++ b/e2e-cypress/files/cypress-installation.config.ts @@ -20,4 +20,7 @@ export default defineConfig({ return (await import('./plugins/index')).default(on, config); }, }, + // env: { + // otp_secret: process.env.OTP_SECRET + // }, }) diff --git a/e2e-cypress/files/cypress-integration.config.ts b/e2e-cypress/files/cypress-integration.config.ts index cd53bd0e9..2a333466e 100644 --- a/e2e-cypress/files/cypress-integration.config.ts +++ b/e2e-cypress/files/cypress-integration.config.ts @@ -20,4 +20,7 @@ export default defineConfig({ return (await import('./plugins/index')).default(on, config); }, }, + // env: { + // otp_secret: process.env.OTP_SECRET + // }, }) diff --git a/e2e-cypress/files/cypress.config.ts b/e2e-cypress/files/cypress.config.ts index a5dad8e2a..df26ab30e 100644 --- a/e2e-cypress/files/cypress.config.ts +++ b/e2e-cypress/files/cypress.config.ts @@ -19,4 +19,7 @@ export default defineConfig({ return (await import('./plugins/index')).default(on, config); }, }, + // env: { + // otp_secret: process.env.OTP_SECRET + // }, }) diff --git a/e2e-cypress/files/package.json b/e2e-cypress/files/package.json index 5fdeda39b..505063a40 100644 --- a/e2e-cypress/files/package.json +++ b/e2e-cypress/files/package.json @@ -24,6 +24,7 @@ "junit-report-merger": "^7.0.0", "mocha-junit-reporter": "^2.2.1", "npm-run-all": "^4.1.5", + "otplib": "^12.0.1", "rimraf": "^6.0.1", "sharp": "^0.33.5", "typescript": "^5.5.4" diff --git a/e2e-cypress/files/support/commands.ts b/e2e-cypress/files/support/commands.ts index 80ff12fde..159e6ed88 100644 --- a/e2e-cypress/files/support/commands.ts +++ b/e2e-cypress/files/support/commands.ts @@ -8,7 +8,10 @@ // *********************************************** import { addGenericLoginCommands } from './generic-login'; -import { addMsalv2LoginCommand } from './msalv2-login'; +import { addLoginToAADWithMFA, addLoginToAAD, addGetTOTP, addSessionLoginWithMFA } from './login-functions'; addGenericLoginCommands(); -addMsalv2LoginCommand(); +addGetTOTP(); +addSessionLoginWithMFA(); +addLoginToAAD(); +addLoginToAADWithMFA(); diff --git a/e2e-cypress/files/support/e2e.ts b/e2e-cypress/files/support/e2e.ts index f42298464..f19edb094 100644 --- a/e2e-cypress/files/support/e2e.ts +++ b/e2e-cypress/files/support/e2e.ts @@ -1,72 +1,4 @@ -//For more details, you can follow this link: -//https://docs.cypress.io/guides/end-to-end-testing/azure-active-directory-authentication#Microsoft-AAD-Application-Setup -function loginViaAAD(username: string, password: string) { - - //Go to your application URL and trigger the login. - cy.visit('') - - //If needed, navigate and click on the login button. - //As an example: - //cy.get('button#signIn').click() - - //Login to your AAD tenant. - //ATENTION: The redirection can happen at the 'login.microsoftonline.com' and also it might redirect as well to 'login.live.com' - cy.origin( - 'https://login.microsoftonline.com', - { - args: { - username, - password, - }, - }, - ({ username, password }) => { - cy.get('input[type="email"]').type(username, { - log: false, - }) - cy.get('input[type="submit"]').click() - cy.get('input[type="password"]').type(password, { - log: false, - }) - cy.get('input[type="submit"]').click() - } - ) - - //Depending on the user and how they are registered with Microsoft, the origin may go to live.com - //cy.origin( - // 'login.live.com', - // { - // args: { - // password, - // }, - // }, - // ({ password }) => { - // cy.get('input[type="password"]').type(password, { - // log: false, - // }) - // cy.get('input[type="submit"]').click() - // cy.get('#idBtn_Back').click() - // } - //) - - // Ensure Microsoft has redirected us back to the sample app with our logged in user. - cy.url().should('equal', Cypress.config().baseUrl) -} - -//See how to use it at: -//tests/acceptance/acceptance.spec.cy.ts -Cypress.Commands.add('loginToAAD', (username: string, password: string) => { - const log = Cypress.log({ - displayName: 'Azure Active Directory Login', - message: [`🔐 Authenticating | ${username}`], - autoEnd: false, - }) - log.snapshot('before') - - loginViaAAD(username, password) - - log.snapshot('after') - log.end() -}) +import './commands'; export const consoleLogs: string[] = []; diff --git a/e2e-cypress/files/support/login-functions.ts b/e2e-cypress/files/support/login-functions.ts new file mode 100644 index 000000000..caa67ce44 --- /dev/null +++ b/e2e-cypress/files/support/login-functions.ts @@ -0,0 +1,120 @@ +//For more details, you can follow this link: +//https://docs.cypress.io/guides/end-to-end-testing/azure-active-directory-authentication#Microsoft-AAD-Application-Setup + +import { authenticator } from 'otplib'; + +function loginViaAAD(username: string, password: string) { + + //Go to your application URL and trigger the login. + cy.visit('') + + //If needed, navigate and click on the login button. + //As an example: + //cy.get('button#signIn').click() + + //Login to your AAD tenant. + //ATENTION: The redirection can happen at the 'login.microsoftonline.com' and also it might redirect as well to 'login.live.com' + cy.origin( + 'https://login.microsoftonline.com', + { + args: { + username, + password, + }, + }, + ({ username, password }) => { + cy.get('input[type="email"]').type(username, { + log: false, + }) + cy.get('input[type="submit"]').click() + cy.get('input[type="password"]').type(password, { + log: false, + }) + cy.get('input[type="submit"]').click() + } + ) + + //Depending on the user and how they are registered with Microsoft, the origin may go to live.com + //cy.origin( + // 'login.live.com', + // { + // args: { + // password, + // }, + // }, + // ({ password }) => { + // cy.get('input[type="password"]').type(password, { + // log: false, + // }) + // cy.get('input[type="submit"]').click() + // cy.get('#idBtn_Back').click() + // } + //) +} + +//See how to use it at: +//tests/acceptance/acceptance.spec.cy.ts +export function addLoginToAAD() { + Cypress.Commands.add('loginToAAD', (username: string, password: string) => { + cy.session([username], () => { + const log = Cypress.log({ + autoEnd: false, + displayName: 'Azure Active Directory Login', + message: [`🔐 Authenticating | ${username}`], + }); + log.snapshot('before'); + + loginViaAAD(username, password); + + // Ensure Microsoft has redirected us back to the sample app with our logged in user. + cy.url().should('equal', Cypress.config().baseUrl) + + log.snapshot('after'); + log.end(); + }); + }); +} + +export function addSessionLoginWithMFA() { + Cypress.Commands.add('sessionLoginWithMFA', (username: string, password: string) => { + cy.session('login', () => cy.loginToAADWithMFA(username, password), { + validate: () => cy.getAllLocalStorage().should(validateLocalStorage), + cacheAcrossSpecs: true + }) + }) +} + +export function addLoginToAADWithMFA() { + Cypress.Commands.add('loginToAADWithMFA', (username: string, password: string) => { + loginViaAAD(username, password); + cy.getTOTP().then((otp1) => { + const objOTP = { otp: otp1 } + cy.origin('https://login.microsoftonline.com/', { args: objOTP }, ({ otp }) => { + cy.get("[name='otc']").type(otp) + cy.get('input[type="submit"]').click() + }) + }) + }) +} + +export function addGetTOTP() { + Cypress.Commands.add('getTOTP', () => { + return authenticator.generate(Cypress.env('otp_secret')) + }) +} + +const validateLocalStorage = (localStorage: Record) => + Cypress._.some(localStorage, (value: unknown, key: string) => + key.includes('CognitoIdentityServiceProvider'), + ) + +declare global { + namespace Cypress { + interface Chainable<> { + loginToAAD(username: string, password: string); + loginToAADWithMFA(username: string, password: string); + sessionLoginWithMFA(username: string, password: string); + getTOTP(); + } + } +} diff --git a/e2e-cypress/files/support/msalv2-login_obsolete.ts b/e2e-cypress/files/support/msalv2-login_obsolete.ts deleted file mode 100644 index 057113aef..000000000 --- a/e2e-cypress/files/support/msalv2-login_obsolete.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Azure SSO with MSALv2 -// *********************************************** -// Originally developed by @tbabik and @herrkoch -// -// ATTENTION: This approach requires a technical, so-called "cloud-only" Azure user (@.onmicrosoft.com) which does not -// go through federation. (For Boehringer Ingelheim coders: Please refer to the "BI Coders Notebook", section "Cypress and Azure SSO" in -// the "BI Coders" Teams channel for a description on how to order such a technical user in BI) - -// Azure environment variables -// The variables below can be set by providing environment variables upfront. Environment variables for Cypress always need to have -// "CYPRESS_..." prepended, e.g. CYPRESS_TENANT_ID, CYPRESS_CLIENT_ID, etc. -// -// ATTENTION: Please also check the "./Jenkinsfile" on how these environment variables are loaded on Jenkins from OpenShift. -// -// For local development it is also possible to inject environment variables via a "./cypress.env.json" file. You can use -// "./cypress.env.json.template" as a template for this. The leading "CYPRESS_..." is not needed here. - -const aadTenantId = Cypress.env('TENANT_ID'); -const aadClientId = Cypress.env('CLIENT_ID'); -const aadClientSecret = Cypress.env('CLIENT_SECRET'); -const aadUsername = Cypress.env('USERNAME'); -const aadPassword = Cypress.env('PASSWORD'); - -const tenant = `https://login.microsoftonline.com/${aadTenantId}`; -const tenantUrl = `${tenant}/oauth2/v2.0/token`; - -const scopes = [ - 'openid', - 'profile', - 'user.read', - 'email', - 'offline_access', // needed to get a refresh token -]; - -// Adapted snippet taken from https://stackoverflow.com/a/63490929/1236781 -const msalv2Login = () => { - // AUTHENTICATE AGAINST OAUTH2 ENDPOINT for MSALv2 - cy.request({ - method: 'POST', - url: tenantUrl, - form: true, - body: { - client_info: 1, // returns an extra token that MSAL needs - // grant_type: 'client_credentials', - // scope: 'https://graph.microsoft.com/.default', // for grant_type: 'client_credentials' - grant_type: 'password', - scope: scopes.join(' '), // only works for grant_type: 'password'! - client_id: aadClientId, - client_secret: aadClientSecret, - username: aadUsername, // cloud-only user! see comment at the top - password: aadPassword, - }, - }).then(response => { - const tokens = response.body; - // FILL SESSION STORAGE AS MSALv2 EXPECTS IT - // The token tells us how many seconds until expiration; - // MSAL wants to know the timestamp of expiration. - const cachedAt = Math.round(new Date().getTime() / 1000); - const expiresOn = cachedAt + tokens.expires_in; - const extendedExpiresOn = cachedAt + tokens.ext_expires_in; - - // We can pull the rest of the data we need off of the ID token body - const id_token = tokens.id_token ? JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64').toString('utf-8')) : null; - - const clientId = id_token?.aud; - const tenantId = id_token?.tid; - const userId = id_token?.oid; - const name = id_token?.name; - const username = id_token?.preferred_username; - - const environment = 'login.microsoftonline.com'; - const homeAccountId = `${userId}.${tenantId}`; - - const cacheEntries: { [key: string]: any } = {}; - - // client info - cacheEntries[`${homeAccountId}-${environment}-${tenantId}`] = { - authorityType: 'MSSTS', - clientInfo: tokens.client_info, - environment, - homeAccountId, - localAccountId: userId, - name, - realm: tenantId, - username, - }; - - // access token - cacheEntries[`${homeAccountId}-${environment}-accesstoken-${clientId}-${tenantId}-${tokens.scope}`] = { - cachedAt: cachedAt.toString(), - clientId, - credentialType: 'AccessToken', - environment, - expiresOn: expiresOn.toString(), - extendedExpiresOn: extendedExpiresOn.toString(), - homeAccountId, - realm: tenantId, - secret: tokens.access_token, - target: tokens.scope, - }; - - // id token - cacheEntries[`${homeAccountId}-${environment}-idtoken-${clientId}-${tenantId}-`] = { - clientId, - credentialType: 'IdToken', - environment, - homeAccountId, - realm: tenantId, - secret: tokens.id_token, - }; - - // refresh token - cacheEntries[`${homeAccountId}-${environment}-refreshtoken-${clientId}--`] = { - clientId, - credentialType: 'RefreshToken', - environment, - homeAccountId, - secret: tokens.refresh_token, - }; - - // STORE IN SESSION STORAGE - cy.window().then(window => - Object.entries(cacheEntries).forEach(([key, value]) => window.sessionStorage.setItem(key, JSON.stringify(value))) - ); - }); -}; - -export function addMsalv2LoginCommand() { - Cypress.Commands.add('msalv2Login', msalv2Login); -} - -declare global { - namespace Cypress { - interface Chainable { - msalv2Login: typeof msalv2Login; - } - } -} diff --git a/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts b/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts index 81803b840..a179477ef 100644 --- a/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts +++ b/e2e-cypress/files/tests/acceptance/acceptance.spec.cy.ts @@ -2,16 +2,17 @@ import { printTestDOMEvidence, printTestPlainEvidence, takeScreenshotEvidence } /* tslint:disable:no-unused-expression */ // describe('ADD login example test', () => { - // beforeEach(() => { // // log into Azure Active Directory through our sample SPA using our custom command // cy.loginToAAD(Cypress.env('aad_username'), Cypress.env('aad_password')) +// // or you can use the following command to log in with MFA. Make sure to define the OTP_SECRET environment variable +// // with your MFA secret before running the test. Please follow the documentation on how to set this up +// cy.sessionLoginWithMFA(Cypress.env('aad_username'), Cypress.env('aad_password')) // }) // it('Verifies the user can be logged in', () => { // cy.contains('title') // }) - // }); describe('W3 application test', () => {