@okta-dfuhriman/okta-auth-js
Version:
The Okta Auth SDK
618 lines (538 loc) • 19.5 kB
text/typescript
/* 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;
}
}