mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-28 04:10:44 -04:00
UI: Build Okta auth method component (#30316)
* finish okta auth method * convert to ts * move login fields down * rename arg to prep for mfa business
This commit is contained in:
parent
188e0c727d
commit
03fbde820b
9 changed files with 343 additions and 70 deletions
|
|
@ -8,8 +8,10 @@ import { action } from '@ember/object';
|
|||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
|
||||
import type AuthService from 'vault/vault/services/auth';
|
||||
import type { AuthData } from 'vault/vault/services/auth';
|
||||
import type ClusterModel from 'vault/models/cluster';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
|
||||
|
|
@ -39,7 +41,9 @@ export default class AuthBase extends Component<Args> {
|
|||
const data: Record<string, FormDataEntryValue | null> = {};
|
||||
|
||||
for (const key of formData.keys()) {
|
||||
data[key] = formData.get(key);
|
||||
const value = formData.get(key);
|
||||
// strip leading or trailing slashes from path for consistency
|
||||
data[key] = key === 'path' ? sanitizePath(value) : value;
|
||||
}
|
||||
|
||||
// If path is not included in the submitted form data,
|
||||
|
|
@ -53,28 +57,25 @@ export default class AuthBase extends Component<Args> {
|
|||
}
|
||||
|
||||
login = task(
|
||||
waitFor(async (data) => {
|
||||
waitFor(async (formData) => {
|
||||
try {
|
||||
const authResponse = await this.auth.authenticate({
|
||||
clusterId: this.args.cluster.id,
|
||||
backend: this.args.authType,
|
||||
data,
|
||||
data: formData,
|
||||
selectedAuth: this.args.authType,
|
||||
});
|
||||
|
||||
// responsible for redirect after auth data is persisted
|
||||
this.onSuccess(authResponse);
|
||||
this.handleAuthResponse(authResponse);
|
||||
} catch (error) {
|
||||
this.onError(error as Error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// if we move auth service authSuccess method here (or to each auth method component)
|
||||
// then call that before calling parent this.args.onSuccess
|
||||
onSuccess(authResponse: object) {
|
||||
// responsible for redirect after auth data is persisted
|
||||
this.args.onSuccess(authResponse, this.args.authType);
|
||||
handleAuthResponse(authResponse: AuthData) {
|
||||
// calls onAuthResponse in parent auth/page.js component
|
||||
this.args.onSuccess(authResponse);
|
||||
}
|
||||
|
||||
onError(error: Error) {
|
||||
|
|
|
|||
37
ui/app/components/auth/form/okta.hbs
Normal file
37
ui/app/components/auth/form/okta.hbs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#if this.showNumberChallenge}}
|
||||
<OktaNumberChallenge
|
||||
@correctAnswer={{this.challengeAnswer}}
|
||||
@hasError={{this.oktaVerifyError}}
|
||||
@onReturnToLogin={{this.reset}}
|
||||
/>
|
||||
{{else}}
|
||||
<form {{on "submit" this.onSubmit}} data-test-auth-form={{@authType}}>
|
||||
|
||||
{{yield to="namespace"}}
|
||||
|
||||
<div class="has-padding-l">
|
||||
{{yield to="back"}}
|
||||
{{yield to="authSelectOptions"}}
|
||||
{{yield to="error"}}
|
||||
|
||||
<Auth::Fields @loginFields={{this.loginFields}} />
|
||||
|
||||
{{yield to="advancedSettings"}}
|
||||
|
||||
<Hds::Button
|
||||
@text="Sign in"
|
||||
@isFullWidth={{true}}
|
||||
type="submit"
|
||||
class="has-top-margin-m has-bottom-margin-m"
|
||||
data-test-auth-submit
|
||||
/>
|
||||
|
||||
{{yield to="footer"}}
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Okta
|
||||
* see Auth::Base
|
||||
* */
|
||||
|
||||
export default class AuthFormOkta extends AuthBase {
|
||||
loginFields = [{ name: 'username' }, { name: 'password' }];
|
||||
}
|
||||
104
ui/app/components/auth/form/okta.ts
Normal file
104
ui/app/components/auth/form/okta.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { task, timeout } from 'ember-concurrency';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import uuid from 'core/utils/uuid';
|
||||
|
||||
import type AuthService from 'vault/vault/services/auth';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Okta
|
||||
* see Auth::Base
|
||||
* */
|
||||
|
||||
export default class AuthFormOkta extends AuthBase {
|
||||
@service declare readonly auth: AuthService;
|
||||
|
||||
@tracked challengeAnswer = '';
|
||||
@tracked oktaVerifyError = '';
|
||||
@tracked showNumberChallenge = false;
|
||||
|
||||
loginFields = [{ name: 'username' }, { name: 'password' }];
|
||||
|
||||
login = task(
|
||||
waitFor(async (data) => {
|
||||
// wait for 1s to wait to see if there is a login error before polling
|
||||
await timeout(1000);
|
||||
|
||||
data.nonce = uuid();
|
||||
this.pollForOktaNumberChallenge.perform(data.nonce, data.path);
|
||||
|
||||
try {
|
||||
// selecting the correct okta verify answer on the personal device resolves this request
|
||||
const authResponse = await this.auth.authenticate({
|
||||
clusterId: this.args.cluster.id,
|
||||
backend: this.args.authType,
|
||||
data,
|
||||
selectedAuth: this.args.authType,
|
||||
});
|
||||
|
||||
this.handleAuthResponse(authResponse);
|
||||
} catch (error) {
|
||||
// if a user fails the okta verify challenge, the POST login request fails (made by this.auth.authenticate above)
|
||||
// bubble those up for consistency instead of managing error state in this component
|
||||
this.onError(error as Error);
|
||||
// cancel polling tasks and reset state
|
||||
this.reset();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
pollForOktaNumberChallenge = task(
|
||||
waitFor(async (nonce, mountPath) => {
|
||||
this.showNumberChallenge = true;
|
||||
|
||||
// keep polling /auth/okta/verify/:nonce API every 1s until response returns with correct_number
|
||||
let verifyNumber = null;
|
||||
while (verifyNumber === null) {
|
||||
await timeout(1000);
|
||||
// verifyNumber = await this.requestOktaVerify(nonce, mountPath);
|
||||
verifyNumber = await this.requestOktaVerify(nonce, mountPath);
|
||||
}
|
||||
|
||||
// display correct number so user can select on personal MFA device
|
||||
this.challengeAnswer = verifyNumber ?? '';
|
||||
})
|
||||
);
|
||||
|
||||
@action
|
||||
async requestOktaVerify(nonce: string, mountPath: string) {
|
||||
const url = `/v1/auth/${mountPath}/verify/${nonce}`;
|
||||
try {
|
||||
const response = await this.auth.ajax(url, 'GET', {});
|
||||
return response.data.correct_answer;
|
||||
} catch (e) {
|
||||
const error = e as Response;
|
||||
if (error?.status === 404) {
|
||||
// if error status is 404 return null to keep polling for a response
|
||||
return null;
|
||||
} else {
|
||||
// this would be unusual, but handling just in case
|
||||
this.oktaVerifyError = errorMessage(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
reset() {
|
||||
// reset tracked variables and stop polling tasks
|
||||
this.challengeAnswer = '';
|
||||
this.oktaVerifyError = '';
|
||||
this.showNumberChallenge = false;
|
||||
this.login.cancelAll();
|
||||
this.pollForOktaNumberChallenge.cancelAll();
|
||||
}
|
||||
}
|
||||
14
ui/lib/core/addon/utils/uuid.js
Normal file
14
ui/lib/core/addon/utils/uuid.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Use this instead of uuidv4() so that generated UUIDs can be stubbed in tests.
|
||||
*/
|
||||
|
||||
export default function uuid() {
|
||||
return uuidv4();
|
||||
}
|
||||
6
ui/lib/core/app/utils/uuid.js
Normal file
6
ui/lib/core/app/utils/uuid.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'core/utils/uuid';
|
||||
|
|
@ -6,10 +6,15 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import sinon from 'sinon';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import testHelper from './test-helper';
|
||||
import { LOGIN_DATA } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
import * as uuid from 'core/utils/uuid';
|
||||
import { Response } from 'miragejs';
|
||||
|
||||
module('Integration | Component | auth | form | okta', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
|
@ -18,15 +23,28 @@ module('Integration | Component | auth | form | okta', function (hooks) {
|
|||
hooks.beforeEach(function () {
|
||||
this.authType = 'okta';
|
||||
this.expectedFields = ['username', 'password'];
|
||||
|
||||
this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate');
|
||||
this.cluster = { id: 1 };
|
||||
this.onError = sinon.spy();
|
||||
this.onSuccess = sinon.spy();
|
||||
// stub uuid so auth/okta/verify request can be stubbed using mirage
|
||||
this.nonce = '12345';
|
||||
sinon.stub(uuid, 'default').returns(this.nonce);
|
||||
this.response = { data: { correct_answer: 68 } };
|
||||
this.server.get(`/auth/:path/verify/${this.nonce}`, () => this.response);
|
||||
|
||||
this.expectedSubmit = {
|
||||
default: { path: 'okta', username: 'matilda', password: 'password' },
|
||||
custom: { path: 'custom-okta', username: 'matilda', password: 'password' },
|
||||
default: { path: 'okta', username: 'matilda', password: 'password', nonce: this.nonce },
|
||||
custom: { path: 'custom-okta', username: 'matilda', password: 'password', nonce: this.nonce },
|
||||
};
|
||||
|
||||
this.fillInForm = async () => {
|
||||
const { loginData } = LOGIN_DATA.username;
|
||||
for (const [field, value] of Object.entries(loginData)) {
|
||||
await fillIn(GENERAL.inputByAttr(field), value);
|
||||
}
|
||||
};
|
||||
|
||||
this.renderComponent = ({ yieldBlock = false } = {}) => {
|
||||
if (yieldBlock) {
|
||||
return render(hbs`
|
||||
|
|
@ -52,5 +70,101 @@ module('Integration | Component | auth | form | okta', function (hooks) {
|
|||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
testHelper(test, { standardSubmit: false });
|
||||
|
||||
test('it submits form data with defaults', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get(`/auth/okta/verify/${this.nonce}`, () => {
|
||||
// since the yielded input is name="path" we can also assert the okta/verify response is hit as expected
|
||||
assert.true(true, 'it requests okta verify with default mount path');
|
||||
return this.response;
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await this.fillInForm();
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = this.authenticateStub.lastCall.args;
|
||||
assert.propEqual(
|
||||
actual.data,
|
||||
this.expectedSubmit.default,
|
||||
'auth service "authenticate" method is called with form data'
|
||||
);
|
||||
});
|
||||
|
||||
// not representative of real-world submit, that happens in acceptance tests.
|
||||
// component here just yields <:advancedSettings> to test form submits data yielded data
|
||||
test('it submits form data from yielded inputs', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get(`/auth/custom-okta/verify/${this.nonce}`, () => {
|
||||
// since the yielded input is name="path" we can also assert the okta/verify response is hit as expected
|
||||
assert.true(true, 'it requests okta verify with custom mount path');
|
||||
return this.response;
|
||||
});
|
||||
|
||||
await this.renderComponent({ yieldBlock: true });
|
||||
await this.fillInForm();
|
||||
await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`);
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = this.authenticateStub.lastCall.args;
|
||||
assert.propEqual(
|
||||
actual.data,
|
||||
this.expectedSubmit.custom,
|
||||
'auth service "authenticate" method is called with yielded form data'
|
||||
);
|
||||
});
|
||||
|
||||
test('it displays okta number challenge answer', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await this.fillInForm();
|
||||
await click(AUTH_FORM.login);
|
||||
assert
|
||||
.dom('[data-test-okta-number-challenge]')
|
||||
.hasText(
|
||||
'To finish signing in, you will need to complete an additional MFA step. Okta verification Select the following number to complete verification: 68 Back to login'
|
||||
);
|
||||
});
|
||||
|
||||
test('it returns to login when "Back to login" is clicked', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await this.fillInForm();
|
||||
await click(AUTH_FORM.login);
|
||||
assert.dom('[data-test-okta-number-challenge]').exists();
|
||||
await click(GENERAL.backButton);
|
||||
assert.dom(AUTH_FORM.authForm('okta')).exists('it returns to okta form');
|
||||
assert.dom('[data-test-okta-number-challenge]').doesNotExist();
|
||||
assert.dom(GENERAL.inputByAttr('username')).hasValue('', 'username is cleared');
|
||||
assert.dom(GENERAL.inputByAttr('password')).hasValue('', 'password is cleared');
|
||||
});
|
||||
|
||||
test('it shows loading state when polling okta verify request', async function (assert) {
|
||||
assert.expect(2);
|
||||
let count = 0;
|
||||
this.server.get(`/auth/okta/verify/${this.nonce}`, () => {
|
||||
count++;
|
||||
assert.dom('[data-test-okta-number-challenge]').hasText(
|
||||
'To finish signing in, you will need to complete an additional MFA step. Please wait... Back to login',
|
||||
count === 1
|
||||
? // the response hasn't returned anything yet, so the first assertion is just the initial state
|
||||
'it shows loading message before polling initiates'
|
||||
: // by now the response has returned a 404 so this asserts error handling works as expected
|
||||
'it shows loading message while response returns 404'
|
||||
);
|
||||
// okta/verify returns a 404 until the user interacts with okta via their configured MFA app.
|
||||
// to simulate this interaction we return data on the third request - which ends the polling.
|
||||
const response = count < 2 ? new Response(404, {}, { errors: [] }) : this.response;
|
||||
return response;
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await this.fillInForm();
|
||||
await click(AUTH_FORM.login);
|
||||
});
|
||||
|
||||
test('it renders error message when okta verify request errors', async function (assert) {
|
||||
this.server.get(`/auth/okta/verify/${this.nonce}`, () => new Response(500));
|
||||
await this.renderComponent();
|
||||
await this.fillInForm();
|
||||
await click(AUTH_FORM.login);
|
||||
assert.dom(GENERAL.messageError).hasText('Error An error occurred, please try again');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ This is intentional to test component logic specific to auth/form/base or auth/f
|
|||
separately from auth/form-template.
|
||||
*/
|
||||
|
||||
export default (test) => {
|
||||
export default (test, { standardSubmit = true } = {}) => {
|
||||
test('it renders fields', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.authForm(this.authType)).exists(`${this.authType}: it renders form component`);
|
||||
|
|
@ -46,47 +46,50 @@ export default (test) => {
|
|||
assert.strictEqual(actual, 'success!', 'it calls onSuccess');
|
||||
});
|
||||
|
||||
test('it submits form data with defaults', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType);
|
||||
const { loginData } = options;
|
||||
// some methods are tested separately because they have more complex submit logic
|
||||
if (standardSubmit) {
|
||||
test('it submits form data with defaults', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType);
|
||||
const { loginData } = options;
|
||||
|
||||
for (const [field, value] of Object.entries(loginData)) {
|
||||
await fillIn(GENERAL.inputByAttr(field), value);
|
||||
}
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = this.authenticateStub.lastCall.args;
|
||||
assert.propEqual(
|
||||
actual.data,
|
||||
this.expectedSubmit.default,
|
||||
'auth service "authenticate" method is called with form data'
|
||||
);
|
||||
});
|
||||
for (const [field, value] of Object.entries(loginData)) {
|
||||
await fillIn(GENERAL.inputByAttr(field), value);
|
||||
}
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = this.authenticateStub.lastCall.args;
|
||||
assert.propEqual(
|
||||
actual.data,
|
||||
this.expectedSubmit.default,
|
||||
'auth service "authenticate" method is called with form data'
|
||||
);
|
||||
});
|
||||
|
||||
// not for testing real-world submit, that happens in acceptance tests
|
||||
// component here just yields <:advancedSettings> to test form submits data from yielded inputs
|
||||
test('it submits form data from yielded inputs', async function (assert) {
|
||||
await this.renderComponent({ yieldBlock: true });
|
||||
const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType);
|
||||
const { loginData } = options;
|
||||
// not for testing real-world submit, that happens in acceptance tests.
|
||||
// component here just yields <:advancedSettings> to test form submits data from yielded inputs
|
||||
test('it submits form data from yielded inputs', async function (assert) {
|
||||
await this.renderComponent({ yieldBlock: true });
|
||||
const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType);
|
||||
const { loginData } = options;
|
||||
|
||||
for (const [field, value] of Object.entries(loginData)) {
|
||||
await fillIn(GENERAL.inputByAttr(field), value);
|
||||
}
|
||||
for (const [field, value] of Object.entries(loginData)) {
|
||||
await fillIn(GENERAL.inputByAttr(field), value);
|
||||
}
|
||||
|
||||
if (this.authType === 'token') {
|
||||
// token doesn't support custom paths, so just test yielding functionality
|
||||
await fillIn(GENERAL.inputByAttr('yield'), `yield-${this.authType}`);
|
||||
} else {
|
||||
await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`);
|
||||
}
|
||||
if (this.authType === 'token') {
|
||||
// token doesn't support custom paths, so just test yielding functionality
|
||||
await fillIn(GENERAL.inputByAttr('yield'), `yield-${this.authType}`);
|
||||
} else {
|
||||
await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`);
|
||||
}
|
||||
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = this.authenticateStub.lastCall.args;
|
||||
assert.propEqual(
|
||||
actual.data,
|
||||
this.expectedSubmit.custom,
|
||||
'auth service "authenticate" method is called with yielded form data'
|
||||
);
|
||||
});
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = this.authenticateStub.lastCall.args;
|
||||
assert.propEqual(
|
||||
actual.data,
|
||||
this.expectedSubmit.custom,
|
||||
'auth service "authenticate" method is called with yielded form data'
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
11
ui/types/vault/services/auth.d.ts
vendored
11
ui/types/vault/services/auth.d.ts
vendored
|
|
@ -20,6 +20,7 @@ export interface AuthData {
|
|||
export default class AuthService extends Service {
|
||||
authData: AuthData;
|
||||
currentToken: string;
|
||||
mfaErrors: null | Errors[];
|
||||
setLastFetch: (time: number) => void;
|
||||
handleError: (error: Error) => string | error[] | [error];
|
||||
authenticate(params: {
|
||||
|
|
@ -28,5 +29,13 @@ export default class AuthService extends Service {
|
|||
data: Record<string, FormDataEntryValue | null>;
|
||||
selectedAuth: string;
|
||||
}): Promise<any>;
|
||||
mfaErrors: null | Errors[];
|
||||
ajax: (
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
namespace?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue