/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ import Ember from 'ember'; import { next } from '@ember/runloop'; import { inject as service } from '@ember/service'; 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 { 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. * * @example ```js * // All properties are passed in via query params. * ``` * * @param {string} wrappedToken - The auth method that is currently selected in the dropdown. * @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. * @param {string} namespace- The currently active namespace. * @param {string} selectedAuth - The auth method that is currently selected in the dropdown. * @param {function} onSuccess - Fired on auth success. * @param {function} [setOktaNumberChallenge] - Sets whether we are waiting for okta number challenge to be used to sign in. * @param {boolean} [waitingForOktaNumberChallenge=false] - Determines if we are waiting for the Okta Number Challenge to sign in. * @param {function} [setCancellingAuth] - Sets whether we are cancelling or not the login authentication for Okta Number Challenge. * @param {boolean} [cancelAuthForOktaNumberChallenge=false] - Determines if we are cancelling the login authentication for the Okta Number Challenge. */ const DEFAULTS = { token: null, username: null, password: null, customPath: null, }; export default Component.extend(DEFAULTS, { router: service(), auth: service(), flashMessages: service(), store: service(), csp: service('csp-event'), // passed in via a query param selectedAuth: null, methods: null, cluster: null, namespace: null, wrappedToken: null, // internal oldNamespace: null, authMethods: BACKENDS, // number answer for okta number challenge if applicable oktaNumberChallengeAnswer: null, didReceiveAttrs() { this._super(...arguments); const { wrappedToken: token, oldWrappedToken: oldToken, oldNamespace: oldNS, namespace: ns, selectedAuth: newMethod, oldSelectedAuth: oldMethod, cancelAuthForOktaNumberChallenge: cancelAuth, } = this; // if we are cancelling the login then we reset the number challenge answer and cancel the current authenticate and polling tasks if (cancelAuth) { this.set('oktaNumberChallengeAnswer', null); this.authenticate.cancelAll(); this.pollForOktaNumberChallenge.cancelAll(); } next(() => { if (!token && (oldNS === null || oldNS !== ns)) { this.fetchMethods.perform(); } this.set('oldNamespace', ns); // we only want to trigger this once if (token && !oldToken) { this.unwrapToken.perform(token); this.set('oldWrappedToken', token); } if (oldMethod && oldMethod !== newMethod) { this.resetDefaults(); } this.set('oldSelectedAuth', newMethod); }); }, didRender() { this._super(...arguments); // on very narrow viewports the active tab may be overflowed, so we scroll it into view here const activeEle = this.element.querySelector('li.is-active'); if (activeEle) { activeEle.scrollIntoView(); } next(() => { const firstMethod = this.firstMethod(); // set `with` to the first method if ( !this.wrappedToken && ((this.fetchMethods.isIdle && firstMethod && !this.selectedAuth) || (this.selectedAuth && !this.selectedAuthBackend)) ) { this.set('selectedAuth', firstMethod); } }); }, firstMethod() { const firstMethod = this.methodsToShow.firstObject; if (!firstMethod) return; // prefer backends with a path over those with a type return firstMethod.path || firstMethod.type; }, resetDefaults() { this.setProperties(DEFAULTS); }, getAuthBackend(type) { const { wrappedToken, methods, selectedAuth, selectedAuthIsPath: keyIsPath } = this; const selected = type || selectedAuth; if (!methods && !wrappedToken) { return {}; } // if type is provided we can ignore path since we are attempting to lookup a specific backend by type if (keyIsPath && !type) { return methods.findBy('path', selected); } return BACKENDS.findBy('type', selected); }, selectedAuthIsPath: match('selectedAuth', /\/$/), selectedAuthBackend: computed( 'wrappedToken', 'methods', 'methods.[]', 'selectedAuth', 'selectedAuthIsPath', function () { return this.getAuthBackend(); } ), providerName: computed('selectedAuthBackend.type', function () { if (!this.selectedAuthBackend) { return; } let type = this.selectedAuthBackend.type || 'token'; type = type.toLowerCase(); const templateName = dasherize(type); return templateName; }), hasCSPError: alias('csp.connectionViolations.firstObject'), 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 () { const hasMethodsWithPath = this.hasMethodsWithPath; const methodsToShow = this.methodsToShow; return hasMethodsWithPath ? methodsToShow.concat(BACKENDS) : methodsToShow; }), hasMethodsWithPath: computed('methodsToShow', function () { return this.methodsToShow.isAny('path'); }), methodsToShow: computed('methods', function () { const methods = this.methods || []; const shownMethods = methods.filter((m) => BACKENDS.find((b) => b.type.toLowerCase() === m.type.toLowerCase()) ); return shownMethods.length ? shownMethods : BACKENDS; }), unwrapToken: task( waitFor(function* (token) { // will be using the Token Auth Method, so set it here this.set('selectedAuth', 'token'); const adapter = this.store.adapterFor('tools'); try { const response = yield adapter.toolAction('unwrap', null, { clientToken: token }); this.set('token', response.auth.client_token); this.send('doSubmit'); } catch (e) { this.set('error', `Token unwrap failed: ${e.errors[0]}`); } }) ), fetchMethods: task( waitFor(function* () { const store = this.store; try { const methods = yield store.findAll('auth-method', { adapterOptions: { unauthenticated: true, }, }); this.set( 'methods', methods.map((m) => { const method = m.serialize({ includeId: true }); return { ...method, mountDescription: method.description, }; }) ); next(() => { store.unloadAll('auth-method'); }); } catch (e) { this.set('error', `There was an error fetching Auth Methods: ${e.errors[0]}`); } }) ), showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'), authenticate: task( waitFor(function* (backendType, data) { const { selectedAuth, cluster: { id: clusterId }, } = this; try { if (backendType === 'okta') { this.pollForOktaNumberChallenge.perform(data.nonce, data.path); } else { this.delayAuthMessageReminder.perform(); } const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data, selectedAuth, }); this.onSuccess(authResponse, backendType, data); } catch (e) { this.set('isLoading', false); if (!this.auth.mfaError) { this.set('error', `Authentication failed: ${this.auth.handleError(e)}`); } } }) ), pollForOktaNumberChallenge: task(function* (nonce, mount) { // yield for 1s to wait to see if there is a login error before polling yield timeout(1000); if (this.error) { return; } let response = null; this.setOktaNumberChallenge(true); this.setCancellingAuth(false); // keep polling /auth/okta/verify/:nonce API every 1s until a response is given with the correct number for the Okta Number Challenge while (response === null) { // when testing, the polling loop causes promises to be rejected making acceptance tests fail // so disable the poll in tests if (Ember.testing) { return; } yield timeout(1000); response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount); } this.set('oktaNumberChallengeAnswer', response); }), delayAuthMessageReminder: task(function* () { if (Ember.testing) { yield timeout(0); } else { yield timeout(5000); } }), actions: { doSubmit(passedData, event, token) { if (event) { event.preventDefault(); } if (token) { this.set('token', token); } this.set('error', null); // if callback from oidc or jwt we have a token at this point const backend = token ? this.getAuthBackend('token') : this.selectedAuthBackend || {}; const backendMeta = BACKENDS.find( (b) => (b.type || '').toLowerCase() === (backend.type || '').toLowerCase() ); const attributes = (backendMeta || {}).formAttributes || []; const data = this.getProperties(...attributes); if (passedData) { Object.assign(data, passedData); } if (this.customPath || backend.id) { data.path = this.customPath || backend.id; } // add nonce field for okta backend if (backend.type === 'okta') { data.nonce = uuidv4(); // add a default path of okta if it doesn't exist to be used for Okta Number Challenge if (!data.path) { data.path = 'okta'; } } return this.authenticate.unlinked().perform(backend.type, data); }, handleError(e) { this.setProperties({ isLoading: false, error: e ? this.auth.handleError(e) : null, }); }, returnToLoginFromOktaNumberChallenge() { this.setOktaNumberChallenge(false); this.set('oktaNumberChallengeAnswer', null); }, }, });