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:
claire bontempo 2025-04-23 11:06:19 -07:00 committed by GitHub
parent 188e0c727d
commit 03fbde820b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 343 additions and 70 deletions

View file

@ -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) {

View 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}}

View file

@ -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' }];
}

View 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();
}
}

View 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();
}

View file

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/utils/uuid';

View file

@ -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');
});
});

View file

@ -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'
);
});
}
};

View file

@ -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>;
}