diff --git a/.eslintrc.js b/.eslintrc.js index d365812f..38af7bde 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -68,6 +68,21 @@ module.exports = { node: true, browser: true } + }, + { + // SecureRoute/SecureOutlet must import the OktaContext object via the package's + // own self-import so bundlers dedupe it to the same instance provides; + // importing it from the relative path would give this file its own separate Context. + files: ['src/SecureRoute.tsx', 'src/SecureOutlet.tsx'], + rules: { + 'no-restricted-imports': ['error', { + paths: [{ + name: './OktaContext', + importNames: ['default'], + message: "Import OktaContext from '@okta/okta-react' instead of './OktaContext' here - see the comment above this import." + }] + }] + } } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 996387ec..9a9a67e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 7.0.0 + +### Breaking Changes + +- `SecureRoute` and `SecureOutlet` are no longer exported from `@okta/okta-react`. Import `SecureRoute` from `@okta/okta-react/react-router-5` and `SecureOutlet` from `@okta/okta-react/react-router-6` instead. This ensures `react-router-dom` version-specific code is only pulled into your bundle if you actually use it, and avoids build-time errors from unused router APIs. Minimum supported Node version is now `12.17.0`. + # 6.11.0 ### Other diff --git a/README.md b/README.md index 83cf3ea4..1d122327 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ npm install --save react-router-dom # see note below npm install --save @okta/okta-auth-js # requires at least version 5.3.1 ``` -> ⚠️ NOTE ⚠️
The [SecureRoute](#secureroute) component packaged in this SDK only works with `react-router-dom` `5.x`. -If you're using `react-router-dom` `6.x`, you'll have to write your own `SecureRoute` component.

See these [samples](https://github.com/okta/okta-react/tree/master/samples/routing) to get started +> ⚠️ NOTE ⚠️
The [SecureRoute](#secureroute) component only works with `react-router-dom` `5.x`, and is imported from `@okta/okta-react/react-router-5`. +If you're using `react-router-dom` `6.x` or later, use [SecureOutlet](#secureoutlet) instead, imported from `@okta/okta-react/react-router-6`. + +> ⚠️ Upgrading to `7.x` ⚠️
As of `7.0.0`, `SecureRoute` and `SecureOutlet` are no longer exported from the `@okta/okta-react` top-level package. Update your imports to `import { SecureRoute } from '@okta/okta-react/react-router-5';` or `import { SecureOutlet } from '@okta/okta-react/react-router-6';`. This keeps `react-router-dom` version-specific code out of your bundle unless you actually use it. All other exports (`Security`, `withOktaAuth`, `useOktaAuth`, `OktaContext`, `LoginCallback`) are unaffected. ## Usage @@ -111,10 +113,8 @@ If you're using `react-router-dom` `6.x`, you'll have to write your own `SecureR `okta-react` provides a number of pre-built components to connect a `react-router`-based SPA to Okta OIDC information. You can use these components directly, or use them as a basis for building your own components. -- [SecureRoute](#secureroute) - A normal `Route` except authentication is needed to render the component. - -> ⚠️ NOTE ⚠️
The [SecureRoute](#secureroute) component packaged in this SDK only works with `react-router-dom` `5.x`. -If you're using `react-router-dom` `6.x`, you'll have to write your own `SecureRoute` component.

See these [samples](https://github.com/okta/okta-react/tree/master/samples/routing) to get started +- [SecureRoute](#secureroute) - A normal `Route` except authentication is needed to render the component. Only works with `react-router-dom` `5.x`. +- [SecureOutlet](#secureoutlet) - A normal `Outlet` except authentication is needed to render the nested routes. Works with `react-router-dom` `6.x` and later. ### General components @@ -154,7 +154,8 @@ This example defines 3 routes: import React, { Component } from 'react'; import { BrowserRouter as Router, Route, withRouter } from 'react-router-dom'; -import { SecureRoute, Security, LoginCallback } from '@okta/okta-react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureRoute } from '@okta/okta-react/react-router-5'; import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; import Home from './Home'; import Protected from './Protected'; @@ -195,7 +196,8 @@ export default class extends Component { ```jsx import React from 'react'; -import { SecureRoute, Security, LoginCallback } from '@okta/okta-react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureRoute } from '@okta/okta-react/react-router-5'; import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; import { BrowserRouter as Router, Route, useHistory } from 'react-router-dom'; import Home from './Home'; @@ -231,6 +233,51 @@ const AppWithRouterAccess = () => ( export default AppWithRouterAccess; ``` +#### Creating React Router v6+ Routes with SecureOutlet + +```jsx +import React from 'react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureOutlet } from '@okta/okta-react/react-router-6'; +import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; +import { BrowserRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'; +import Home from './Home'; +import Protected from './Protected'; + +const oktaAuth = new OktaAuth({ + issuer: 'https://{yourOktaDomain}/oauth2/default', + clientId: '{clientId}', + redirectUri: window.location.origin + '/login/callback' +}); + +const App = () => { + const navigate = useNavigate(); + const restoreOriginalUri = async (_oktaAuth, originalUri) => { + navigate(toRelativeUrl(originalUri || '/', window.location.origin)); + }; + + return ( + + + } /> + } /> + }> + } /> + + + + ); +}; + +const AppWithRouterAccess = () => ( + + + +); + +export default AppWithRouterAccess; +``` + #### Show Login and Logout Buttons (class-based) ```jsx @@ -470,7 +517,7 @@ class App extends Component { ### `SecureRoute` -`SecureRoute` ensures that a route is only rendered if the user is authenticated. If the user is not authenticated, it calls [onAuthRequired](#onauthrequired) if it exists, otherwise, it redirects to Okta. +Import from `@okta/okta-react/react-router-5`. `SecureRoute` ensures that a route is only rendered if the user is authenticated. If the user is not authenticated, it calls [onAuthRequired](#onauthrequired) if it exists, otherwise, it redirects to Okta. #### onAuthRequired @@ -490,6 +537,30 @@ As with `Route` from `react-router-dom`, `` can take one of: - a `render` prop that is passed a function that returns a component. This function will be passed any additional props that react-router injects (such as `history` or `match`) - children components +### `SecureOutlet` + +Import from `@okta/okta-react/react-router-6`. `SecureOutlet` is the `react-router-dom` `6.x`+ equivalent of [SecureRoute](#secureroute). It renders an `Outlet` for its nested routes only if the user is authenticated. If the user is not authenticated, it calls [onAuthRequired](#onauthrequired) if it exists, otherwise, it redirects to Okta. + +Use it as the `element` of a parent `Route` that wraps the routes you want to protect: + +```jsx +}> + } /> + +``` + +#### onAuthRequired + +`SecureOutlet` accepts `onAuthRequired` as an optional prop, it overrides [onAuthRequired](#onauthrequired) from the [Security](#security) component if exists. + +#### errorComponent + +`SecureOutlet` runs internal `handleLogin` process which may throw Error when `authState.isAuthenticated` is false. By default, the Error will be rendered with `OktaError` component. If you wish to customise the display of such error messages, you can pass your own component as an `errorComponent` prop to ``. The error value will be passed to the `errorComponent` as the `error` prop. + +#### loadingElement + +By default, `SecureOutlet` will display nothing while the user is not yet authenticated. If you wish to customize this, you can pass your React element (not component) as `loadingElement` prop to ``. Example: `

Loading...

` + ### `LoginCallback` `LoginCallback` handles the callback after the redirect to and back from the Okta-hosted login page. By default, it parses the tokens from the uri, stores them, then redirects to `/`. If a `SecureRoute` caused the redirect, then the callback redirects to the secured route. For more advanced cases, this component can be copied to your own source tree and modified as needed. diff --git a/build.js b/build.js index 5a19633e..7019bd03 100644 --- a/build.js +++ b/build.js @@ -6,6 +6,7 @@ const fs = require('fs'); const NPM_DIR = `dist`; const BUNDLE_CMD = 'yarn bundle'; +const TYPES_CMD = 'yarn types'; const BANNER_CMD = `yarn banners`; shell.echo(`Start building...`); @@ -18,6 +19,13 @@ if (shell.exec(BUNDLE_CMD).code !== 0) { shell.exit(1); } +// Emit type declarations (kept separate from the rollup bundles above so the +// multiple entry points don't fight over writing .d.ts files to the same directory) +if (shell.exec(TYPES_CMD).code !== 0) { + shell.echo(chalk.red(`Error: Type declaration generation failed`)); + shell.exit(1); +} + // Maintain banners if (shell.exec(BANNER_CMD).code !== 0) { shell.echo(chalk.red(`Error: Maintain banners failed`)); @@ -38,11 +46,19 @@ delete packageJSON.workspaces; // remove yarn workspace section // Remove "build/" from the entrypoint paths. ['main', 'module', 'types'].forEach(function(key) { - if (packageJSON[key]) { + if (packageJSON[key]) { packageJSON[key] = packageJSON[key].replace(`${NPM_DIR}/`, ''); } }); +['.', './react-router-5', './react-router-6'].forEach(function(name) { + ['types', 'import', 'require', 'default'].forEach(function(key) { + if (packageJSON['exports'][name][key]) { + packageJSON['exports'][name][key] = packageJSON['exports'][name][key].replace(`${NPM_DIR}/`, ''); + } + }); +}); + fs.writeFileSync(`./${NPM_DIR}/package.json`, JSON.stringify(packageJSON, null, 4)); shell.echo(chalk.green(`End building`)); diff --git a/jest.config.js b/jest.config.js index 071bd962..11b12b78 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,7 +20,11 @@ module.exports = { // avoid react conflict in yarn workspace '^react$': '/node_modules/react', '^react-dom$': '/node_modules/react-dom', - '^react-router-dom$': '/node_modules/react-router-dom' + '^react-router-dom$': '/node_modules/react-router-dom', + // resolve self-imports of OktaContext used by SecureRoute/SecureOutlet + '^@okta/okta-react$': '/src', + '^@okta/okta-react/react-router-5$': '/src/react-router-5.ts', + '^@okta/okta-react/react-router-6$': '/src/react-router-6.ts' }, roots: [ './test/jest' diff --git a/package.json b/package.json index 7410a636..c748aec4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@okta/okta-react", - "version": "6.12.0", + "version": "7.0.0", "description": "React support for Okta", "private": true, "scripts": { @@ -16,6 +16,7 @@ "test:e2e": "yarn workspace @okta/test.e2e test", "test:unit": "jest", "bundle": "rollup -c", + "types": "tsc -p tsconfig.json --declaration --emitDeclarationOnly --declarationDir dist/bundles/types", "dev": "yarn bundle --watch", "generate": "yarn --cwd generator install && yarn --cwd generator generate" }, @@ -29,6 +30,25 @@ "main": "dist/bundles/okta-react.cjs.js", "module": "dist/bundles/okta-react.esm.js", "types": "dist/bundles/types", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/bundles/types/index.d.ts", + "import": "./dist/bundles/okta-react.esm.js", + "require": "./dist/bundles/okta-react.cjs.js", + "default": "./dist/bundles/okta-react.umd.js" + }, + "./react-router-5": { + "types": "./dist/bundles/types/react-router-5.d.ts", + "import": "./dist/bundles/okta-react-router-5.esm.js", + "require": "./dist/bundles/okta-react-router-5.cjs.js" + }, + "./react-router-6": { + "types": "./dist/bundles/types/react-router-6.d.ts", + "import": "./dist/bundles/okta-react-router-6.esm.js", + "require": "./dist/bundles/okta-react-router-6.cjs.js" + } + }, "author": "", "license": "Apache-2.0", "bugs": { @@ -36,7 +56,7 @@ }, "homepage": "https://github.com/okta/okta-react#readme", "engines": { - "node": ">=10.3", + "node": ">=12.17.0", "yarn": "^1.7.0" }, "resolutions": { @@ -61,6 +81,11 @@ "react-dom": ">=16.8.0", "react-router-dom": ">=5.1.0" }, + "peerDependenciesMeta": { + "react-router-dom": { + "optional": true + } + }, "devDependencies": { "@babel/cli": "^7.19.3", "@babel/core": "^7.19.3", @@ -106,6 +131,7 @@ "react": "^16.9.0", "react-dom": "^16.9.0", "react-router-dom": "5.2.0", + "react-router-dom-v6": "npm:react-router-dom@^6.4.0", "rollup": "^4.62.3", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-terser": "^7.0.2", diff --git a/rollup.config.js b/rollup.config.js index e8f91f09..30657b62 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -13,6 +13,7 @@ const makeExternalPredicate = () => { const externalArr = [ ...Object.keys(pkg.peerDependencies || {}), ...Object.keys(pkg.dependencies || {}), + '@okta/okta-react', ]; if (externalArr.length === 0) { @@ -26,10 +27,13 @@ const extensions = ['js', 'jsx', 'ts', 'tsx']; const input = 'src/index.ts'; const external = makeExternalPredicate(); + +// Type declarations are emitted separately (see `yarn types`, a single whole-program +// `tsc --emitDeclarationOnly` pass) rather than by this plugin, so multiple entry points +// below can share one `typescript()` instance without fighting over declaration output. const commonPlugins = [ typescript({ - typescript: ts, - useTsconfigDeclarationDir: true + typescript: ts }), replace({ values: { @@ -45,7 +49,7 @@ const commonPlugins = [ delimiters: ['\\b', '\\b'], preventAssignment: true }), - cleanup({ + cleanup({ extensions, comments: 'none' }) @@ -112,5 +116,69 @@ export default [ sourcemap: true } ] + }, + { + input: 'src/react-router-5.ts', + external, + plugins: [ + ...commonPlugins, + babel({ + babelHelpers: 'runtime', + presets: [ + '@babel/preset-env', + '@babel/preset-react' + ], + plugins: [ + '@babel/plugin-transform-runtime' + ], + extensions + }), + ], + output: [ + { + format: 'cjs', + file: 'dist/bundles/okta-react-router-5.cjs.js', + exports: 'named', + sourcemap: true + }, + { + format: 'esm', + file: 'dist/bundles/okta-react-router-5.esm.js', + exports: 'named', + sourcemap: true + } + ] + }, + { + input: 'src/react-router-6.ts', + external, + plugins: [ + ...commonPlugins, + babel({ + babelHelpers: 'runtime', + presets: [ + '@babel/preset-env', + '@babel/preset-react' + ], + plugins: [ + '@babel/plugin-transform-runtime' + ], + extensions + }), + ], + output: [ + { + format: 'cjs', + file: 'dist/bundles/okta-react-router-6.cjs.js', + exports: 'named', + sourcemap: true + }, + { + format: 'esm', + file: 'dist/bundles/okta-react-router-6.esm.js', + exports: 'named', + sourcemap: true + } + ] } ]; diff --git a/samples/custom-login/src/App.jsx b/samples/custom-login/src/App.jsx index 8812543a..f22ffe2c 100644 --- a/samples/custom-login/src/App.jsx +++ b/samples/custom-login/src/App.jsx @@ -13,7 +13,8 @@ import React from 'react'; import { Route, useHistory, Switch } from 'react-router-dom'; import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; -import { Security, SecureRoute, LoginCallback } from '@okta/okta-react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureRoute } from '@okta/okta-react/react-router-5'; import { Container } from 'semantic-ui-react'; import config from './config'; import Home from './Home'; diff --git a/samples/doc-direct-auth/src/App.jsx b/samples/doc-direct-auth/src/App.jsx index 4e8116d8..990b58ea 100644 --- a/samples/doc-direct-auth/src/App.jsx +++ b/samples/doc-direct-auth/src/App.jsx @@ -12,7 +12,8 @@ import React from 'react'; import { Route, useHistory } from 'react-router-dom'; -import { Security, SecureRoute, LoginCallback } from '@okta/okta-react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureRoute } from '@okta/okta-react/react-router-5'; import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; import Home from './Home'; import Login from './Login'; diff --git a/samples/doc-embedded-widget/src/App.jsx b/samples/doc-embedded-widget/src/App.jsx index 4e8116d8..990b58ea 100644 --- a/samples/doc-embedded-widget/src/App.jsx +++ b/samples/doc-embedded-widget/src/App.jsx @@ -12,7 +12,8 @@ import React from 'react'; import { Route, useHistory } from 'react-router-dom'; -import { Security, SecureRoute, LoginCallback } from '@okta/okta-react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureRoute } from '@okta/okta-react/react-router-5'; import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; import Home from './Home'; import Login from './Login'; diff --git a/samples/okta-hosted-login/src/App.jsx b/samples/okta-hosted-login/src/App.jsx index 32ab1b3d..fbadc2ce 100644 --- a/samples/okta-hosted-login/src/App.jsx +++ b/samples/okta-hosted-login/src/App.jsx @@ -13,7 +13,8 @@ import React from 'react'; import { Route, useHistory, Switch } from 'react-router-dom'; import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; -import { Security, SecureRoute, LoginCallback } from '@okta/okta-react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureRoute } from '@okta/okta-react/react-router-5'; import { Container } from 'semantic-ui-react'; import config from './config'; import Home from './Home'; diff --git a/src/OktaContext.ts b/src/OktaContext.ts index bdab0a93..a485fec1 100644 --- a/src/OktaContext.ts +++ b/src/OktaContext.ts @@ -25,6 +25,6 @@ export interface IOktaContext { const OktaContext = React.createContext(null); -export const useOktaAuth = (): IOktaContext => React.useContext(OktaContext) as IOktaContext; +export const useOktaAuth = (context?: typeof OktaContext): IOktaContext => React.useContext(context ?? OktaContext) as IOktaContext; export default OktaContext; diff --git a/src/SecureOutlet.tsx b/src/SecureOutlet.tsx new file mode 100644 index 00000000..9b2cedf1 --- /dev/null +++ b/src/SecureOutlet.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2017-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 * as React from 'react'; +import { useOktaAuth, OnAuthRequiredFunction } from './OktaContext'; +import * as ReactRouterDom from 'react-router-dom'; +import { toRelativeUrl, AuthSdkError } from '@okta/okta-auth-js'; +// Important! Don't import OktaContext from './OktaContext' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +// eslint-disable-next-line import/no-extraneous-dependencies +import { OktaContext } from '@okta/okta-react'; +import OktaError from './OktaError'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let Outlet: any; +if ('Outlet' in ReactRouterDom) { + // trick static analyzer to avoid "'Outlet' is not exported" error + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Outlet = (ReactRouterDom as any)['Outlet' in ReactRouterDom ? 'Outlet' : '']; +} else { + // throw when Outlet is rendered + Outlet = () => { + throw new AuthSdkError('Unsupported: SecureOutlet only works with react-router-dom v6 or any router library with compatible APIs. See examples under the "samples" folder for how to implement your own custom SecureRoute Component.'); + }; +} + +export interface SecureOutletProps { + onAuthRequired?: OnAuthRequiredFunction; + errorComponent?: React.ComponentType<{ error: Error }>; + loadingElement?: React.ReactElement | null; +} + +const SecureOutlet: React.FC> = ({ + onAuthRequired, + errorComponent, + loadingElement = null, + ...outletProps +}) => { + // Need to use OktaContext imported from `@okta/okta-react` + // Because SecureOutlet needs to be imported from `@okta/okta-react/react-router-6` + const { oktaAuth, authState, _onAuthRequired } = useOktaAuth(OktaContext); + const pendingLogin = React.useRef(false); + const [handleLoginError, setHandleLoginError] = React.useState(null); + const ErrorReporter = errorComponent || OktaError; + + React.useEffect(() => { + const handleLogin = async () => { + if (pendingLogin.current) { + return; + } + + pendingLogin.current = true; + + const originalUri = toRelativeUrl(window.location.href, window.location.origin); + oktaAuth.setOriginalUri(originalUri); + const onAuthRequiredFn = onAuthRequired || _onAuthRequired; + if (onAuthRequiredFn) { + await onAuthRequiredFn(oktaAuth); + } else { + await oktaAuth.signInWithRedirect(); + } + }; + + if (!authState) { + return; + } + + if (authState.isAuthenticated) { + pendingLogin.current = false; + return; + } + + // Start login if app has decided it is not logged in and there is no pending signin + if (!authState.isAuthenticated) { + handleLogin().catch(err => { + setHandleLoginError(err as Error); + }); + } + + }, [ + authState, + oktaAuth, + onAuthRequired, + _onAuthRequired + ]); + + if (handleLoginError) { + return ; + } + + if (authState?.isAuthenticated) { + return ( + + ); + } + + return loadingElement; +}; + +export default SecureOutlet; diff --git a/src/SecureRoute.tsx b/src/SecureRoute.tsx index 0d114fb4..922651a9 100644 --- a/src/SecureRoute.tsx +++ b/src/SecureRoute.tsx @@ -14,6 +14,11 @@ import * as React from 'react'; import { useOktaAuth, OnAuthRequiredFunction } from './OktaContext'; import * as ReactRouterDom from 'react-router-dom'; import { toRelativeUrl, AuthSdkError } from '@okta/okta-auth-js'; +// Important! Don't import OktaContext from './OktaContext' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +// eslint-disable-next-line import/no-extraneous-dependencies +import { OktaContext } from '@okta/okta-react'; import OktaError from './OktaError'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -37,7 +42,9 @@ const SecureRoute: React.FC<{ errorComponent, ...routeProps }) => { - const { oktaAuth, authState, _onAuthRequired } = useOktaAuth(); + // Need to use OktaContext imported from `@okta/okta-react` + // Because SecureRoute needs to be imported from `@okta/okta-react/react-router-5` + const { oktaAuth, authState, _onAuthRequired } = useOktaAuth(OktaContext); const match = useMatch(routeProps); const pendingLogin = React.useRef(false); const [handleLoginError, setHandleLoginError] = React.useState(null); diff --git a/src/index.ts b/src/index.ts index b73df6a2..91c730a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,6 @@ import Security from './Security'; import withOktaAuth from './withOktaAuth'; import OktaContext, { useOktaAuth } from './OktaContext'; import LoginCallback from './LoginCallback'; -import SecureRoute from './SecureRoute'; export { Security, @@ -22,5 +21,4 @@ export { useOktaAuth, OktaContext, LoginCallback, - SecureRoute, }; diff --git a/src/react-router-5.ts b/src/react-router-5.ts new file mode 100644 index 00000000..db9b231b --- /dev/null +++ b/src/react-router-5.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2017-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 SecureRoute from './SecureRoute'; + +export { + SecureRoute, +}; diff --git a/src/react-router-6.ts b/src/react-router-6.ts new file mode 100644 index 00000000..125618ca --- /dev/null +++ b/src/react-router-6.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2017-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 SecureOutlet from './SecureOutlet'; + +export { + SecureOutlet, +}; diff --git a/test/apps/test-harness-app/src/App.tsx b/test/apps/test-harness-app/src/App.tsx index 98d34f24..bddd7ac5 100644 --- a/test/apps/test-harness-app/src/App.tsx +++ b/test/apps/test-harness-app/src/App.tsx @@ -13,7 +13,8 @@ import * as React from 'react'; import { Route, Switch, useHistory } from 'react-router-dom'; import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; -import { Security, LoginCallback, SecureRoute } from '@okta/okta-react'; +import { Security, LoginCallback } from '@okta/okta-react'; +import { SecureRoute } from '@okta/okta-react/react-router-5'; import Home from './Home'; import Protected from './Protected'; import CustomLogin from './CustomLogin'; diff --git a/test/jest/secureOutlet.test.tsx b/test/jest/secureOutlet.test.tsx new file mode 100644 index 00000000..4c9ec824 --- /dev/null +++ b/test/jest/secureOutlet.test.tsx @@ -0,0 +1,337 @@ +/*! + * Copyright (c) 2017-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. + */ + +jest.mock('react-router-dom', () => jest.requireActual('react-router-dom-v6')); + +import * as React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import SecureOutlet from '../../src/SecureOutlet'; +import Security from '../../src/Security'; +import OktaContext from '../../src/OktaContext'; + +describe('', () => { + let oktaAuth; + let authState; + let mockProps; + const restoreOriginalUri = async (_, url) => { + location.href = url; + }; + + beforeEach(() => { + authState = null; + oktaAuth = { + options: {}, + authStateManager: { + getAuthState: jest.fn().mockImplementation(() => authState), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + updateAuthState: jest.fn(), + }, + isLoginRedirect: jest.fn().mockImplementation(() => false), + handleLoginRedirect: jest.fn(), + signInWithRedirect: jest.fn(), + setOriginalUri: jest.fn(), + start: jest.fn(), + }; + mockProps = { + oktaAuth, + restoreOriginalUri + }; + }); + + describe('With changing authState', () => { + let emitAuthState; + + beforeEach(() => { + oktaAuth.authStateManager.subscribe = (cb) => { + emitAuthState = () => { + act(cb.bind(null, authState)); + }; + }; + }); + + function updateAuthState(newProps = {}) { + authState = Object.assign({}, authState || {}, newProps); + emitAuthState(); + } + + it('calls login() only once until user is authenticated', () => { + authState = { + isAuthenticated: false + }; + + mount( + + + + } /> + + + + ); + expect(oktaAuth.signInWithRedirect).toHaveBeenCalledTimes(1); + oktaAuth.signInWithRedirect.mockClear(); + + updateAuthState(null); + expect(oktaAuth.signInWithRedirect).not.toHaveBeenCalled(); + + updateAuthState({}); + expect(oktaAuth.signInWithRedirect).not.toHaveBeenCalled(); + + updateAuthState({ isAuthenticated: true }); + expect(oktaAuth.signInWithRedirect).not.toHaveBeenCalled(); + + // If the state returns to unauthenticated, the secure outlet should still work + updateAuthState({ isAuthenticated: false }); + expect(oktaAuth.signInWithRedirect).toHaveBeenCalledTimes(1); + }); + }); + + describe('isAuthenticated: true', () => { + + beforeEach(() => { + authState = { + isAuthenticated: true + }; + }); + + it('will render nested route content via Outlet', () => { + const MyComponent = function() { return
hello world
; }; + const wrapper = mount( + + + + }> + } /> + + + + + ); + expect(wrapper.find(MyComponent).html()).toBe('
hello world
'); + }); + }); + + describe('isAuthenticated: false', () => { + + beforeEach(() => { + authState = { + isAuthenticated: false + }; + }); + + it('will not render nested route content', () => { + const MyComponent = function() { return
hello world
; }; + const wrapper = mount( + + + + }> + } /> + + + + + ); + expect(wrapper.find(MyComponent).length).toBe(0); + }); + + describe('authState is not null', () => { + + beforeEach(() => { + authState = {}; + }); + + it('calls signInWithRedirect()', () => { + mount( + + + + } /> + + + + ); + expect(oktaAuth.setOriginalUri).toHaveBeenCalled(); + expect(oktaAuth.signInWithRedirect).toHaveBeenCalled(); + }); + + it('calls onAuthRequired if provided from Security', () => { + const onAuthRequired = jest.fn(); + mount( + + + + } /> + + + + ); + expect(oktaAuth.setOriginalUri).toHaveBeenCalled(); + expect(oktaAuth.signInWithRedirect).not.toHaveBeenCalled(); + expect(onAuthRequired).toHaveBeenCalledWith(oktaAuth); + }); + + it('calls onAuthRequired from SecureOutlet if provided from both Security and SecureOutlet', () => { + const onAuthRequired1 = jest.fn(); + const onAuthRequired2 = jest.fn(); + mount( + + + + } /> + + + + ); + expect(oktaAuth.setOriginalUri).toHaveBeenCalled(); + expect(oktaAuth.signInWithRedirect).not.toHaveBeenCalled(); + expect(onAuthRequired1).not.toHaveBeenCalled(); + expect(onAuthRequired2).toHaveBeenCalledWith(oktaAuth); + }); + }); + + describe('authState is null', () => { + + beforeEach(() => { + authState = null; + }); + + it('does not call signInWithRedirect()', () => { + mount( + + + + } /> + + + + ); + expect(oktaAuth.signInWithRedirect).not.toHaveBeenCalled(); + }); + }); + }); + + describe('loadingElement', () => { + let container = null; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + authState = { + isAuthenticated: false + }; + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('renders nothing by default', async () => { + await act(async () => { + render( + + + + } /> + + + , + container + ); + }); + expect(container.innerHTML).toBe(''); + }); + + it('renders a custom loadingElement', async () => { + await act(async () => { + render( + + + + Loading...} />} /> + + + , + container + ); + }); + expect(container.innerHTML).toBe('
Loading...
'); + }); + }); + + describe('Error handling', () => { + let container = null; + beforeEach(() => { + // setup a DOM element as a render target + container = document.createElement('div'); + document.body.appendChild(container); + + authState = { + isAuthenticated: false + }; + + oktaAuth.setOriginalUri = jest.fn().mockImplementation(() => { + throw new Error(`DOMException: Failed to read the 'sessionStorage' property from 'Window': Access is denied for this document.`); + }); + }); + + afterEach(() => { + // cleanup on exiting + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('shows error with default OktaError component', async () => { + await act(async () => { + render( + + + + + , + container + ); + }); + expect(container.innerHTML).toBe('

Error: DOMException: Failed to read the \'sessionStorage\' property from \'Window\': Access is denied for this document.

'); + }); + + it('shows error with provided custom error component', async () => { + const CustomErrorComponent = ({ error }) => { + return
Custom Error: {error.message}
; + }; + await act(async () => { + render( + + + + + , + container + ); + }); + expect(container.innerHTML).toBe('
Custom Error: DOMException: Failed to read the \'sessionStorage\' property from \'Window\': Access is denied for this document.
'); + }); + }); +}); diff --git a/test/jest/secureOutletWithRR5.test.tsx b/test/jest/secureOutletWithRR5.test.tsx new file mode 100644 index 00000000..6c0d07ef --- /dev/null +++ b/test/jest/secureOutletWithRR5.test.tsx @@ -0,0 +1,72 @@ +/*! + * Copyright (c) 2017-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. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as React from 'react'; +import { act } from 'react-dom/test-utils'; +import { render } from 'react-dom'; +import SecureOutlet from '../../src/SecureOutlet'; +import OktaContext from '../../src/OktaContext'; +import { ErrorBoundary } from './support/ErrorBoundary'; + +jest.mock('react-router-dom', () => ({ + __esModule: true, + useRouteMatch: jest.fn() +})); + +describe('react-router-dom v5', () => { + let oktaAuth: any; + let authState: any; + + beforeEach(() => { + // prevents logging error to console + // eslint-disable-next-line @typescript-eslint/no-empty-function + console.error = (()=>{}); // noop + + authState = { + isAuthenticated: true + }; + oktaAuth = { + options: {}, + authStateManager: { + getAuthState: jest.fn().mockImplementation(() => authState), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + updateAuthState: jest.fn(), + }, + isLoginRedirect: jest.fn().mockImplementation(() => false), + handleLoginRedirect: jest.fn(), + signInWithRedirect: jest.fn(), + setOriginalUri: jest.fn(), + start: jest.fn(), + }; + }); + + it('throws unsupported error', async () => { + const container = document.createElement('div'); + await act(async () => { + render( + + + + + , + container + ); + }); + expect(container.innerHTML).toBe('

AuthSdkError: Unsupported: SecureOutlet only works with react-router-dom v6 or any router library with compatible APIs. See examples under the "samples" folder for how to implement your own custom SecureRoute Component.

'); + }) +}); diff --git a/test/jest/reactRouterV6.test.tsx b/test/jest/secureRouteWithRR6.test.tsx similarity index 82% rename from test/jest/reactRouterV6.test.tsx rename to test/jest/secureRouteWithRR6.test.tsx index bd5ffd0d..43482796 100644 --- a/test/jest/reactRouterV6.test.tsx +++ b/test/jest/secureRouteWithRR6.test.tsx @@ -17,37 +17,13 @@ import { act } from 'react-dom/test-utils'; import { render } from 'react-dom'; import SecureRoute from '../../src/SecureRoute'; import OktaContext from '../../src/OktaContext'; -import { AuthSdkError } from '@okta/okta-auth-js'; +import { ErrorBoundary } from './support/ErrorBoundary'; jest.mock('react-router-dom', () => ({ __esModule: true, useMatch: jest.fn() })); -class ErrorBoundary extends React.Component { - constructor(props: any) { - super(props); - this.state = { - error: null - } as { - error: AuthSdkError | null - }; - } - - componentDidCatch(error: AuthSdkError) { - this.setState({ error: error }); - } - - render() { - if (this.state.error) { - // You can render any custom fallback UI - return

{ this.state.error.toString() }

; - } - - return this.props.children; - } -} - describe('react-router-dom v6', () => { let oktaAuth: any; let authState: any; @@ -73,7 +49,7 @@ describe('react-router-dom v6', () => { start: jest.fn(), }; }); - + it('throws unsupported error', async () => { const container = document.createElement('div'); await act(async () => { @@ -91,4 +67,4 @@ describe('react-router-dom v6', () => { }); expect(container.innerHTML).toBe('

AuthSdkError: Unsupported: SecureRoute only works with react-router-dom v5 or any router library with compatible APIs. See examples under the "samples" folder for how to implement your own custom SecureRoute Component.

'); }) -}); \ No newline at end of file +}); diff --git a/test/jest/support/ErrorBoundary.tsx b/test/jest/support/ErrorBoundary.tsx new file mode 100644 index 00000000..7b7bacc3 --- /dev/null +++ b/test/jest/support/ErrorBoundary.tsx @@ -0,0 +1,40 @@ +/*! + * Copyright (c) 2017-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. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as React from 'react'; +import { AuthSdkError } from '@okta/okta-auth-js'; + +export class ErrorBoundary extends React.Component { + constructor(props: any) { + super(props); + this.state = { + error: null + } as { + error: AuthSdkError | null + }; + } + + componentDidCatch(error: AuthSdkError) { + this.setState({ error: error }); + } + + render() { + if (this.state.error) { + // You can render any custom fallback UI + return

{ this.state.error.toString() }

; + } + + return this.props.children; + } +} diff --git a/test/jest/tsconfig.json b/test/jest/tsconfig.json index cc9c5454..3954d221 100644 --- a/test/jest/tsconfig.json +++ b/test/jest/tsconfig.json @@ -4,6 +4,12 @@ "jsx": "react", "esModuleInterop": true, "resolveJsonModule": true, - "strict": true + "strict": true, + "baseUrl": ".", + "paths": { + "@okta/okta-react": ["../../src"], + "@okta/okta-react/react-router-5": ["../../src/react-router-5.ts"], + "@okta/okta-react/react-router-6": ["../../src/react-router-6.ts"] + } } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e4761a49..7c67bcf1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { - "declaration": true, - "declarationDir": "dist/bundles/types", + "skipLibCheck": true, "target": "ES2019", "sourceMap": true, "jsx": "react", diff --git a/yarn.lock b/yarn.lock index 198ce613..286c4619 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1723,6 +1723,11 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" +"@remix-run/router@1.23.4": + version "1.23.4" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.4.tgz#d2becd2afca4a40c30a659d913b9b0bcc28bced8" + integrity sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q== + "@rolldown/pluginutils@1.0.0-beta.27": version "1.0.0-beta.27" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" @@ -8328,6 +8333,14 @@ react-refresh@^0.17.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== +"react-router-dom-v6@npm:react-router-dom@^6.4.0": + version "6.30.6" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.30.6.tgz#acaf0db65efeddabd0840616243c97f578b3baf3" + integrity sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ== + dependencies: + "@remix-run/router" "1.23.4" + react-router "6.30.6" + react-router-dom@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" @@ -8416,6 +8429,13 @@ react-router@6.3.0: dependencies: history "^5.2.0" +react-router@6.30.6: + version "6.30.6" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.30.6.tgz#a2ad70f3472de61c61e44182b3e5a25fd91f9f68" + integrity sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg== + dependencies: + "@remix-run/router" "1.23.4" + react-test-renderer@^16.0.0-0: version "16.14.0" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae"