From 2c92f04e2703f5eae10ff84fc4f46bbbcd500777 Mon Sep 17 00:00:00 2001 From: Denys Oblohin <72614880+denysoblohin-okta@users.noreply.github.com> Date: Wed, 14 Dec 2022 14:35:20 +0200 Subject: [PATCH] feat: Enroll Authenticator via /authorize (#1324) OKTA-539548 feat: Enroll Authenticator via /authorize --- CHANGELOG.md | 1 + README.md | 129 +++++++++---- jest.server.js | 1 + lib/core/mixin.ts | 4 + lib/core/types/api.ts | 1 + lib/oidc/endpoints/authorize.ts | 5 +- lib/oidc/enrollAuthenticator.ts | 34 ++++ lib/oidc/factory/api.ts | 26 +-- lib/oidc/getWithRedirect.ts | 6 +- lib/oidc/handleOAuthResponse.ts | 5 +- lib/oidc/mixin/index.ts | 13 +- lib/oidc/types/api.ts | 10 +- lib/oidc/types/endpoints.ts | 23 +++ lib/oidc/types/index.ts | 1 + lib/oidc/types/meta.ts | 3 +- lib/oidc/types/options.ts | 8 +- lib/oidc/types/proto.ts | 1 + .../util/defaultEnrollAuthenticatorParams.ts | 36 ++++ lib/oidc/util/enrollAuthenticatorMeta.ts | 24 +++ lib/oidc/util/index.ts | 3 + .../util/prepareEnrollAuthenticatorParams.ts | 54 ++++++ .../support/management-api/listFactors.ts | 33 ++++ test/apps/app/src/config.ts | 3 + test/apps/app/src/form.ts | 4 + test/apps/app/src/testApp.ts | 36 +++- test/e2e/config.js | 3 +- .../e2e/features/enroll-authenticator.feature | 43 +++++ test/e2e/features/step-definitions/steps.ts | 129 ++++++++++++- test/e2e/pageobjects/OktaLogin.js | 18 +- test/e2e/pageobjects/TestApp.js | 8 +- test/integration/util/getTokens.ts | 11 +- test/spec/OktaAuth/browser.ts | 23 +++ test/spec/oidc/endpoints/authorize.ts | 16 +- test/spec/oidc/enrollAuthenticator.ts | 107 +++++++++++ test/spec/oidc/getWithRedirect.ts | 32 +++- .../spec/oidc/util/enrollAuthenticatorMeta.ts | 72 ++++++++ test/spec/oidc/util/handleOAuthResponse.ts | 10 + test/spec/oidc/util/oauthMeta.ts | 8 +- .../util/prepareEnrollAuthenticatorParams.ts | 174 ++++++++++++++++++ test/support/oauthUtil.js | 6 +- test/support/util.js | 4 - test/types/auth.test-d.ts | 3 + test/types/token.test-d.ts | 30 +++ 43 files changed, 1056 insertions(+), 105 deletions(-) create mode 100644 lib/oidc/enrollAuthenticator.ts 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 samples/test/support/management-api/listFactors.ts create mode 100644 test/e2e/features/enroll-authenticator.feature create mode 100644 test/spec/oidc/enrollAuthenticator.ts create mode 100644 test/spec/oidc/util/enrollAuthenticatorMeta.ts create mode 100644 test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts 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 15e60f351..75a8dd179 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 [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) 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 } @@ -891,7 +891,8 @@ 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) +* [handleRedirect](#handleredirectoriginaluri) * [setHeaders](#setheaders) * [tx.resume](#txresume) * [tx.exists](#txexists) @@ -903,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](#endpointsauthorizeenrollauthenticatoroptions) * [token](#token) * [token.getWithoutPrompt](#tokengetwithoutpromptoptions) * [token.getWithPopup](#tokengetwithpopupoptions) @@ -966,7 +969,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 } @@ -1174,7 +1177,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 } @@ -1186,12 +1189,23 @@ 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. > **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](#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 authenticator enrollment. + ### `setHeaders()` Can set (or unset) request headers after construction. @@ -1238,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. @@ -1257,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). @@ -1275,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. @@ -1298,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 @@ -1315,39 +1328,71 @@ 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 [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). 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). +#### `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. + +* `options` - See [Authorize options](#authorize-options) + + Options that will be omitted: `scopes`, `nonce`. + + Options that will be overridden: `responseType: 'none', prompt: 'enroll_authenticator'`. + + Required options: + + * `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 | + | ------------- | -------------------- | + | `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 | + | `swk` | Custom App | + | `pop` | WebAuthn | + | `oath_otp` | On-Prem MFA | + | `rsa` | RSA SecurID | + | `yubikey` | Yubikey | + | `otp` | Custom HOTP | + | `fed` | External IdP | + | `sc` + `swk` | SmartCard/PIV | + + 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 ```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; - - // Do something with tokens, such as - authClient.tokenManager.setTokens(tokens); -}) -.catch(function(err) { - // handle OAuthError or AuthSdkError -}); +try { + authClient.endpoints.authorize.enrollAuthenticator({ + enrollAmrValues: ['okta_verify'], + acrValues: 'urn:okta:2fa:any:ifpossible' + }) +} catch(err) { + // handle AuthSdkError +} ``` +### `token` + #### `token.getWithoutPrompt(options)` > :link: web browser only
@@ -1358,11 +1403,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; @@ -1492,7 +1548,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. @@ -1597,6 +1653,7 @@ 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`. + ### `tokenManager` API #### `tokenManager.add(key, token)` 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/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/lib/oidc/endpoints/authorize.ts b/lib/oidc/endpoints/authorize.ts index 8814bffba..3704ca775 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.scopes) { oauthParams.scope = tokenParams.scopes!.join(' '); } diff --git a/lib/oidc/enrollAuthenticator.ts b/lib/oidc/enrollAuthenticator.ts new file mode 100644 index 000000000..9eaffb8a6 --- /dev/null +++ b/lib/oidc/enrollAuthenticator.ts @@ -0,0 +1,34 @@ +/* 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 { prepareEnrollAuthenticatorParams, createEnrollAuthenticatorMeta } from './util'; +import { buildAuthorizeParams } from './endpoints/authorize'; + +export function enrollAuthenticator( + sdk: OktaAuthOAuthInterface, + options: EnrollAuthenticatorOptions +): void { + options = clone(options) || {}; + + 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); + } else { + window.location.assign(requestUrl); + } +} diff --git a/lib/oidc/factory/api.ts b/lib/oidc/factory/api.ts index 8b572f6fa..6d6e97cba 100644 --- a/lib/oidc/factory/api.ts +++ b/lib/oidc/factory/api.ts @@ -26,16 +26,17 @@ import { revokeToken } from '../revokeToken'; import { AccessToken, CustomUserClaims, - GetWithRedirectAPI, GetWithRedirectFunction, IDToken, 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 { @@ -44,16 +45,7 @@ export function createTokenAPI(sdk: OktaAuthOAuthInterface, queue: PromiseQueue) }; 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; - } - } - }); + // eslint-disable-next-line max-len const parseFromUrlFn = useQueue(parseFromUrl.bind(null, sdk)) as ParseFromUrlInterface; const parseFromUrlApi: ParseFromUrlInterface = Object.assign(parseFromUrlFn, { @@ -78,7 +70,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), @@ -111,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/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/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..7a96f07f5 100644 --- a/lib/oidc/mixin/index.ts +++ b/lib/oidc/mixin/index.ts @@ -25,9 +25,10 @@ import { TransactionManagerInterface, TransactionManagerConstructor, UserClaims, + 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'; @@ -56,6 +57,7 @@ export function mixinOAuth tokenManager: TokenManager; transactionManager: TM; pkce: PkceAPI; + endpoints: Endpoints; _pending: { handleLogin: boolean }; _tokenQueue: PromiseQueue; @@ -81,6 +83,8 @@ export function mixinOAuth // TokenManager this.tokenManager = new TokenManager(this, this.options.tokenManager); + + this.endpoints = createEndpoints(this); } // inherited from subclass @@ -180,15 +184,16 @@ 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 { return isLoginRedirect(this); } - isPKCE(): boolean { return !!this.options.pkce; } diff --git a/lib/oidc/types/api.ts b/lib/oidc/types/api.ts index 69103eebc..975f65a52 100644 --- a/lib/oidc/types/api.ts +++ b/lib/oidc/types/api.ts @@ -13,12 +13,14 @@ import { JWTObject } from './JWT'; import { OAuthTransactionMeta, PKCETransactionMeta } from './meta'; import { CustomUrls, OktaAuthOAuthOptions, SigninWithRedirectOptions, TokenParams } from './options'; +import { OAuthResponseType } from './proto'; import { OAuthStorageManagerInterface } from './storage'; import { AccessToken, IDToken, RefreshToken, RevocableToken, Token, Tokens } from './Token'; 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; @@ -29,6 +31,7 @@ export interface TokenResponse { tokens: Tokens; state: string; code?: string; + responseType?: OAuthResponseType | OAuthResponseType[] | 'none'; } export interface ParseFromUrlOptions { @@ -48,10 +51,6 @@ export type GetWithRedirectFunction = (params?: TokenParams) => Promise; export type SetLocationFunction = (loc: string) => void; -export interface GetWithRedirectAPI extends GetWithRedirectFunction { - _setLocation: SetLocationFunction; -} - export interface BaseTokenAPI { decode(token: string): JWTObject; prepareTokenParams(params?: TokenParams): Promise; @@ -63,7 +62,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; @@ -134,6 +133,7 @@ export interface OktaAuthOAuthInterface tokenManager: TokenManagerInterface; pkce: PkceAPI; transactionManager: TM; + endpoints: Endpoints; isPKCE(): boolean; getIdToken(): string | undefined; 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/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/types/options.ts b/lib/oidc/types/options.ts index 03c4a2b86..648df455d 100644 --- a/lib/oidc/types/options.ts +++ b/lib/oidc/types/options.ts @@ -30,11 +30,12 @@ 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[]; + enrollAmrValues?: string | string[]; display?: string; ignoreSignature?: boolean; codeVerifier?: string; @@ -66,6 +67,11 @@ export interface TokenManagerOptions { syncStorage?: boolean; } +export interface EnrollAuthenticatorOptions extends TokenParams { + enrollAmrValues: string | string[]; + acrValues: 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/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/prepareEnrollAuthenticatorParams.ts b/lib/oidc/util/prepareEnrollAuthenticatorParams.ts new file mode 100644 index 000000000..356b3fe63 --- /dev/null +++ b/lib/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -0,0 +1,54 @@ +/* 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', + 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 + delete params.scopes; + delete params.nonce; + + 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/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/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..ce8200121 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,21 @@ class TestApp { } } + 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, + acrValues: this.config.acrValues, + }); + try { + this.oktaAuth.endpoints.authorize.enrollAuthenticator(options); + } catch(e) { + this.renderError(e); + } + } + configHTML(): string { const config = htmlString(this.config); return ` @@ -947,6 +969,9 @@ class TestApp {
  • Simulate cross-tab token renew
  • +
  • + Enroll Authenticator +
  • ${protectedLink(this)} @@ -970,12 +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 = success ? '' : 'Tokens not returned. Check error console for more details'; - const successMessage = success ? - 'Successfully received tokens on the callback page: ' + tokensReceived.join(', ') : ''; + const errorMessage = isEnrollSuccess ? '' : + success ? '' : 'Tokens not returned. Check error console for more details'; + 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 new file mode 100644 index 000000000..3899fdf7c --- /dev/null +++ b/test/e2e/features/enroll-authenticator.feature @@ -0,0 +1,43 @@ +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 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/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/OktaAuth/browser.ts b/test/spec/OktaAuth/browser.ts index 5971806ed..829d013c0 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', () => { @@ -727,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/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..ce0580e14 --- /dev/null +++ b/test/spec/oidc/enrollAuthenticator.ts @@ -0,0 +1,107 @@ +import { enrollAuthenticator } from '../../../lib/oidc/enrollAuthenticator'; + +jest.mock('../../../lib/oidc/util', () => { + return { + prepareEnrollAuthenticatorParams: () => {}, + createEnrollAuthenticatorMeta: () => {}, + 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; + 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' + }, + transactionManager: { + save: () => {} + } + }; + const preparedParams = { + 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, + preparedParams, + authorizeParams, + enrollParams, + urls, + meta + }; + 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, 'createEnrollAuthenticatorMeta').mockReturnValue(testContext.meta); + }); + + afterEach(() => { + global.window.location = originalLocation; + }); + + describe('transactionMeta', () => { + beforeEach(() => { + const { sdk } = testContext; + jest.spyOn(sdk.transactionManager, 'save'); + }); + + it('saves the transaction meta', () => { + const { sdk, meta, enrollParams } = testContext; + enrollAuthenticator(sdk, enrollParams); + expect(sdk.transactionManager.save).toHaveBeenCalledWith(meta); + }); + }); + + it('redirects to the authorize endpoint with options.setLocation', () => { + const { sdk, preparedParams, enrollParams, authorizeParams } = testContext; + sdk.options.setLocation = jest.fn(); + 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', () => { + const { sdk, preparedParams, enrollParams, authorizeParams } = testContext; + enrollAuthenticator(sdk, enrollParams); + expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(preparedParams); + 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 51bcd3404..cf4e457bf 100644 --- a/test/spec/oidc/getWithRedirect.ts +++ b/test/spec/oidc/getWithRedirect.ts @@ -21,19 +21,23 @@ 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: { - }, getOriginalUri: () => {}, transactionManager: { save: () => {} - }, - token: { - getWithRedirect: { - _setLocation: () => {} - } } }; const tokenParams = { @@ -59,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 @@ -83,13 +91,19 @@ describe('getWithRedirect', () => { }); + 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', async () => { + it('redirects to the authorize endpoint with window.location.assign if options.setLocation is not set', 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(window.location.assign).toHaveBeenCalledWith('http://fake-authorize?fake=true'); }); }); \ No newline at end of file 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/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..7f23ded8d 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 = { @@ -72,6 +67,7 @@ describe('oauthMeta', () => { codeChallenge: 'efgh', codeChallengeMethod: 'fake', acrValues: 'foo', + enrollAmrValues: ['a', 'b'] }); const meta = createOAuthMeta(sdk, tokenParams); diff --git a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts new file mode 100644 index 000000000..4ab16cee3 --- /dev/null +++ b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -0,0 +1,174 @@ +/*! + * 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'; + +const DEFAULT_ACR_VALUES = 'urn:okta:2fa:any:ifpossible'; + +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: '', + acrValues: DEFAULT_ACR_VALUES, + }); + } 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('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'], + acrValues: DEFAULT_ACR_VALUES, + }); + 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'], + acrValues: DEFAULT_ACR_VALUES, + }); + 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'], + acrValues: DEFAULT_ACR_VALUES, + }); + 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'], + acrValues: DEFAULT_ACR_VALUES, + }); + 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'], + acrValues: DEFAULT_ACR_VALUES, + }); + 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'], + acrValues: DEFAULT_ACR_VALUES, + }); + expect(params.acrValues).toBe(DEFAULT_ACR_VALUES); + }); + + 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(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: + // 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/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'); }; 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()); diff --git a/test/types/token.test-d.ts b/test/types/token.test-d.ts index eeefb3d2b..c33ef16d2 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, @@ -64,6 +65,8 @@ const refreshTokenExample = { }; expectAssignable(refreshTokenExample); +const DEFAULT_ACR_VALUES = 'urn:okta:2fa:any:ifpossible'; + const tokens = { accessToken: accessTokenExample, idToken: idTokenExample, @@ -86,6 +89,33 @@ const tokens = { expectType(await authClient.token.getWithRedirect(authorizeOptions)); expectType(await authClient.token.parseFromUrl()); + const enrollAuthenticatorOptons: EnrollAuthenticatorOptions = { + 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 () => { + // 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(); + }); + const customUrls = { issuer: 'https://{yourOktaDomain}/oauth2/{authorizationServerId}', authorizeUrl: 'https://{yourOktaDomain}/oauth2/v1/authorize',