diff --git a/e2e-tests/admin/pages/auth-methods.js b/e2e-tests/admin/pages/auth-methods.js index 280228c839..b1d40c71df 100644 --- a/e2e-tests/admin/pages/auth-methods.js +++ b/e2e-tests/admin/pages/auth-methods.js @@ -33,6 +33,97 @@ export class AuthMethodsPage extends BaseResourcePage { return authMethodName; } + /** + * Creates a new OIDC Auth Method. Assumes you have selected the desired scope. + * @param {string} issuer OIDC issuer + * @param {string} clientId OIDC client ID + * @param {string} clientSecret OIDC client secret + * @returns Name of the auth method + */ + async createOidcAuthMethod(issuer, clientId, clientSecret, boundaryAddr) { + const authMethodName = 'Auth Method ' + nanoid(); + await this.page + .getByRole('navigation', { name: 'IAM' }) + .getByRole('link', { name: 'Auth Methods' }) + .click(); + await this.page.getByRole('button', { name: 'New' }).click(); + await this.page.getByRole('link', { name: 'OIDC' }).click(); + await this.page.getByLabel('Name').fill(authMethodName); + await this.page.getByLabel('Description').fill('OIDC Auth Method'); + await this.page.getByLabel('Issuer').fill(issuer); + await this.page.getByLabel('Client ID').fill(clientId); + await this.page.getByLabel('Client Secret').fill(clientSecret); + await this.page + .getByRole('group', { name: 'Signing Algorithms' }) + .getByRole('combobox') + .selectOption('RS256'); + await this.page + .getByRole('group', { name: 'Signing Algorithms' }) + .getByRole('button', { name: 'Add' }) + .click(); + + await this.page + .getByRole('group', { name: 'Claims Scopes' }) + .getByRole('textbox') + .last() + .fill('groups'); + await this.page + .getByRole('group', { name: 'Claims Scopes' }) + .getByRole('button', { name: 'Add' }) + .click(); + await this.page + .getByRole('group', { name: 'Claims Scopes' }) + .getByRole('textbox') + .last() + .fill('user'); + await this.page + .getByRole('group', { name: 'Claims Scopes' }) + .getByRole('button', { name: 'Add' }) + .click(); + + await this.page + .getByRole('group', { name: 'Account Claim Maps' }) + .getByLabel('From Claim') + .last() + .fill('username'); + await this.page + .getByRole('group', { name: 'Account Claim Maps' }) + .getByLabel('To Claim') + .last() + .selectOption('name'); + await this.page + .getByRole('group', { name: 'Account Claim Maps' }) + .getByRole('button', { name: 'Add' }) + .click(); + await this.page + .getByRole('group', { name: 'Account Claim Maps' }) + .getByLabel('From Claim') + .last() + .fill('email'); + await this.page + .getByRole('group', { name: 'Account Claim Maps' }) + .getByLabel('To Claim') + .last() + .selectOption('email'); + await this.page + .getByRole('group', { name: 'Account Claim Maps' }) + .getByRole('button', { name: 'Add' }) + .click(); + + await this.page.getByLabel('Maximum Age').fill('20'); + await this.page.getByLabel('API URL Prefix').fill(boundaryAddr); + + await this.page.getByRole('button', { name: 'Save' }).click(); + await this.dismissSuccessAlert(); + await expect( + this.page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText(authMethodName), + ).toBeVisible(); + + return authMethodName; + } + /** * Makes the first available auth method primary. * Assumes you have created new auth method. diff --git a/e2e-tests/admin/tests/auth-method-ldap.spec.js b/e2e-tests/admin/tests/auth-method-ldap.spec.js index ff2e5ce6d2..0845713592 100644 --- a/e2e-tests/admin/tests/auth-method-ldap.spec.js +++ b/e2e-tests/admin/tests/auth-method-ldap.spec.js @@ -106,8 +106,8 @@ test('Set up LDAP auth method @ce @ent @docker', async ({ ).toBeVisible(); // Change state to active-public - page.getByTitle('Inactive').click(); - page.getByText('Public').click(); + await page.getByTitle('Inactive').click(); + await page.getByText('Public').click(); await expect( page.getByRole('alert').getByText('Success', { exact: true }), ).toBeVisible(); @@ -190,8 +190,13 @@ test('Set up LDAP auth method @ce @ent @docker', async ({ .click(); await page.getByRole('link', { name: ldapAuthMethodName }).click(); await page.getByRole('link', { name: 'Accounts' }).click(); + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText('Accounts'), + ).toBeVisible(); - const headersCount = await page + let headersCount = await page .getByRole('table') .getByRole('columnheader') .count(); @@ -211,21 +216,61 @@ test('Set up LDAP auth method @ce @ent @docker', async ({ } expect( - await page + page .getByRole('cell', { name: ldapAccountName }) .locator('..') .getByRole('cell') - .nth(fullNameIndex) - .innerText(), - ).toBe(ldapUserName); + .nth(fullNameIndex), + ).toHaveText(ldapUserName); expect( - await page + page .getByRole('cell', { name: ldapAccountName }) .locator('..') .getByRole('cell') - .nth(emailIndex) - .innerText(), - ).toBe(ldapUserName + '@mail.com'); + .nth(emailIndex), + ).toHaveText(ldapUserName + '@mail.com'); + + // View the Managed Group + await page.getByRole('link', { name: 'Managed Groups' }).click(); + await page.getByRole('link', { name: ldapManagedGroupName }).click(); + await page.getByRole('link', { name: 'Members' }).click(); + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText('Members'), + ).toBeVisible(); + + headersCount = await page + .getByRole('table') + .getByRole('columnheader') + .count(); + for (let i = 0; i < headersCount; i++) { + const header = await page + .getByRole('table') + .getByRole('columnheader') + .nth(i) + .innerText(); + if (header == 'Full Name') { + fullNameIndex = i; + } else if (header == 'Email') { + emailIndex = i; + } + } + + expect( + page + .getByRole('cell', { name: ldapAccountName }) + .locator('..') + .getByRole('cell') + .nth(fullNameIndex), + ).toHaveText(ldapUserName); + expect( + page + .getByRole('cell', { name: ldapAccountName }) + .locator('..') + .getByRole('cell') + .nth(emailIndex), + ).toHaveText(ldapUserName + '@mail.com'); // View the User account and verify attributes await page diff --git a/e2e-tests/admin/tests/auth-method-oidc-vault.spec.js b/e2e-tests/admin/tests/auth-method-oidc-vault.spec.js new file mode 100644 index 0000000000..fc7907c1d3 --- /dev/null +++ b/e2e-tests/admin/tests/auth-method-oidc-vault.spec.js @@ -0,0 +1,264 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { test } from '../../global-setup.js'; +import { expect } from '@playwright/test'; +import { execSync } from 'child_process'; + +import * as boundaryCli from '../../helpers/boundary-cli'; +import * as vaultCli from '../../helpers/vault-cli'; +import { AuthMethodsPage } from '../pages/auth-methods.js'; +import { LoginPage } from '../pages/login.js'; +import { OrgsPage } from '../pages/orgs.js'; +import { RolesPage } from '../pages/roles.js'; + +// Reset storage state for this file to avoid being authenticated +test.use({ storageState: { cookies: [], origins: [] } }); + +test.beforeAll(async () => { + await boundaryCli.checkBoundaryCli(); + await vaultCli.checkVaultCli(); +}); + +test.beforeEach(async () => { + execSync(`vault auth disable userpass`); +}); + +test('Set up OIDC auth method @ce @ent @docker @aws', async ({ + page, + context, + baseURL, + adminAuthMethodId, + adminLoginName, + adminPassword, + vaultAddr, +}) => { + await page.goto('/'); + let orgName; + let policyName; + try { + const userName = 'end-user'; + const password = 'password123'; + const email = 'vault@hashicorp.com'; + const { issuer, clientId, clientSecret, authPolicyName } = + await vaultCli.setupVaultOidc( + vaultAddr, + userName, + password, + email, + baseURL, + ); + policyName = authPolicyName; + + // Log in + const loginPage = new LoginPage(page); + await loginPage.login(adminLoginName, adminPassword); + await expect( + page.getByRole('navigation', { name: 'breadcrumbs' }).getByText('Orgs'), + ).toBeVisible(); + + // Create OIDC Auth Method + const orgsPage = new OrgsPage(page); + orgName = await orgsPage.createOrg(); + const authMethodsPage = new AuthMethodsPage(page); + const oidcAuthMethodName = await authMethodsPage.createOidcAuthMethod( + issuer, + clientId, + clientSecret, + baseURL, + ); + + // Change OIDC Auth Method state to active-public + await page.getByTitle('Inactive').click(); + await page.getByText('Public').click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + + // Set auth method as primary + await page.getByText('Manage', { exact: true }).click(); + await page.getByRole('button', { name: 'Make Primary' }).click(); + await page.getByRole('button', { name: 'OK' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + + // Create an OIDC managed group + await page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText(oidcAuthMethodName) + .click(); + await page.getByText('Manage', { exact: true }).click(); + await page.getByRole('link', { name: 'Create Managed Group' }).click(); + const oidcManagedGroupName = 'OIDC Managed Group'; + await page.getByLabel('Name (Optional)').fill(oidcManagedGroupName); + await page.getByLabel('Description').fill('This is an automated test'); + await page.getByLabel('Filter').fill(`"engineering" in "/userinfo/groups"`); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + + // Create a role and add LDAP managed group to role + const rolesPage = new RolesPage(page); + await rolesPage.createRole(); + await rolesPage.addPrincipalToRole(oidcManagedGroupName); + + // Log in using oidc account + await loginPage.logout(adminLoginName); + await page.getByText('Choose a different scope').click(); + await page.getByRole('link', { name: orgName }).click(); + await page.getByRole('link', { name: oidcAuthMethodName }).click(); + const pagePromise = context.waitForEvent('page'); + await page.getByRole('button', { name: 'Sign In' }).click(); + const vaultPage = await pagePromise; + await vaultPage.getByLabel('Method').selectOption('Username'); + await vaultPage.getByLabel('Username').fill(userName); + await vaultPage.getByLabel('Password').fill(password); + await vaultPage.getByRole('button', { name: 'Sign In' }).click(); + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText('Projects'), + ).toBeVisible(); + + // Log back in as an admin + // WORKAROUND: Currently, users logging using OIDC don't have a username + // displayed in the UI, so there's no simple locator to access this menu. + await page.locator('details').filter({ hasText: 'Sign Out' }).click(); + await page.getByRole('button', { name: 'Sign Out' }).click(); + await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible(); + await loginPage.login(adminLoginName, adminPassword); + await expect( + page.getByRole('navigation', { name: 'breadcrumbs' }).getByText('Orgs'), + ).toBeVisible(); + + // View the OIDC account and verify account attributes + await page.getByRole('link', { name: orgName }).click(); + await page + .getByRole('navigation', { name: 'IAM' }) + .getByRole('link', { name: 'Auth Methods' }) + .click(); + await page.getByRole('link', { name: oidcAuthMethodName }).click(); + await page.getByRole('link', { name: 'Accounts' }).click(); + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText('Accounts'), + ).toBeVisible(); + + let headersCount = await page + .getByRole('table') + .getByRole('columnheader') + .count(); + let fullNameIndex; + let emailIndex; + for (let i = 0; i < headersCount; i++) { + const header = await page + .getByRole('table') + .getByRole('columnheader') + .nth(i) + .innerText(); + if (header == 'Full Name') { + fullNameIndex = i; + } else if (header == 'Email') { + emailIndex = i; + } + } + + expect( + page + .getByRole('cell', { name: userName }) + .locator('..') + .getByRole('cell') + .nth(fullNameIndex), + ).toHaveText(userName); + expect( + page + .getByRole('cell', { name: userName }) + .locator('..') + .getByRole('cell') + .nth(emailIndex), + ).toHaveText(email); + + // View the OIDC Managed Group and verify member in managed group + await page.getByRole('link', { name: 'Managed Groups' }).click(); + await page.getByRole('link', { name: oidcManagedGroupName }).click(); + await page.getByRole('link', { name: 'Members' }).click(); + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText('Members'), + ).toBeVisible(); + + headersCount = await page + .getByRole('table') + .getByRole('columnheader') + .count(); + for (let i = 0; i < headersCount; i++) { + const header = await page + .getByRole('table') + .getByRole('columnheader') + .nth(i) + .innerText(); + if (header == 'Full Name') { + fullNameIndex = i; + } else if (header == 'Email') { + emailIndex = i; + } + } + + expect( + page + .getByRole('cell', { name: userName }) + .locator('..') + .getByRole('cell') + .nth(fullNameIndex), + ).toHaveText(userName); + expect( + page + .getByRole('cell', { name: userName }) + .locator('..') + .getByRole('cell') + .nth(emailIndex), + ).toHaveText(email); + + // View the User account and verify attributes + await page + .getByRole('navigation', { name: 'IAM' }) + .getByRole('link', { name: 'Users' }) + .click(); + await page.getByRole('cell', { hasText: email }).getByRole('link').click(); + await page.getByRole('link', { name: 'Accounts' }).click(); + expect( + await page + .getByRole('table') + .getByRole('row') + .nth(1) // Account row + .getByRole('cell') + .nth(0) // Name field + .innerText(), + ).toContain(email); + } finally { + execSync(`vault auth disable userpass`); + execSync(`vault policy delete ${policyName}`); + + if (orgName) { + await boundaryCli.authenticateBoundary( + baseURL, + adminAuthMethodId, + adminLoginName, + adminPassword, + ); + const orgId = await boundaryCli.getOrgIdFromName(orgName); + if (orgId) { + await boundaryCli.deleteScope(orgId); + } + } + } +}); diff --git a/e2e-tests/admin/tests/fixtures/auth-policy.hcl b/e2e-tests/admin/tests/fixtures/auth-policy.hcl new file mode 100644 index 0000000000..75fa6743e0 --- /dev/null +++ b/e2e-tests/admin/tests/fixtures/auth-policy.hcl @@ -0,0 +1,6 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +path "identity/oidc/provider/my-provider/authorize" { + capabilities = [ "read" ] +} diff --git a/e2e-tests/helpers/vault-cli.js b/e2e-tests/helpers/vault-cli.js index bc510d965f..7c6fbd5635 100644 --- a/e2e-tests/helpers/vault-cli.js +++ b/e2e-tests/helpers/vault-cli.js @@ -50,3 +50,116 @@ export async function getVaultToken(boundaryPolicyName, secretPolicyName) { return clientToken; } + +/** + * Sets up vault oidc provider + * @param {string} vaultAddr address of vault server + * @param {string} userName name of user to create in OIDC server + * @param {string} password password of user + * @param {string} email email of user + * @param {string} boundaryAddr address of boundary server for the redirect uri + * @returns {Promise} OIDC info + */ +export async function setupVaultOidc( + vaultAddr, + userName, + password, + email, + boundaryAddr, +) { + const authPolicyName = 'auth-policy'; + + execSync(`vault auth enable userpass`); + execSync( + `vault policy write ${authPolicyName} ./admin/tests/fixtures/auth-policy.hcl`, + ); + execSync( + `vault write auth/userpass/users/${userName} password=${password} token_policies=${authPolicyName} token_ttl=1h`, + ); + execSync( + `vault write identity/entity name=${userName} metadata=email=${email} metadata=phone_number=123-456-7890 disabled=false`, + ); + const entityId = execSync( + `vault read -field=id identity/entity/name/${userName}`, + ) + .toString() + .trim(); + const groupName = 'engineering'; + execSync( + `vault write identity/group name=${groupName} member_entity_ids=${entityId}`, + ); + const groupId = execSync( + `vault read -field=id identity/group/name/${groupName}`, + ) + .toString() + .trim(); + const authList = JSON.parse( + execSync(`vault auth list -detailed -format json`), + ); + const userpassAccessor = authList['userpass/'].accessor; + execSync( + `vault write identity/entity-alias name=${userName} canonical_id=${entityId} mount_accessor=${userpassAccessor}`, + ); + const assignmentName = 'my-assignment'; + execSync( + `vault write identity/oidc/assignment/${assignmentName} entity_ids=${entityId} group_ids=${groupId}`, + ); + const keyName = 'my-key'; + execSync( + `vault write identity/oidc/key/${keyName} allowed_client_ids=* verification_ttl=2h rotation_period=1h algorithm=RS256`, + ); + const oidcClientName = 'boundary'; + execSync( + `vault write identity/oidc/client/${oidcClientName}` + + ` redirect_uris=${boundaryAddr}/v1/auth-methods/oidc:authenticate:callback` + + ` assignments=${assignmentName}` + + ` key=${keyName}` + + ` id_token_ttl=30m` + + ` access_token_ttl=1h`, + ); + const clientId = execSync( + `vault read -field=client_id identity/oidc/client/${oidcClientName}`, + ) + .toString() + .trim(); + const userScopeTemplate = ` + { + "username": {{identity.entity.name}}, + "email": {{identity.entity.metadata.email}}, + "phone_number": {{identity.entity.metadata.phone_number}} + }`; + const userScopeEncoded = Buffer.from(userScopeTemplate).toString('base64'); + execSync(`vault write identity/oidc/scope/user template=${userScopeEncoded}`); + const groupScopeTemplate = ` + { + "groups": {{identity.entity.groups.names}} + }`; + const groupScopeEncoded = Buffer.from(groupScopeTemplate).toString('base64'); + execSync( + `vault write identity/oidc/scope/groups template=${groupScopeEncoded}`, + ); + + const providerName = 'my-provider'; + execSync( + `vault write identity/oidc/provider/${providerName} allowed_client_ids=${clientId} scopes_supported=groups,user issuer=${vaultAddr} &> /dev/null`, + ); + + const oidcConfig = JSON.parse( + execSync( + `curl -s ${vaultAddr}/v1/identity/oidc/provider/${providerName}/.well-known/openid-configuration`, + ), + ); + const issuer = oidcConfig.issuer; + const clientSecret = execSync( + `vault read -field=client_secret identity/oidc/client/${oidcClientName}`, + ) + .toString() + .trim(); + + return { + issuer: issuer, + clientId: clientId, + clientSecret: clientSecret, + authPolicyName: authPolicyName, + }; +}