diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a089c4b..3de32e972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - inf-terraform-[aws|azure]: bump terraform versions, pre-commit-hooks, library versions ([#1036](https://github.com/opendevstack/ods-quickstarters/pull/1036)) - jenkins-agent-terraform-2408: add jenkins agent terraform-2408, add go-task, go for experimental terratest, add python-3.12 (drop python-3.8), use tenv for terraform (tofu) version management, ruby version 3.3.4, terraform 1.9.4 ([#1036](https://github.com/opendevstack/ods-quickstarters/pull/1036)) - Replaced centos8 repository for RockyLinux 8 due to deprecation in terraform agents ([#1067](https://github.com/opendevstack/ods-quickstarters/pull/1036)) +- 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..715feb6b5 100644 --- a/e2e-cypress/Jenkinsfile.template +++ b/e2e-cypress/Jenkinsfile.template @@ -29,6 +29,17 @@ odsComponentPipeline( // 'release/': 'test' ] ) { context -> + + // Note: Testing in the production environment is not recommended as it can lead to unintended consequences, + // including potential downtime, data corruption, or exposure of sensitive information. + // This block is designed to skip tests in the production environment to avoid these risks. + // If you choose to enable testing in production, do so at your own risk and take all necessary precautions. + if (context.environment == 'prod') { + currentBuild.result = 'SUCCESS' + echo 'Skipping the entire test build for production environment' + return + } + def targetDirectory = "${context.projectId}/${context.componentId}/${context.gitBranch.replaceAll('/', '-')}/${context.buildNumber}" stageTest(context) @@ -49,25 +60,31 @@ odsComponentPipeline( def stageTest(def context) { stage('Integration Test') { - // OPTIONAL: load environment variables for Azure SSO with MSALv2; please adapt variable names to your OpenShift config + // OPTIONAL: load environment variables for Azure SSO; please adapt variable names to your OpenShift config // sh "oc project " // cypressUser = sh(returnStdout: true, script:"oc get secret e2euser -o jsonpath='{.data.USERNAME}' | base64 -d") // cypressPassword = sh(returnStdout: true, script:"oc get secret e2euser -o jsonpath='{.data.PASSWORD}' | base64 -d") - // 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 + + // Define your DEV and QA base URLs in a config map in OpenShift; please adapt variable names to your OpenShift config + // sh "oc project " + def baseUrls = [ + : // remove this line once you have defined the config map and uncommented the next two lines, it's only here to make the example default case work + // dev: sh(returnStdout: true, script:"oc get configmaps cypress-config -o jsonpath='{.data.DEV_BASE_URL}'").trim(), + // test: sh(returnStdout: true, script:"oc get configmaps cypress-config -o jsonpath='{.data.TEST_BASE_URL}'").trim() + ] + + def baseUrl = baseUrls.get(context.environment ?: 'dev', 'https://www.w3schools.com') // default to W3Schools for demo purposes, replace with your own default withEnv(["TAGVERSION=${context.tagversion}", "NEXUS_HOST=${context.nexusHost}", "OPENSHIFT_PROJECT=${context.targetProject}", "OPENSHIFT_APP_DOMAIN=${context.getOpenshiftApplicationDomain()}", - // "CYPRESS_TENANT_ID=${azureTenantId}", - // "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}", + "CYPRESS_BASE_URL=${baseUrl}", ]) { sh 'npm install' def status = sh(script: 'npm run e2e', returnStatus: true) diff --git a/e2e-cypress/files/cypress-acceptance.config.ts b/e2e-cypress/files/cypress-acceptance.config.ts index 034f50bf1..177e87083 100644 --- a/e2e-cypress/files/cypress-acceptance.config.ts +++ b/e2e-cypress/files/cypress-acceptance.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ toConsole: true, }, e2e: { - baseUrl: 'https://www.w3schools.com', + baseUrl: process.env.CYPRESS_BASE_URL, fixturesFolder: "fixtures", specPattern: 'tests/acceptance/*.cy.ts', supportFile: "support/e2e.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..ac679a67d 100644 --- a/e2e-cypress/files/cypress-installation.config.ts +++ b/e2e-cypress/files/cypress-installation.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ toConsole: true, }, e2e: { - baseUrl: 'https://www.w3schools.com', + baseUrl: process.env.CYPRESS_BASE_URL, fixturesFolder: "fixtures", specPattern: 'tests/installation/*.cy.ts', supportFile: "support/e2e.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..24231ba94 100644 --- a/e2e-cypress/files/cypress-integration.config.ts +++ b/e2e-cypress/files/cypress-integration.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ toConsole: true, }, e2e: { - baseUrl: 'https://www.w3schools.com', + baseUrl: process.env.CYPRESS_BASE_URL, fixturesFolder: "fixtures", specPattern: 'tests/integration/*.cy.ts', supportFile: "support/e2e.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..479b3638c 100644 --- a/e2e-cypress/files/cypress.config.ts +++ b/e2e-cypress/files/cypress.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ toConsole: true, }, e2e: { - baseUrl: 'https://www.w3schools.com', + baseUrl: process.env.CYPRESS_BASE_URL || 'https://www.w3schools.com', fixturesFolder: "fixtures", specPattern: 'tests/**/*.cy.ts', supportFile: "support/e2e.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', () => {