UNPKG

@okta-dfuhriman/okta-auth-js

Version:
618 lines (538 loc) 19.5 kB
/* eslint max-statements: [2, 22], complexity: [2, 12] */ /*! * Copyright (c) 2015-2016, 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 { _, Model, loc, internal, ModelProperty } from 'okta'; import config from 'config/config.json'; import hbs from 'handlebars-inline-precompile'; import Q from 'q'; import BrowserFeatures from 'util/BrowserFeatures'; import { ConfigError, UnsupportedBrowserError } from 'util/Errors'; import IDP from 'util/IDP'; import Logger from 'util/Logger'; import Util from 'util/Util'; import CountryUtil from 'util/CountryUtil'; import { OktaAuth } from '@okta/okta-auth-js'; const SharedUtil = internal.util.Util; const assetBaseUrlTpl = hbs('https://global.oktacdn.com/okta-signin-widget/{{version}}'); const local: Record<string, ModelProperty> = { authClient: ['object', false, undefined], baseUrl: ['string', true], recoveryToken: ['string', false, undefined], stateToken: ['string', false, undefined], username: ['string', false], relayState: ['string', false], // These two settings are aliases. Setting either value will set `backToSignInUri` signOutLink: ['string', false], // for backward compatibility backToSignInLink: ['string', false], // preferred setting redirect: { type: 'string', values: ['never', 'always', 'auto'], value: 'auto', }, // allows bootstrapping the Widget into a specific view such // as register or forgot password flow: ['string', false, 'default'], // Function to transform the username before passing it to the API // for Primary Auth, Forgot Password and Unlock Account. transformUsername: ['function', false], // CALLBACKS globalSuccessFn: 'function', globalErrorFn: 'function', processCreds: 'function', hooks: 'object', // IMAGES logo: 'string', logoText: ['string', false], helpSupportNumber: 'string', // <OIE> // attribute to hold proxy (fake) idx response // to render static pages without initiating idx pipeline proxyIdxResponse: ['object', false], // <OIE> // By default, state handle will be saved to session storage // and will be clear when terminal error or success redirect. // Set this flag to true if you want to override this behavior. // a.k.a dishonor the state handle stored in session storage. overrideExistingStateToken: ['boolean', false, false], // FEATURES 'features.router': ['boolean', true, false], 'features.securityImage': ['boolean', true, false], 'features.rememberMe': ['boolean', true, true], 'features.autoPush': ['boolean', true, false], 'features.smsRecovery': ['boolean', true, false], 'features.callRecovery': ['boolean', true, false], 'features.emailRecovery': ['boolean', false, true], 'features.webauthn': ['boolean', true, false], 'features.selfServiceUnlock': ['boolean', true, false], 'features.multiOptionalFactorEnroll': ['boolean', true, false], 'features.deviceFingerprinting': ['boolean', false, false], 'features.hideSignOutLinkInMFA': ['boolean', false, false], 'features.skipIdpFactorVerificationBtn': ['boolean', false, false], 'features.hideBackToSignInForReset': ['boolean', false, false], 'features.customExpiredPassword': ['boolean', true, false], 'features.registration': ['boolean', false, false], 'features.idpDiscovery': ['boolean', false, false], 'features.passwordlessAuth': ['boolean', false, false], 'features.showPasswordToggleOnSignInPage': ['boolean', false, false], 'features.trackTypingPattern': ['boolean', false, false], 'features.redirectByFormSubmit': ['boolean', false, false], 'features.useDeviceFingerprintForSecurityImage': ['boolean', false, true], 'features.showPasswordRequirementsAsHtmlList': ['boolean', false, false], 'features.mfaOnlyFlow': ['boolean', false, false], 'features.scrollOnError': ['boolean', false, true], 'features.showKeepMeSignedIn': ['boolean', false, true], 'features.showIdentifier': ['boolean', false, true], 'features.autoFocus': ['boolean', false, true], 'features.rememberMyUsernameOnOIE': ['boolean', false, false], 'features.showSessionRevocation': ['boolean', false, false], defaultCountryCode: ['string', false, 'US'], // I18N language: ['any', false], // Can be a string or a function i18n: ['object', false], // ASSETS 'assets.baseUrl': ['string', false], 'assets.rewrite': { type: 'function', value: _.identity, }, 'assets.languages': ['array', false], // OAUTH2 issuer: 'string', clientId: 'string', redirectUri: 'string', state: 'string', nonce: 'string', scopes: 'array', codeChallenge: 'string', codeChallengeMethod: ['string', false], oAuthTimeout: ['number', false], authScheme: ['string', false, 'OAUTH2'], // External IdPs idps: ['array', false, []], idpDisplay: { type: 'string', values: ['PRIMARY', 'SECONDARY'], value: 'SECONDARY', }, // HELP LINKS 'helpLinks.help': 'string', 'helpLinks.forgotPassword': 'string', 'helpLinks.unlock': 'string', 'helpLinks.custom': 'array', 'helpLinks.factorPage.href': 'string', 'helpLinks.factorPage.text': 'string', //Custom Buttons customButtons: ['array', false, []], //Registration policyId: 'string', 'registration.click': 'function', 'registration.parseSchema': 'function', 'registration.preSubmit': 'function', 'registration.postSubmit': 'function', //Consent 'consent.cancel': 'function', //IDP Discovery 'idpDiscovery.requestContext': 'string', //Colors 'colors.brand': 'string', //cspNonce 'cspNonce': 'string', //Descriptions brandName: 'string', //PIV piv: ['object', false, {}], //Email verify callback otp: 'string', //Support classic engine useClassicEngine: ['boolean', false, false] }; const derived: Record<string, ModelProperty> = { backToSignInUri: { deps: ['backToSignInLink', 'signOutLink'], fn: function(backToSignInLink, signOutLink) { return backToSignInLink || signOutLink; // prefer backToSignInLink over signOutLink, but they are aliases } }, showPasswordToggle: { deps: ['features.showPasswordToggleOnSignInPage'], fn: function() { // showPasswordToggle is for OIE only. // Used to default showPasswordToggleOnSignInPage to true. const defaultValue = true; const customizedValue = this.options?.features?.showPasswordToggleOnSignInPage ?? this.options?.['features.showPasswordToggleOnSignInPage']; return customizedValue ?? defaultValue; }, cache: true, }, redirectUtilFn: { deps: ['features.redirectByFormSubmit'], fn: function(redirectByFormSubmit) { return redirectByFormSubmit ? Util.redirectWithFormGet.bind(Util) : SharedUtil.redirect.bind(SharedUtil); }, cache: true, }, supportedLanguages: { deps: ['i18n', 'language', 'assets.languages'], fn: function(i18n, language, hostedLanguages) { // By default, the language be automatically detected from the browser // Developers can specify the language. It will be added to the supportedLanguages list. // Developers can also provide a list of languages with hosted assets, these replace the default list const supportedLanguages = hostedLanguages || config.supportedLanguages; return _.union( supportedLanguages, _.keys(i18n), _.isString(language) ? [language] : [] ); }, cache: true, }, languageCode: { deps: ['language', 'supportedLanguages'], fn: function(language, supportedLanguages) { const userLanguages = BrowserFeatures.getUserLanguages(); // TODO: revisit this fix - OKTA-491150 userLanguages.forEach((val, idx) => { if (val === 'nl') { userLanguages[idx] = 'nl-NL'; } else if (val === 'pt') { userLanguages[idx] = 'pt-BR'; } }); const preferred = _.clone(userLanguages); const supportedLowerCase = Util.toLower(supportedLanguages); // Any developer defined "language" takes highest priority: // As a string, i.e. 'en', 'ja', 'zh-CN' if (_.isString(language)) { preferred.unshift(language); } else if (_.isFunction(language)) { // As a callback function, which is passed the list of supported // languages and detected user languages. This function must return // a languageCode, i.e. 'en', 'ja', 'zh-CN' preferred.unshift(language(supportedLanguages, userLanguages)); } // Add default language, and expand to include any language // codes that do not include region, dialect, etc. preferred.push(config.defaultLanguage); const expanded = Util.toLower(Util.expandLanguages(preferred)); // Perform a case insensitive search - this is necessary in the case // of browsers like Safari let i; let supportedPos; for (i = 0; i < expanded.length; i++) { supportedPos = supportedLowerCase.indexOf(expanded[i]); if (supportedPos > -1) { return supportedLanguages[supportedPos]; } } }, }, countryCode: { deps: ['defaultCountryCode'], fn: function(defaultCountryCode) { const countries = CountryUtil.getCountries(); return Object.keys(countries).includes(defaultCountryCode) ? defaultCountryCode : 'US'; }, }, mode: { deps: ['codeChallenge'], fn: function(codeChallenge) { if (codeChallenge) { return 'remediation'; } return 'relying-party'; } }, oauth2Enabled: { deps: ['clientId', 'authScheme', 'authClient'], fn: function(clientId, authScheme, authClient) { if (!clientId && authClient) { clientId = authClient.options.clientId; } return (!!clientId) && authScheme.toLowerCase() === 'oauth2'; }, cache: true, }, // Redirect Uri to provide in the oauth API oauthRedirectUri: { deps: ['redirectUri'], fn: function(redirectUri) { if (redirectUri) { return redirectUri; } let origin = window.location.origin; // IE8 if (!origin) { const href = window.location.href; const path = window.location.pathname; if (path !== '') { origin = href.substring(0, href.lastIndexOf(path)); } } return encodeURI(origin); }, }, // Adjusts the idps passed into the widget based on if they get explicit support configuredSocialIdps: { deps: ['idps'], fn: function(idps) { return _.map(idps, function(idpConfig) { const idp = _.clone(idpConfig); let type = idp.type && idp.type.toLowerCase(); if (!(type && _.contains(IDP.SUPPORTED_SOCIAL_IDPS, type))) { type = 'general-idp'; idp.text = idp.text || '{ Please provide a text value }'; } idp.className = [ 'social-auth-button', 'social-auth-' + type + '-button ', idp.className ? idp.className : '', ].join(' '); idp.dataAttr = 'social-auth-' + type + '-button'; idp.i18nKey = 'socialauth.' + type + '.label'; return idp; }); }, cache: true, }, // Can support piv authentication hasPivCard: { deps: ['piv'], fn: function(piv) { return piv && piv.certAuthUrl; }, cache: true, }, // social auth buttons order - 'above'/'below' the primary auth form (boolean) socialAuthPositionTop: { deps: ['configuredSocialIdps', 'hasPivCard', 'idpDisplay'], fn: function(configuredSocialIdps, hasPivCard, idpDisplay) { return (!_.isEmpty(configuredSocialIdps) || hasPivCard) && idpDisplay.toUpperCase() === 'PRIMARY'; }, cache: true, }, hasConfiguredButtons: { deps: ['configuredSocialIdps', 'customButtons', 'hasPivCard'], fn: function(configuredSocialIdps, customButtons, hasPivCard) { return !_.isEmpty(configuredSocialIdps) || !_.isEmpty(customButtons) || hasPivCard; }, cache: true, } }; type SettingsProps = typeof local & typeof derived; export default class Settings extends Model { authClient:OktaAuth; constructor(attributes, options) { // base class constructor will call preinitialize() and initialize() and compute derived properties super(attributes, options); // apply languageCode const authClient = this.getAuthClient(); this.setAcceptLanguageHeader(authClient); } get<A extends Backbone._StringKey<SettingsProps>>(attributeName: A): any { return Model.prototype.get.call(this, attributeName); } preinitialize(...args) { this.local = local; this.derived = derived; Model.prototype.preinitialize.apply(this, args); } initialize(options) { options = options || {}; const { colors, authClient } = options; let { baseUrl } = options; if (!baseUrl) { // infer baseUrl from the issuer // favor authClient API first if (authClient) { baseUrl = authClient.getIssuerOrigin(); } else { // issuer can also be passed a top-level option or exist in authParams const { authParams } = options; let { issuer } = options; issuer = issuer || authParams?.issuer; baseUrl = issuer?.split('/oauth2/')[0]; } this.set('baseUrl', baseUrl); } if (!baseUrl) { this.callGlobalError(new ConfigError(loc('error.required.baseUrl'))); } else if (colors && _.isString(colors.brand) && !colors.brand.match(/^#[0-9A-Fa-f]{6}$/)) { this.callGlobalError(new ConfigError(loc('error.invalid.colors.brand'))); } else if (BrowserFeatures.corsIsNotSupported()) { this.callGlobalError(new UnsupportedBrowserError(loc('error.unsupported.cors'))); } } setAcceptLanguageHeader(authClient) { if (authClient) { authClient.http.setRequestHeader('Accept-Language', this.get('languageCode')); } } setAuthClient(authClient) { this.set('authClient', authClient); this.setAcceptLanguageHeader(authClient); } getAuthClient() { return this.get('authClient'); } set(...args: any[]) { try { return Model.prototype.set.apply(this, args); } catch (e) { const message = e.message ? e.message : e; this.callGlobalError(new ConfigError(message)); } } // Invokes the global success function. This should only be called on a // terminal part of the code (i.e. authStatus SUCCESS or after sending // a recovery email) callGlobalSuccess(status, data) { const res = _.extend(data, { status: status }); // Defer this to ensure that our functions have rendered completely // before invoking their function _.defer(_.partial(this.get('globalSuccessFn'), res)); } // Invokes the global error function. This should only be called on non // recoverable errors (i.e. configuration errors, browser unsupported // errors, etc) callGlobalError(err) { const globalErrorFn = this.get('globalErrorFn') || this.options.globalErrorFn; // Note: Must use "this.options.globalErrorFn" when they've passed invalid // arguments - globalErrorFn will not have been set yet if (globalErrorFn) { globalErrorFn(err); } else { // Only throw the error if they have not registered a globalErrorFn throw err; } } // Get the username by applying the transform function if it exists. transformUsername(username, operation) { const transformFn = this.get('transformUsername'); if (transformFn && _.isFunction(transformFn)) { return transformFn(username, operation); } return username; } processCreds(creds) { const processCreds = this.get('processCreds'); return Q.Promise(function(resolve) { if (!_.isFunction(processCreds)) { resolve(); } else if (processCreds.length === 2) { processCreds(creds, resolve); } else { processCreds(creds); resolve(); } }); } parseRegistrationSchema(schema, onSuccess, onFailure) { const parseSchema = this.get('registration.parseSchema'); //check for parseSchema callback if (_.isFunction(parseSchema)) { parseSchema( schema, function(schema) { onSuccess(schema); }, function(error) { error = error || { errorSummary: loc('registration.default.callbackhook.error') }; error['callback'] = 'parseSchema'; onFailure(error); } ); } else { //no callback onSuccess(schema); } } preRegistrationSubmit(postData, onSuccess, onFailure) { const preSubmit = this.get('registration.preSubmit'); //check for preSubmit callback if (_.isFunction(preSubmit)) { preSubmit( postData, function(postData) { onSuccess(postData); }, function(error) { error = error || { errorSummary: loc('registration.default.callbackhook.error') }; error['callback'] = 'preSubmit'; onFailure(error); } ); } else { //no callback onSuccess(postData); } } postRegistrationSubmit(response, onSuccess, onFailure) { const postSubmit = this.get('registration.postSubmit'); //check for postSubmit callback if (_.isFunction(postSubmit)) { postSubmit( response, function(response) { onSuccess(response); }, function(error) { error = error || { errorSummary: loc('registration.default.callbackhook.error') }; error['callback'] = 'postSubmit'; onFailure(error); } ); } else { //no callback onSuccess(response); } } // Use the parse function to transform config options to the standard // settings we currently support. This is a good place to deprecate old // option formats. parse(options) { if (options.labels || options.country) { Logger.deprecate('Use "i18n" instead of "labels" and "country"'); const overrides = options.labels || {}; _.each(options.country, function(val, key) { overrides['country.' + key] = val; }); // Old behavior is to treat the override as a global override, so we // need to add these overrides to each language options.i18n = {}; _.each(config.supportedLanguages, function(language) { options.i18n[language] = overrides; }); delete options.labels; delete options.country; } // Default the assets.baseUrl to the cdn, or remove any trailing slashes if (!options.assets) { options.assets = {}; } const abu = options.assets.baseUrl; if (!abu) { options.assets.baseUrl = assetBaseUrlTpl({ version: config.version }); } else if (abu[abu.length - 1] === '/') { options.assets.baseUrl = abu.substring(0, abu.length - 1); } return options; } isDsTheme() { return false; } }