@okta/okta-signin-widget
Version:
The Okta Sign-In Widget
421 lines (373 loc) • 15.4 kB
text/typescript
/*!
* Copyright (c) 2020, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/
import { _, Controller, loc } from '@okta/courage';
import ViewFactory from '../view-builder/ViewFactory';
import IonResponseHelper from '../ion/IonResponseHelper';
import { getV1ClassName } from '../ion/ViewClassNamesFactory';
import { FORMS, TERMINAL_FORMS, FORM_NAME_TO_OPERATION_MAP } from '../ion/RemediationConstants';
import transformPayload from '../ion/payloadTransformer';
import Util from 'util/Util';
import sessionStorageHelper from '../client/sessionStorageHelper';
import { HttpResponse, IdxStatus, ProceedOptions } from '@okta/okta-auth-js';
import { EventErrorContext } from 'types/events';
import { CONFIGURED_FLOW } from '../client/constants';
import { ConfigError } from 'util/Errors';
import { updateAppState } from 'v2/client';
import CookieUtil from '../../util/CookieUtil';
import BrowserFeatures from "util/BrowserFeatures";
export interface ContextData {
controller: string;
formName: string;
authenticatorKey?: string;
methodType?: string;
}
export default Controller.extend({
className: 'form-controller',
appStateEvents: {
'change:currentFormName': 'handleFormNameChange',
'afterError': 'handleAfterError',
'invokeAction': 'handleInvokeAction',
'saveForm': 'handleSaveForm',
'switchForm': 'handleSwitchForm',
},
preRender() {
this.removeChildren();
},
postRender() {
const currentViewState = this.options.appState.getCurrentViewState();
// TODO: add comments regarding when `currentViewState` would be null?
if (!currentViewState) {
return;
}
this.clearMetadata();
let formName = currentViewState.name;
if (formName === 'identify' && this.options.settings.get('flow') === CONFIGURED_FLOW.RESET_PASSWORD) {
formName = 'identify-recovery';
}
const TheView = ViewFactory.create(
formName,
this.options.appState.get('authenticatorKey'),
);
try {
this.formView = this.add(TheView, {
options: {
currentViewState,
}
}).last();
} catch (error) {
// This is the place where runtime error (NPE) happens at most of time.
// It has been swallowed by Q.js hence add try/catch to surface up errors.
this.options.settings.callGlobalError(error);
return;
}
this.triggerAfterRenderEvent();
},
clearMetadata() {
const formName = this.options.appState.get('currentFormName');
// TODO: OKTA-392835 shall not clear state handle at terminal page
if (TERMINAL_FORMS.includes(formName)) {
sessionStorageHelper.removeStateHandle();
}
},
triggerAfterRenderEvent() {
const contextData = this.createAfterEventContext();
this.trigger('afterRender', contextData);
},
handleFormNameChange() {
this.render();
},
handleAfterError(error: HttpResponse) {
const contextData = this.createAfterEventContext();
const errorContextData: EventErrorContext = {
xhr: error,
errorSummary: error.responseJSON && error.responseJSON.errorSummary,
};
// TODO: need some enhancement after https://github.com/okta/okta-idx-js/pull/27
// OKTA-318062
this.trigger('afterError', contextData, errorContextData);
},
createAfterEventContext(): ContextData {
const formName = this.options.appState.get('currentFormName');
const authenticatorKey = this.options.appState.get('authenticatorKey');
const methodType = this.options.appState.get('authenticatorMethodType');
const isPasswordRecoveryFlow = this.options.appState.get('isPasswordRecoveryFlow');
const v1ControllerClassName = getV1ClassName(
formName,
authenticatorKey,
methodType,
isPasswordRecoveryFlow,
);
const eventData: ContextData = {
controller: v1ControllerClassName,
formName,
};
if (authenticatorKey) {
eventData.authenticatorKey = authenticatorKey;
}
if (methodType) {
eventData.methodType = methodType;
}
return eventData;
},
handleSwitchForm(formName) {
// trigger formName change to change view
if (this.options.appState.get('messages')) {
// Clear messages before calling switch form.
// If a form has errors sent form API inside messages
// and user hits back to factors list which triggers switchForm,
// those error will show up on another screen that gets rendered after switchForm
this.options.appState.unset('messages');
}
this.options.appState.set('currentFormName', formName);
},
// eslint-disable-next-line max-statements
async handleInvokeAction(actionPath = '', actionParams = {}) {
const { appState, settings } = this.options;
const idx = appState.get('idx');
const { stateHandle } = idx.context;
let invokeOptions: ProceedOptions = {
exchangeCodeForTokens: false, // we handle this in interactionCodeFlow.js
stateHandle
};
let error;
// Cancel action is executes synchronously
if (actionPath === 'cancel') {
// TODO: resolve race conditions caused by event pattern: OKTA-490220
settings.getAuthClient().transactionManager.clear({ clearIdxResponse: false });
sessionStorageHelper.removeStateHandle();
appState.clearAppStateCache();
if (settings.get('oauth2Enabled')) {
// In this case we need to restart login flow and recreate transaction meta
// that will be used in interactionCodeFlow function
appState.trigger('restartLoginFlow');
return;
}
}
// Build options to invoke or throw error for invalid action
if (FORMS.LAUNCH_AUTHENTICATOR === actionPath && actionParams) {
//https://oktainc.atlassian.net/browse/OKTA-562885 a temp solution to send rememberMe when click the launch OV buttion.
//will redesign to handle FastPass silent probing case where no username and rememberMe opiton at all.
invokeOptions = {
...invokeOptions,
actions: [{
name: actionPath,
params: actionParams
}]
};
} else if (idx['neededToProceed'].find(item => item.name === actionPath)) {
invokeOptions = { ...invokeOptions, step: actionPath };
} else if (_.isFunction(idx['actions'][actionPath])) {
invokeOptions = {
...invokeOptions,
actions: [{
name: actionPath,
params: actionParams
}]
};
} else {
error = new ConfigError(`Invalid action selected: ${actionPath}`);
this.options.settings.callGlobalError(error);
await this.showFormErrors(this.formView.model, error, this.formView.form);
return;
}
// action will be executed asynchronously
await this.invokeAction(invokeOptions);
},
async invokeAction(invokeOptions) {
const authClient = this.options.settings.getAuthClient();
let resp;
let error;
try {
resp = await authClient.idx.proceed(invokeOptions);
if (resp.requestDidSucceed === false) {
error = resp;
}
} catch (e) {
error = e;
}
// if request did not succeed, show error on the current form
if (error) {
await this.showFormErrors(this.formView.model, error, this.formView.form);
return;
}
// process response, may render a new form
await this.handleIdxResponse(resp);
},
// eslint-disable-next-line max-statements, complexity
async handleSaveForm(model) {
const formName = model.get('formName');
// Toggle Form saving status (e.g. disabling save button, etc)
this.toggleFormButtonState(true);
model.trigger('request');
// Use full page redirection if necessary
if (model.get('useRedirect')) {
// Clear when navigates away from SIW page, e.g. success, IdP Authenticator.
// Because SIW sort of finished its current /transaction/
sessionStorageHelper.removeStateHandle();
// OKTA-635926: do not redirect without user gesture for ov enrollment on android
// if Util.isAndroidOVEnrollment() returns true we use a user gesture to complete the redirect in AutoRedirectView
if (!Util.isAndroidOVEnrollment()) {
const currentViewState = this.options.appState.getCurrentViewState();
// OKTA-702402: redirect only if/when the page is visible
Util.executeOnVisiblePage(() => {
Util.redirectWithFormGet(currentViewState.href);
});
}
return;
}
const payload = transformPayload(formName, model);
// Run hook: transform the user name (a.k.a identifier)
const values = this.transformIdentifier(formName, payload);
// widget rememberMe feature stores the entered identifier in a cookie, to pre-fill the form on subsequent visits to page
if (this.options.settings.get('features.rememberMe')) {
if (values.identifier) {
CookieUtil.setUsernameCookie(values.identifier);
}
}
else {
CookieUtil.removeUsernameCookie();
}
// Error out when this is not a remediation form. Unexpected Exception.
if (!this.options.appState.hasRemediationObject(formName)) {
this.options.settings.callGlobalError(`Cannot find http action for "${formName}".`);
await this.showFormErrors(this.formView.model, 'Cannot find action to proceed.', this.formView.form);
return;
}
// Reset password in identity-first flow needs some help to auto-select password and begin the reset flow
if (formName === 'identify' && this.options.settings.get('flow') === CONFIGURED_FLOW.RESET_PASSWORD) {
values.authenticator = 'okta_password';
}
// Submit request to idx endpoint
const authClient = this.options.settings.getAuthClient();
const idxOptions: ProceedOptions = {
exchangeCodeForTokens: false, // we handle this in interactionCodeFlow.js
};
try {
const idx = this.options.appState.get('idx');
const { stateHandle } = idx.context;
const resp = await authClient.idx.proceed({
...idxOptions,
step: formName,
stateHandle,
...values
});
if (resp.status === IdxStatus.FAILURE) {
throw resp.error; // caught and handled in this function
}
// follow idx transaction to render terminal view for session expired error
if (IonResponseHelper.isIdxSessionExpiredError(resp)) {
const authClient = this.settings.getAuthClient();
authClient.transactionManager.clear();
await this.handleIdxResponse(resp);
return;
}
// If the last request did not succeed, show errors on the current form
// Special case: Okta server responds 401 status code with WWW-Authenticate header and new remediation
// so that the iOS/MacOS credential SSO extension (Okta Verify) can intercept
// the response reaches here when Okta Verify is not installed
// we need to return an idx object so that
// the SIW can proceed to the next step without showing error
if (resp.requestDidSucceed === false && !resp.stepUp) {
await this.showFormErrors(model, resp, this.formView.form);
return;
}
const onSuccess = this.handleIdxResponse.bind(this, resp);
if (formName === FORMS.ENROLL_PROFILE) {
// call registration (aka enroll profile) hook
this.settings.postRegistrationSubmit(values?.userProfile?.email, onSuccess, (error) => {
model.trigger('error', model, {
responseJSON: error,
});
});
} else {
await onSuccess();
}
} catch(error) {
if (error.is?.('terminal')) {
this.options.appState.setNonIdxError(error);
} else {
await this.showFormErrors(model, error, this.formView.form);
}
} finally {
this.toggleFormButtonState(false);
}
},
transformIdentifier(formName, modelJSON) {
if (Object.prototype.hasOwnProperty.call(modelJSON, 'identifier')) {
// The callback function is passed two arguments:
// 1) username: The name entered by the user
// 2) operation: The type of operation the user is trying to perform:
// - PRIMARY_AUTH
// - FORGOT_PASSWORD
// - UNLOCK_ACCOUNT
const operation = FORM_NAME_TO_OPERATION_MAP[formName];
modelJSON.identifier = this.settings.transformUsername(modelJSON.identifier, operation);
}
return modelJSON;
},
/**
* @param model current form model
* @param error any errors after user action
* @param form current form
* Handle errors that get displayed right after any user action. After such form errors widget doesn't
* reload or re-render, but updates the AppSate with latest remediation.
*/
async showFormErrors(model, error, form) {
/* eslint max-statements: [2, 24] */
let errorObj;
let idxStateError;
let showErrorBanner = true;
model.trigger('clearFormError');
if (!error) {
error = 'FormController - unknown error found';
this.options.settings.callGlobalError(error);
}
if(error?.rawIdxState) {
idxStateError = error;
error = error.rawIdxState;
}
if (IonResponseHelper.isIonErrorResponse(error)) {
errorObj = IonResponseHelper.convertFormErrors(error);
} else if (error.errorSummary) {
errorObj = { responseJSON: error };
} else {
Util.logConsoleError(error);
errorObj = { responseJSON: { errorSummary: loc('error.unsupported.response', 'login')}};
}
if(_.isFunction(form?.showCustomFormErrorCallout)) {
showErrorBanner = !form.showCustomFormErrorCallout(errorObj, idxStateError.messages);
}
// show error before updating app state.
model.trigger('error', model, errorObj, showErrorBanner);
idxStateError = Object.assign({}, idxStateError, {hasFormError: true});
// TODO OKTA-408410: Widget should update the state on every new response. It should NOT do selective update.
// For eg 429 rate-limit errors, we have to skip updating idx state, because error response is not an idx response.
if (Array.isArray(idxStateError?.neededToProceed) && idxStateError?.neededToProceed.length) {
await this.handleIdxResponse(idxStateError);
}
},
async handleIdxResponse(idxResp) {
await updateAppState(this.options.appState, idxResp);
},
/**
* SignIn widget has its own (hacky) way to customize the button disabled state:
* adding `link-button-disabled` despite the name was intend only to disable
* `link-button`.
* Instead of doing decent refactor, we want to follow the convention for now.
*
* @param {boolean} disabled whether add extra disable CSS class.
*/
toggleFormButtonState: function(disabled) {
const button = this.$el.find('.o-form-button-bar .button');
button.toggleClass('link-button-disabled', disabled);
},
});