diff --git a/changelog/_22640.txt b/changelog/_22640.txt new file mode 100644 index 0000000000..cbc85c7511 --- /dev/null +++ b/changelog/_22640.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: Add support for SAML login flow +``` diff --git a/ui/app/adapters/auth-method.js b/ui/app/adapters/auth-method.js index c23a1ad469..01d7465174 100644 --- a/ui/app/adapters/auth-method.js +++ b/ui/app/adapters/auth-method.js @@ -68,6 +68,12 @@ export default ApplicationAdapter.extend({ return this.ajax(`/v1/auth/${encodePath(path)}/oidc/callback`, 'GET', { data: { state, code } }); }, + pollSAMLToken(path, token_poll_id, client_verifier) { + return this.ajax(`/v1/auth/${encodePath(path)}/token`, 'PUT', { + data: { token_poll_id, client_verifier }, + }); + }, + tune(path, data) { const url = `${this.buildURL()}/${this.pathForType()}/${encodePath(path)}tune`; return this.ajax(url, 'POST', { data }); diff --git a/ui/app/adapters/role-saml.js b/ui/app/adapters/role-saml.js new file mode 100644 index 0000000000..052f2024f6 --- /dev/null +++ b/ui/app/adapters/role-saml.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import ApplicationAdapter from './application'; +import { inject as service } from '@ember/service'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; +import { v4 as uuidv4 } from 'uuid'; + +export default ApplicationAdapter.extend({ + router: service(), + + // generateClientChallenge generates a client challenge from a verifier. + // The client challenge is the base64(sha256(verifier)). The verifier is + // later presented to the server to obtain the resulting Vault token. + async generateClientChallenge(verifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = new Uint8Array(hashBuffer); + return btoa(String.fromCharCode.apply(null, hashArray)); + }, + + async findRecord(store, type, id, snapshot) { + let [path, role] = JSON.parse(id); + path = preparePathSegment(path); + + // Create the ACS URL based on the cluster the UI is targeting + let acs_url = `${window.location.origin}/v1/`; + let namespace = snapshot?.adapterOptions.namespace; + if (namespace) { + namespace = preparePathSegment(namespace); + acs_url = acs_url.concat(namespace, '/'); + } + acs_url = acs_url.concat('auth/', path, '/callback'); + + // Create the client verifier and challenge + const verifier = uuidv4(); + const challenge = await this.generateClientChallenge(verifier); + // Kick off the authentication flow by generating the SSO service URL + // It requires the client challenge generated from the verifier. We'll + // later provide the verifier to match up with the challenge on the server + // when we poll for the Vault token by its returned token poll ID. + const response = await this.ajax(`/v1/auth/${path}/sso_service_url`, 'PUT', { + data: { + acs_url, + role, + client_challenge: challenge, + client_type: 'browser', + }, + }); + return { + ...response.data, + client_verifier: verifier, + }; + }, +}); + +// preparePathSegment prepares the given segment for being included in a URL +// path by trimming leading and trailing forward slashes and URL encoding. +function preparePathSegment(segment) { + segment = segment.replace(/^\//, ''); + segment = segment.replace(/\/$/, ''); + return encodePath(segment); +} diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index e0ca05e7c6..4f44fdc6d3 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -10,13 +10,11 @@ import { match, alias, or } from '@ember/object/computed'; import { dasherize } from '@ember/string'; import Component from '@ember/component'; import { computed } from '@ember/object'; -import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; +import { allSupportedAuthBackends, supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; import { task, timeout } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; import { v4 as uuidv4 } from 'uuid'; -const BACKENDS = supportedAuthBackends(); - /** * @module AuthForm * The `AuthForm` is used to sign users into Vault. @@ -49,6 +47,7 @@ export default Component.extend(DEFAULTS, { flashMessages: service(), store: service(), csp: service('csp-event'), + version: service(), // passed in via a query param selectedAuth: null, @@ -58,11 +57,14 @@ export default Component.extend(DEFAULTS, { wrappedToken: null, // internal oldNamespace: null, - authMethods: BACKENDS, // number answer for okta number challenge if applicable oktaNumberChallengeAnswer: null, + authMethods: computed('version.isEnterprise', function () { + return this.version.isEnterprise ? allSupportedAuthBackends() : supportedAuthBackends(); + }), + didReceiveAttrs() { this._super(...arguments); const { @@ -139,7 +141,7 @@ export default Component.extend(DEFAULTS, { if (keyIsPath && !type) { return methods.findBy('path', selected); } - return BACKENDS.findBy('type', selected); + return this.authMethods.findBy('type', selected); }, selectedAuthIsPath: match('selectedAuth', /\/$/), @@ -168,21 +170,21 @@ export default Component.extend(DEFAULTS, { cspErrorText: `This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`, - allSupportedMethods: computed('methodsToShow', 'hasMethodsWithPath', function () { + allSupportedMethods: computed('methodsToShow', 'hasMethodsWithPath', 'authMethods', function () { const hasMethodsWithPath = this.hasMethodsWithPath; const methodsToShow = this.methodsToShow; - return hasMethodsWithPath ? methodsToShow.concat(BACKENDS) : methodsToShow; + return hasMethodsWithPath ? methodsToShow.concat(this.authMethods) : methodsToShow; }), hasMethodsWithPath: computed('methodsToShow', function () { return this.methodsToShow.isAny('path'); }), - methodsToShow: computed('methods', function () { + methodsToShow: computed('methods', 'authMethods', function () { const methods = this.methods || []; const shownMethods = methods.filter((m) => - BACKENDS.find((b) => b.type.toLowerCase() === m.type.toLowerCase()) + this.authMethods.find((b) => b.type.toLowerCase() === m.type.toLowerCase()) ); - return shownMethods.length ? shownMethods : BACKENDS; + return shownMethods.length ? shownMethods : this.authMethods; }), unwrapToken: task( @@ -299,9 +301,9 @@ export default Component.extend(DEFAULTS, { this.set('token', token); } this.set('error', null); - // if callback from oidc or jwt we have a token at this point + // if callback from oidc, jwt, or saml we have a token at this point const backend = token ? this.getAuthBackend('token') : this.selectedAuthBackend || {}; - const backendMeta = BACKENDS.find( + const backendMeta = this.authMethods.find( (b) => (b.type || '').toLowerCase() === (backend.type || '').toLowerCase() ); const attributes = (backendMeta || {}).formAttributes || []; diff --git a/ui/app/components/auth-saml.js b/ui/app/components/auth-saml.js new file mode 100644 index 0000000000..9b5814e800 --- /dev/null +++ b/ui/app/components/auth-saml.js @@ -0,0 +1,162 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { inject as service } from '@ember/service'; +import Component from './outer-html'; +import { task, timeout, waitForEvent } from 'ember-concurrency'; +import { computed } from '@ember/object'; +import errorMessage from 'vault/utils/error-message'; + +const WAIT_TIME = 500; +const ERROR_WINDOW_CLOSED = + 'The provider window was closed before authentication was complete. Your web browser may have blocked or closed a pop-up window. Please check your settings and click Sign In to try again.'; +const ERROR_TIMEOUT = 'The authentication request has timed out. Please click Sign In to try again.'; +const ERROR_MISSING_PARAMS = + 'The callback from the provider did not supply all of the required parameters. Please click Sign In to try again. If the problem persists, you may want to contact your administrator.'; +export { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS }; + +export default Component.extend({ + store: service(), + featureFlagService: service('featureFlag'), + + selectedAuthPath: null, + selectedAuthType: null, + roleName: null, + errorMessage: null, + onRoleName() {}, + onLoading() {}, + onError() {}, + onNamespace() {}, + + didReceiveAttrs() { + this._super(); + this.set('errorMessage', null); + }, + + getWindow() { + return this.window || window; + }, + + canLoginSaml: computed('getWindow', function () { + return this.getWindow().isSecureContext; + }), + + async fetchRole(roleName) { + const path = this.selectedAuthPath || this.selectedAuthType; + const id = JSON.stringify([path, roleName]); + return this.store.findRecord('role-saml', id, { + adapterOptions: { namespace: this.namespace }, + }); + }, + + cancelLogin(samlWindow, errorMessage) { + this.closeWindow(samlWindow); + this.handleSAMLError(errorMessage); + this.exchangeSAMLTokenPollID.cancelAll(); + }, + + closeWindow(samlWindow) { + this.watchPopup.cancelAll(); + this.watchCurrent.cancelAll(); + samlWindow.close(); + }, + + handleSAMLError(err) { + this.onLoading(false); + this.onError(err); + }, + + watchPopup: task(function* (samlWindow) { + while (true) { + yield timeout(WAIT_TIME); + if (!samlWindow || samlWindow.closed) { + this.exchangeSAMLTokenPollID.cancelAll(); + return this.handleSAMLError(ERROR_WINDOW_CLOSED); + } + } + }), + + watchCurrent: task(function* (samlWindow) { + // when user is about to change pages, close the popup window + yield waitForEvent(this.getWindow(), 'beforeunload'); + samlWindow?.close(); + }), + + exchangeSAMLTokenPollID: task(function* (samlWindow, role) { + this.onLoading(true); + + // start watching the popup window and the current one + this.watchPopup.perform(samlWindow); + this.watchCurrent.perform(samlWindow); + + const path = this.selectedAuthPath || this.selectedAuthType; + const adapter = this.store.adapterFor('auth-method'); + this.onNamespace(this.namespace); + + // Wait up to 3 minutes for the token to become available + let resp; + for (let i = 0; i < 180; i++) { + yield timeout(WAIT_TIME); + try { + resp = yield adapter.pollSAMLToken(path, role.tokenPollID, role.clientVerifier); + if (!resp?.auth) { + continue; + } + // We've obtained the Vault token for the authentication flow, now log in. + yield this.onSubmit(null, null, resp.auth.client_token); + this.closeWindow(samlWindow); + return; + } catch (e) { + if (e.httpStatus === 401) { + // Continue to retry on 401 Unauthorized + continue; + } + return this.cancelLogin(samlWindow, errorMessage(e)); + } + } + this.cancelLogin(samlWindow, ERROR_TIMEOUT); + }), + + actions: { + setRole(roleName) { + this.onRoleName(roleName); + }, + /* Saml auth flow on login button click: + * 1. find role-saml record which returns role info + * 2. open popup at url defined returned from role + * 3. watch popup window for close (and cancel polling if it closes) + * 4. poll vault for 200 token response + * 5. close popup, stop polling, and trigger onSubmit with token data + */ + async startSAMLAuth(callback, data, e) { + this.onError(null); + this.onLoading(true); + if (e && e.preventDefault) { + e.preventDefault(); + } + const roleName = data.role; + let role; + try { + role = await this.fetchRole(roleName); + } catch (error) { + this.handleSAMLError(error); + return; + } + + const win = this.getWindow(); + const POPUP_WIDTH = 500; + const POPUP_HEIGHT = 600; + const left = win.screen.width / 2 - POPUP_WIDTH / 2; + const top = win.screen.height / 2 - POPUP_HEIGHT / 2; + const samlWindow = win.open( + role.ssoServiceURL, + 'vaultSAMLWindow', + `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}` + ); + + this.exchangeSAMLTokenPollID.perform(samlWindow, role); + }, + }, +}); diff --git a/ui/app/components/mount-backend/type-form.js b/ui/app/components/mount-backend/type-form.js index cdec52a095..f6cf98fda6 100644 --- a/ui/app/components/mount-backend/type-form.js +++ b/ui/app/components/mount-backend/type-form.js @@ -5,7 +5,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; -import { methods } from 'vault/helpers/mountable-auth-methods'; +import { allMethods, methods } from 'vault/helpers/mountable-auth-methods'; import { allEngines, mountableEngines } from 'vault/helpers/mountable-secret-engines'; import { tracked } from '@glimmer/tracking'; @@ -31,7 +31,11 @@ export default class MountBackendTypeForm extends Component { return this.version.isEnterprise ? allEngines() : mountableEngines(); } + get authMethods() { + return this.version.isEnterprise ? allMethods() : methods(); + } + get mountTypes() { - return this.args.mountType === 'secret' ? this.secretEngines : methods(); + return this.args.mountType === 'secret' ? this.secretEngines : this.authMethods; } } diff --git a/ui/app/helpers/mountable-auth-methods.js b/ui/app/helpers/mountable-auth-methods.js index bdb865f8a1..303e9baff4 100644 --- a/ui/app/helpers/mountable-auth-methods.js +++ b/ui/app/helpers/mountable-auth-methods.js @@ -5,6 +5,15 @@ import { helper as buildHelper } from '@ember/component/helper'; +const ENTERPRISE_AUTH_METHODS = [ + { + displayName: 'SAML', + value: 'saml', + type: 'saml', + category: 'generic', + }, +]; + const MOUNTABLE_AUTH_METHODS = [ { displayName: 'AliCloud', @@ -106,4 +115,8 @@ export function methods() { return MOUNTABLE_AUTH_METHODS.slice(); } +export function allMethods() { + return [...MOUNTABLE_AUTH_METHODS, ...ENTERPRISE_AUTH_METHODS]; +} + export default buildHelper(methods); diff --git a/ui/app/helpers/supported-auth-backends.js b/ui/app/helpers/supported-auth-backends.js index 10361bc839..e06cbd3387 100644 --- a/ui/app/helpers/supported-auth-backends.js +++ b/ui/app/helpers/supported-auth-backends.js @@ -72,8 +72,23 @@ const SUPPORTED_AUTH_BACKENDS = [ }, ]; +const ENTERPRISE_AUTH_METHODS = [ + { + type: 'saml', + typeDisplay: 'SAML', + description: 'Authenticate using SAML provider.', + tokenPath: 'client_token', + displayNamePath: 'display_name', + formAttributes: ['role'], + }, +]; + export function supportedAuthBackends() { return SUPPORTED_AUTH_BACKENDS; } +export function allSupportedAuthBackends() { + return [...SUPPORTED_AUTH_BACKENDS, ...ENTERPRISE_AUTH_METHODS]; +} + export default buildHelper(supportedAuthBackends); diff --git a/ui/app/models/role-saml.js b/ui/app/models/role-saml.js new file mode 100644 index 0000000000..430845b094 --- /dev/null +++ b/ui/app/models/role-saml.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Model, { attr } from '@ember-data/model'; + +export default class RoleSamlModel extends Model { + @attr('string') ssoServiceURL; + @attr('string') tokenPollID; + @attr('string') clientVerifier; +} diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index a66ccfdc1f..d44b41bfb7 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -17,12 +17,12 @@ import { resolve, reject } from 'rsvp'; import getStorage from 'vault/lib/token-storage'; import ENV from 'vault/config/environment'; -import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; +import { allSupportedAuthBackends } from 'vault/helpers/supported-auth-backends'; const TOKEN_SEPARATOR = '☃'; const TOKEN_PREFIX = 'vault-'; const ROOT_PREFIX = '_root_'; -const BACKENDS = supportedAuthBackends(); +const BACKENDS = allSupportedAuthBackends(); export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX }; diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index f93b524dc2..cb56335804 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -52,12 +52,14 @@ {{/if}}
{{this.selectedAuthBackend.path}}
- - {{this.selectedAuthBackend.mountDescription}} - -{{this.selectedAuthBackend.path}}
+ + {{this.selectedAuthBackend.mountDescription}} + +