From a3449d65083f6f42833ab2cb71a7117c8e47c9e3 Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Wed, 19 Oct 2022 13:02:09 +0300 Subject: [PATCH 01/11] wip lint fix lint fix --- README.md | 54 ++++++++++++++++++++++++++--- lib/oidc/endpoints/authorize.ts | 5 +-- lib/oidc/enrollAuthenticator.ts | 31 +++++++++++++++++ lib/oidc/factory/api.ts | 27 +++++++++++---- lib/oidc/handleOAuthResponse.ts | 5 +-- lib/oidc/mixin/index.ts | 6 ++-- lib/oidc/types/api.ts | 17 ++++++++- lib/oidc/types/options.ts | 8 ++++- lib/oidc/types/proto.ts | 1 + lib/oidc/util/prepareTokenParams.ts | 31 ++++++++++++++--- test/apps/app/src/config.ts | 3 ++ test/apps/app/src/form.ts | 4 +++ test/apps/app/src/testApp.ts | 30 ++++++++++++++-- test/types/token.test-d.ts | 12 +++++++ 14 files changed, 209 insertions(+), 25 deletions(-) create mode 100644 lib/oidc/enrollAuthenticator.ts diff --git a/README.md b/README.md index 15e60f351..f0c48d739 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ var authClient = new OktaAuth(config); ### Running as a service -By default, creating a new instance of `OktaAuth` will not create any asynchronous side-effects. However, certain features such as [token auto renew](#autorenew), [token auto remove](#autoremove) and [cross-tab synchronization](#syncstorage) require `OktaAuth` to be running as a service. This means timeouts are set in the background which will continue working until the service is stopped. To start the `OktaAuth` service, simply call the `start` method right after creation and before calling other methods like [handleLoginRedirect](#handleloginredirecttokens). To terminate all background processes, call `stop`. See [Service Configuration](#services) for more info. +By default, creating a new instance of `OktaAuth` will not create any asynchronous side-effects. However, certain features such as [token auto renew](#autorenew), [token auto remove](#autoremove) and [cross-tab synchronization](#syncstorage) require `OktaAuth` to be running as a service. This means timeouts are set in the background which will continue working until the service is stopped. To start the `OktaAuth` service, simply call the `start` method right after creation and before calling other methods like [handleLoginRedirect](#handleloginredirecttokens-originaluri). To terminate all background processes, call `stop`. See [Service Configuration](#services) for more info. ```javascript var authClient = new OktaAuth(config); @@ -536,7 +536,7 @@ oktaAuth.authStateManager.updateAuthState(); > :link: web browser only
-Callback function. When [sdk.handleLoginRedirect](#handleloginredirecttokens) is called, by default it uses `window.location.replace` to redirect back to the [originalUri](#setoriginaluriuri). This option overrides the default behavior. +Callback function. When [sdk.handleLoginRedirect](#handleloginredirecttokens-originaluri) is called, by default it uses `window.location.replace` to redirect back to the [originalUri](#setoriginaluriuri). This option overrides the default behavior. ```javascript const config = { @@ -891,7 +891,7 @@ This is accomplished by selecting a single tab to handle the network requests to * [getOriginalUri](#getoriginaluristate) * [removeOriginalUri](#removeoriginaluri) * [isLoginRedirect](#isloginredirect) -* [handleLoginRedirect](#handleloginredirecttokens) +* [handleLoginRedirect](#handleloginredirecttokens-originaluri) * [setHeaders](#setheaders) * [tx.resume](#txresume) * [tx.exists](#txexists) @@ -915,6 +915,7 @@ This is accomplished by selecting a single tab to handle the network requests to * [token.isLoginRedirect](#tokenisloginredirect) * [token.prepareTokenParams](#tokenpreparetokenparams) * [token.exchangeCodeForTokens](#tokenexchangecodefortokens) + * [token.enrollAuthenticator](#tokenenrollauthenticatoroptions) * [tokenManager](#tokenmanager-api) * [tokenManager.add](#tokenmanageraddkey-token) * [tokenManager.get](#tokenmanagergetkey) @@ -1315,9 +1316,10 @@ The following configuration options can be included in `token.getWithoutPrompt`, | `idp` | Identity provider to use if there is no Okta Session. | | `idpScope` | A space delimited list of scopes to be provided to the Social Identity Provider when performing [Social Login][social-login] These scopes are used in addition to the scopes already configured on the Identity Provider. | | `display` | The display parameter to be passed to the Social Identity Provider when performing [Social Login][social-login]. | -| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. | +| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [token.enrollAuthenticator](#tokenenrollauthenticatoroptions). | | `maxAge` | Allowable elapsed time, in seconds, since the last time the end user was actively authenticated by Okta. | | `acrValues` | [[EA][early-access]] Optional parameter to increase the level of user assurance. See [Predefined ACR values](https://developer.okta.com/docs/guides/step-up-authentication/main/#predefined-parameter-values) for more information. | +| `enrollAmrValues` | List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [token.enrollAuthenticator](#tokenenrollauthenticatoroptions) | | `loginHint` | A username to prepopulate if prompting for authentication. | For more details, see Okta's [Authorize Request API](https://developer.okta.com/docs/api/resources/oidc#request-parameters). @@ -1597,6 +1599,50 @@ Returns a `TokenParams` object. If `PKCE` is enabled, this object will contain v Used internally to perform the final step of the `PKCE` authorization code flow. Accepts a `TokenParams` object which should contain a `codeVerifier` and an `authorizationCode`. +#### `token.enrollAuthenticator(options)` + +> :link: web browser only
+> :hourglass: async + +Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). URL will not contain any tokens. You can use [sdk.handleLoginRedirect](#handleloginredirecttokens-originaluri). + +* `options` - See [Authorize options](#authorize-options) + + Options that will be omitted: `scopes`, `nonce`. + + Options that will be overridden: `responseType: 'none', prompt: 'enroll_authenticator'`. + + ##### `options.enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). + + List of AMR values: + | AMR Value | Authenticator | + | ------------- | -------------------- | + | `pwd` | Okta Password | + | `kba` | Security question | + | `email` | Okta Email | + | `sms` | SMS | + | `tel` | Voice call | + | `duo` | DUO | + | `symantec` | Symantec VIP | + | `google_otp` | Google Authenticator | + | `okta_verify` | Okta Verify | + | `pop` | WebAuthn | + | `oath_otp` | On-Prem MFA | + | `rsa` | RSA SecurID | + | `yubikey` | Yubikey | + | `otp` | Custom HOTP | + | `fed` | External IdP | + | `sc` | SmartCard/PIV | + +```javascript +authClient.token.enrollAuthenticator({ + enrollAmrValues: ['okta_verify'] +}) +.catch(function(err) { + // handle AuthSdkError +}); +``` + ### `tokenManager` API #### `tokenManager.add(key, token)` diff --git a/lib/oidc/endpoints/authorize.ts b/lib/oidc/endpoints/authorize.ts index 8814bffba..b5d8dd642 100644 --- a/lib/oidc/endpoints/authorize.ts +++ b/lib/oidc/endpoints/authorize.ts @@ -44,10 +44,11 @@ export function convertTokenParamsToOAuthParams(tokenParams: TokenParams) { 'sessionToken': tokenParams.sessionToken, 'state': tokenParams.state, 'acr_values': tokenParams.acrValues, + 'enroll_amr_values': tokenParams.enrollAmrValues, }; oauthParams = removeNils(oauthParams) as OAuthParams; - ['idp_scope', 'response_type'].forEach(function (mayBeArray) { + ['idp_scope', 'response_type', 'enroll_amr_values'].forEach(function (mayBeArray) { if (Array.isArray(oauthParams[mayBeArray])) { oauthParams[mayBeArray] = oauthParams[mayBeArray].join(' '); } @@ -56,7 +57,7 @@ export function convertTokenParamsToOAuthParams(tokenParams: TokenParams) { if (tokenParams.responseType!.indexOf('id_token') !== -1 && tokenParams.scopes!.indexOf('openid') === -1) { throw new AuthSdkError('openid scope must be specified in the scopes argument when requesting an id_token'); - } else { + } else if (tokenParams.responseType! !== 'none') { oauthParams.scope = tokenParams.scopes!.join(' '); } diff --git a/lib/oidc/enrollAuthenticator.ts b/lib/oidc/enrollAuthenticator.ts new file mode 100644 index 000000000..1b7740da7 --- /dev/null +++ b/lib/oidc/enrollAuthenticator.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/*! + * Copyright (c) 2015-present, 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 { OktaAuthOAuthInterface, EnrollAuthenticatorOptions } from './types'; +import { clone } from '../util'; +import { prepareTokenParams, createOAuthMeta } from './util'; +import { buildAuthorizeParams } from './endpoints/authorize'; + +export async function enrollAuthenticator( + sdk: OktaAuthOAuthInterface, + options: EnrollAuthenticatorOptions +): Promise { + options = clone(options) || {}; + options.prompt = 'enroll_authenticator'; + + const tokenParams = await prepareTokenParams(sdk, options); + const meta = createOAuthMeta(sdk, tokenParams); + const requestUrl = meta.urls.authorizeUrl + buildAuthorizeParams(tokenParams); + sdk.transactionManager.save(meta); + sdk.token.enrollAuthenticator._setLocation(requestUrl); +} diff --git a/lib/oidc/factory/api.ts b/lib/oidc/factory/api.ts index 8b572f6fa..57a4874d9 100644 --- a/lib/oidc/factory/api.ts +++ b/lib/oidc/factory/api.ts @@ -18,6 +18,7 @@ import { getUserInfo } from '../getUserInfo'; import { getWithoutPrompt } from '../getWithoutPrompt'; import { getWithPopup } from '../getWithPopup'; import { getWithRedirect } from '../getWithRedirect'; +import { enrollAuthenticator } from '../enrollAuthenticator'; import { parseFromUrl } from '../parseFromUrl'; import { renewToken } from '../renewToken'; import { renewTokens } from '../renewTokens'; @@ -28,6 +29,8 @@ import { CustomUserClaims, GetWithRedirectAPI, GetWithRedirectFunction, + EnrollAuthenticatorAPI, + EnrollAuthenticatorFunction, IDToken, OktaAuthOAuthInterface, ParseFromUrlInterface, @@ -43,17 +46,26 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) return PromiseQueue.prototype.push.bind(queue, method, null); }; + const _setLocation = (url) => { + if (sdk.options.setLocation) { + sdk.options.setLocation(url); + } else { + window.location = url; + } + }; + const getWithRedirectFn = useQueue(getWithRedirect.bind(null, sdk)) as GetWithRedirectFunction; const getWithRedirectApi: GetWithRedirectAPI = Object.assign(getWithRedirectFn, { // This is exposed so we can set window.location in our tests - _setLocation: (url) => { - if (sdk.options.setLocation) { - sdk.options.setLocation(url); - } else { - window.location = url; - } - } + _setLocation }); + + const enrollAuthenticatorFn = useQueue(enrollAuthenticator.bind(null, sdk)) as EnrollAuthenticatorFunction; + const enrollAuthenticatorApi: EnrollAuthenticatorAPI = Object.assign(enrollAuthenticatorFn, { + // This is exposed so we can set window.location in our tests + _setLocation + }); + // eslint-disable-next-line max-len const parseFromUrlFn = useQueue(parseFromUrl.bind(null, sdk)) as ParseFromUrlInterface; const parseFromUrlApi: ParseFromUrlInterface = Object.assign(parseFromUrlFn, { @@ -79,6 +91,7 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) getWithoutPrompt: getWithoutPrompt.bind(null, sdk), getWithPopup: getWithPopup.bind(null, sdk), getWithRedirect: getWithRedirectApi, + enrollAuthenticator: enrollAuthenticatorApi, parseFromUrl: parseFromUrlApi, decode: decodeToken, revoke: revokeToken.bind(null, sdk), diff --git a/lib/oidc/handleOAuthResponse.ts b/lib/oidc/handleOAuthResponse.ts index a0ad9100f..cfa7cb50f 100644 --- a/lib/oidc/handleOAuthResponse.ts +++ b/lib/oidc/handleOAuthResponse.ts @@ -62,7 +62,7 @@ export async function handleOAuthResponse( urls = urls || getOAuthUrls(sdk, tokenParams); let responseType = tokenParams.responseType || []; - if (!Array.isArray(responseType)) { + if (!Array.isArray(responseType) && responseType !== 'none') { responseType = [responseType]; } @@ -152,7 +152,8 @@ export async function handleOAuthResponse( return { tokens: tokenDict, state: res.state!, - code: res.code + code: res.code, + responseType }; } \ No newline at end of file diff --git a/lib/oidc/mixin/index.ts b/lib/oidc/mixin/index.ts index 099281419..6a274539e 100644 --- a/lib/oidc/mixin/index.ts +++ b/lib/oidc/mixin/index.ts @@ -180,8 +180,10 @@ export function mixinOAuth * Store parsed tokens from redirect url */ async storeTokensFromRedirect(): Promise { - const { tokens } = await this.token.parseFromUrl(); - this.tokenManager.setTokens(tokens); + const { tokens, responseType } = await this.token.parseFromUrl(); + if (responseType !== 'none') { + this.tokenManager.setTokens(tokens); + } } isLoginRedirect(): boolean { diff --git a/lib/oidc/types/api.ts b/lib/oidc/types/api.ts index 69103eebc..43644bd47 100644 --- a/lib/oidc/types/api.ts +++ b/lib/oidc/types/api.ts @@ -12,7 +12,14 @@ import { JWTObject } from './JWT'; import { OAuthTransactionMeta, PKCETransactionMeta } from './meta'; -import { CustomUrls, OktaAuthOAuthOptions, SigninWithRedirectOptions, TokenParams } from './options'; +import { + CustomUrls, + OktaAuthOAuthOptions, + SigninWithRedirectOptions, + EnrollAuthenticatorOptions, + TokenParams +} from './options'; +import { OAuthResponseType } from './proto'; import { OAuthStorageManagerInterface } from './storage'; import { AccessToken, IDToken, RefreshToken, RevocableToken, Token, Tokens } from './Token'; import { TokenManagerInterface } from './TokenManager'; @@ -29,6 +36,7 @@ export interface TokenResponse { tokens: Tokens; state: string; code?: string; + responseType?: OAuthResponseType | OAuthResponseType[] | 'none'; } export interface ParseFromUrlOptions { @@ -46,12 +54,18 @@ export interface ParseFromUrlInterface extends ParseFromUrlFunction { export type GetWithRedirectFunction = (params?: TokenParams) => Promise; +export type EnrollAuthenticatorFunction = (params: EnrollAuthenticatorOptions) => Promise; + export type SetLocationFunction = (loc: string) => void; export interface GetWithRedirectAPI extends GetWithRedirectFunction { _setLocation: SetLocationFunction; } +export interface EnrollAuthenticatorAPI extends EnrollAuthenticatorFunction { + _setLocation: SetLocationFunction; +} + export interface BaseTokenAPI { decode(token: string): JWTObject; prepareTokenParams(params?: TokenParams): Promise; @@ -64,6 +78,7 @@ export interface TokenAPI extends BaseTokenAPI { idToken?: IDToken ): Promise>; getWithRedirect: GetWithRedirectAPI; + enrollAuthenticator: EnrollAuthenticatorAPI; parseFromUrl: ParseFromUrlInterface; getWithoutPrompt(params?: TokenParams): Promise; getWithPopup(params?: TokenParams): Promise; diff --git a/lib/oidc/types/options.ts b/lib/oidc/types/options.ts index 03c4a2b86..85073a630 100644 --- a/lib/oidc/types/options.ts +++ b/lib/oidc/types/options.ts @@ -30,11 +30,13 @@ export interface TokenParams extends CustomUrls { pkce?: boolean; clientId?: string; redirectUri?: string; - responseType?: OAuthResponseType | OAuthResponseType[]; + responseType?: OAuthResponseType | OAuthResponseType[] | 'none'; responseMode?: OAuthResponseMode; state?: string; nonce?: string; scopes?: string[]; + acrValues?: string; + enrollAmrValues?: string | string[]; display?: string; ignoreSignature?: boolean; codeVerifier?: string; @@ -66,6 +68,10 @@ export interface TokenManagerOptions { syncStorage?: boolean; } +export interface EnrollAuthenticatorOptions extends TokenParams { + enrollAmrValues: string | string[]; +} + export interface SigninWithRedirectOptions extends TokenParams { originalUri?: string; } diff --git a/lib/oidc/types/proto.ts b/lib/oidc/types/proto.ts index 6fd23b761..e38bd9885 100644 --- a/lib/oidc/types/proto.ts +++ b/lib/oidc/types/proto.ts @@ -32,6 +32,7 @@ export interface OAuthParams { code?: string; interaction_code?: string; acr_values?: string; + enroll_amr_values?: string | string[]; } export interface OAuthResponse { diff --git a/lib/oidc/util/prepareTokenParams.ts b/lib/oidc/util/prepareTokenParams.ts index ca0c24df2..32848bf58 100644 --- a/lib/oidc/util/prepareTokenParams.ts +++ b/lib/oidc/util/prepareTokenParams.ts @@ -78,6 +78,28 @@ export async function preparePKCE( return tokenParams; } +function prepareEnrollAuthenticator( + sdk: OktaAuthOAuthInterface, + tokenParams: TokenParams +): TokenParams { + tokenParams = { + ...tokenParams, + responseType: 'none' // responseType is forced + }; + + if (!tokenParams.enrollAmrValues) { + throw new AuthSdkError('enroll_amr_values must be specified'); + } + if (tokenParams.maxAge && tokenParams.maxAge > 0) { + throw new AuthSdkError('max_age cannot be more than 0'); + } + // scope, nonce, and resource must be omitted + delete tokenParams.scopes; + delete tokenParams.nonce; + + return tokenParams; +} + // Prepares params for a call to /authorize or /token export async function prepareTokenParams( sdk: OktaAuthOAuthInterface, @@ -87,10 +109,11 @@ export async function prepareTokenParams( const defaults = getDefaultTokenParams(sdk); tokenParams = { ...defaults, ...tokenParams }; - if (tokenParams.pkce === false) { - // Implicit flow or authorization_code without PKCE - return tokenParams; + if (tokenParams.prompt === 'enroll_authenticator') { + tokenParams = prepareEnrollAuthenticator(sdk, tokenParams); + } else if (tokenParams.pkce) { + tokenParams = await preparePKCE(sdk, tokenParams); } - return preparePKCE(sdk, tokenParams); + return tokenParams; } \ No newline at end of file diff --git a/test/apps/app/src/config.ts b/test/apps/app/src/config.ts index 9d880e980..62fbd672f 100644 --- a/test/apps/app/src/config.ts +++ b/test/apps/app/src/config.ts @@ -31,6 +31,7 @@ export interface Config extends OktaAuthOptions { enableSharedStorage: boolean; // TransactionManager isTokenRenewPage?: boolean; // special lite /renew page to test cross-tab token renew crossTabsCount?: number; + enrollAmrValues?: string[]; } export function getDefaultConfig(): Config { @@ -86,6 +87,7 @@ export function getConfigFromUrl(): Config { const enableSharedStorage = url.searchParams.get('enableSharedStorage') !== 'false'; // on by default const syncStorage = url.searchParams.get('syncStorage') !== 'false'; // on by default const acrValues = url.searchParams.get('acrValues') || undefined; + const enrollAmrValues = (url.searchParams.get('enrollAmrValues') || '').split(','); let crossTabsCount = parseInt(url.searchParams.get('crossTabsCount')); if (isNaN(crossTabsCount)) { crossTabsCount = DEFAULT_CROSS_TABS_COUNT; @@ -100,6 +102,7 @@ export function getConfigFromUrl(): Config { defaultScopes, scopes, acrValues, + enrollAmrValues, responseType, responseMode, postLogoutRedirectUri, diff --git a/test/apps/app/src/form.ts b/test/apps/app/src/form.ts index 8aad59c73..ae68d598c 100644 --- a/test/apps/app/src/form.ts +++ b/test/apps/app/src/form.ts @@ -64,6 +64,9 @@ const Form = `
+ +
+
@@ -153,6 +156,7 @@ export function updateForm(origConfig: Config): void { (document.getElementById('f_responseType') as HTMLInputElement).value = config.responseType.join(','); (document.getElementById('f_scopes') as HTMLInputElement).value = config.scopes.join(','); (document.getElementById('f_acrValues') as HTMLInputElement).value = config.acrValues || ''; + (document.getElementById('f_enroll_amr_values') as HTMLInputElement).value = (config.enrollAmrValues || []).join(','); (document.getElementById('f_postLogoutRedirectUri') as HTMLInputElement).value = config.postLogoutRedirectUri; (document.getElementById('f_clientId') as HTMLInputElement).value = config.clientId; (document.getElementById('f_clientSecret') as HTMLInputElement).value = config.clientSecret; diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts index 9108116a9..f75205366 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -22,6 +22,7 @@ import { AccessToken, AuthnTransaction, TokenParams, + EnrollAuthenticatorOptions, isAuthorizationCodeError, IdxStatus, IdxTransaction, @@ -90,6 +91,9 @@ function loginLinks(app: TestApp, onProtectedPage?: boolean): string {
  • Test Concurrent Login
  • +
  • + Enroll Authenticator +
  • ${protectedPageLink}
    @@ -184,6 +188,7 @@ function bindFunctions(testApp: TestApp, window: Window): void { simulateCrossTabTokenRenew: testApp.simulateCrossTabTokenRenew.bind(testApp), startService: testApp.startService.bind(testApp), stopService: testApp.stopService.bind(testApp), + enrollAuthenticator: testApp.enrollAuthenticator.bind(testApp), }; Object.keys(boundFunctions).forEach(functionName => { (window as any)[functionName] = makeClickHandler((boundFunctions as any)[functionName]); @@ -807,7 +812,9 @@ class TestApp { async getTokensFromUrl(): Promise { // parseFromUrl() Will parse the authorization code from the URL fragment and exchange it for tokens const res = await this.oktaAuth.token.parseFromUrl(); - this.oktaAuth.tokenManager.setTokens(res.tokens); + if (res.responseType !== 'none') { + this.oktaAuth.tokenManager.setTokens(res.tokens); + } return res; } @@ -892,6 +899,20 @@ class TestApp { } } + async enrollAuthenticator(): Promise { + this.config.state = this.config.state || 'enroll-authenticator-redirect' + Math.round(Math.random() * 1000); + saveConfigToStorage(this.config); + const options: EnrollAuthenticatorOptions = Object.assign({}, { + state: this.config.state, + enrollAmrValues: this.config.enrollAmrValues, + }); + return this.oktaAuth.token.enrollAuthenticator(options) + .catch(e => { + this.renderError(e); + throw e; + }); + } + configHTML(): string { const config = htmlString(this.config); return ` @@ -947,6 +968,9 @@ class TestApp {
  • Simulate cross-tab token renew
  • +
  • + Enroll Authenticator +
  • ${protectedLink(this)} @@ -972,8 +996,10 @@ class TestApp { callbackHTML(res: TokenResponse): string { const tokensReceived = res.tokens ? Object.keys(res.tokens): []; + const isEnrollSuccess = res.responseType === 'none'; const success = res.tokens && tokensReceived.length; - const errorMessage = success ? '' : 'Tokens not returned. Check error console for more details'; + const errorMessage = isEnrollSuccess ? 'Authenticator enrollment completed' : + success ? '' : 'Tokens not returned. Check error console for more details'; const successMessage = success ? 'Successfully received tokens on the callback page: ' + tokensReceived.join(', ') : ''; const originalUri = this.oktaAuth.getOriginalUri(res.state); diff --git a/test/types/token.test-d.ts b/test/types/token.test-d.ts index eeefb3d2b..f9541c601 100644 --- a/test/types/token.test-d.ts +++ b/test/types/token.test-d.ts @@ -18,6 +18,7 @@ import { Tokens, UserClaims, TokenParams, + EnrollAuthenticatorOptions, TokenResponse, JWTObject, RefreshToken, @@ -86,6 +87,17 @@ const tokens = { expectType(await authClient.token.getWithRedirect(authorizeOptions)); expectType(await authClient.token.parseFromUrl()); + const enrollAuthenticatorOptons: EnrollAuthenticatorOptions = { + enrollAmrValues: ['email', 'kba'] + }; + expectType(await authClient.token.enrollAuthenticator(enrollAuthenticatorOptons)); + expectError(async () => { + await authClient.token.enrollAuthenticator({}); + }); + expectError(async () => { + await authClient.token.enrollAuthenticator(); + }); + const customUrls = { issuer: 'https://{yourOktaDomain}/oauth2/{authorizationServerId}', authorizeUrl: 'https://{yourOktaDomain}/oauth2/v1/authorize', From 1055fd3851eeed3404b80a60b2eb79021d5f861d Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Wed, 19 Oct 2022 15:53:55 +0300 Subject: [PATCH 02/11] added unit tests more tests for responseType none --- lib/oidc/types/meta.ts | 3 +- lib/oidc/util/oauthMeta.ts | 1 + lib/oidc/util/prepareTokenParams.ts | 11 ++- test/spec/OktaAuth/browser.ts | 7 ++ test/spec/oidc/endpoints/authorize.ts | 16 +++- test/spec/oidc/enrollAuthenticator.ts | 105 +++++++++++++++++++++ test/spec/oidc/util/handleOAuthResponse.ts | 10 ++ test/spec/oidc/util/oauthMeta.ts | 2 + test/spec/oidc/util/prepareTokenParams.ts | 71 +++++++++++++- test/types/token.test-d.ts | 5 + 10 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 test/spec/oidc/enrollAuthenticator.ts diff --git a/lib/oidc/types/meta.ts b/lib/oidc/types/meta.ts index c220d6a2a..8b857a2b1 100644 --- a/lib/oidc/types/meta.ts +++ b/lib/oidc/types/meta.ts @@ -25,7 +25,8 @@ export interface OAuthTransactionMeta extends 'pkce' | 'ignoreSignature' | 'nonce' | - 'acrValues' + 'acrValues' | + 'enrollAmrValues' > { urls: CustomUrls; diff --git a/lib/oidc/util/oauthMeta.ts b/lib/oidc/util/oauthMeta.ts index 6e00e1e6e..56dee8dc4 100644 --- a/lib/oidc/util/oauthMeta.ts +++ b/lib/oidc/util/oauthMeta.ts @@ -20,6 +20,7 @@ export function createOAuthMeta( nonce: tokenParams.nonce!, ignoreSignature: tokenParams.ignoreSignature!, acrValues: tokenParams.acrValues, + enrollAmrValues: tokenParams.enrollAmrValues, }; if (tokenParams.pkce === false) { diff --git a/lib/oidc/util/prepareTokenParams.ts b/lib/oidc/util/prepareTokenParams.ts index 32848bf58..665490dd1 100644 --- a/lib/oidc/util/prepareTokenParams.ts +++ b/lib/oidc/util/prepareTokenParams.ts @@ -90,13 +90,16 @@ function prepareEnrollAuthenticator( if (!tokenParams.enrollAmrValues) { throw new AuthSdkError('enroll_amr_values must be specified'); } - if (tokenParams.maxAge && tokenParams.maxAge > 0) { - throw new AuthSdkError('max_age cannot be more than 0'); - } - // scope, nonce, and resource must be omitted + + // scope, nonce must be omitted delete tokenParams.scopes; delete tokenParams.nonce; + // maxAge is not supported + if (tokenParams.maxAge && tokenParams.maxAge > 0) { + delete tokenParams.maxAge; + } + return tokenParams; } diff --git a/test/spec/OktaAuth/browser.ts b/test/spec/OktaAuth/browser.ts index 5971806ed..42118a11f 100644 --- a/test/spec/OktaAuth/browser.ts +++ b/test/spec/OktaAuth/browser.ts @@ -482,6 +482,13 @@ describe('OktaAuth (browser)', function() { await auth.storeTokensFromRedirect(); expect(auth.tokenManager.setTokens).toHaveBeenCalledWith({ accessToken, idToken }); }); + it('does not store tokens if responseType is "none"', async () => { + auth.token.parseFromUrl = jest.fn().mockResolvedValue({ + responseType: 'none' + }); + await auth.storeTokensFromRedirect(); + expect(auth.tokenManager.setTokens).not.toHaveBeenCalled(); + }); }); describe('setOriginalUri', () => { diff --git a/test/spec/oidc/endpoints/authorize.ts b/test/spec/oidc/endpoints/authorize.ts index 4627f9529..629cded59 100644 --- a/test/spec/oidc/endpoints/authorize.ts +++ b/test/spec/oidc/endpoints/authorize.ts @@ -38,14 +38,15 @@ describe('authorize endpoint', () => { })).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&response_type=id_token&scope=openid%20email'); }); - it('converts array parameters "idpScope", "responseType", and "scopes" to space-separated string', () => { + it('converts array parameters "idpScope", "responseType", "scopes" and "enrollAmrValues" to space-separated string', () => { expect(buildAuthorizeParams({ clientId: 'fakeClientId', codeChallenge: 'fakeCodeChallenge', scopes: ['openid', 'email'], idpScope: ['scope1', 'scope2'], - responseType: ['id_token', 'token'] - })).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&idp_scope=scope1%20scope2&response_type=id_token%20token&scope=openid%20email'); + responseType: ['id_token', 'token'], + enrollAmrValues: ['okta_verify', 'pop'], + })).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&idp_scope=scope1%20scope2&response_type=id_token%20token&enroll_amr_values=okta_verify%20pop&scope=openid%20email'); }); it('throws if responseType includes id_token but scopes does not include openid', () => { @@ -79,5 +80,14 @@ describe('authorize endpoint', () => { acrValues: 'urn:okta:loa:1fa:any' })).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&response_type=code&acr_values=urn%3Aokta%3Aloa%3A1fa%3Aany&scope=openid'); }); + + it('respects enroll_amr_values', () => { + expect(buildAuthorizeParams({ + clientId: 'fakeClientId', + prompt: 'enroll_authenticator', + responseType: 'none', + enrollAmrValues: ['okta_verify', 'pop'] + })).toBe('?client_id=fakeClientId&prompt=enroll_authenticator&response_type=none&enroll_amr_values=okta_verify%20pop'); + }); }); }); diff --git a/test/spec/oidc/enrollAuthenticator.ts b/test/spec/oidc/enrollAuthenticator.ts new file mode 100644 index 000000000..22a0d570a --- /dev/null +++ b/test/spec/oidc/enrollAuthenticator.ts @@ -0,0 +1,105 @@ +import { enrollAuthenticator } from '../../../lib/oidc/enrollAuthenticator'; + +jest.mock('../../../lib/oidc/util', () => { + return { + prepareTokenParams: () => {}, + createOAuthMeta: () => {}, + getOAuthUrls: () => {} + }; +}); + +jest.mock('../../../lib/oidc/endpoints/authorize', () => { + return { + buildAuthorizeParams: () => {} + }; +}); + +const mocked = { + util: require('../../../lib/oidc/util'), + authorize: require('../../../lib/oidc/endpoints/authorize') +}; + +describe('enrollAuthenticator', () => { + let testContext; + beforeEach(() => { + const sdk = { + options: { + issuer: 'http://fake', + clientId: 'fakeClientId', + redirectUri: 'http://fake-redirect', + }, + transactionManager: { + save: () => {} + }, + token: { + enrollAuthenticator: { + _setLocation: () => {} + } + } + }; + const tokenParams = { + clientId: 'fakeClientId', + responseType: 'none', + prompt: 'enroll_authenticator', + enrollAmrValues: ['okta_verify'] + }; + const enrollParams = { + enrollAmrValues: ['okta_verify'] + }; + const authorizeParams = '?client_id=fakeClientId&prompt=enroll_authenticator&response_type=none&enroll_amr_values=okta_verify'; + const urls = { + authorizeUrl: 'http://fake-authorize' + }; + const meta = { + urls + }; + testContext = { + sdk, + tokenParams, + authorizeParams, + enrollParams, + urls, + meta + }; + jest.spyOn(mocked.util, 'prepareTokenParams').mockResolvedValue(testContext.tokenParams); + jest.spyOn(mocked.util, 'getOAuthUrls').mockReturnValue(testContext.urls); + jest.spyOn(mocked.authorize, 'buildAuthorizeParams').mockReturnValue(testContext.authorizeParams); + jest.spyOn(mocked.util, 'createOAuthMeta').mockReturnValue(testContext.meta); + }); + + describe('transactionMeta', () => { + beforeEach(() => { + const { sdk } = testContext; + jest.spyOn(sdk.transactionManager, 'save'); + }); + + it('saves the transaction meta', async () => { + const { sdk, meta, enrollParams } = testContext; + await enrollAuthenticator(sdk, enrollParams); + expect(sdk.transactionManager.save).toHaveBeenCalledWith(meta); + }); + }); + + it('overrides prompt with enroll_authenticator', async () => { + const { sdk, enrollParams } = testContext; + const badEnrollParams = { + ...enrollParams, + prompt: 'none' + }; + const tokenParams = { + ...badEnrollParams, + prompt: 'enroll_authenticator' + }; + await enrollAuthenticator(sdk, badEnrollParams); + expect(mocked.util.prepareTokenParams).toHaveBeenCalledWith(sdk, tokenParams); + }); + + it('redirects to the authorize endpoint', async () => { + const { sdk, tokenParams, enrollParams, authorizeParams } = testContext; + jest.spyOn(sdk.token.enrollAuthenticator, '_setLocation'); + await enrollAuthenticator(sdk, enrollParams); + expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); + expect(sdk.token.enrollAuthenticator._setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); + }); + +}); \ No newline at end of file diff --git a/test/spec/oidc/util/handleOAuthResponse.ts b/test/spec/oidc/util/handleOAuthResponse.ts index 408a7e760..df719afdc 100644 --- a/test/spec/oidc/util/handleOAuthResponse.ts +++ b/test/spec/oidc/util/handleOAuthResponse.ts @@ -104,6 +104,16 @@ describe('handleOAuthResponse', () => { expect(errorThrown).toBe(false); }); + it('does not throw if responseType is "none" and response contains no tokens', async () => { + let errorThrown = false; + try { + await handleOAuthResponse(sdk, {responseType: 'none'}, {}, undefined as unknown as CustomUrls); + } catch (err) { + errorThrown = true; + } + expect(errorThrown).toBe(false); + }); + it('throws if response contains both "error" and "error_description"', async () => { let errorThrown = false; try { diff --git a/test/spec/oidc/util/oauthMeta.ts b/test/spec/oidc/util/oauthMeta.ts index 8c424bd9d..d8803dc93 100644 --- a/test/spec/oidc/util/oauthMeta.ts +++ b/test/spec/oidc/util/oauthMeta.ts @@ -72,6 +72,7 @@ describe('oauthMeta', () => { codeChallenge: 'efgh', codeChallengeMethod: 'fake', acrValues: 'foo', + enrollAmrValues: ['a', 'b'] }); const meta = createOAuthMeta(sdk, tokenParams); @@ -88,6 +89,7 @@ describe('oauthMeta', () => { codeChallenge: 'efgh', codeChallengeMethod: 'fake', acrValues: 'foo', + enrollAmrValues: ['a', 'b'], }); }); }); diff --git a/test/spec/oidc/util/prepareTokenParams.ts b/test/spec/oidc/util/prepareTokenParams.ts index da43965dd..e7e8fd542 100644 --- a/test/spec/oidc/util/prepareTokenParams.ts +++ b/test/spec/oidc/util/prepareTokenParams.ts @@ -30,7 +30,7 @@ jest.mock('../../../../lib/features', () => { jest.mock('../../../../lib/oidc/endpoints/well-known', () => { return mocked.wellKnown; }); -import { OktaAuth } from '@okta/okta-auth-js'; +import { OktaAuth, AuthSdkError } from '@okta/okta-auth-js'; import { prepareTokenParams, pkce } from '../../../../lib/oidc'; import { createTransactionManager } from '../../../../lib/oidc/TransactionManager'; @@ -115,5 +115,74 @@ describe('prepareTokenParams', function() { expect(oauthParams.codeChallenge).toBe(codeChallenge); }); }); + + + describe('prompt=enroll_authenticator', function() { + it('throws an error if enrollAmrValues not specified', async () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + let errorThrown = false; + try { + await prepareTokenParams(sdk, { + prompt: 'enroll_authenticator', + }); + } catch (err) { + errorThrown = true; + expect(err).toBeInstanceOf(AuthSdkError); + expect((err as AuthSdkError).message).toEqual('enroll_amr_values must be specified'); + } + expect(errorThrown).toBe(true); + }); + + it('sets responseType to none', async () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = await prepareTokenParams(sdk, { + prompt: 'enroll_authenticator', + enrollAmrValues: ['a'] + }); + expect(params.responseType).toBe('none'); + }); + + it('does not prepare PKCE params', async () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com', + pkce: true + }); + spyOn(mocked.features, 'isPKCESupported').and.returnValue(true); + const params = await prepareTokenParams(sdk, { + prompt: 'enroll_authenticator', + enrollAmrValues: ['a'] + }); + expect(params.codeVerifier).toBe(undefined); + expect(params.codeChallenge).toBe(undefined); + expect(params.codeChallengeMethod).toBe(undefined); + }); + + it('removes scopes, nonce, maxAge', async () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = await prepareTokenParams(sdk, { + prompt: 'enroll_authenticator', + enrollAmrValues: ['a'], + scopes: ['openid','email'], + nonce: 'fake-nonce', + maxAge: 100, + }); + expect(params.scopes).toBe(undefined); + expect(params.nonce).toBe(undefined); + expect(params.maxAge).toBe(undefined); + }); + + // Note: + // The only suported `acrValues` is 'urn:okta:2fa:ifpossible' + // Autorize endpoint will throw an error otherwise, + // but this can change in the future, + // so not checking this in okta-auth-js + + }); }); \ No newline at end of file diff --git a/test/types/token.test-d.ts b/test/types/token.test-d.ts index f9541c601..7717ba9ab 100644 --- a/test/types/token.test-d.ts +++ b/test/types/token.test-d.ts @@ -90,7 +90,12 @@ const tokens = { const enrollAuthenticatorOptons: EnrollAuthenticatorOptions = { enrollAmrValues: ['email', 'kba'] }; + const enrollAuthenticatorOptons2: EnrollAuthenticatorOptions = { + enrollAmrValues: 'email', + responseType: 'none' + }; expectType(await authClient.token.enrollAuthenticator(enrollAuthenticatorOptons)); + expectType(await authClient.token.enrollAuthenticator(enrollAuthenticatorOptons2)); expectError(async () => { await authClient.token.enrollAuthenticator({}); }); From 4e8d57ec5465ce781b03baddc5e3e74e60fff2d7 Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Mon, 24 Oct 2022 11:45:36 +0300 Subject: [PATCH 03/11] moved enrollAuthenticator to top level --- README.md | 93 ++++++++++++++------------- lib/oidc/enrollAuthenticator.ts | 2 +- lib/oidc/factory/api.ts | 10 --- lib/oidc/mixin/index.ts | 18 ++++++ lib/oidc/types/api.ts | 2 +- lib/oidc/util/prepareTokenParams.ts | 3 +- test/apps/app/src/testApp.ts | 2 +- test/spec/oidc/enrollAuthenticator.ts | 10 ++- test/types/token.test-d.ts | 8 +-- 9 files changed, 77 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index f0c48d739..7c798688e 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ This is accomplished by selecting a single tab to handle the network requests to * [forgotPassword](#forgotpasswordoptions) * [unlockAccount](#unlockaccountoptions) * [verifyRecoveryToken](#verifyrecoverytokenoptions) +* [enrollAuthenticator](#enrollauthenticatoroptions) * [webfinger](#webfingeroptions) * [fingerprint](#fingerprintoptions) * [isAuthenticated](#isauthenticatedoptions) @@ -915,7 +916,6 @@ This is accomplished by selecting a single tab to handle the network requests to * [token.isLoginRedirect](#tokenisloginredirect) * [token.prepareTokenParams](#tokenpreparetokenparams) * [token.exchangeCodeForTokens](#tokenexchangecodefortokens) - * [token.enrollAuthenticator](#tokenenrollauthenticatoroptions) * [tokenManager](#tokenmanager-api) * [tokenManager.add](#tokenmanageraddkey-token) * [tokenManager.get](#tokenmanagergetkey) @@ -1079,6 +1079,50 @@ See [authn API](docs/authn.md#unlockaccountoptions). See [authn API](docs/authn.md#verifyrecoverytokenoptions). +#### `enrollAuthenticator(options)` + +> :link: web browser only
    +> :hourglass: async + +Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). URL will not contain any tokens. You can use [sdk.handleLoginRedirect](#handleloginredirecttokens-originaluri). + +* `options` - See [Authorize options](#authorize-options) + + Options that will be omitted: `scopes`, `nonce`. + + Options that will be overridden: `responseType: 'none', prompt: 'enroll_authenticator'`. + + ##### `options.enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). + + List of AMR values: + | AMR Value | Authenticator | + | ------------- | -------------------- | + | `pwd` | Okta Password | + | `kba` | Security question | + | `email` | Okta Email | + | `sms` | SMS | + | `tel` | Voice call | + | `duo` | DUO | + | `symantec` | Symantec VIP | + | `google_otp` | Google Authenticator | + | `okta_verify` | Okta Verify | + | `pop` | WebAuthn | + | `oath_otp` | On-Prem MFA | + | `rsa` | RSA SecurID | + | `yubikey` | Yubikey | + | `otp` | Custom HOTP | + | `fed` | External IdP | + | `sc` | SmartCard/PIV | + +```javascript +authClient.enrollAuthenticator({ + enrollAmrValues: ['okta_verify'] +}) +.catch(function(err) { + // handle AuthSdkError +}); +``` + ### `webfinger(options)` > :hourglass: async @@ -1316,10 +1360,10 @@ The following configuration options can be included in `token.getWithoutPrompt`, | `idp` | Identity provider to use if there is no Okta Session. | | `idpScope` | A space delimited list of scopes to be provided to the Social Identity Provider when performing [Social Login][social-login] These scopes are used in addition to the scopes already configured on the Identity Provider. | | `display` | The display parameter to be passed to the Social Identity Provider when performing [Social Login][social-login]. | -| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [token.enrollAuthenticator](#tokenenrollauthenticatoroptions). | +| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [enrollAuthenticator](#enrollauthenticatoroptions). | | `maxAge` | Allowable elapsed time, in seconds, since the last time the end user was actively authenticated by Okta. | | `acrValues` | [[EA][early-access]] Optional parameter to increase the level of user assurance. See [Predefined ACR values](https://developer.okta.com/docs/guides/step-up-authentication/main/#predefined-parameter-values) for more information. | -| `enrollAmrValues` | List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [token.enrollAuthenticator](#tokenenrollauthenticatoroptions) | +| `enrollAmrValues` | List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [enrollAuthenticator](#enrollauthenticatoroptions) | | `loginHint` | A username to prepopulate if prompting for authentication. | For more details, see Okta's [Authorize Request API](https://developer.okta.com/docs/api/resources/oidc#request-parameters). @@ -1599,49 +1643,6 @@ Returns a `TokenParams` object. If `PKCE` is enabled, this object will contain v Used internally to perform the final step of the `PKCE` authorization code flow. Accepts a `TokenParams` object which should contain a `codeVerifier` and an `authorizationCode`. -#### `token.enrollAuthenticator(options)` - -> :link: web browser only
    -> :hourglass: async - -Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). URL will not contain any tokens. You can use [sdk.handleLoginRedirect](#handleloginredirecttokens-originaluri). - -* `options` - See [Authorize options](#authorize-options) - - Options that will be omitted: `scopes`, `nonce`. - - Options that will be overridden: `responseType: 'none', prompt: 'enroll_authenticator'`. - - ##### `options.enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). - - List of AMR values: - | AMR Value | Authenticator | - | ------------- | -------------------- | - | `pwd` | Okta Password | - | `kba` | Security question | - | `email` | Okta Email | - | `sms` | SMS | - | `tel` | Voice call | - | `duo` | DUO | - | `symantec` | Symantec VIP | - | `google_otp` | Google Authenticator | - | `okta_verify` | Okta Verify | - | `pop` | WebAuthn | - | `oath_otp` | On-Prem MFA | - | `rsa` | RSA SecurID | - | `yubikey` | Yubikey | - | `otp` | Custom HOTP | - | `fed` | External IdP | - | `sc` | SmartCard/PIV | - -```javascript -authClient.token.enrollAuthenticator({ - enrollAmrValues: ['okta_verify'] -}) -.catch(function(err) { - // handle AuthSdkError -}); -``` ### `tokenManager` API diff --git a/lib/oidc/enrollAuthenticator.ts b/lib/oidc/enrollAuthenticator.ts index 1b7740da7..6c058f037 100644 --- a/lib/oidc/enrollAuthenticator.ts +++ b/lib/oidc/enrollAuthenticator.ts @@ -27,5 +27,5 @@ export async function enrollAuthenticator( const meta = createOAuthMeta(sdk, tokenParams); const requestUrl = meta.urls.authorizeUrl + buildAuthorizeParams(tokenParams); sdk.transactionManager.save(meta); - sdk.token.enrollAuthenticator._setLocation(requestUrl); + sdk.enrollAuthenticator._setLocation(requestUrl); } diff --git a/lib/oidc/factory/api.ts b/lib/oidc/factory/api.ts index 57a4874d9..b7adf0faa 100644 --- a/lib/oidc/factory/api.ts +++ b/lib/oidc/factory/api.ts @@ -18,7 +18,6 @@ import { getUserInfo } from '../getUserInfo'; import { getWithoutPrompt } from '../getWithoutPrompt'; import { getWithPopup } from '../getWithPopup'; import { getWithRedirect } from '../getWithRedirect'; -import { enrollAuthenticator } from '../enrollAuthenticator'; import { parseFromUrl } from '../parseFromUrl'; import { renewToken } from '../renewToken'; import { renewTokens } from '../renewTokens'; @@ -29,8 +28,6 @@ import { CustomUserClaims, GetWithRedirectAPI, GetWithRedirectFunction, - EnrollAuthenticatorAPI, - EnrollAuthenticatorFunction, IDToken, OktaAuthOAuthInterface, ParseFromUrlInterface, @@ -60,12 +57,6 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) _setLocation }); - const enrollAuthenticatorFn = useQueue(enrollAuthenticator.bind(null, sdk)) as EnrollAuthenticatorFunction; - const enrollAuthenticatorApi: EnrollAuthenticatorAPI = Object.assign(enrollAuthenticatorFn, { - // This is exposed so we can set window.location in our tests - _setLocation - }); - // eslint-disable-next-line max-len const parseFromUrlFn = useQueue(parseFromUrl.bind(null, sdk)) as ParseFromUrlInterface; const parseFromUrlApi: ParseFromUrlInterface = Object.assign(parseFromUrlFn, { @@ -91,7 +82,6 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) getWithoutPrompt: getWithoutPrompt.bind(null, sdk), getWithPopup: getWithPopup.bind(null, sdk), getWithRedirect: getWithRedirectApi, - enrollAuthenticator: enrollAuthenticatorApi, parseFromUrl: parseFromUrlApi, decode: decodeToken, revoke: revokeToken.bind(null, sdk), diff --git a/lib/oidc/mixin/index.ts b/lib/oidc/mixin/index.ts index 6a274539e..d02be0bbf 100644 --- a/lib/oidc/mixin/index.ts +++ b/lib/oidc/mixin/index.ts @@ -25,11 +25,14 @@ import { TransactionManagerInterface, TransactionManagerConstructor, UserClaims, + EnrollAuthenticatorAPI, + EnrollAuthenticatorFunction, } from '../types'; import PKCE from '../util/pkce'; import { createTokenAPI } from '../factory'; import { TokenManager } from '../TokenManager'; import { getOAuthUrls, isLoginRedirect } from '../util'; +import { enrollAuthenticator } from '../enrollAuthenticator'; import { OktaAuthSessionInterface } from '../../session/types'; import { provideOriginalUri } from './node'; @@ -56,6 +59,7 @@ export function mixinOAuth tokenManager: TokenManager; transactionManager: TM; pkce: PkceAPI; + enrollAuthenticator: EnrollAuthenticatorAPI; _pending: { handleLogin: boolean }; _tokenQueue: PromiseQueue; @@ -81,6 +85,20 @@ export function mixinOAuth // TokenManager this.tokenManager = new TokenManager(this, this.options.tokenManager); + + const _setLocation = (url) => { + if (this.options.setLocation) { + this.options.setLocation(url); + } else { + window.location = url; + } + }; + const enrollAuthenticatorFn = enrollAuthenticator.bind(null, this) as EnrollAuthenticatorFunction; + const enrollAuthenticatorApi: EnrollAuthenticatorAPI = Object.assign(enrollAuthenticatorFn, { + // This is exposed so we can set window.location in our tests + _setLocation + }); + this.enrollAuthenticator = enrollAuthenticatorApi; } // inherited from subclass diff --git a/lib/oidc/types/api.ts b/lib/oidc/types/api.ts index 43644bd47..7d167bd00 100644 --- a/lib/oidc/types/api.ts +++ b/lib/oidc/types/api.ts @@ -78,7 +78,6 @@ export interface TokenAPI extends BaseTokenAPI { idToken?: IDToken ): Promise>; getWithRedirect: GetWithRedirectAPI; - enrollAuthenticator: EnrollAuthenticatorAPI; parseFromUrl: ParseFromUrlInterface; getWithoutPrompt(params?: TokenParams): Promise; getWithPopup(params?: TokenParams): Promise; @@ -161,6 +160,7 @@ export interface OktaAuthOAuthInterface storeTokensFromRedirect(): Promise; getUser(): Promise>; signInWithRedirect(opts?: SigninWithRedirectOptions): Promise; + enrollAuthenticator: EnrollAuthenticatorAPI; revokeAccessToken(accessToken?: AccessToken): Promise; revokeRefreshToken(refreshToken?: RefreshToken): Promise; diff --git a/lib/oidc/util/prepareTokenParams.ts b/lib/oidc/util/prepareTokenParams.ts index 665490dd1..a2640fe27 100644 --- a/lib/oidc/util/prepareTokenParams.ts +++ b/lib/oidc/util/prepareTokenParams.ts @@ -79,7 +79,6 @@ export async function preparePKCE( } function prepareEnrollAuthenticator( - sdk: OktaAuthOAuthInterface, tokenParams: TokenParams ): TokenParams { tokenParams = { @@ -113,7 +112,7 @@ export async function prepareTokenParams( tokenParams = { ...defaults, ...tokenParams }; if (tokenParams.prompt === 'enroll_authenticator') { - tokenParams = prepareEnrollAuthenticator(sdk, tokenParams); + tokenParams = prepareEnrollAuthenticator(tokenParams); } else if (tokenParams.pkce) { tokenParams = await preparePKCE(sdk, tokenParams); } diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts index f75205366..7d553662a 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -906,7 +906,7 @@ class TestApp { state: this.config.state, enrollAmrValues: this.config.enrollAmrValues, }); - return this.oktaAuth.token.enrollAuthenticator(options) + return this.oktaAuth.enrollAuthenticator(options) .catch(e => { this.renderError(e); throw e; diff --git a/test/spec/oidc/enrollAuthenticator.ts b/test/spec/oidc/enrollAuthenticator.ts index 22a0d570a..6ec5c2c01 100644 --- a/test/spec/oidc/enrollAuthenticator.ts +++ b/test/spec/oidc/enrollAuthenticator.ts @@ -31,10 +31,8 @@ describe('enrollAuthenticator', () => { transactionManager: { save: () => {} }, - token: { - enrollAuthenticator: { - _setLocation: () => {} - } + enrollAuthenticator: { + _setLocation: () => {} } }; const tokenParams = { @@ -96,10 +94,10 @@ describe('enrollAuthenticator', () => { it('redirects to the authorize endpoint', async () => { const { sdk, tokenParams, enrollParams, authorizeParams } = testContext; - jest.spyOn(sdk.token.enrollAuthenticator, '_setLocation'); + jest.spyOn(sdk.enrollAuthenticator, '_setLocation'); await enrollAuthenticator(sdk, enrollParams); expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); - expect(sdk.token.enrollAuthenticator._setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); + expect(sdk.enrollAuthenticator._setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); }); }); \ No newline at end of file diff --git a/test/types/token.test-d.ts b/test/types/token.test-d.ts index 7717ba9ab..d998c73e1 100644 --- a/test/types/token.test-d.ts +++ b/test/types/token.test-d.ts @@ -94,13 +94,13 @@ const tokens = { enrollAmrValues: 'email', responseType: 'none' }; - expectType(await authClient.token.enrollAuthenticator(enrollAuthenticatorOptons)); - expectType(await authClient.token.enrollAuthenticator(enrollAuthenticatorOptons2)); + expectType(await authClient.enrollAuthenticator(enrollAuthenticatorOptons)); + expectType(await authClient.enrollAuthenticator(enrollAuthenticatorOptons2)); expectError(async () => { - await authClient.token.enrollAuthenticator({}); + await authClient.enrollAuthenticator({}); }); expectError(async () => { - await authClient.token.enrollAuthenticator(); + await authClient.enrollAuthenticator(); }); const customUrls = { From f84ede27c112d9043dd0073d3161611a8b9c6709 Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Mon, 24 Oct 2022 20:18:29 +0300 Subject: [PATCH 04/11] added handleRedirect . --- README.md | 24 ++++++++++++++++++------ lib/core/mixin.ts | 4 ++++ lib/core/types/api.ts | 1 + test/spec/OktaAuth/browser.ts | 16 ++++++++++++++++ test/types/auth.test-d.ts | 3 +++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7c798688e..cac0fbdf6 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ var authClient = new OktaAuth(config); ### Running as a service -By default, creating a new instance of `OktaAuth` will not create any asynchronous side-effects. However, certain features such as [token auto renew](#autorenew), [token auto remove](#autoremove) and [cross-tab synchronization](#syncstorage) require `OktaAuth` to be running as a service. This means timeouts are set in the background which will continue working until the service is stopped. To start the `OktaAuth` service, simply call the `start` method right after creation and before calling other methods like [handleLoginRedirect](#handleloginredirecttokens-originaluri). To terminate all background processes, call `stop`. See [Service Configuration](#services) for more info. +By default, creating a new instance of `OktaAuth` will not create any asynchronous side-effects. However, certain features such as [token auto renew](#autorenew), [token auto remove](#autoremove) and [cross-tab synchronization](#syncstorage) require `OktaAuth` to be running as a service. This means timeouts are set in the background which will continue working until the service is stopped. To start the `OktaAuth` service, simply call the `start` method right after creation and before calling other methods like [handleRedirect](#handleredirectoriginaluri). To terminate all background processes, call `stop`. See [Service Configuration](#services) for more info. ```javascript var authClient = new OktaAuth(config); @@ -536,7 +536,7 @@ oktaAuth.authStateManager.updateAuthState(); > :link: web browser only
    -Callback function. When [sdk.handleLoginRedirect](#handleloginredirecttokens-originaluri) is called, by default it uses `window.location.replace` to redirect back to the [originalUri](#setoriginaluriuri). This option overrides the default behavior. +Callback function. When [sdk.handleRedirect](#handleredirectoriginaluri) is called, by default it uses `window.location.replace` to redirect back to the [originalUri](#setoriginaluriuri). This option overrides the default behavior. ```javascript const config = { @@ -552,7 +552,7 @@ const config = { const oktaAuth = new OktaAuth(config); if (oktaAuth.isLoginRedirect()) { try { - await oktaAuth.handleLoginRedirect(); + await oktaAuth.handleRedirect(); } catch (e) { // log or display error details } @@ -893,6 +893,7 @@ This is accomplished by selecting a single tab to handle the network requests to * [removeOriginalUri](#removeoriginaluri) * [isLoginRedirect](#isloginredirect) * [handleLoginRedirect](#handleloginredirecttokens-originaluri) +* [handleRedirect](#handleredirectoriginaluri) * [setHeaders](#setheaders) * [tx.resume](#txresume) * [tx.exists](#txexists) @@ -967,7 +968,7 @@ You can use [storeTokensFromRedirect](#storetokensfromredirect) to store tokens ```javascript if (authClient.isLoginRedirect()) { try { - await authClient.handleLoginRedirect(); + await authClient.handleRedirect(); } catch (e) { // log or display error details } @@ -1084,7 +1085,7 @@ See [authn API](docs/authn.md#verifyrecoverytokenoptions). > :link: web browser only
    > :hourglass: async -Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). URL will not contain any tokens. You can use [sdk.handleLoginRedirect](#handleloginredirecttokens-originaluri). +Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). You can use [sdk.handleRedirect](#handleredirectoriginaluri) to handle the redirect on successful enrollment or an error. * `options` - See [Authorize options](#authorize-options) @@ -1219,7 +1220,7 @@ Check `window.location` to verify if the app is in OAuth callback state or not. if (authClient.isLoginRedirect()) { // callback flow try { - await authClient.handleLoginRedirect(); + await authClient.handleRedirect(); } catch (e) { // log or display error details } @@ -1232,11 +1233,22 @@ if (authClient.isLoginRedirect()) { > :link: web browser only
    > :hourglass: async +> :warning: Deprecated, this method could be removed in next major release, use [sdk.handleRedirect](#handleredirectoriginaluri) instead. Stores passed in tokens or tokens from redirect url into storage, then redirect users back to the [originalUri](#setoriginaluriuri). When using `PKCE` authorization code flow, this method also exchanges authorization code for tokens. By default it calls `window.location.replace` for the redirection. The default behavior can be overrided by providing [options.restoreOriginalUri](#configuration-options). By default, [originalUri](#getoriginaluristate) will be retrieved from storage, but this can be overridden by passing a value fro `originalUri` to this function in the 2nd parameter. > **Note:** `handleLoginRedirect` throws `OAuthError` or `AuthSdkError` in case there are errors during token retrieval. +### `handleRedirect(originalUri?)` + +> :link: web browser only
    +> :hourglass: async + +Handle a redirect to the configured [redirectUri](#configuration-options) that happens on the end of [login](#signInWithRedirectoptions) flow, [enroll authenticator](#enrollauthenticatoroptions) flow or on an error. +Stores tokens from redirect url into storage (for login flow), then redirect users back to the [originalUri](#setoriginaluriuri). When using `PKCE` authorization code flow, this method also exchanges authorization code for tokens. By default it calls `window.location.replace` for the redirection. The default behavior can be overrided by providing [options.restoreOriginalUri](#configuration-options). By default, [originalUri](#getoriginaluristate) will be retrieved from storage, but this can be overridden by specifying `originalUri` in the first parameter to this function. + +> **Note:** `handleRedirect` throws `OAuthError` or `AuthSdkError` in case there are errors during token retrieval or building URL to enroll authenticator. + ### `setHeaders()` Can set (or unset) request headers after construction. diff --git a/lib/core/mixin.ts b/lib/core/mixin.ts index a81c74e80..63214f5ea 100644 --- a/lib/core/mixin.ts +++ b/lib/core/mixin.ts @@ -53,6 +53,10 @@ export function mixinCore await this.serviceManager.stop(); } + async handleRedirect(originalUri?: string): Promise { + await this.handleLoginRedirect(undefined, originalUri); + } + // eslint-disable-next-line complexity async handleLoginRedirect(tokens?: Tokens, originalUri?: string): Promise { let state = this.options.state; diff --git a/lib/core/types/api.ts b/lib/core/types/api.ts index 635ea30b3..12529b6e0 100644 --- a/lib/core/types/api.ts +++ b/lib/core/types/api.ts @@ -37,4 +37,5 @@ extends OktaAuthOAuthInterface start(): Promise; stop(): Promise; handleLoginRedirect(tokens?: Tokens, originalUri?: string): Promise; + handleRedirect(originalUri?: string): Promise; } diff --git a/test/spec/OktaAuth/browser.ts b/test/spec/OktaAuth/browser.ts index 42118a11f..829d013c0 100644 --- a/test/spec/OktaAuth/browser.ts +++ b/test/spec/OktaAuth/browser.ts @@ -734,4 +734,20 @@ describe('OktaAuth (browser)', function() { }); + describe('handleRedirect', () => { + beforeEach(() => { + jest.spyOn(auth, 'handleLoginRedirect'); + }); + + it('calls handleLoginRedirect', async () => { + await auth.handleRedirect(); + expect(auth.handleLoginRedirect).toHaveBeenCalledWith(undefined, undefined); + }); + + it('calls handleLoginRedirect and passes originalUri', async () => { + await auth.handleRedirect('/overridden'); + expect(auth.handleLoginRedirect).toHaveBeenCalledWith(undefined, '/overridden'); + }); + }); + }); diff --git a/test/types/auth.test-d.ts b/test/types/auth.test-d.ts index db82c05e3..41607e61d 100644 --- a/test/types/auth.test-d.ts +++ b/test/types/auth.test-d.ts @@ -120,7 +120,10 @@ const authorizeOptions2: TokenParams = { expectType(await authClient.handleLoginRedirect()); const tokens = await authClient.tokenManager.getTokens(); expectType(await authClient.handleLoginRedirect(tokens)); + expectType(await authClient.handleLoginRedirect(tokens, `${window.location.href}`)); expectType(await authClient.storeTokensFromRedirect()); + expectType(await authClient.handleRedirect()); + expectType(await authClient.handleRedirect(`${window.location.href}`)); // signOut expectType(await authClient.signOut()); From a3e6895ea386798e84430e70b4222614a7df7131 Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Mon, 24 Oct 2022 21:27:24 +0300 Subject: [PATCH 05/11] get rid of _setLocation --- lib/oidc/enrollAuthenticator.ts | 6 +++++- lib/oidc/factory/api.ts | 15 +-------------- lib/oidc/getWithRedirect.ts | 6 +++++- lib/oidc/mixin/index.ts | 17 ++--------------- lib/oidc/types/api.ts | 12 ++---------- test/integration/util/getTokens.ts | 11 ++++++----- test/spec/oidc/enrollAuthenticator.ts | 7 ++----- test/spec/oidc/getWithRedirect.ts | 10 ++-------- test/spec/oidc/util/oauthMeta.ts | 7 +------ test/support/oauthUtil.js | 6 +++--- test/support/util.js | 4 ---- 11 files changed, 29 insertions(+), 72 deletions(-) diff --git a/lib/oidc/enrollAuthenticator.ts b/lib/oidc/enrollAuthenticator.ts index 6c058f037..11b9e12d4 100644 --- a/lib/oidc/enrollAuthenticator.ts +++ b/lib/oidc/enrollAuthenticator.ts @@ -27,5 +27,9 @@ export async function enrollAuthenticator( const meta = createOAuthMeta(sdk, tokenParams); const requestUrl = meta.urls.authorizeUrl + buildAuthorizeParams(tokenParams); sdk.transactionManager.save(meta); - sdk.enrollAuthenticator._setLocation(requestUrl); + if (sdk.options.setLocation) { + sdk.options.setLocation(requestUrl); + } else { + window.location.assign(requestUrl); + } } diff --git a/lib/oidc/factory/api.ts b/lib/oidc/factory/api.ts index b7adf0faa..01c5f86e9 100644 --- a/lib/oidc/factory/api.ts +++ b/lib/oidc/factory/api.ts @@ -26,7 +26,6 @@ import { revokeToken } from '../revokeToken'; import { AccessToken, CustomUserClaims, - GetWithRedirectAPI, GetWithRedirectFunction, IDToken, OktaAuthOAuthInterface, @@ -43,19 +42,7 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) return PromiseQueue.prototype.push.bind(queue, method, null); }; - const _setLocation = (url) => { - if (sdk.options.setLocation) { - sdk.options.setLocation(url); - } else { - window.location = url; - } - }; - const getWithRedirectFn = useQueue(getWithRedirect.bind(null, sdk)) as GetWithRedirectFunction; - const getWithRedirectApi: GetWithRedirectAPI = Object.assign(getWithRedirectFn, { - // This is exposed so we can set window.location in our tests - _setLocation - }); // eslint-disable-next-line max-len const parseFromUrlFn = useQueue(parseFromUrl.bind(null, sdk)) as ParseFromUrlInterface; @@ -81,7 +68,7 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) exchangeCodeForTokens: exchangeCodeForTokens.bind(null, sdk), getWithoutPrompt: getWithoutPrompt.bind(null, sdk), getWithPopup: getWithPopup.bind(null, sdk), - getWithRedirect: getWithRedirectApi, + getWithRedirect: getWithRedirectFn, parseFromUrl: parseFromUrlApi, decode: decodeToken, revoke: revokeToken.bind(null, sdk), diff --git a/lib/oidc/getWithRedirect.ts b/lib/oidc/getWithRedirect.ts index 4d05dc8ea..f27deece0 100644 --- a/lib/oidc/getWithRedirect.ts +++ b/lib/oidc/getWithRedirect.ts @@ -28,5 +28,9 @@ export async function getWithRedirect(sdk: OktaAuthOAuthInterface, options?: Tok const meta = createOAuthMeta(sdk, tokenParams); const requestUrl = meta.urls.authorizeUrl + buildAuthorizeParams(tokenParams); sdk.transactionManager.save(meta); - sdk.token.getWithRedirect._setLocation(requestUrl); + if (sdk.options.setLocation) { + sdk.options.setLocation(requestUrl); + } else { + window.location.assign(requestUrl); + } } diff --git a/lib/oidc/mixin/index.ts b/lib/oidc/mixin/index.ts index d02be0bbf..165c8d954 100644 --- a/lib/oidc/mixin/index.ts +++ b/lib/oidc/mixin/index.ts @@ -25,7 +25,6 @@ import { TransactionManagerInterface, TransactionManagerConstructor, UserClaims, - EnrollAuthenticatorAPI, EnrollAuthenticatorFunction, } from '../types'; import PKCE from '../util/pkce'; @@ -59,7 +58,7 @@ export function mixinOAuth tokenManager: TokenManager; transactionManager: TM; pkce: PkceAPI; - enrollAuthenticator: EnrollAuthenticatorAPI; + enrollAuthenticator: EnrollAuthenticatorFunction; _pending: { handleLogin: boolean }; _tokenQueue: PromiseQueue; @@ -86,19 +85,7 @@ export function mixinOAuth // TokenManager this.tokenManager = new TokenManager(this, this.options.tokenManager); - const _setLocation = (url) => { - if (this.options.setLocation) { - this.options.setLocation(url); - } else { - window.location = url; - } - }; - const enrollAuthenticatorFn = enrollAuthenticator.bind(null, this) as EnrollAuthenticatorFunction; - const enrollAuthenticatorApi: EnrollAuthenticatorAPI = Object.assign(enrollAuthenticatorFn, { - // This is exposed so we can set window.location in our tests - _setLocation - }); - this.enrollAuthenticator = enrollAuthenticatorApi; + this.enrollAuthenticator = enrollAuthenticator.bind(null, this) as EnrollAuthenticatorFunction; } // inherited from subclass diff --git a/lib/oidc/types/api.ts b/lib/oidc/types/api.ts index 7d167bd00..74de31f28 100644 --- a/lib/oidc/types/api.ts +++ b/lib/oidc/types/api.ts @@ -58,14 +58,6 @@ export type EnrollAuthenticatorFunction = (params: EnrollAuthenticatorOptions) = export type SetLocationFunction = (loc: string) => void; -export interface GetWithRedirectAPI extends GetWithRedirectFunction { - _setLocation: SetLocationFunction; -} - -export interface EnrollAuthenticatorAPI extends EnrollAuthenticatorFunction { - _setLocation: SetLocationFunction; -} - export interface BaseTokenAPI { decode(token: string): JWTObject; prepareTokenParams(params?: TokenParams): Promise; @@ -77,7 +69,7 @@ export interface TokenAPI extends BaseTokenAPI { accessToken?: AccessToken, idToken?: IDToken ): Promise>; - getWithRedirect: GetWithRedirectAPI; + getWithRedirect: GetWithRedirectFunction; parseFromUrl: ParseFromUrlInterface; getWithoutPrompt(params?: TokenParams): Promise; getWithPopup(params?: TokenParams): Promise; @@ -160,7 +152,7 @@ export interface OktaAuthOAuthInterface storeTokensFromRedirect(): Promise; getUser(): Promise>; signInWithRedirect(opts?: SigninWithRedirectOptions): Promise; - enrollAuthenticator: EnrollAuthenticatorAPI; + enrollAuthenticator: EnrollAuthenticatorFunction; revokeAccessToken(accessToken?: AccessToken): Promise; revokeRefreshToken(refreshToken?: RefreshToken): Promise; diff --git a/test/integration/util/getTokens.ts b/test/integration/util/getTokens.ts index fa746344c..9b6ef4c9d 100644 --- a/test/integration/util/getTokens.ts +++ b/test/integration/util/getTokens.ts @@ -9,16 +9,17 @@ import { sleep } from './sleep'; function mockGetWithRedirect(client, testContext) { jest.spyOn(client, 'getOriginalUri').mockImplementation(() => {}); jest.spyOn(client, 'setOriginalUri').mockImplementation(() => {}); - jest.spyOn(client.token.getWithRedirect, '_setLocation').mockImplementation(authorizeUrl => { + testContext.origSetLocation = client.options.setLocation; + client.options.setLocation = authorizeUrl => { testContext.authorizeUrl = authorizeUrl; - }); + }; jest.spyOn(client.token.parseFromUrl, '_getLocation').mockImplementation(() => {}); } -function unmockGetWithRedirect(client) { +function unmockGetWithRedirect(client, testContext) { client.getOriginalUri.mockRestore(); client.setOriginalUri.mockRestore(); - client.token.getWithRedirect._setLocation.mockRestore(); + client.options.setLocation = testContext.origSetLocation; client.token.parseFromUrl._getLocation.mockRestore(); } @@ -43,7 +44,7 @@ async function getTokens(client, tokenParams) { }); const transactionMeta = client.transactionManager.load(); const tokenResponse = await handleOAuthResponse(client, transactionMeta, oauthResponse, undefined as unknown as CustomUrls); - unmockGetWithRedirect(client); + unmockGetWithRedirect(client, localContext); return tokenResponse; } diff --git a/test/spec/oidc/enrollAuthenticator.ts b/test/spec/oidc/enrollAuthenticator.ts index 6ec5c2c01..b4dc16a48 100644 --- a/test/spec/oidc/enrollAuthenticator.ts +++ b/test/spec/oidc/enrollAuthenticator.ts @@ -27,12 +27,10 @@ describe('enrollAuthenticator', () => { issuer: 'http://fake', clientId: 'fakeClientId', redirectUri: 'http://fake-redirect', + setLocation: jest.fn() }, transactionManager: { save: () => {} - }, - enrollAuthenticator: { - _setLocation: () => {} } }; const tokenParams = { @@ -94,10 +92,9 @@ describe('enrollAuthenticator', () => { it('redirects to the authorize endpoint', async () => { const { sdk, tokenParams, enrollParams, authorizeParams } = testContext; - jest.spyOn(sdk.enrollAuthenticator, '_setLocation'); await enrollAuthenticator(sdk, enrollParams); expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); - expect(sdk.enrollAuthenticator._setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); + expect(sdk.options.setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); }); }); \ No newline at end of file diff --git a/test/spec/oidc/getWithRedirect.ts b/test/spec/oidc/getWithRedirect.ts index 51bcd3404..52f20fc38 100644 --- a/test/spec/oidc/getWithRedirect.ts +++ b/test/spec/oidc/getWithRedirect.ts @@ -24,16 +24,11 @@ describe('getWithRedirect', () => { beforeEach(() => { const sdk = { options: { - + setLocation: jest.fn() }, getOriginalUri: () => {}, transactionManager: { save: () => {} - }, - token: { - getWithRedirect: { - _setLocation: () => {} - } } }; const tokenParams = { @@ -86,10 +81,9 @@ describe('getWithRedirect', () => { it('redirects to the authorize endpoint', async () => { const { sdk, tokenParams } = testContext; - jest.spyOn(sdk.token.getWithRedirect, '_setLocation'); await getWithRedirect(sdk, {}); expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); - expect(sdk.token.getWithRedirect._setLocation).toHaveBeenCalledWith('http://fake-authorize?fake=true'); + expect(sdk.options.setLocation).toHaveBeenCalledWith('http://fake-authorize?fake=true'); }); }); \ No newline at end of file diff --git a/test/spec/oidc/util/oauthMeta.ts b/test/spec/oidc/util/oauthMeta.ts index d8803dc93..ae98136b8 100644 --- a/test/spec/oidc/util/oauthMeta.ts +++ b/test/spec/oidc/util/oauthMeta.ts @@ -16,16 +16,11 @@ describe('oauthMeta', () => { beforeEach(() => { const sdk = { options: { - + setLocation: () => {} }, getOriginalUri: () => {}, transactionManager: { save: () => {} - }, - token: { - getWithRedirect: { - _setLocation: () => {} - } } }; const tokenParams = { diff --git a/test/support/oauthUtil.js b/test/support/oauthUtil.js index 99e9131a2..a9a45feae 100644 --- a/test/support/oauthUtil.js +++ b/test/support/oauthUtil.js @@ -419,14 +419,14 @@ oauthUtil.setupRedirect = function(opts) { pkce: false, issuer: 'https://auth-js-test.okta.com', clientId: 'NPSfOkH5eZrTy8PMDlvx', - redirectUri: 'https://example.com/redirect' + redirectUri: 'https://example.com/redirect', + setLocation: jest.fn() }, opts.oktaAuthArgs)); // Mock the well-known and keys request oauthUtil.loadWellKnownAndKeysCache(client); oauthUtil.mockStateAndNonce(); - var windowLocationMock = util.mockSetWindowLocation(client); var setCookieMock = util.mockSetCookie(); jest.spyOn(storageUtil, 'getSessionStorage') @@ -446,7 +446,7 @@ oauthUtil.setupRedirect = function(opts) { return promise .then(function() { - expect(windowLocationMock).toHaveBeenCalledWith(opts.expectedRedirectUrl); + expect(client.options.setLocation).toHaveBeenCalledWith(opts.expectedRedirectUrl); expect(setCookieMock.mock.calls).toEqual(opts.expectedCookies); }) .finally(() => { diff --git a/test/support/util.js b/test/support/util.js index f026bb030..a942d4644 100644 --- a/test/support/util.js +++ b/test/support/util.js @@ -381,10 +381,6 @@ util.parseQueryParams = function (query) { return obj; }; -util.mockSetWindowLocation = function (client) { - return jest.spyOn(client.token.getWithRedirect, '_setLocation'); -}; - util.mockSetCookie = function () { return jest.spyOn(cookies, 'set'); }; From f58e380ebc2cb50206d4ace23817e76f4efca16f Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Tue, 25 Oct 2022 00:29:01 +0300 Subject: [PATCH 06/11] added feature --- .../e2e/features/enroll-authenticator.feature | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/e2e/features/enroll-authenticator.feature diff --git a/test/e2e/features/enroll-authenticator.feature b/test/e2e/features/enroll-authenticator.feature new file mode 100644 index 000000000..6b29f2957 --- /dev/null +++ b/test/e2e/features/enroll-authenticator.feature @@ -0,0 +1,46 @@ +Feature: Enroll Authenticator via Authorize Endpoint + +Background: + Given an App that assigned to a test group + And a Policy that defines "Authentication" + And with a Policy Rule that defines "Password as the only factor" + And a Policy that defines "Profile Enrollment" + And with a Policy Rule that defines "collecting default attributes and emailVerification is not required" + And a Policy that defines "MFA Enrollment" with properties + | okta_password | REQUIRED | + | okta_email | REQUIRED | + | security_question | OPTIONAL | + And with a Policy Rule that defines "MFA Enrollment Challenge" + And a user named "Mary" + And she has an account with "active" state in the org + +Scenario: Mary Enrolls into Security Question + Given I am on the home page + And I see text "Unauthenticated" + When I enter "kba" into "Enroll AMR values (coma separated)" + And I click "Update Config" + Then I see "kba" in "Enroll AMR values (coma separated)" + When I click "Enroll Authenticator" + Then I am on the "enroll_authenticator" page with title "Sign In" + When I enter correct username into "Username" + And I enter correct password into "Password" + And I click "Sign in" + Then I am on the "enroll_authenticator" page with title "Get a verification email" + When I click "Send me an email" + Then I am on the "enroll_authenticator" page with title "Verify with your email" + When I click "Enter a verification code instead" + And I enter correct code into "Enter Code" + And I click "Verify" + Then I am on the "enroll_authenticator" page with title "Set up security methods" + When I click "Set up" for "Security Question" + Then I am on the "enroll_authenticator" page with title "Set up security question" + And I see radio with "Choose a security question" and "Create my own security question" + And The option "Choose a security question" is selected + When I enter correct answer into "Answer" + And I click "Verify" + Then I am on the "login_callback" page + When I click "Handle callback (Continue Login)" + Then I see text "Authenticator enrollment completed" + When I click "Return Home" + Then I am on the home page + And I see text "Unauthenticated" From 09a32a636aee08728f6079925a10ec19ae69c064 Mon Sep 17 00:00:00 2001 From: Aaron Granick Date: Mon, 24 Oct 2022 17:38:50 -0700 Subject: [PATCH 07/11] refactor, avoid explicit cast --- lib/oidc/mixin/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/oidc/mixin/index.ts b/lib/oidc/mixin/index.ts index 165c8d954..c3b3e29e2 100644 --- a/lib/oidc/mixin/index.ts +++ b/lib/oidc/mixin/index.ts @@ -25,7 +25,7 @@ import { TransactionManagerInterface, TransactionManagerConstructor, UserClaims, - EnrollAuthenticatorFunction, + EnrollAuthenticatorOptions, } from '../types'; import PKCE from '../util/pkce'; import { createTokenAPI } from '../factory'; @@ -58,7 +58,6 @@ export function mixinOAuth tokenManager: TokenManager; transactionManager: TM; pkce: PkceAPI; - enrollAuthenticator: EnrollAuthenticatorFunction; _pending: { handleLogin: boolean }; _tokenQueue: PromiseQueue; @@ -84,8 +83,6 @@ export function mixinOAuth // TokenManager this.tokenManager = new TokenManager(this, this.options.tokenManager); - - this.enrollAuthenticator = enrollAuthenticator.bind(null, this) as EnrollAuthenticatorFunction; } // inherited from subclass @@ -343,6 +340,10 @@ export function mixinOAuth } } + async enrollAuthenticator(options: EnrollAuthenticatorOptions) { + return enrollAuthenticator(this, options); + } + }; } From 9b50738efae046add4ef9eea05680d22d6b38d2e Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Tue, 25 Oct 2022 13:36:26 +0300 Subject: [PATCH 08/11] test with and without setLocation . . --- jest.server.js | 1 + test/spec/oidc/enrollAuthenticator.ts | 27 ++++++++++++++++++++++++--- test/spec/oidc/getWithRedirect.ts | 26 +++++++++++++++++++++++--- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/jest.server.js b/jest.server.js index 5deee933b..63481493f 100644 --- a/jest.server.js +++ b/jest.server.js @@ -24,6 +24,7 @@ const config = Object.assign({}, baseConfig, { 'oidc/getWithoutPrompt', 'oidc/renewToken.ts', 'oidc/renewTokens.ts', + 'oidc/enrollAuthenticator', 'TokenManager/browser', 'SyncStorageService', 'LeaderElectionService', diff --git a/test/spec/oidc/enrollAuthenticator.ts b/test/spec/oidc/enrollAuthenticator.ts index b4dc16a48..df40a5f27 100644 --- a/test/spec/oidc/enrollAuthenticator.ts +++ b/test/spec/oidc/enrollAuthenticator.ts @@ -21,13 +21,22 @@ const mocked = { describe('enrollAuthenticator', () => { let testContext; + let originalLocation; beforeEach(() => { + originalLocation = global.window.location; + delete (global.window as any).location; + global.window.location = { + protocol: 'https:', + hostname: 'somesite.local', + href: 'https://somesite.local', + assign: jest.fn() + } as unknown as Location; + const sdk = { options: { issuer: 'http://fake', clientId: 'fakeClientId', - redirectUri: 'http://fake-redirect', - setLocation: jest.fn() + redirectUri: 'http://fake-redirect' }, transactionManager: { save: () => {} @@ -63,6 +72,10 @@ describe('enrollAuthenticator', () => { jest.spyOn(mocked.util, 'createOAuthMeta').mockReturnValue(testContext.meta); }); + afterEach(() => { + global.window.location = originalLocation; + }); + describe('transactionMeta', () => { beforeEach(() => { const { sdk } = testContext; @@ -90,11 +103,19 @@ describe('enrollAuthenticator', () => { expect(mocked.util.prepareTokenParams).toHaveBeenCalledWith(sdk, tokenParams); }); - it('redirects to the authorize endpoint', async () => { + it('redirects to the authorize endpoint with options.setLocation', async () => { const { sdk, tokenParams, enrollParams, authorizeParams } = testContext; + sdk.options.setLocation = jest.fn(); await enrollAuthenticator(sdk, enrollParams); expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); expect(sdk.options.setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); }); + it('redirects to the authorize endpoint with window.location.assign if options.setLocation is not set', async () => { + const { sdk, tokenParams, enrollParams, authorizeParams } = testContext; + await enrollAuthenticator(sdk, enrollParams); + expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); + expect(window.location.assign).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); + }); + }); \ No newline at end of file diff --git a/test/spec/oidc/getWithRedirect.ts b/test/spec/oidc/getWithRedirect.ts index 52f20fc38..cf4e457bf 100644 --- a/test/spec/oidc/getWithRedirect.ts +++ b/test/spec/oidc/getWithRedirect.ts @@ -21,10 +21,19 @@ const mocked = { describe('getWithRedirect', () => { let testContext; + let originalLocation; beforeEach(() => { + originalLocation = global.window.location; + delete (global.window as any).location; + global.window.location = { + protocol: 'https:', + hostname: 'somesite.local', + href: 'https://somesite.local', + assign: jest.fn() + } as unknown as Location; + const sdk = { options: { - setLocation: jest.fn() }, getOriginalUri: () => {}, transactionManager: { @@ -54,6 +63,10 @@ describe('getWithRedirect', () => { jest.spyOn(mocked.util, 'createOAuthMeta').mockReturnValue(testContext.meta); }); + afterEach(() => { + global.window.location = originalLocation; + }); + it('throws an error if more than 2 parameters are passed', async () => { const { sdk } = testContext; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -78,12 +91,19 @@ describe('getWithRedirect', () => { }); - - it('redirects to the authorize endpoint', async () => { + it('redirects to the authorize endpoint with options.setLocation', async () => { const { sdk, tokenParams } = testContext; + sdk.options.setLocation = jest.fn(); await getWithRedirect(sdk, {}); expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); expect(sdk.options.setLocation).toHaveBeenCalledWith('http://fake-authorize?fake=true'); }); + it('redirects to the authorize endpoint with window.location.assign if options.setLocation is not set', async () => { + const { sdk, tokenParams } = testContext; + await getWithRedirect(sdk, {}); + expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); + expect(window.location.assign).toHaveBeenCalledWith('http://fake-authorize?fake=true'); + }); + }); \ No newline at end of file From 9a671015401cdd96f153dad04d894526898692b7 Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Mon, 14 Nov 2022 11:19:13 +0200 Subject: [PATCH 09/11] refactor: endpoints.autorize.enrollAuthenticator lint fix lint fix --- README.md | 133 ++++++++--------- lib/oidc/enrollAuthenticator.ts | 17 ++- lib/oidc/factory/api.ts | 12 +- lib/oidc/mixin/index.ts | 13 +- lib/oidc/types/api.ts | 6 +- lib/oidc/types/endpoints.ts | 23 +++ lib/oidc/types/index.ts | 1 + .../util/defaultEnrollAuthenticatorParams.ts | 36 +++++ lib/oidc/util/enrollAuthenticatorMeta.ts | 24 ++++ lib/oidc/util/index.ts | 3 + lib/oidc/util/oauthMeta.ts | 1 - .../util/prepareEnrollAuthenticatorParams.ts | 53 +++++++ lib/oidc/util/prepareTokenParams.ts | 28 +--- test/apps/app/src/testApp.ts | 12 +- test/spec/oidc/enrollAuthenticator.ts | 46 +++--- .../spec/oidc/util/enrollAuthenticatorMeta.ts | 72 ++++++++++ test/spec/oidc/util/oauthMeta.ts | 1 - .../util/prepareEnrollAuthenticatorParams.ts | 134 ++++++++++++++++++ test/spec/oidc/util/prepareTokenParams.ts | 71 +--------- test/types/token.test-d.ts | 8 +- 20 files changed, 460 insertions(+), 234 deletions(-) create mode 100644 lib/oidc/types/endpoints.ts create mode 100644 lib/oidc/util/defaultEnrollAuthenticatorParams.ts create mode 100644 lib/oidc/util/enrollAuthenticatorMeta.ts create mode 100644 lib/oidc/util/prepareEnrollAuthenticatorParams.ts create mode 100644 test/spec/oidc/util/enrollAuthenticatorMeta.ts create mode 100644 test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts diff --git a/README.md b/README.md index cac0fbdf6..819cb0ac2 100644 --- a/README.md +++ b/README.md @@ -880,7 +880,6 @@ This is accomplished by selecting a single tab to handle the network requests to * [forgotPassword](#forgotpasswordoptions) * [unlockAccount](#unlockaccountoptions) * [verifyRecoveryToken](#verifyrecoverytokenoptions) -* [enrollAuthenticator](#enrollauthenticatoroptions) * [webfinger](#webfingeroptions) * [fingerprint](#fingerprintoptions) * [isAuthenticated](#isauthenticatedoptions) @@ -905,6 +904,8 @@ This is accomplished by selecting a single tab to handle the network requests to * [session.refresh](#sessionrefresh) * [idx](#idx) * [myaccount](#myaccount) +* [endpoints](#endpoints) + * [endpoints.autorize.enrollAuthenticator](#endpointsautorizeenrollauthenticatoroptions) * [token](#token) * [token.getWithoutPrompt](#tokengetwithoutpromptoptions) * [token.getWithPopup](#tokengetwithpopupoptions) @@ -1080,50 +1081,6 @@ See [authn API](docs/authn.md#unlockaccountoptions). See [authn API](docs/authn.md#verifyrecoverytokenoptions). -#### `enrollAuthenticator(options)` - -> :link: web browser only
    -> :hourglass: async - -Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). You can use [sdk.handleRedirect](#handleredirectoriginaluri) to handle the redirect on successful enrollment or an error. - -* `options` - See [Authorize options](#authorize-options) - - Options that will be omitted: `scopes`, `nonce`. - - Options that will be overridden: `responseType: 'none', prompt: 'enroll_authenticator'`. - - ##### `options.enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). - - List of AMR values: - | AMR Value | Authenticator | - | ------------- | -------------------- | - | `pwd` | Okta Password | - | `kba` | Security question | - | `email` | Okta Email | - | `sms` | SMS | - | `tel` | Voice call | - | `duo` | DUO | - | `symantec` | Symantec VIP | - | `google_otp` | Google Authenticator | - | `okta_verify` | Okta Verify | - | `pop` | WebAuthn | - | `oath_otp` | On-Prem MFA | - | `rsa` | RSA SecurID | - | `yubikey` | Yubikey | - | `otp` | Custom HOTP | - | `fed` | External IdP | - | `sc` | SmartCard/PIV | - -```javascript -authClient.enrollAuthenticator({ - enrollAmrValues: ['okta_verify'] -}) -.catch(function(err) { - // handle AuthSdkError -}); -``` - ### `webfinger(options)` > :hourglass: async @@ -1244,7 +1201,7 @@ Stores passed in tokens or tokens from redirect url into storage, then redirect > :link: web browser only
    > :hourglass: async -Handle a redirect to the configured [redirectUri](#configuration-options) that happens on the end of [login](#signInWithRedirectoptions) flow, [enroll authenticator](#enrollauthenticatoroptions) flow or on an error. +Handle a redirect to the configured [redirectUri](#configuration-options) that happens on the end of [login](#signInWithRedirectoptions) flow, [enroll authenticator](#endpointsautorizeenrollauthenticatoroptions) flow or on an error. Stores tokens from redirect url into storage (for login flow), then redirect users back to the [originalUri](#setoriginaluriuri). When using `PKCE` authorization code flow, this method also exchanges authorization code for tokens. By default it calls `window.location.replace` for the redirection. The default behavior can be overrided by providing [options.restoreOriginalUri](#configuration-options). By default, [originalUri](#getoriginaluristate) will be retrieved from storage, but this can be overridden by specifying `originalUri` in the first parameter to this function. > **Note:** `handleRedirect` throws `OAuthError` or `AuthSdkError` in case there are errors during token retrieval or building URL to enroll authenticator. @@ -1355,8 +1312,7 @@ See detail in [IDX README](docs/idx.md) See detail in [MyAccount API README](docs/myaccount/README.md) - -### `token` +### `endpoints` #### Authorize options @@ -1372,40 +1328,60 @@ The following configuration options can be included in `token.getWithoutPrompt`, | `idp` | Identity provider to use if there is no Okta Session. | | `idpScope` | A space delimited list of scopes to be provided to the Social Identity Provider when performing [Social Login][social-login] These scopes are used in addition to the scopes already configured on the Identity Provider. | | `display` | The display parameter to be passed to the Social Identity Provider when performing [Social Login][social-login]. | -| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [enrollAuthenticator](#enrollauthenticatoroptions). | +| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [enrollAuthenticator](#endpointsautorizeenrollauthenticatoroptions). | | `maxAge` | Allowable elapsed time, in seconds, since the last time the end user was actively authenticated by Okta. | | `acrValues` | [[EA][early-access]] Optional parameter to increase the level of user assurance. See [Predefined ACR values](https://developer.okta.com/docs/guides/step-up-authentication/main/#predefined-parameter-values) for more information. | -| `enrollAmrValues` | List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [enrollAuthenticator](#enrollauthenticatoroptions) | +| `enrollAmrValues` | [[EA][early-access]] List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [enrollAuthenticator](#endpointsautorizeenrollauthenticatoroptions) | | `loginHint` | A username to prepopulate if prompting for authentication. | For more details, see Okta's [Authorize Request API](https://developer.okta.com/docs/api/resources/oidc#request-parameters). -##### Example +#### `endpoints.authorize.enrollAuthenticator(options)` -```javascript -authClient.token.getWithoutPrompt({ - sessionToken: '00p8RhRDCh_8NxIin-wtF5M6ofFtRhfKWGBAbd2WmE', - scopes: [ - 'openid', - 'email', - 'profile' - ], - state: '8rFzn3MH5q', - nonce: '51GePTswrm', - // Use a custom IdP for social authentication - idp: '0oa62b57p7c8PaGpU0h7' - }) -.then(function(res) { - var tokens = res.tokens; +> :link: web browser only
    - // Do something with tokens, such as - authClient.tokenManager.setTokens(tokens); -}) -.catch(function(err) { - // handle OAuthError or AuthSdkError -}); +Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). You can use [sdk.handleRedirect](#handleredirectoriginaluri) to handle the redirect on successful enrollment or an error. + +* `options` - See [Authorize options](#authorize-options) + + Options that will be omitted: `scopes`, `nonce`. + + Options that will be overridden: `responseType: 'none', prompt: 'enroll_authenticator'`. + + ##### `options.enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). + + List of AMR values: + | AMR Value | Authenticator | + | ------------- | -------------------- | + | `pwd` | Okta Password | + | `kba` | Security question | + | `email` | Okta Email | + | `sms` | SMS | + | `tel` | Voice call | + | `duo` | DUO | + | `symantec` | Symantec VIP | + | `google_otp` | Google Authenticator | + | `okta_verify` | Okta Verify | + | `pop` | WebAuthn | + | `oath_otp` | On-Prem MFA | + | `rsa` | RSA SecurID | + | `yubikey` | Yubikey | + | `otp` | Custom HOTP | + | `fed` | External IdP | + | `sc` | SmartCard/PIV | + +```javascript +try { + authClient.endpoints.authorize.enrollAuthenticator({ + enrollAmrValues: ['okta_verify'] + }) +} catch(err) { + // handle AuthSdkError +} ``` +### `token` + #### `token.getWithoutPrompt(options)` > :link: web browser only
    @@ -1416,11 +1392,22 @@ When you've obtained a sessionToken from the authorization flows, or a session a * `options` - See [Authorize options](#authorize-options) +##### Example + ```javascript authClient.token.getWithoutPrompt({ responseType: 'id_token', // or array of types sessionToken: 'testSessionToken' // optional if the user has an existing Okta session -}) + scopes: [ + 'openid', + 'email', + 'profile' + ], + state: '8rFzn3MH5q', + nonce: '51GePTswrm', + // Use a custom IdP for social authentication + idp: '0oa62b57p7c8PaGpU0h7' + }) .then(function(res) { var tokens = res.tokens; diff --git a/lib/oidc/enrollAuthenticator.ts b/lib/oidc/enrollAuthenticator.ts index 11b9e12d4..9eaffb8a6 100644 --- a/lib/oidc/enrollAuthenticator.ts +++ b/lib/oidc/enrollAuthenticator.ts @@ -13,19 +13,18 @@ */ import { OktaAuthOAuthInterface, EnrollAuthenticatorOptions } from './types'; import { clone } from '../util'; -import { prepareTokenParams, createOAuthMeta } from './util'; +import { prepareEnrollAuthenticatorParams, createEnrollAuthenticatorMeta } from './util'; import { buildAuthorizeParams } from './endpoints/authorize'; -export async function enrollAuthenticator( - sdk: OktaAuthOAuthInterface, - options: EnrollAuthenticatorOptions -): Promise { +export function enrollAuthenticator( + sdk: OktaAuthOAuthInterface, + options: EnrollAuthenticatorOptions +): void { options = clone(options) || {}; - options.prompt = 'enroll_authenticator'; - const tokenParams = await prepareTokenParams(sdk, options); - const meta = createOAuthMeta(sdk, tokenParams); - const requestUrl = meta.urls.authorizeUrl + buildAuthorizeParams(tokenParams); + const params = prepareEnrollAuthenticatorParams(sdk, options); + const meta = createEnrollAuthenticatorMeta(sdk, params); + const requestUrl = meta.urls.authorizeUrl + buildAuthorizeParams(params); sdk.transactionManager.save(meta); if (sdk.options.setLocation) { sdk.options.setLocation(requestUrl); diff --git a/lib/oidc/factory/api.ts b/lib/oidc/factory/api.ts index 01c5f86e9..6d6e97cba 100644 --- a/lib/oidc/factory/api.ts +++ b/lib/oidc/factory/api.ts @@ -31,10 +31,12 @@ import { OktaAuthOAuthInterface, ParseFromUrlInterface, TokenAPI, - UserClaims + UserClaims, + Endpoints, } from '../types'; import { isLoginRedirect, prepareTokenParams } from '../util'; import { verifyToken } from '../verifyToken'; +import { enrollAuthenticator } from '../enrollAuthenticator'; // Factory export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue): TokenAPI { @@ -101,3 +103,11 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) return token; } + +export function createEndpoints(sdk: OktaAuthOAuthInterface): Endpoints { + return { + authorize: { + enrollAuthenticator: enrollAuthenticator.bind(null, sdk), + } + }; +} diff --git a/lib/oidc/mixin/index.ts b/lib/oidc/mixin/index.ts index c3b3e29e2..7a96f07f5 100644 --- a/lib/oidc/mixin/index.ts +++ b/lib/oidc/mixin/index.ts @@ -25,13 +25,12 @@ import { TransactionManagerInterface, TransactionManagerConstructor, UserClaims, - EnrollAuthenticatorOptions, + Endpoints, } from '../types'; import PKCE from '../util/pkce'; -import { createTokenAPI } from '../factory'; +import { createEndpoints, createTokenAPI } from '../factory'; import { TokenManager } from '../TokenManager'; import { getOAuthUrls, isLoginRedirect } from '../util'; -import { enrollAuthenticator } from '../enrollAuthenticator'; import { OktaAuthSessionInterface } from '../../session/types'; import { provideOriginalUri } from './node'; @@ -58,6 +57,7 @@ export function mixinOAuth tokenManager: TokenManager; transactionManager: TM; pkce: PkceAPI; + endpoints: Endpoints; _pending: { handleLogin: boolean }; _tokenQueue: PromiseQueue; @@ -83,6 +83,8 @@ export function mixinOAuth // TokenManager this.tokenManager = new TokenManager(this, this.options.tokenManager); + + this.endpoints = createEndpoints(this); } // inherited from subclass @@ -192,7 +194,6 @@ export function mixinOAuth return isLoginRedirect(this); } - isPKCE(): boolean { return !!this.options.pkce; } @@ -340,10 +341,6 @@ export function mixinOAuth } } - async enrollAuthenticator(options: EnrollAuthenticatorOptions) { - return enrollAuthenticator(this, options); - } - }; } diff --git a/lib/oidc/types/api.ts b/lib/oidc/types/api.ts index 74de31f28..062f34be5 100644 --- a/lib/oidc/types/api.ts +++ b/lib/oidc/types/api.ts @@ -16,7 +16,6 @@ import { CustomUrls, OktaAuthOAuthOptions, SigninWithRedirectOptions, - EnrollAuthenticatorOptions, TokenParams } from './options'; import { OAuthResponseType } from './proto'; @@ -26,6 +25,7 @@ import { TokenManagerInterface } from './TokenManager'; import { CustomUserClaims, UserClaims } from './UserClaims'; import { TransactionManagerInterface } from './TransactionManager'; import { OktaAuthSessionInterface } from '../../session/types'; +import { Endpoints } from './endpoints'; export interface PopupParams { popupTitle?: string; @@ -54,8 +54,6 @@ export interface ParseFromUrlInterface extends ParseFromUrlFunction { export type GetWithRedirectFunction = (params?: TokenParams) => Promise; -export type EnrollAuthenticatorFunction = (params: EnrollAuthenticatorOptions) => Promise; - export type SetLocationFunction = (loc: string) => void; export interface BaseTokenAPI { @@ -140,6 +138,7 @@ export interface OktaAuthOAuthInterface tokenManager: TokenManagerInterface; pkce: PkceAPI; transactionManager: TM; + endpoints: Endpoints; isPKCE(): boolean; getIdToken(): string | undefined; @@ -152,7 +151,6 @@ export interface OktaAuthOAuthInterface storeTokensFromRedirect(): Promise; getUser(): Promise>; signInWithRedirect(opts?: SigninWithRedirectOptions): Promise; - enrollAuthenticator: EnrollAuthenticatorFunction; revokeAccessToken(accessToken?: AccessToken): Promise; revokeRefreshToken(refreshToken?: RefreshToken): Promise; diff --git a/lib/oidc/types/endpoints.ts b/lib/oidc/types/endpoints.ts new file mode 100644 index 000000000..5ddd6bba1 --- /dev/null +++ b/lib/oidc/types/endpoints.ts @@ -0,0 +1,23 @@ +/*! + * Copyright (c) 2021-present, 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 { + EnrollAuthenticatorOptions +} from './options'; + +export type EnrollAuthenticatorFunction = (params: EnrollAuthenticatorOptions) => void; + +export interface Endpoints { + authorize: { + enrollAuthenticator: EnrollAuthenticatorFunction; + } +} diff --git a/lib/oidc/types/index.ts b/lib/oidc/types/index.ts index 5be702456..98d9d4573 100644 --- a/lib/oidc/types/index.ts +++ b/lib/oidc/types/index.ts @@ -21,3 +21,4 @@ export * from './TokenManager'; export * from './Transaction'; export * from './TransactionManager'; export * from './UserClaims'; +export * from './endpoints'; diff --git a/lib/oidc/util/defaultEnrollAuthenticatorParams.ts b/lib/oidc/util/defaultEnrollAuthenticatorParams.ts new file mode 100644 index 000000000..135ba1ca6 --- /dev/null +++ b/lib/oidc/util/defaultEnrollAuthenticatorParams.ts @@ -0,0 +1,36 @@ + +/* global window */ +/*! + * Copyright (c) 2015-present, 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 { generateState } from './oauth'; +import { OktaAuthOAuthInterface, TokenParams } from '../types'; +import { isBrowser } from '../../features'; +import { removeNils } from '../../util'; + +export function getDefaultEnrollAuthenticatorParams(sdk: OktaAuthOAuthInterface): TokenParams { + const { + clientId, + redirectUri, + responseMode, + state, + } = sdk.options; + const defaultRedirectUri = isBrowser() ? window.location.href : undefined; + return removeNils({ + clientId, + redirectUri: redirectUri || defaultRedirectUri, + responseMode, + state: state || generateState(), + responseType: 'none', + prompt: 'enroll_authenticator', + }); +} \ No newline at end of file diff --git a/lib/oidc/util/enrollAuthenticatorMeta.ts b/lib/oidc/util/enrollAuthenticatorMeta.ts new file mode 100644 index 000000000..fffec2cc9 --- /dev/null +++ b/lib/oidc/util/enrollAuthenticatorMeta.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { OAuthTransactionMeta, OktaAuthOAuthInterface, EnrollAuthenticatorOptions } from '../types'; +import { getOAuthUrls } from './oauth'; + +export function createEnrollAuthenticatorMeta( + sdk: OktaAuthOAuthInterface, + params: EnrollAuthenticatorOptions +): OAuthTransactionMeta { + const issuer = sdk.options.issuer!; + const urls = getOAuthUrls(sdk, params); + const oauthMeta: OAuthTransactionMeta = { + issuer, + urls, + clientId: params.clientId!, + redirectUri: params.redirectUri!, + responseType: params.responseType!, + responseMode: params.responseMode!, + state: params.state!, + acrValues: params.acrValues, + enrollAmrValues: params.enrollAmrValues, + }; + + return oauthMeta; +} diff --git a/lib/oidc/util/index.ts b/lib/oidc/util/index.ts index 7701b60f5..a77851d53 100644 --- a/lib/oidc/util/index.ts +++ b/lib/oidc/util/index.ts @@ -14,13 +14,16 @@ export * from './browser'; export * from './defaultTokenParams'; +export * from './defaultEnrollAuthenticatorParams'; export * from './errors'; export * from './loginRedirect'; export * from './oauth'; export * from './oauthMeta'; +export * from './enrollAuthenticatorMeta'; import pkce from './pkce'; export { pkce }; export * from './prepareTokenParams'; +export * from './prepareEnrollAuthenticatorParams'; export * from './refreshToken'; export * from './urlParams'; export * from './validateClaims'; diff --git a/lib/oidc/util/oauthMeta.ts b/lib/oidc/util/oauthMeta.ts index 56dee8dc4..6e00e1e6e 100644 --- a/lib/oidc/util/oauthMeta.ts +++ b/lib/oidc/util/oauthMeta.ts @@ -20,7 +20,6 @@ export function createOAuthMeta( nonce: tokenParams.nonce!, ignoreSignature: tokenParams.ignoreSignature!, acrValues: tokenParams.acrValues, - enrollAmrValues: tokenParams.enrollAmrValues, }; if (tokenParams.pkce === false) { diff --git a/lib/oidc/util/prepareEnrollAuthenticatorParams.ts b/lib/oidc/util/prepareEnrollAuthenticatorParams.ts new file mode 100644 index 000000000..f8f876b45 --- /dev/null +++ b/lib/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -0,0 +1,53 @@ +/* eslint-disable complexity */ +/*! + * Copyright (c) 2015-present, 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 { AuthSdkError } from '../../errors'; +import { OktaAuthOAuthInterface, EnrollAuthenticatorOptions } from '../types'; +import { getDefaultEnrollAuthenticatorParams } from './defaultEnrollAuthenticatorParams'; + +function prepareParams( + params: EnrollAuthenticatorOptions +): EnrollAuthenticatorOptions { + params = { + ...params, + // forced params: + responseType: 'none', + prompt: 'enroll_authenticator', + }; + + if (!params.enrollAmrValues) { + throw new AuthSdkError('enroll_amr_values must be specified'); + } + + // scope, nonce must be omitted + delete params.scopes; + delete params.nonce; + + // maxAge is not supported + if (params.maxAge && params.maxAge > 0) { + delete params.maxAge; + } + + return params; +} + +// Prepares params for a call to /authorize +export function prepareEnrollAuthenticatorParams( + sdk: OktaAuthOAuthInterface, + options: EnrollAuthenticatorOptions +): EnrollAuthenticatorOptions { + return prepareParams({ + ...getDefaultEnrollAuthenticatorParams(sdk), + ...options + }); +} diff --git a/lib/oidc/util/prepareTokenParams.ts b/lib/oidc/util/prepareTokenParams.ts index a2640fe27..1460fa393 100644 --- a/lib/oidc/util/prepareTokenParams.ts +++ b/lib/oidc/util/prepareTokenParams.ts @@ -78,30 +78,6 @@ export async function preparePKCE( return tokenParams; } -function prepareEnrollAuthenticator( - tokenParams: TokenParams -): TokenParams { - tokenParams = { - ...tokenParams, - responseType: 'none' // responseType is forced - }; - - if (!tokenParams.enrollAmrValues) { - throw new AuthSdkError('enroll_amr_values must be specified'); - } - - // scope, nonce must be omitted - delete tokenParams.scopes; - delete tokenParams.nonce; - - // maxAge is not supported - if (tokenParams.maxAge && tokenParams.maxAge > 0) { - delete tokenParams.maxAge; - } - - return tokenParams; -} - // Prepares params for a call to /authorize or /token export async function prepareTokenParams( sdk: OktaAuthOAuthInterface, @@ -111,9 +87,7 @@ export async function prepareTokenParams( const defaults = getDefaultTokenParams(sdk); tokenParams = { ...defaults, ...tokenParams }; - if (tokenParams.prompt === 'enroll_authenticator') { - tokenParams = prepareEnrollAuthenticator(tokenParams); - } else if (tokenParams.pkce) { + if (tokenParams.pkce) { tokenParams = await preparePKCE(sdk, tokenParams); } diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts index 7d553662a..e762321df 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -899,18 +899,18 @@ class TestApp { } } - async enrollAuthenticator(): Promise { + enrollAuthenticator(): void { this.config.state = this.config.state || 'enroll-authenticator-redirect' + Math.round(Math.random() * 1000); saveConfigToStorage(this.config); const options: EnrollAuthenticatorOptions = Object.assign({}, { state: this.config.state, enrollAmrValues: this.config.enrollAmrValues, }); - return this.oktaAuth.enrollAuthenticator(options) - .catch(e => { - this.renderError(e); - throw e; - }); + try { + this.oktaAuth.endpoints.authorize.enrollAuthenticator(options); + } catch(e) { + this.renderError(e); + } } configHTML(): string { diff --git a/test/spec/oidc/enrollAuthenticator.ts b/test/spec/oidc/enrollAuthenticator.ts index df40a5f27..ce0580e14 100644 --- a/test/spec/oidc/enrollAuthenticator.ts +++ b/test/spec/oidc/enrollAuthenticator.ts @@ -2,8 +2,8 @@ import { enrollAuthenticator } from '../../../lib/oidc/enrollAuthenticator'; jest.mock('../../../lib/oidc/util', () => { return { - prepareTokenParams: () => {}, - createOAuthMeta: () => {}, + prepareEnrollAuthenticatorParams: () => {}, + createEnrollAuthenticatorMeta: () => {}, getOAuthUrls: () => {} }; }); @@ -42,7 +42,7 @@ describe('enrollAuthenticator', () => { save: () => {} } }; - const tokenParams = { + const preparedParams = { clientId: 'fakeClientId', responseType: 'none', prompt: 'enroll_authenticator', @@ -60,16 +60,16 @@ describe('enrollAuthenticator', () => { }; testContext = { sdk, - tokenParams, + preparedParams, authorizeParams, enrollParams, urls, meta }; - jest.spyOn(mocked.util, 'prepareTokenParams').mockResolvedValue(testContext.tokenParams); + jest.spyOn(mocked.util, 'prepareEnrollAuthenticatorParams').mockReturnValue(testContext.preparedParams); jest.spyOn(mocked.util, 'getOAuthUrls').mockReturnValue(testContext.urls); jest.spyOn(mocked.authorize, 'buildAuthorizeParams').mockReturnValue(testContext.authorizeParams); - jest.spyOn(mocked.util, 'createOAuthMeta').mockReturnValue(testContext.meta); + jest.spyOn(mocked.util, 'createEnrollAuthenticatorMeta').mockReturnValue(testContext.meta); }); afterEach(() => { @@ -82,39 +82,25 @@ describe('enrollAuthenticator', () => { jest.spyOn(sdk.transactionManager, 'save'); }); - it('saves the transaction meta', async () => { + it('saves the transaction meta', () => { const { sdk, meta, enrollParams } = testContext; - await enrollAuthenticator(sdk, enrollParams); + enrollAuthenticator(sdk, enrollParams); expect(sdk.transactionManager.save).toHaveBeenCalledWith(meta); }); }); - it('overrides prompt with enroll_authenticator', async () => { - const { sdk, enrollParams } = testContext; - const badEnrollParams = { - ...enrollParams, - prompt: 'none' - }; - const tokenParams = { - ...badEnrollParams, - prompt: 'enroll_authenticator' - }; - await enrollAuthenticator(sdk, badEnrollParams); - expect(mocked.util.prepareTokenParams).toHaveBeenCalledWith(sdk, tokenParams); - }); - - it('redirects to the authorize endpoint with options.setLocation', async () => { - const { sdk, tokenParams, enrollParams, authorizeParams } = testContext; + it('redirects to the authorize endpoint with options.setLocation', () => { + const { sdk, preparedParams, enrollParams, authorizeParams } = testContext; sdk.options.setLocation = jest.fn(); - await enrollAuthenticator(sdk, enrollParams); - expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); + enrollAuthenticator(sdk, enrollParams); + expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(preparedParams); expect(sdk.options.setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); }); - it('redirects to the authorize endpoint with window.location.assign if options.setLocation is not set', async () => { - const { sdk, tokenParams, enrollParams, authorizeParams } = testContext; - await enrollAuthenticator(sdk, enrollParams); - expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams); + it('redirects to the authorize endpoint with window.location.assign if options.setLocation is not set', () => { + const { sdk, preparedParams, enrollParams, authorizeParams } = testContext; + enrollAuthenticator(sdk, enrollParams); + expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(preparedParams); expect(window.location.assign).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`); }); diff --git a/test/spec/oidc/util/enrollAuthenticatorMeta.ts b/test/spec/oidc/util/enrollAuthenticatorMeta.ts new file mode 100644 index 000000000..44240bcb2 --- /dev/null +++ b/test/spec/oidc/util/enrollAuthenticatorMeta.ts @@ -0,0 +1,72 @@ +import { createEnrollAuthenticatorMeta } from '../../../../lib/oidc/util/enrollAuthenticatorMeta'; + +jest.mock('../../../../lib/oidc/util/oauth', () => { + return { + getOAuthUrls: () => {} + }; +}); + + +const mocked = { + oauth: require('../../../../lib/oidc/util/oauth'), +}; + +describe('enrollAuthenticatorMeta', () => { + let testContext; + beforeEach(() => { + const sdk = { + options: { + }, + }; + const enrollAuthenticatorOptions = { + }; + const urls = { + authorizeUrl: 'http://fake-authorize' + }; + testContext = { + sdk, + enrollAuthenticatorOptions, + urls, + }; + }); + + it('saves issuer from sdk', async () => { + const { sdk, enrollAuthenticatorOptions } = testContext; + const issuer = 'http://fake'; + sdk.options.issuer = issuer; + const meta = createEnrollAuthenticatorMeta(sdk, enrollAuthenticatorOptions); + expect(meta.issuer).toBe(issuer); + }); + + it('saves urls from `getOAuthUrls`', async () => { + const { sdk, urls, enrollAuthenticatorOptions } = testContext; + jest.spyOn(mocked.oauth, 'getOAuthUrls').mockReturnValue(urls); + const meta = createEnrollAuthenticatorMeta(sdk, enrollAuthenticatorOptions); + expect(mocked.oauth.getOAuthUrls).toHaveBeenCalledWith(sdk, enrollAuthenticatorOptions); + expect(meta.urls).toEqual(urls); + }); + + it('saves OAuth values from the enrollAuthenticatorOptions', async () => { + const { sdk, enrollAuthenticatorOptions } = testContext; + Object.assign(enrollAuthenticatorOptions, { + responseType: 'none', + responseMode: 'query', + state: 'mock-state', + clientId: 'mock-clientId', + redirectUri: 'http://localhost/login/callback', + acrValues: 'foo', + enrollAmrValues: ['a', 'b'] + }); + + const meta = createEnrollAuthenticatorMeta(sdk, enrollAuthenticatorOptions); + expect(meta).toEqual({ + responseType: 'none', + responseMode: 'query', + state: 'mock-state', + clientId: 'mock-clientId', + redirectUri: 'http://localhost/login/callback', + acrValues: 'foo', + enrollAmrValues: ['a', 'b'] + }); + }); +}); diff --git a/test/spec/oidc/util/oauthMeta.ts b/test/spec/oidc/util/oauthMeta.ts index ae98136b8..7f23ded8d 100644 --- a/test/spec/oidc/util/oauthMeta.ts +++ b/test/spec/oidc/util/oauthMeta.ts @@ -84,7 +84,6 @@ describe('oauthMeta', () => { codeChallenge: 'efgh', codeChallengeMethod: 'fake', acrValues: 'foo', - enrollAmrValues: ['a', 'b'], }); }); }); diff --git a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts new file mode 100644 index 000000000..bbdf6734f --- /dev/null +++ b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -0,0 +1,134 @@ +/*! + * Copyright (c) 2015-present, 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. + */ + + +const mocked = { + features: { + isBrowser: () => typeof window !== 'undefined', + isLocalhost: () => true, + isHTTPS: () => false, + isPKCESupported: () => true, + }, +}; +jest.mock('../../../../lib/features', () => { + return mocked.features; +}); +import { OktaAuth, AuthSdkError } from '@okta/okta-auth-js'; +import { prepareEnrollAuthenticatorParams } from '../../../../lib/oidc'; + +describe('prepareEnrollAuthenticatorParams', function() { + it('throws an error if enrollAmrValues not specified', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + let errorThrown = false; + try { + prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: '' + }); + } catch (err) { + errorThrown = true; + expect(err).toBeInstanceOf(AuthSdkError); + expect((err as AuthSdkError).message).toEqual('enroll_amr_values must be specified'); + } + expect(errorThrown).toBe(true); + }); + + it('sets responseType to none', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: ['a'] + }); + expect(params.responseType).toBe('none'); + }); + + it('overrides responseType with none', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = prepareEnrollAuthenticatorParams(sdk, { + responseType: 'token', + enrollAmrValues: ['a'] + }); + expect(params.responseType).toBe('none'); + }); + + it('sets prompt to enroll_authenticator', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: ['a'] + }); + expect(params.prompt).toBe('enroll_authenticator'); + }); + + it('overrides prompt with enroll_authenticator', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = prepareEnrollAuthenticatorParams(sdk, { + prompt: 'login', + enrollAmrValues: ['a'] + }); + expect(params.prompt).toBe('enroll_authenticator'); + }); + + it('does not prepare PKCE params', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com', + pkce: true + }); + spyOn(mocked.features, 'isPKCESupported').and.returnValue(true); + const params = prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: ['a'] + }); + expect(params.codeVerifier).toBe(undefined); + expect(params.codeChallenge).toBe(undefined); + expect(params.codeChallengeMethod).toBe(undefined); + }); + + it('does not use acrValues from sdk.options', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com', + acrValues: 'foo' + }); + const params = prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: ['a'], + }); + expect(params.acrValues).toBe(undefined); + }); + + it('removes scopes, nonce, maxAge', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: ['a'], + scopes: ['openid','email'], + nonce: 'fake-nonce', + maxAge: 100, + }); + expect(params.scopes).toBe(undefined); + expect(params.nonce).toBe(undefined); + expect(params.maxAge).toBe(undefined); + }); + + // Note: + // The only suported `acrValues` is 'urn:okta:2fa:ifpossible' + // Autorize endpoint will throw an error otherwise, + // but this can change in the future, + // so not checking this in okta-auth-js + +}); \ No newline at end of file diff --git a/test/spec/oidc/util/prepareTokenParams.ts b/test/spec/oidc/util/prepareTokenParams.ts index e7e8fd542..da43965dd 100644 --- a/test/spec/oidc/util/prepareTokenParams.ts +++ b/test/spec/oidc/util/prepareTokenParams.ts @@ -30,7 +30,7 @@ jest.mock('../../../../lib/features', () => { jest.mock('../../../../lib/oidc/endpoints/well-known', () => { return mocked.wellKnown; }); -import { OktaAuth, AuthSdkError } from '@okta/okta-auth-js'; +import { OktaAuth } from '@okta/okta-auth-js'; import { prepareTokenParams, pkce } from '../../../../lib/oidc'; import { createTransactionManager } from '../../../../lib/oidc/TransactionManager'; @@ -115,74 +115,5 @@ describe('prepareTokenParams', function() { expect(oauthParams.codeChallenge).toBe(codeChallenge); }); }); - - - describe('prompt=enroll_authenticator', function() { - it('throws an error if enrollAmrValues not specified', async () => { - const sdk = new OktaAuth({ - issuer: 'https://foo.com' - }); - let errorThrown = false; - try { - await prepareTokenParams(sdk, { - prompt: 'enroll_authenticator', - }); - } catch (err) { - errorThrown = true; - expect(err).toBeInstanceOf(AuthSdkError); - expect((err as AuthSdkError).message).toEqual('enroll_amr_values must be specified'); - } - expect(errorThrown).toBe(true); - }); - - it('sets responseType to none', async () => { - const sdk = new OktaAuth({ - issuer: 'https://foo.com' - }); - const params = await prepareTokenParams(sdk, { - prompt: 'enroll_authenticator', - enrollAmrValues: ['a'] - }); - expect(params.responseType).toBe('none'); - }); - - it('does not prepare PKCE params', async () => { - const sdk = new OktaAuth({ - issuer: 'https://foo.com', - pkce: true - }); - spyOn(mocked.features, 'isPKCESupported').and.returnValue(true); - const params = await prepareTokenParams(sdk, { - prompt: 'enroll_authenticator', - enrollAmrValues: ['a'] - }); - expect(params.codeVerifier).toBe(undefined); - expect(params.codeChallenge).toBe(undefined); - expect(params.codeChallengeMethod).toBe(undefined); - }); - - it('removes scopes, nonce, maxAge', async () => { - const sdk = new OktaAuth({ - issuer: 'https://foo.com' - }); - const params = await prepareTokenParams(sdk, { - prompt: 'enroll_authenticator', - enrollAmrValues: ['a'], - scopes: ['openid','email'], - nonce: 'fake-nonce', - maxAge: 100, - }); - expect(params.scopes).toBe(undefined); - expect(params.nonce).toBe(undefined); - expect(params.maxAge).toBe(undefined); - }); - - // Note: - // The only suported `acrValues` is 'urn:okta:2fa:ifpossible' - // Autorize endpoint will throw an error otherwise, - // but this can change in the future, - // so not checking this in okta-auth-js - - }); }); \ No newline at end of file diff --git a/test/types/token.test-d.ts b/test/types/token.test-d.ts index d998c73e1..45db61f78 100644 --- a/test/types/token.test-d.ts +++ b/test/types/token.test-d.ts @@ -94,13 +94,13 @@ const tokens = { enrollAmrValues: 'email', responseType: 'none' }; - expectType(await authClient.enrollAuthenticator(enrollAuthenticatorOptons)); - expectType(await authClient.enrollAuthenticator(enrollAuthenticatorOptons2)); + expectType(await authClient.endpoints.authorize.enrollAuthenticator(enrollAuthenticatorOptons)); + expectType(await authClient.endpoints.authorize.enrollAuthenticator(enrollAuthenticatorOptons2)); expectError(async () => { - await authClient.enrollAuthenticator({}); + await authClient.endpoints.authorize.enrollAuthenticator({}); }); expectError(async () => { - await authClient.enrollAuthenticator(); + await authClient.endpoints.authorize.enrollAuthenticator(); }); const customUrls = { From 8b6c09c05585b68c06bb9413e241170031677985 Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Thu, 8 Dec 2022 13:37:11 +0200 Subject: [PATCH 10/11] e2e --- README.md | 71 +++++----- lib/oidc/endpoints/authorize.ts | 2 +- lib/oidc/types/api.ts | 7 +- lib/oidc/types/options.ts | 2 +- .../util/prepareEnrollAuthenticatorParams.ts | 13 +- lib/oidc/util/prepareTokenParams.ts | 7 +- .../support/management-api/listFactors.ts | 33 +++++ test/apps/app/src/testApp.ts | 8 +- test/e2e/config.js | 3 +- .../e2e/features/enroll-authenticator.feature | 69 +++++----- test/e2e/features/step-definitions/steps.ts | 129 +++++++++++++++++- test/e2e/pageobjects/OktaLogin.js | 18 ++- test/e2e/pageobjects/TestApp.js | 8 +- .../util/prepareEnrollAuthenticatorParams.ts | 58 ++++++-- test/types/token.test-d.ts | 17 ++- 15 files changed, 340 insertions(+), 105 deletions(-) create mode 100644 samples/test/support/management-api/listFactors.ts diff --git a/README.md b/README.md index 819cb0ac2..7fa8047af 100644 --- a/README.md +++ b/README.md @@ -905,7 +905,7 @@ This is accomplished by selecting a single tab to handle the network requests to * [idx](#idx) * [myaccount](#myaccount) * [endpoints](#endpoints) - * [endpoints.autorize.enrollAuthenticator](#endpointsautorizeenrollauthenticatoroptions) + * [endpoints.autorize.enrollAuthenticator](#endpointsauthorizeenrollauthenticatoroptions) * [token](#token) * [token.getWithoutPrompt](#tokengetwithoutpromptoptions) * [token.getWithPopup](#tokengetwithpopupoptions) @@ -1189,7 +1189,7 @@ if (authClient.isLoginRedirect()) { ### `handleLoginRedirect(tokens?, originalUri?)` > :link: web browser only
    -> :hourglass: async +> :hourglass: async
    > :warning: Deprecated, this method could be removed in next major release, use [sdk.handleRedirect](#handleredirectoriginaluri) instead. Stores passed in tokens or tokens from redirect url into storage, then redirect users back to the [originalUri](#setoriginaluriuri). When using `PKCE` authorization code flow, this method also exchanges authorization code for tokens. By default it calls `window.location.replace` for the redirection. The default behavior can be overrided by providing [options.restoreOriginalUri](#configuration-options). By default, [originalUri](#getoriginaluristate) will be retrieved from storage, but this can be overridden by passing a value fro `originalUri` to this function in the 2nd parameter. @@ -1201,10 +1201,10 @@ Stores passed in tokens or tokens from redirect url into storage, then redirect > :link: web browser only
    > :hourglass: async -Handle a redirect to the configured [redirectUri](#configuration-options) that happens on the end of [login](#signInWithRedirectoptions) flow, [enroll authenticator](#endpointsautorizeenrollauthenticatoroptions) flow or on an error. +Handle a redirect to the configured [redirectUri](#configuration-options) that happens on the end of [login](#signInWithRedirectoptions) flow, [enroll authenticator](#endpointsauthorizeenrollauthenticatoroptions) flow or on an error. Stores tokens from redirect url into storage (for login flow), then redirect users back to the [originalUri](#setoriginaluriuri). When using `PKCE` authorization code flow, this method also exchanges authorization code for tokens. By default it calls `window.location.replace` for the redirection. The default behavior can be overrided by providing [options.restoreOriginalUri](#configuration-options). By default, [originalUri](#getoriginaluristate) will be retrieved from storage, but this can be overridden by specifying `originalUri` in the first parameter to this function. -> **Note:** `handleRedirect` throws `OAuthError` or `AuthSdkError` in case there are errors during token retrieval or building URL to enroll authenticator. +> **Note:** `handleRedirect` throws `OAuthError` or `AuthSdkError` in case there are errors during token retrieval or authenticator enrollment. ### `setHeaders()` @@ -1252,7 +1252,7 @@ See [authn API](docs/authn.md#sessionsetcookieandredirectsessiontoken-redirectur #### `session.exists()` > :link: web browser only
    -> :warning: This method requires access to [third party cookies]
    (#third-party-cookies) +> :warning: This method requires access to [third party cookies](#third-party-cookies)
    > :hourglass: async Returns a promise that resolves with `true` if there is an existing Okta [session](https://developer.okta.com/docs/api/resources/sessions#example), or `false` if not. @@ -1271,7 +1271,7 @@ authClient.session.exists() #### `session.get()` > :link: web browser only
    -> :warning: This method requires access to [third party cookies]
    (#third-party-cookies) +> :warning: This method requires access to [third party cookies](#third-party-cookies)
    > :hourglass: async Gets the active [session](https://developer.okta.com/docs/api/resources/sessions#example). @@ -1289,7 +1289,7 @@ authClient.session.get() #### `session.refresh()` > :link: web browser only
    -> :warning: This method requires access to [third party cookies]
    (#third-party-cookies) +> :warning: This method requires access to [third party cookies](#third-party-cookies)
    > :hourglass: async Refresh the current session by extending its lifetime. This can be used as a keep-alive operation. @@ -1328,10 +1328,10 @@ The following configuration options can be included in `token.getWithoutPrompt`, | `idp` | Identity provider to use if there is no Okta Session. | | `idpScope` | A space delimited list of scopes to be provided to the Social Identity Provider when performing [Social Login][social-login] These scopes are used in addition to the scopes already configured on the Identity Provider. | | `display` | The display parameter to be passed to the Social Identity Provider when performing [Social Login][social-login]. | -| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [enrollAuthenticator](#endpointsautorizeenrollauthenticatoroptions). | +| `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [enrollAuthenticator](#endpointsauthorizeenrollauthenticatoroptions). | | `maxAge` | Allowable elapsed time, in seconds, since the last time the end user was actively authenticated by Okta. | | `acrValues` | [[EA][early-access]] Optional parameter to increase the level of user assurance. See [Predefined ACR values](https://developer.okta.com/docs/guides/step-up-authentication/main/#predefined-parameter-values) for more information. | -| `enrollAmrValues` | [[EA][early-access]] List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [enrollAuthenticator](#endpointsautorizeenrollauthenticatoroptions) | +| `enrollAmrValues` | [[EA][early-access]] List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [enrollAuthenticator](#endpointsauthorizeenrollauthenticatoroptions) | | `loginHint` | A username to prepopulate if prompting for authentication. | For more details, see Okta's [Authorize Request API](https://developer.okta.com/docs/api/resources/oidc#request-parameters). @@ -1348,32 +1348,39 @@ Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with spe Options that will be overridden: `responseType: 'none', prompt: 'enroll_authenticator'`. - ##### `options.enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). - - List of AMR values: - | AMR Value | Authenticator | - | ------------- | -------------------- | - | `pwd` | Okta Password | - | `kba` | Security question | - | `email` | Okta Email | - | `sms` | SMS | - | `tel` | Voice call | - | `duo` | DUO | - | `symantec` | Symantec VIP | - | `google_otp` | Google Authenticator | - | `okta_verify` | Okta Verify | - | `pop` | WebAuthn | - | `oath_otp` | On-Prem MFA | - | `rsa` | RSA SecurID | - | `yubikey` | Yubikey | - | `otp` | Custom HOTP | - | `fed` | External IdP | - | `sc` | SmartCard/PIV | + Required options: + + * `enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). + + List of AMR values: + | AMR Value | Authenticator | + | ------------- | -------------------- | + | `pwd` | Okta Password | + | `kba` | Security question | + | `email` | Okta Email | + | `sms` | SMS | + | `tel` | Voice call | + | `duo` | DUO | + | `symantec` | Symantec VIP | + | `google_otp` | Google Authenticator | + | `okta_verify` | Okta Verify | + | `pop` | WebAuthn | + | `oath_otp` | On-Prem MFA | + | `rsa` | RSA SecurID | + | `yubikey` | Yubikey | + | `otp` | Custom HOTP | + | `fed` | External IdP | + | `sc` | SmartCard/PIV | + + * `acrValues` - should equal `urn:okta:2fa:any:ifpossible` + +##### Example ```javascript try { authClient.endpoints.authorize.enrollAuthenticator({ - enrollAmrValues: ['okta_verify'] + enrollAmrValues: ['okta_verify'], + acrValues: 'urn:okta:2fa:any:ifpossible' }) } catch(err) { // handle AuthSdkError @@ -1537,7 +1544,7 @@ console.log(decodedToken.header, decodedToken.payload, decodedToken.signature); #### `token.renew(tokenToRenew)` -> :warning: This method requires access to [third party cookies](#third-party-cookies) +> :warning: This method requires access to [third party cookies](#third-party-cookies)
    > :hourglass: async Returns a new token if the Okta [session](https://developer.okta.com/docs/api/resources/sessions#example) is still valid. diff --git a/lib/oidc/endpoints/authorize.ts b/lib/oidc/endpoints/authorize.ts index b5d8dd642..3704ca775 100644 --- a/lib/oidc/endpoints/authorize.ts +++ b/lib/oidc/endpoints/authorize.ts @@ -57,7 +57,7 @@ export function convertTokenParamsToOAuthParams(tokenParams: TokenParams) { if (tokenParams.responseType!.indexOf('id_token') !== -1 && tokenParams.scopes!.indexOf('openid') === -1) { throw new AuthSdkError('openid scope must be specified in the scopes argument when requesting an id_token'); - } else if (tokenParams.responseType! !== 'none') { + } else if (tokenParams.scopes) { oauthParams.scope = tokenParams.scopes!.join(' '); } diff --git a/lib/oidc/types/api.ts b/lib/oidc/types/api.ts index 062f34be5..975f65a52 100644 --- a/lib/oidc/types/api.ts +++ b/lib/oidc/types/api.ts @@ -12,12 +12,7 @@ import { JWTObject } from './JWT'; import { OAuthTransactionMeta, PKCETransactionMeta } from './meta'; -import { - CustomUrls, - OktaAuthOAuthOptions, - SigninWithRedirectOptions, - TokenParams -} from './options'; +import { CustomUrls, OktaAuthOAuthOptions, SigninWithRedirectOptions, TokenParams } from './options'; import { OAuthResponseType } from './proto'; import { OAuthStorageManagerInterface } from './storage'; import { AccessToken, IDToken, RefreshToken, RevocableToken, Token, Tokens } from './Token'; diff --git a/lib/oidc/types/options.ts b/lib/oidc/types/options.ts index 85073a630..648df455d 100644 --- a/lib/oidc/types/options.ts +++ b/lib/oidc/types/options.ts @@ -35,7 +35,6 @@ export interface TokenParams extends CustomUrls { state?: string; nonce?: string; scopes?: string[]; - acrValues?: string; enrollAmrValues?: string | string[]; display?: string; ignoreSignature?: boolean; @@ -70,6 +69,7 @@ export interface TokenManagerOptions { export interface EnrollAuthenticatorOptions extends TokenParams { enrollAmrValues: string | string[]; + acrValues: string; } export interface SigninWithRedirectOptions extends TokenParams { diff --git a/lib/oidc/util/prepareEnrollAuthenticatorParams.ts b/lib/oidc/util/prepareEnrollAuthenticatorParams.ts index f8f876b45..356b3fe63 100644 --- a/lib/oidc/util/prepareEnrollAuthenticatorParams.ts +++ b/lib/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -23,21 +23,22 @@ function prepareParams( // forced params: responseType: 'none', prompt: 'enroll_authenticator', + maxAge: 0, }; if (!params.enrollAmrValues) { throw new AuthSdkError('enroll_amr_values must be specified'); } + if (!params.acrValues) { + // `acr_values` is required and should equal 'urn:okta:2fa:any:ifpossible' + // But this can be changed in future + throw new AuthSdkError('acr_values must be specified'); + } - // scope, nonce must be omitted + // `scope`, `nonce` must be omitted delete params.scopes; delete params.nonce; - // maxAge is not supported - if (params.maxAge && params.maxAge > 0) { - delete params.maxAge; - } - return params; } diff --git a/lib/oidc/util/prepareTokenParams.ts b/lib/oidc/util/prepareTokenParams.ts index 1460fa393..ca0c24df2 100644 --- a/lib/oidc/util/prepareTokenParams.ts +++ b/lib/oidc/util/prepareTokenParams.ts @@ -87,9 +87,10 @@ export async function prepareTokenParams( const defaults = getDefaultTokenParams(sdk); tokenParams = { ...defaults, ...tokenParams }; - if (tokenParams.pkce) { - tokenParams = await preparePKCE(sdk, tokenParams); + if (tokenParams.pkce === false) { + // Implicit flow or authorization_code without PKCE + return tokenParams; } - return tokenParams; + return preparePKCE(sdk, tokenParams); } \ No newline at end of file diff --git a/samples/test/support/management-api/listFactors.ts b/samples/test/support/management-api/listFactors.ts new file mode 100644 index 000000000..81ea4da54 --- /dev/null +++ b/samples/test/support/management-api/listFactors.ts @@ -0,0 +1,33 @@ +/*! + * Copyright (c) 2015-present, 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 { UserFactor } from '@okta/okta-sdk-nodejs'; +import getOktaClient, { OktaClientConfig } from './util/getOktaClient'; + +type Options = { + userId: string; +}; + +export default async function(config: OktaClientConfig, options: Options) { + const client = getOktaClient(config); + const factors = await client.listFactors(options.userId); + + const factorTypes = []; + for await (let f of factors) { + if (f) { + factorTypes.push((f as UserFactor).factorType); + } + } + + return factorTypes; +} diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts index e762321df..ce8200121 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -905,6 +905,7 @@ class TestApp { const options: EnrollAuthenticatorOptions = Object.assign({}, { state: this.config.state, enrollAmrValues: this.config.enrollAmrValues, + acrValues: this.config.acrValues, }); try { this.oktaAuth.endpoints.authorize.enrollAuthenticator(options); @@ -994,14 +995,15 @@ class TestApp { `; } + /* eslint-disable complexity */ callbackHTML(res: TokenResponse): string { const tokensReceived = res.tokens ? Object.keys(res.tokens): []; const isEnrollSuccess = res.responseType === 'none'; const success = res.tokens && tokensReceived.length; - const errorMessage = isEnrollSuccess ? 'Authenticator enrollment completed' : + const errorMessage = isEnrollSuccess ? '' : success ? '' : 'Tokens not returned. Check error console for more details'; - const successMessage = success ? - 'Successfully received tokens on the callback page: ' + tokensReceived.join(', ') : ''; + const successMessage = isEnrollSuccess ? 'Authenticator enrollment completed' : + success ? 'Successfully received tokens on the callback page: ' + tokensReceived.join(', ') : ''; const originalUri = this.oktaAuth.getOriginalUri(res.state); const content = `
    diff --git a/test/e2e/config.js b/test/e2e/config.js index 625bd01e4..3ba75cf51 100644 --- a/test/e2e/config.js +++ b/test/e2e/config.js @@ -19,7 +19,8 @@ const config = [ ], features: [ 'login.feature', - 'acr-values.feature' + 'acr-values.feature', + 'enroll-authenticator.feature', ] }, { diff --git a/test/e2e/features/enroll-authenticator.feature b/test/e2e/features/enroll-authenticator.feature index 6b29f2957..3899fdf7c 100644 --- a/test/e2e/features/enroll-authenticator.feature +++ b/test/e2e/features/enroll-authenticator.feature @@ -3,44 +3,41 @@ Feature: Enroll Authenticator via Authorize Endpoint Background: Given an App that assigned to a test group And a Policy that defines "Authentication" - And with a Policy Rule that defines "Password as the only factor" - And a Policy that defines "Profile Enrollment" - And with a Policy Rule that defines "collecting default attributes and emailVerification is not required" + And with a Policy Rule that defines "Password as the only factor" + And a Policy that defines "Profile Enrollment" + And with a Policy Rule that defines "collecting default attributes and emailVerification is not required" And a Policy that defines "MFA Enrollment" with properties - | okta_password | REQUIRED | - | okta_email | REQUIRED | + | okta_password | REQUIRED | + | okta_email | REQUIRED | | security_question | OPTIONAL | - And with a Policy Rule that defines "MFA Enrollment Challenge" + And with a Policy Rule that defines "MFA Enrollment Challenge" And a user named "Mary" - And she has an account with "active" state in the org + And she has an account with "active" state in the org Scenario: Mary Enrolls into Security Question - Given I am on the home page - And I see text "Unauthenticated" - When I enter "kba" into "Enroll AMR values (coma separated)" - And I click "Update Config" - Then I see "kba" in "Enroll AMR values (coma separated)" - When I click "Enroll Authenticator" - Then I am on the "enroll_authenticator" page with title "Sign In" - When I enter correct username into "Username" - And I enter correct password into "Password" - And I click "Sign in" - Then I am on the "enroll_authenticator" page with title "Get a verification email" - When I click "Send me an email" - Then I am on the "enroll_authenticator" page with title "Verify with your email" - When I click "Enter a verification code instead" - And I enter correct code into "Enter Code" - And I click "Verify" - Then I am on the "enroll_authenticator" page with title "Set up security methods" - When I click "Set up" for "Security Question" - Then I am on the "enroll_authenticator" page with title "Set up security question" - And I see radio with "Choose a security question" and "Create my own security question" - And The option "Choose a security question" is selected - When I enter correct answer into "Answer" - And I click "Verify" - Then I am on the "login_callback" page - When I click "Handle callback (Continue Login)" - Then I see text "Authenticator enrollment completed" - When I click "Return Home" - Then I am on the home page - And I see text "Unauthenticated" + Given Mary is on the default view in an UNAUTHENTICATED state + And she is not enrolled in the "question" factors + When she enters "kba" into "Enroll AMR values" + And she selects "urn:okta:loa:2fa:any:ifpossible" into "ACR values" + And she clicks the "Update Config" button + Then she sees "kba" in "Enroll AMR values" + Then she sees "urn:okta:loa:2fa:any:ifpossible" in "ACR values" + When she clicks the "Enroll Authenticator" button + Then the app should construct an authorize request with params + | prompt | enroll_authenticator | + | acr_values | urn:okta:loa:2fa:any:ifpossible | + | enroll_amr_values | kba | + | response_type | none | + | max_age | 0 | + And she should be redirected to the Okta Sign In Widget + When she inputs her username and password in widget + Then she should be challenged to verify her email + When she verifies her email + Then she is required to set up authenticator "Security Question" + When she creates security question answer + Then she is redirected to the handle callback page + When she clicks the "Handle callback (Continue Login)" button + Then the callback is handled with message "Authenticator enrollment completed" + When she returns home + Then she is redirected to the default view in an UNAUTHENTICATED state + And she is enrolled in the "question" factors diff --git a/test/e2e/features/step-definitions/steps.ts b/test/e2e/features/step-definitions/steps.ts index 3dccadbb9..bdebfac6d 100644 --- a/test/e2e/features/step-definitions/steps.ts +++ b/test/e2e/features/step-definitions/steps.ts @@ -3,9 +3,14 @@ import ActionContext from 'support/context'; import TestApp from '../../pageobjects/TestApp'; import OktaLogin from '../../pageobjects/OktaLogin'; import { openPKCE } from '../../util/appUtils'; +import listFactors from 'management-api/listFactors'; const ORG_OIE_ENABLED = process.env.ORG_OIE_ENABLED; +interface DataTable { + rawTable: string[][] +} + When(/^she logins with (\w+) and (.+)$/, async function (username, password) { await $('#username').setValue(username); await $('#password').setValue(password); @@ -43,6 +48,9 @@ When('she clicks the {string} button', async function (buttonName) { case 'Login with ACR': el = await TestApp.loginWithAcrBtn; break; + case 'Enroll Authenticator': + el = await TestApp.enrollAuthenticator; + break; case 'Handle callback (Continue Login)': el = await TestApp.handleCallbackBtn; break; @@ -72,6 +80,18 @@ Then( } ); +Then( + 'the callback is handled with message {string}', + async function (expectedMsg: string) { + await (await TestApp.success).waitForDisplayed({ + timeout: 3*1000, + }); + + const successText = await (await TestApp.success).getText(); + expect(successText).toBe(expectedMsg); + } +); + Then( 'the app should construct an authorize request for the protected action, not including an ACR Token in the request or an ACR value', async function () { @@ -120,6 +140,34 @@ Then( } ); +Then( + 'the app should construct an authorize request with params', + async function (dataTable: DataTable) { + const expectedParams: Record = dataTable?.rawTable. + reduce((acc: any, [key, value]) => { + acc[key] = value; + return acc; + }, {}); + + await browser.waitUntil(async () => { + const url = await browser.getUrl(); + return url.includes('/authorize'); + }); + + const url = await browser.getUrl(); + const queryStr = url.split('?')[1]; + const urlParams = new URLSearchParams(queryStr); + const params = {}; + urlParams.forEach((value, key) => { + params[key] = value; + }); + + for (const [k, v] of Object.entries(expectedParams)) { + expect(params[k]).toBe(v); + } + } +); + Then( 'she should be redirected to the Okta Sign In Widget', async function () { @@ -137,7 +185,6 @@ When( } ); - Then( /^she (?:is redirected to|sees) the default view in an AUTHENTICATED state$/, { timeout: 10*1000 }, @@ -160,6 +207,14 @@ When( } ); +Then( + /^she (?:is redirected to|sees) the default view in an UNAUTHENTICATED state$/, + { timeout: 10*1000 }, + async function () { + await TestApp.assertLoggedOut(); + } +); + Then( 'she sees her ID and Access Tokens', async function () { @@ -178,7 +233,7 @@ When('she selects {string} into {string}', async function (value, field) { let f; switch (field) { case 'ACR values': - f = await TestApp.acrValues; + f = await TestApp.acrValues; break; default: throw new Error(`Unknown field ${field}`); @@ -186,6 +241,18 @@ When('she selects {string} into {string}', async function (value, field) { await f.selectByAttribute('value', value); }); +Given('she enters {string} into {string}', async function (value, field) { + let f; + switch (field) { + case 'Enroll AMR values': + f = await TestApp.enrollAmrValues; + break; + default: + throw new Error(`Unknown field ${field}`); + } + await f.setValue(value); +}); + When('she selects incorrect value in {string}', async function (field) { let f: string; switch (field) { @@ -207,6 +274,9 @@ When('she selects incorrect value in {string}', async function (field) { Then('she sees {string} in {string}', async function (value, field) { let el; switch (field) { + case 'Enroll AMR values': + el = await TestApp.enrollAmrValues; + break; case 'ACR values': el = await TestApp.acrValues; break; @@ -253,7 +323,7 @@ When( await OktaLogin.verifyWithEmailCode(); const code = await this.a18nClient.getEmailCode(this.credentials.profileId); await OktaLogin.enterCode(code); - await OktaLogin.clickVerifyEmail(); + await OktaLogin.clickVerify(); } ); @@ -275,3 +345,56 @@ Then( async function() {} ); +Then( + 'she is required to set up authenticator "Security Question"', + { timeout: 10*1000 }, + async function () { + await browser.waitUntil(async () => { + const list = await OktaLogin.authenticatorsList; + const isListDisplayed = await list?.isDisplayed(); + return isListDisplayed; + }, { + timeout: 10*1000 + }); + + await OktaLogin.selectSecurityQuestionAuthenticator(); + } +); + +When( + 'she creates security question answer', + { timeout: 20*1000 }, + async function (this: ActionContext) { + const answer = 'okta'; + await OktaLogin.enterAnswer(answer); + await OktaLogin.clickVerify(); + } +); + +Given( + 'she is enrolled in the {string} factors', + { timeout: 30*1000 }, + async function(this: ActionContext, factorTypesStr: string) { + const enrolledFactorTypes = await listFactors(this.config, { + userId: this.user.id + }); + const factorTypes = factorTypesStr.split(',').map(f => f.trim()); + for (const f of factorTypes) { + expect(enrolledFactorTypes).toContain(f); + } + } +); + +Given( + 'she is not enrolled in the {string} factors', + { timeout: 30*1000 }, + async function(this: ActionContext, factorTypesStr: string) { + const enrolledFactorTypes = await listFactors(this.config, { + userId: this.user.id + }); + const factorTypes = factorTypesStr.split(',').map(f => f.trim()); + for (const f of factorTypes) { + expect(enrolledFactorTypes).not.toContain(f); + } + } +); diff --git a/test/e2e/pageobjects/OktaLogin.js b/test/e2e/pageobjects/OktaLogin.js index ca34cb262..c34c41755 100644 --- a/test/e2e/pageobjects/OktaLogin.js +++ b/test/e2e/pageobjects/OktaLogin.js @@ -44,6 +44,8 @@ class OktaLogin { get verifyBtn() { return $('form[data-se="o-form"] input[type=submit][value=Verify]'); } get authenticatorsList() { return $('form[data-se="o-form"] .authenticator-list'); } get authenticatorEmail() { return $('form[data-se="o-form"] .authenticator-list [data-se="okta_email"] .select-factor'); } + get authenticatorSecurityQuestion() { return $('form[data-se="o-form"] .authenticator-list [data-se="security_question"] .select-factor'); } + get securityQuestionAnswer() { return $('form[data-se="o-form"] input[name="credentials.answer"]'); } async signin(username, password) { await this.waitForLoad(); @@ -83,7 +85,14 @@ class OktaLogin { (await this.authenticatorEmail).click(); } - async clickVerifyEmail() { + async selectSecurityQuestionAuthenticator() { + await browser.waitUntil(async () => { + return (await this.authenticatorSecurityQuestion).isDisplayed(); + }, 5000, 'wait for email authenticator in list'); + (await this.authenticatorSecurityQuestion).click(); + } + + async clickVerify() { await browser.waitUntil(async () => { return (await this.verifyBtn).isDisplayed(); }, 5000, 'wait for verify btn'); @@ -113,6 +122,13 @@ class OktaLogin { (await this.code).setValue(code); } + async enterAnswer(answer) { + await browser.waitUntil(async () => { + return (await this.securityQuestionAnswer).isDisplayed(); + }, 5000, 'wait for security question answer'); + (await this.securityQuestionAnswer).setValue(answer); + } + async waitForLoad() { if (process.env.ORG_OIE_ENABLED) { // With Step Up MFA there can be no Submit button displayed, diff --git a/test/e2e/pageobjects/TestApp.js b/test/e2e/pageobjects/TestApp.js index 82b029695..eb513cea2 100644 --- a/test/e2e/pageobjects/TestApp.js +++ b/test/e2e/pageobjects/TestApp.js @@ -36,6 +36,7 @@ class TestApp { get sessionExpired() { return $('#session-expired'); } get testConcurrentGetTokenBtn() { return $('#test-concurrent-get-token'); } get loginWithAcrBtn() { return $('#login-acr'); } + get enrollAuthenticator() { return $('#enroll-authenticator'); } get tokenError() { return $('#token-error'); } get tokenMsg() { return $('#token-msg'); } @@ -60,6 +61,7 @@ class TestApp { get issuer() { return $('#f_issuer'); } get interactionCodeOption() { return $('#f_useInteractionCodeFlow-on'); } get acrValues() { return $('#f_acrValues'); } + get enrollAmrValues() { return $('#f_enroll_amr_values'); } get submit() { return $('#f_submit'); } // Callback @@ -86,7 +88,11 @@ class TestApp { async open(queryObj, openInNewWindow) { const qs = toQueryString(queryObj); - await openInNewWindow ? browser.newWindow(qs, { windowFeatures: 'noopener=yes' }) : browser.url(qs); + if (openInNewWindow) { + await browser.newWindow(qs, { windowFeatures: 'noopener=yes' }); + } else { + await browser.url('/' + qs); + } await browser.waitUntil(async () => this.readySelector.then(el => el.isExisting()), 5000, 'wait for ready selector'); } diff --git a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts index bbdf6734f..4ab16cee3 100644 --- a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts +++ b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -25,6 +25,8 @@ jest.mock('../../../../lib/features', () => { import { OktaAuth, AuthSdkError } from '@okta/okta-auth-js'; import { prepareEnrollAuthenticatorParams } from '../../../../lib/oidc'; +const DEFAULT_ACR_VALUES = 'urn:okta:2fa:any:ifpossible'; + describe('prepareEnrollAuthenticatorParams', function() { it('throws an error if enrollAmrValues not specified', () => { const sdk = new OktaAuth({ @@ -33,7 +35,8 @@ describe('prepareEnrollAuthenticatorParams', function() { let errorThrown = false; try { prepareEnrollAuthenticatorParams(sdk, { - enrollAmrValues: '' + enrollAmrValues: '', + acrValues: DEFAULT_ACR_VALUES, }); } catch (err) { errorThrown = true; @@ -43,12 +46,31 @@ describe('prepareEnrollAuthenticatorParams', function() { expect(errorThrown).toBe(true); }); + it('throws an error if acrValues not specified', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + let errorThrown = false; + try { + prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: 'foo', + acrValues: '', + }); + } catch (err) { + errorThrown = true; + expect(err).toBeInstanceOf(AuthSdkError); + expect((err as AuthSdkError).message).toEqual('acr_values must be specified'); + } + expect(errorThrown).toBe(true); + }); + it('sets responseType to none', () => { const sdk = new OktaAuth({ issuer: 'https://foo.com' }); const params = prepareEnrollAuthenticatorParams(sdk, { - enrollAmrValues: ['a'] + enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, }); expect(params.responseType).toBe('none'); }); @@ -59,7 +81,8 @@ describe('prepareEnrollAuthenticatorParams', function() { }); const params = prepareEnrollAuthenticatorParams(sdk, { responseType: 'token', - enrollAmrValues: ['a'] + enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, }); expect(params.responseType).toBe('none'); }); @@ -69,7 +92,8 @@ describe('prepareEnrollAuthenticatorParams', function() { issuer: 'https://foo.com' }); const params = prepareEnrollAuthenticatorParams(sdk, { - enrollAmrValues: ['a'] + enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, }); expect(params.prompt).toBe('enroll_authenticator'); }); @@ -80,7 +104,8 @@ describe('prepareEnrollAuthenticatorParams', function() { }); const params = prepareEnrollAuthenticatorParams(sdk, { prompt: 'login', - enrollAmrValues: ['a'] + enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, }); expect(params.prompt).toBe('enroll_authenticator'); }); @@ -92,7 +117,8 @@ describe('prepareEnrollAuthenticatorParams', function() { }); spyOn(mocked.features, 'isPKCESupported').and.returnValue(true); const params = prepareEnrollAuthenticatorParams(sdk, { - enrollAmrValues: ['a'] + enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, }); expect(params.codeVerifier).toBe(undefined); expect(params.codeChallenge).toBe(undefined); @@ -106,23 +132,37 @@ describe('prepareEnrollAuthenticatorParams', function() { }); const params = prepareEnrollAuthenticatorParams(sdk, { enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, }); - expect(params.acrValues).toBe(undefined); + expect(params.acrValues).toBe(DEFAULT_ACR_VALUES); }); - it('removes scopes, nonce, maxAge', () => { + it('removes scopes, nonce', () => { const sdk = new OktaAuth({ issuer: 'https://foo.com' }); const params = prepareEnrollAuthenticatorParams(sdk, { enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, scopes: ['openid','email'], nonce: 'fake-nonce', maxAge: 100, }); expect(params.scopes).toBe(undefined); expect(params.nonce).toBe(undefined); - expect(params.maxAge).toBe(undefined); + expect(params.maxAge).toBe(0); + }); + + it('overrides maxAge with 0', () => { + const sdk = new OktaAuth({ + issuer: 'https://foo.com' + }); + const params = prepareEnrollAuthenticatorParams(sdk, { + enrollAmrValues: ['a'], + acrValues: DEFAULT_ACR_VALUES, + maxAge: 100, + }); + expect(params.maxAge).toBe(0); }); // Note: diff --git a/test/types/token.test-d.ts b/test/types/token.test-d.ts index 45db61f78..c33ef16d2 100644 --- a/test/types/token.test-d.ts +++ b/test/types/token.test-d.ts @@ -65,6 +65,8 @@ const refreshTokenExample = { }; expectAssignable(refreshTokenExample); +const DEFAULT_ACR_VALUES = 'urn:okta:2fa:any:ifpossible'; + const tokens = { accessToken: accessTokenExample, idToken: idTokenExample, @@ -88,16 +90,27 @@ const tokens = { expectType(await authClient.token.parseFromUrl()); const enrollAuthenticatorOptons: EnrollAuthenticatorOptions = { - enrollAmrValues: ['email', 'kba'] + enrollAmrValues: ['email', 'kba'], + acrValues: DEFAULT_ACR_VALUES }; const enrollAuthenticatorOptons2: EnrollAuthenticatorOptions = { enrollAmrValues: 'email', + acrValues: DEFAULT_ACR_VALUES, responseType: 'none' }; expectType(await authClient.endpoints.authorize.enrollAuthenticator(enrollAuthenticatorOptons)); expectType(await authClient.endpoints.authorize.enrollAuthenticator(enrollAuthenticatorOptons2)); expectError(async () => { - await authClient.endpoints.authorize.enrollAuthenticator({}); + // missing acrValues + await authClient.endpoints.authorize.enrollAuthenticator({ + enrollAmrValues: ['email', 'kba'], + }); + }); + expectError(async () => { + // missing enrollAmrValues + await authClient.endpoints.authorize.enrollAuthenticator({ + acrValues: DEFAULT_ACR_VALUES + }); }); expectError(async () => { await authClient.endpoints.authorize.enrollAuthenticator(); From 738ad21a697e1edbe992c7a97b6935c4d9edd06d Mon Sep 17 00:00:00 2001 From: "denys.oblohin" Date: Thu, 8 Dec 2022 14:25:47 +0200 Subject: [PATCH 11/11] chlog, readme --- CHANGELOG.md | 1 + README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2606074c4..d381a074e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - [#1333](https://github.com/okta/okta-auth-js/pull/1333) Adds support for MyAccount API password methods +- [#1324](https://github.com/okta/okta-auth-js/pull/1324) Adds `endpoints.authorize.enrollAuthenticator`. Adds `handleRedirect` and deprecates `handleLoginRedirect`. ### Fixes diff --git a/README.md b/README.md index 7fa8047af..75a8dd179 100644 --- a/README.md +++ b/README.md @@ -1331,7 +1331,7 @@ The following configuration options can be included in `token.getWithoutPrompt`, | `prompt` | Determines whether the Okta login will be displayed on failure. Use `none` to prevent this behavior. Valid values: `none`, `consent`, `login`, or `consent login`. See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. Special value `enroll_authenticator` is used for [enrollAuthenticator](#endpointsauthorizeenrollauthenticatoroptions). | | `maxAge` | Allowable elapsed time, in seconds, since the last time the end user was actively authenticated by Okta. | | `acrValues` | [[EA][early-access]] Optional parameter to increase the level of user assurance. See [Predefined ACR values](https://developer.okta.com/docs/guides/step-up-authentication/main/#predefined-parameter-values) for more information. | -| `enrollAmrValues` | [[EA][early-access]] List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [enrollAuthenticator](#endpointsauthorizeenrollauthenticatoroptions) | +| `enrollAmrValues` | [[EA][early-access]] List of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) used to enroll authenticators with [enrollAuthenticator](#endpointsauthorizeenrollauthenticatoroptions). See [Parameter details](https://developer.okta.com/docs/reference/api/oidc/#parameter-details) for more information. | | `loginHint` | A username to prepopulate if prompting for authentication. | For more details, see Okta's [Authorize Request API](https://developer.okta.com/docs/api/resources/oidc#request-parameters). @@ -1339,6 +1339,7 @@ For more details, see Okta's [Authorize Request API](https://developer.okta.com/ #### `endpoints.authorize.enrollAuthenticator(options)` > :link: web browser only
    +> [Early Access][early-access] Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with special parameters. After a successful enrollment, the browser will be redirected to the configured [redirectUri](#configuration-options). You can use [sdk.handleRedirect](#handleredirectoriginaluri) to handle the redirect on successful enrollment or an error. @@ -1350,7 +1351,7 @@ Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with spe Required options: - * `enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html). + * `enrollAmrValues` - list of [authentication methods](https://self-issued.info/docs/draft-jones-oauth-amr-values-00.html) to allow the user to enroll in. List of AMR values: | AMR Value | Authenticator | @@ -1364,15 +1365,18 @@ Enroll authenticators using a redirect to [authorizeUrl](#authorizeurl) with spe | `symantec` | Symantec VIP | | `google_otp` | Google Authenticator | | `okta_verify` | Okta Verify | + | `swk` | Custom App | | `pop` | WebAuthn | | `oath_otp` | On-Prem MFA | | `rsa` | RSA SecurID | | `yubikey` | Yubikey | | `otp` | Custom HOTP | | `fed` | External IdP | - | `sc` | SmartCard/PIV | + | `sc` + `swk` | SmartCard/PIV | - * `acrValues` - should equal `urn:okta:2fa:any:ifpossible` + See [enroll_amr_values parameter details](https://developer.okta.com/docs/reference/api/oidc/#request-parameters) for more information. + + * `acrValues` - must be `urn:okta:2fa:any:ifpossible`, which means the user is prompted for at least one factor before enrollment. ##### Example