diff --git a/CHANGELOG.md b/CHANGELOG.md index 6470096c57..ee72eef0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ - Removes signOut option `clearTokensAfterRedirect` - Adds signOut option `clearTokensBeforeRedirect` (default: `false`) to remove local tokens before logout redirect happen - [#1057](https://github.com/okta/okta-auth-js/pull/1057) Strict checks are now enabled in the Typescript compiler options. Some type signatures have been changed to match current behavior. +- [#1062](https://github.com/okta/okta-auth-js/pull/1062) + - Authn method `introspect` is renamed to `introspectAuthn` (still callable as `tx.introspect`) + - `IdxFeature` enum is now defined as strings instead of numbers ### Features @@ -25,6 +28,9 @@ - `autoRemediate`. If false, there will be no attempt to satisfy remediations even if values have been passed. - TransactionManager supports new option: - `saveLastResponse`. If false, IDX responses will not be cached. +- [#1062](https://github.com/okta/okta-auth-js/pull/1062) + - All IDX methods are exported. + - `useInteractionCodeFlow` defaults to `true` for sample and test apps. ## 5.10.1 diff --git a/lib/OktaAuth.ts b/lib/OktaAuth.ts index e2d7633174..9045a4886a 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -52,7 +52,7 @@ import { transactionStatus, resumeTransaction, transactionExists, - introspect, + introspectAuthn, postToTransaction, AuthTransaction } from './tx'; @@ -101,7 +101,7 @@ import TransactionManager from './TransactionManager'; import { buildOptions } from './options'; import { interact, - introspect as introspectV2, + introspect, authenticate, cancel, poll, @@ -168,7 +168,7 @@ class OktaAuth implements SDKInterface, SigninAPI, SignoutAPI { return storage.get(name); } }), - introspect: introspect.bind(null, this) + introspect: introspectAuthn.bind(null, this) }; this.pkce = { @@ -285,7 +285,7 @@ class OktaAuth implements SDKInterface, SigninAPI, SignoutAPI { const boundStartTransaction = startTransaction.bind(null, this); this.idx = { interact: interact.bind(null, this), - introspect: introspectV2.bind(null, this), + introspect: introspect.bind(null, this), authenticate: authenticate.bind(null, this), register: register.bind(null, this), start: boundStartTransaction, @@ -357,12 +357,13 @@ class OktaAuth implements SDKInterface, SigninAPI, SignoutAPI { this.options.headers = Object.assign({}, this.options.headers, headers); } + + // Authn V1 async signIn(opts: SigninOptions): Promise { - // TODO: support interaction code flow - // Authn V1 flow return this.signInWithCredentials(opts as SigninWithCredentialsOptions); } + // Authn V1 async signInWithCredentials(opts: SigninWithCredentialsOptions): Promise { opts = clone(opts || {}); const _postToTransaction = (options?) => { diff --git a/lib/idx/types/index.ts b/lib/idx/types/index.ts index a8c7ef05ec..a4939afd78 100644 --- a/lib/idx/types/index.ts +++ b/lib/idx/types/index.ts @@ -74,9 +74,9 @@ export type NextStep = { } export enum IdxFeature { - PASSWORD_RECOVERY, - REGISTRATION, - SOCIAL_IDP, + PASSWORD_RECOVERY = 'recover-password', + REGISTRATION = 'enroll-profile', + SOCIAL_IDP = 'redirect-idp', } export interface IdxTransaction { diff --git a/lib/index.ts b/lib/index.ts index e4148c8f2c..538bf2648c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -15,6 +15,7 @@ import * as crypto from './crypto'; export { default as OktaAuth } from './OktaAuth'; export * from './constants'; +export * from './idx'; export * from './types'; export * from './tx'; export * from './errors'; diff --git a/lib/tx/api.ts b/lib/tx/api.ts index 80b42fd510..157dc6836b 100644 --- a/lib/tx/api.ts +++ b/lib/tx/api.ts @@ -18,12 +18,12 @@ import { STATE_TOKEN_KEY_NAME } from '../constants'; import { addStateToken } from './util'; import { AuthTransaction } from './AuthTransaction'; -function transactionStatus(sdk, args) { +export function transactionStatus(sdk, args) { args = addStateToken(sdk, args); return post(sdk, sdk.getIssuerOrigin() + '/api/v1/authn', args, { withCredentials: true }); } -function resumeTransaction(sdk, args) { +export function resumeTransaction(sdk, args) { if (!args || !args.stateToken) { var stateToken = sdk.tx.exists._get(STATE_TOKEN_KEY_NAME); if (stateToken) { @@ -40,7 +40,7 @@ function resumeTransaction(sdk, args) { }); } -function introspect (sdk, args) { +export function introspectAuthn (sdk, args) { if (!args || !args.stateToken) { var stateToken = sdk.tx.exists._get(STATE_TOKEN_KEY_NAME); if (stateToken) { @@ -57,29 +57,21 @@ function introspect (sdk, args) { }); } -function transactionStep(sdk, args) { +export function transactionStep(sdk, args) { args = addStateToken(sdk, args); // v1 pipeline introspect API return post(sdk, sdk.getIssuerOrigin() + '/api/v1/authn/introspect', args, { withCredentials: true }); } -function transactionExists(sdk) { +export function transactionExists(sdk) { // We have a cookie state token return !!sdk.tx.exists._get(STATE_TOKEN_KEY_NAME); } -function postToTransaction(sdk, url, args, options?) { +export function postToTransaction(sdk, url, args, options?) { options = Object.assign({ withCredentials: true }, options); return post(sdk, url, args, options) .then(function(res) { return new AuthTransaction(sdk, res); }); } - -export { - transactionStatus, - resumeTransaction, - transactionExists, - postToTransaction, - introspect, -}; diff --git a/samples/config.js b/samples/config.js index 35a52034aa..56f7e3400a 100644 --- a/samples/config.js +++ b/samples/config.js @@ -17,7 +17,7 @@ const defaults = { const spaDefaults = Object.assign({ redirectPath: '/login/callback', - flow: 'redirect', + authMethod: 'form', scopes: ['openid', 'email'], storage: 'sessionStorage', requireUserSession: true, diff --git a/samples/generated/static-spa/README.md b/samples/generated/static-spa/README.md index 4d7a4b47b3..3d41bf11f9 100644 --- a/samples/generated/static-spa/README.md +++ b/samples/generated/static-spa/README.md @@ -28,20 +28,20 @@ The following parameters are accepted by this app: * `issuer` - (string) - set the issuer * `storage` - ("memory"|"sessionStorage"|"localStorage"|"cookie") - set the `storage` option for the `TokenManager` token storage * `requireUserSession` - (true|false) - by default, a user will be considered authenticated if there are tokens in storage. This check does not require a network request. If the `requireUserSession` option is set to `true`, an additional check will be done to verify that the user has a valid Okta SSO -* `flow` - ("redirect"|"form"|"widget") - set the authorization flow +* `authMethod` - ("redirect"|"form"|"widget") - set the method used to sign in the user. -## Authorization flows +## Authorization methods -Okta supports several methods of authentication. An authorization "flow" begins with one of these methods and ends when the app receives OIDC tokens. This sample demonstrates how to authenticate using the following flows: +Okta supports several methods of authentication. An authorization flow begins with one of these methods and ends when the app receives OIDC tokens. This sample demonstrates how to authenticate using the following methods: ### Redirect -Redirecting to Okta for authentication means your app does not need to provide any UI for signin. The signin page hosted by Okta will handle all details such as collecting credentials and multi-factor challenges and redirects back to your app on success. +Redirecting to Okta for authentication means your app does not need to provide any UI for signin. The sign-in page hosted by Okta will handle all details such as collecting credentials and multi-factor challenges and redirects back to your app on success. -### Self-hosted signin widget +### Embedded Sign-in Widget -The [Okta signin widget](https://github.com/okta/okta-signin-widget) can be embedded within your app. This provides the same signin experience as the Okta-hosted signin page within your app's UI and avoids a redirect round-trip. +The [Okta Sign-in Widget](https://github.com/okta/okta-signin-widget) can be embedded within your app. This provides the same sign-in experience as the Okta-hosted sign-in page within your app's UI and avoids a redirect round-trip. -### Custom signin form +### Custom forms -This is a standard form which collects username and password. The UI is completely controlled by the app, including error handling. This flow will not work if MFA (multi-factor authentication) is enabled for this app. +These are a standard forms which collects username, password and other credentials for sign-in. The UI is completely controlled by the app, including error handling. diff --git a/samples/generated/static-spa/public/app.js b/samples/generated/static-spa/public/app.js index 5032b016a0..e840cc4700 100644 --- a/samples/generated/static-spa/public/app.js +++ b/samples/generated/static-spa/public/app.js @@ -46,12 +46,13 @@ var config = { clientId: '', scopes: ['openid','email'], storage: 'sessionStorage', + useInteractionCodeFlow: true, requireUserSession: 'true', - flow: 'redirect', + authMethod: 'form', startService: false, + useDynamicForm: false, uniq: Date.now() + Math.round(Math.random() * 1000), // to guarantee a unique state idps: '', - useInteractionCodeFlow: false, }; /* eslint-disable max-statements,complexity */ @@ -71,11 +72,13 @@ function loadConfig() { var clientId; var appUri; var storage; - var flow; + var authMethod; var startService; var requireUserSession; var scopes; var useInteractionCodeFlow; + var useDynamicForm; + var idps; var state; @@ -90,11 +93,12 @@ function loadConfig() { issuer = state.issuer; clientId = state.clientId; storage = state.storage; - flow = state.flow; + authMethod = state.authMethod; startService = state.startService; requireUserSession = state.requireUserSession; scopes = state.scopes; useInteractionCodeFlow = state.useInteractionCodeFlow; + useDynamicForm = state.useDynamicForm; config.uniq = state.uniq; idps = state.idps; } else { @@ -103,12 +107,13 @@ function loadConfig() { issuer = url.searchParams.get('issuer') || config.issuer; clientId = url.searchParams.get('clientId') || config.clientId; storage = url.searchParams.get('storage') || config.storage; - flow = url.searchParams.get('flow') || config.flow; + authMethod = url.searchParams.get('authMethod') || config.authMethod; startService = url.searchParams.get('startService') === 'true' || config.startService; requireUserSession = url.searchParams.get('requireUserSession') ? url.searchParams.get('requireUserSession') === 'true' : config.requireUserSession; scopes = url.searchParams.get('scopes') ? url.searchParams.get('scopes').split(' ') : config.scopes; useInteractionCodeFlow = url.searchParams.get('useInteractionCodeFlow') === 'true' || config.useInteractionCodeFlow; + useDynamicForm = url.searchParams.get('useDynamicForm') === 'true' || config.useDynamicForm; idps = url.searchParams.get('idps') || config.idps; } // Create a canonical app URI that allows clean reloading with this config @@ -117,10 +122,11 @@ function loadConfig() { clientId, storage, requireUserSession, - flow, + authMethod, startService, scopes: scopes.join(' '), useInteractionCodeFlow, + useDynamicForm, idps, }).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&'); // Add all app options to the state, to preserve config across redirects @@ -130,10 +136,11 @@ function loadConfig() { clientId, storage, requireUserSession, - flow, + authMethod, startService, scopes, useInteractionCodeFlow, + useDynamicForm, idps, }; var newConfig = {}; @@ -167,7 +174,7 @@ function showForm() { document.getElementById('scopes').value = config.scopes.join(' '); document.getElementById('idps').value = config.idps; try { - document.querySelector(`#flow [value="${config.flow || ''}"]`).selected = true; + document.querySelector(`#authMethod [value="${config.authMethod || ''}"]`).selected = true; } catch (e) { showError(e); } if (config.startService) { @@ -191,23 +198,33 @@ function showForm() { document.getElementById('useInteractionCodeFlow-off').checked = true; } + if (config.useDynamicForm) { + document.getElementById('useDynamicForm-on').checked = true; + } else { + document.getElementById('useDynamicForm-off').checked = true; + } + + // Show the form document.getElementById('config-form').style.display = 'block'; // show form - onChangeFlow(); + onChangeAuthMethod(); } -function onChangeFlow() { - const flow = document.getElementById('flow').value; - const display = flow == 'widget' ? 'inline-block' : 'none'; - document.getElementById('idps').style.display = display; - document.querySelector(`label[for=idps]`).style.display = display; +function onChangeAuthMethod() { + const authMethod = document.getElementById('authMethod').value; + document.querySelector('#form .field-useDynamicForm').style.display = authMethod == 'form' ? 'block' : 'none'; + document.querySelector('#form .field-idps').style.display = authMethod == 'widget' ? 'block' : 'none'; } -window._onChangeFlow = onChangeFlow; +window._onChangeAuthMethod = onChangeAuthMethod; // Keep us in the same tab function onSubmitForm(event) { event.preventDefault(); + + // clear transaction data to prevent odd behavior when switching to static form + sessionStorage.clear(); + // eslint-disable-next-line no-new new FormData(document.getElementById('form')); // will fire formdata event } @@ -457,7 +474,7 @@ function renewToken() { window._renewToken = bindClick(renewToken); function beginAuthFlow() { - switch (config.flow) { + switch (config.authMethod) { case 'redirect': showRedirectButton(); break; @@ -480,7 +497,7 @@ function endAuthFlow(tokens) { } function showRedirectButton() { - document.getElementById('flow-redirect').style.display = 'block'; + document.getElementById('authMethod-redirect').style.display = 'block'; } function logout(e) { @@ -576,7 +593,7 @@ function showSigninWidget(options) { el: '#signin-widget' }) .then(function(response) { - document.getElementById('flow-widget').style.display = 'none'; + document.getElementById('authMethod-widget').style.display = 'none'; signIn.remove(); endAuthFlow(response.tokens); }) @@ -584,7 +601,7 @@ function showSigninWidget(options) { console.log('login error', error); }); - document.getElementById('flow-widget').style.display = 'block'; // show login UI + document.getElementById('authMethod-widget').style.display = 'block'; // show login UI } function resumeTransaction(options) { if (!config.useInteractionCodeFlow) { @@ -608,22 +625,33 @@ function showSigninForm(options) { hideRecoveryChallenge(); hideNewPasswordForm(); - // Is there an existing transaction we can resume? - if (resumeTransaction(options)) { + // Authn must use static login form + if (config.useDynamicForm === false || !config.useInteractionCodeFlow) { + // Is there an existing transaction we can resume? If so, we will be in MFA flow + if (resumeTransaction(options)) { + return; + } + document.getElementById('static-signin-form').style.display = 'block'; return; } - document.getElementById('login-form').style.display = 'block'; + // Dynamic form + document.getElementById('static-signin-form').style.display = 'none'; + renderDynamicSigninForm(); // will be empty until first server response + return authClient.idx.authenticate() + .then(handleTransaction) + .catch(showError); } window._showSigninForm = bindClick(showSigninForm); function hideSigninForm() { - document.getElementById('login-form').style.display = 'none'; + document.getElementById('static-signin-form').style.display = 'none'; + document.getElementById('dynamic-signin-form').style.display = 'none'; } -function submitSigninForm() { - const username = document.getElementById('username').value; - const password = document.getElementById('password').value; +function submitStaticSigninForm() { + const username = document.querySelector('#static-signin-form input[name=username]').value; + const password = document.querySelector('#static-signin-form input[name=password]').value; if (!config.useInteractionCodeFlow) { // Authn @@ -637,7 +665,44 @@ function submitSigninForm() { .catch(showError); } -window._submitSigninForm = bindClick(submitSigninForm); +window._submitStaticSigninForm = bindClick(submitStaticSigninForm); + +function renderDynamicSigninForm(transaction) { + document.getElementById('dynamic-signin-form').style.display = 'block'; + [ + '.field-username', + '.field-password', + '.link-recover-password', + '.link-signin' + ].forEach(function(key) { + document.querySelector(`#dynamic-signin-form ${key}`).style.display = 'none'; + }); + if (!transaction) { + return; + } + const inputs = transaction.nextStep.inputs; + if (inputs.some(input => input.name === 'username')) { + document.querySelector('#dynamic-signin-form .field-username').style.display = 'block'; + } + if (inputs.some(input => input.name === 'password')) { + document.querySelector('#dynamic-signin-form .field-password').style.display = 'block'; + } + if (transaction.enabledFeatures.includes('recover-password')) { + document.querySelector('#dynamic-signin-form .link-recover-password').style.display = 'inline-block'; + } + document.querySelector('#dynamic-signin-form .link-signin').style.display = 'inline-block'; +} + +function submitDynamicSigninForm() { + const username = document.querySelector('#dynamic-signin-form input[name=username]').value; + const password = document.querySelector('#dynamic-signin-form input[name=password]').value; + hideSigninForm(); + return authClient.idx.authenticate({ username, password }) + .then(handleTransaction) + .catch(showError); + +} +window._submitDynamicSigninForm = bindClick(submitDynamicSigninForm); function handleTransaction(transaction) { if (!config.useInteractionCodeFlow) { @@ -652,6 +717,10 @@ function handleTransaction(transaction) { switch (transaction.status) { case 'PENDING': + if (transaction.nextStep.name === 'identify') { + renderDynamicSigninForm(transaction); + break; + } hideSigninForm(); updateAppState({ transaction }); showMfa(); @@ -1285,7 +1354,12 @@ function submitChallengeAuthenticator() { } function showRecoverPassword() { // Copy username from login form to recover password form - const username = document.querySelector('#login-form input[name=username]').value; + let username; + if (config.useDynamicForm && config.useInteractionCodeFlow) { + username = document.querySelector('#dynamic-signin-form input[name=username]').value; + } else { + username = document.querySelector('#static-signin-form input[name=username]').value; + } document.querySelector('#recover-password-form input[name=recover-username]').value = username; hideSigninForm(); diff --git a/samples/generated/static-spa/public/index.html b/samples/generated/static-spa/public/index.html index 5ed9402391..ebe59a338f 100644 --- a/samples/generated/static-spa/public/index.html +++ b/samples/generated/static-spa/public/index.html @@ -2,8 +2,8 @@ - - + + @@ -96,17 +96,27 @@
- - +
+
+ +
-
- - +
+ + YES + NO +
+ +
+ +
@@ -133,39 +143,50 @@ NO
-
- - -
-
- -