diff --git a/CHANGELOG.md b/CHANGELOG.md index 19315267..edafa3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 5.0.0 + +### Breaking Changes + +- [#71](https://github.com/okta/okta-react/pull/71) Adds required prop `restoreOriginalUri` to `Security` that will override `restoreOriginalUri` callback of `oktaAuth` + # 4.1.1 ### Bug Fixes diff --git a/README.md b/README.md index 79b5155e..b779f2d2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,15 @@ [![npm version](https://img.shields.io/npm/v/@okta/okta-react.svg?style=flat-square)](https://www.npmjs.com/package/@okta/okta-react) [![build status](https://img.shields.io/travis/okta/okta-react/master.svg?style=flat-square)](https://travis-ci.org/okta/okta-react) +* [Release status](#release-status) +* [Getting started](#getting-started) +* [Installation](#installation) +* [Usage](#usage) +* [Reference](#reference) +* [Migrating between versions](#migrating-between-versions) +* [Contributing](#contributing) +* [Development](#development) + Okta React SDK builds on top of the [Okta Auth SDK][]. This SDK is a toolkit to build Okta integration with many common "router" packages, such as [react-router][], [reach-router][], and others. @@ -40,6 +49,21 @@ This library currently supports: - [OAuth 2.0 Implicit Flow](https://tools.ietf.org/html/rfc6749#section-1.3.2) - [OAuth 2.0 Authorization Code Flow](https://tools.ietf.org/html/rfc6749#section-1.3.1) with [Proof Key for Code Exchange (PKCE)](https://tools.ietf.org/html/rfc7636) +## Release Status + +:heavy_check_mark: The current stable major version series is: `5.x` + +| Version | Status | +| ------- | -------------------------------- | +| `5.x` | :heavy_check_mark: Stable | +| `4.x` | :warning: Retiring on 2021-12-09 | +| `3.x` | :warning: Retiring on 2021-08-20 | +| `2.x` | :x: Retired | +| `1.x` | :x: Retired | + +The latest release can always be found on the [releases page][github-releases]. + + ## Getting Started - If you do not already have a **Developer Edition Account**, you can create one at [https://developer.okta.com/signup/](https://developer.okta.com/signup/). @@ -121,32 +145,42 @@ This example defines 3 routes: // src/App.js import React, { Component } from 'react'; -import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Route, withRouter } from 'react-router-dom'; import { SecureRoute, Security, LoginCallback } from '@okta/okta-react'; -import { OktaAuth } from '@okta/okta-auth-js'; +import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; import Home from './Home'; import Protected from './Protected'; -const oktaAuth = new OktaAuth({ - issuer: 'https://{yourOktaDomain}.com/oauth2/default', - clientId: '{clientId}', - redirectUri: window.location.origin + '/login/callback' -}); class App extends Component { + constructor(props) { + super(props); + this.oktaAuth = new OktaAuth({ + issuer: 'https://{yourOktaDomain}.com/oauth2/default', + clientId: '{clientId}', + redirectUri: window.location.origin + '/login/callback' + }); + this.restoreOriginalUri = async (_oktaAuth, originalUri) => { + props.history.replace(toRelativeUrl(originalUri, window.location.origin)); + }; + } + render() { return ( - - - - - - - + + + + + ); } } -export default App; +const AppWithRouterAccess = withRouter(App); +export default class extends Component { + render() { + return (); + } +} ``` #### Creating React Router Routes with function-based components @@ -154,7 +188,8 @@ export default App; ```jsx import React from 'react'; import { SecureRoute, Security, LoginCallback } from '@okta/okta-react'; -import { OktaAuth } from '@okta/okta-auth-js'; +import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; +import { useHistory } from 'react-router-dom'; import Home from './Home'; import Protected from './Protected'; @@ -163,15 +198,23 @@ const oktaAuth = new OktaAuth({ clientId: '{clientId}', redirectUri: window.location.origin + '/login/callback' }); -const App = () => ( - - - - - - - -); + +const App = () => { + const history = useHistory(); + const restoreOriginalUri = async (_oktaAuth, originalUri) => { + history.replace(toRelativeUrl(originalUri, window.location.origin)); + }; + + return ( + + + + + + + + ); +}; export default App; ``` @@ -338,6 +381,10 @@ export default MessageList = () => { *(required)* The pre-initialized [oktaAuth][Okta Auth SDK] instance. See [Configuration Reference](https://github.com/okta/okta-auth-js#configuration-reference) for details of how to initialize the instance. +#### restoreOriginalUri + +*(required)* Callback function. Called to restore original URI during [oktaAuth.handleLoginRedirect()](https://github.com/okta/okta-auth-js#handleloginredirecttokens) is called. Will override [restoreOriginalUri option of oktaAuth](https://github.com/okta/okta-auth-js#restoreoriginaluri) + #### onAuthRequired *(optional)* Callback function. Called when authentication is required. If this is not supplied, `okta-react` redirects to Okta. This callback will receive [oktaAuth][Okta Auth SDK] instance as the first function parameter. This is triggered when a [SecureRoute](#secureroute) is accessed without authentication. A common use case for this callback is to redirect users to a custom login route when authentication is required for a [SecureRoute](#secureroute). @@ -346,7 +393,7 @@ export default MessageList = () => { ```jsx import { useHistory } from 'react-router-dom'; -import { OktaAuth } from '@okta/okta-auth-js'; +import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; const oktaAuth = new OktaAuth({ issuer: 'https://{yourOktaDomain}.com/oauth2/default', @@ -363,10 +410,15 @@ export default App = () => { history.push('/login'); }; + const restoreOriginalUri = async (_oktaAuth, originalUri) => { + history.replace(toRelativeUrl(originalUri, window.location.origin)); + }; + return ( {/* some routes here */} @@ -395,8 +447,8 @@ class App extends Component { render() { return ( - - + + @@ -452,6 +504,49 @@ export default MyComponent = () => { ## Migrating between versions +### Migrating from 4.x to 5.x + +From version 5.0, the Security component explicitly requires prop [restoreOriginalUri](#restoreoriginaluri) to decouple from `react-router`. +Example of implementation of this callback for `react-router`: + +```jsx +import { Security } from '@okta/okta-react'; +import { useHistory } from 'react-router-dom'; +import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'; + +const oktaAuth = new OktaAuth({ + issuer: 'https://{yourOktaDomain}.com/oauth2/default', + clientId: '{clientId}', + redirectUri: window.location.origin + '/login/callback' +}); + +export default App = () => { + const history = useHistory(); + const restoreOriginalUri = async (_oktaAuth, originalUri) => { + history.replace(toRelativeUrl(originalUri, window.location.origin)); + }; + + return ( + + {/* some routes here */} + + ); +}; +``` + +**Note:** If you use `basename` prop for ``, use this implementation to fix `basename` duplication problem: +```jsx + import { toRelativeUrl } from '@okta/okta-auth-js'; + const restoreOriginalUri = async (_oktaAuth, originalUri) => { + const basepath = history.createHref({}); + const originalUriWithoutBasepath = originalUri.replace(basepath, '/'); + history.replace(toRelativeUrl(originalUriWithoutBasepath, window.location.origin)); + }; +``` + ### Migrating from 3.x to 4.x #### Updating the Security component diff --git a/package.json b/package.json index 6e50aa02..cded68da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@okta/okta-react", - "version": "4.2.0", + "version": "5.0.0", "description": "React support for Okta", "private": true, "scripts": { diff --git a/src/OktaContext.ts b/src/OktaContext.ts index b0103a0e..e1f64ff0 100644 --- a/src/OktaContext.ts +++ b/src/OktaContext.ts @@ -14,6 +14,8 @@ import { AuthState, OktaAuth } from '@okta/okta-auth-js'; export type OnAuthRequiredFunction = (oktaAuth: OktaAuth) => Promise | void; +export type RestoreOriginalUriFunction = (oktaAuth: OktaAuth, originalUri: string) => Promise | void; + export interface IOktaContext { oktaAuth: OktaAuth; authState: AuthState; diff --git a/src/Security.tsx b/src/Security.tsx index 80d95f0b..93c80071 100644 --- a/src/Security.tsx +++ b/src/Security.tsx @@ -11,21 +11,21 @@ */ import * as React from 'react'; -import { useHistory } from 'react-router-dom'; -import { toRelativeUrl, AuthSdkError, OktaAuth } from '@okta/okta-auth-js'; -import OktaContext, { OnAuthRequiredFunction } from './OktaContext'; +import { AuthSdkError, OktaAuth } from '@okta/okta-auth-js'; +import OktaContext, { OnAuthRequiredFunction, RestoreOriginalUriFunction } from './OktaContext'; import OktaError from './OktaError'; const Security: React.FC<{ - oktaAuth: OktaAuth, + oktaAuth: OktaAuth, + restoreOriginalUri: RestoreOriginalUriFunction, onAuthRequired?: OnAuthRequiredFunction, children?: React.ReactNode } & React.HTMLAttributes> = ({ - oktaAuth, + oktaAuth, + restoreOriginalUri, onAuthRequired, - children + children }) => { - const history = useHistory(); const [authState, setAuthState] = React.useState(() => { if (!oktaAuth) { return { @@ -39,18 +39,17 @@ const Security: React.FC<{ }); React.useEffect(() => { - if (!oktaAuth) { + if (!oktaAuth || !restoreOriginalUri) { return; } // Add default restoreOriginalUri callback - if (!oktaAuth.options.restoreOriginalUri) { - oktaAuth.options.restoreOriginalUri = async (_, originalUri) => { - const basepath = history.createHref({}); - const originalUriWithoutBasepath = originalUri.replace(basepath, '/'); - history.replace(toRelativeUrl(originalUriWithoutBasepath, window.location.origin)); - }; + if (oktaAuth.options.restoreOriginalUri && restoreOriginalUri) { + console.warn('Two custom restoreOriginalUri callbacks are detected. The one from the OktaAuth configuration will be overridden by the provided restoreOriginalUri prop from the Security component.'); } + oktaAuth.options.restoreOriginalUri = async (oktaAuth: unknown, originalUri: string) => { + restoreOriginalUri(oktaAuth as OktaAuth, originalUri); + }; // Add okta-react userAgent oktaAuth.userAgent = `${process.env.PACKAGE_NAME}/${process.env.PACKAGE_VERSION} ${oktaAuth.userAgent}`; @@ -66,13 +65,18 @@ const Security: React.FC<{ } return () => oktaAuth.authStateManager.unsubscribe(); - }, [oktaAuth, history]); + }, [oktaAuth, restoreOriginalUri]); if (!oktaAuth) { const err = new AuthSdkError('No oktaAuth instance passed to Security Component.'); return ; } + if (!restoreOriginalUri) { + const err = new AuthSdkError('No restoreOriginalUri callback passed to Security Component.'); + return ; + } + return ( { + history.replace(toRelativeUrl(originalUri, window.location.origin)); + }; + return ( diff --git a/test/jest/loginCallback.test.tsx b/test/jest/loginCallback.test.tsx index f6f91b7b..6986c436 100644 --- a/test/jest/loginCallback.test.tsx +++ b/test/jest/loginCallback.test.tsx @@ -19,6 +19,9 @@ describe('', () => { let oktaAuth; let authState; let mockProps; + const restoreOriginalUri = async (_, url) => { + location.href = url; + }; beforeEach(() => { authState = { isPending: true @@ -33,7 +36,10 @@ describe('', () => { isLoginRedirect: jest.fn().mockImplementation(() => false), handleLoginRedirect: jest.fn() }; - mockProps = { oktaAuth }; + mockProps = { + oktaAuth, + restoreOriginalUri + }; }); it('renders the component', () => { diff --git a/test/jest/secureRoute.test.tsx b/test/jest/secureRoute.test.tsx index 22819056..521a7c71 100644 --- a/test/jest/secureRoute.test.tsx +++ b/test/jest/secureRoute.test.tsx @@ -13,7 +13,7 @@ import * as React from 'react'; import { mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; -import { MemoryRouter, Route } from 'react-router-dom'; +import { MemoryRouter, Route, RouteProps } from 'react-router-dom'; import SecureRoute from '../../src/SecureRoute'; import Security from '../../src/Security'; @@ -21,6 +21,9 @@ describe('', () => { let oktaAuth; let authState; let mockProps; + const restoreOriginalUri = async (_, url) => { + location.href = url; + }; beforeEach(() => { authState = { @@ -39,7 +42,10 @@ describe('', () => { signInWithRedirect: jest.fn(), setOriginalUri: jest.fn() }; - mockProps = { oktaAuth }; + mockProps = { + oktaAuth, + restoreOriginalUri + }; }); describe('With changing authState', () => { @@ -305,7 +311,8 @@ describe('', () => { ); const secureRoute = wrapper.find(SecureRoute); - expect(secureRoute.find(Route).props().exact).toBe(true); + const props: RouteProps = secureRoute.find(Route).props(); + expect(props.exact).toBe(true); expect(wrapper.find(MyComponent).html()).toBe('
hello world
'); }); @@ -322,7 +329,8 @@ describe('', () => { ); const secureRoute = wrapper.find(SecureRoute); - expect(secureRoute.find(Route).props().strict).toBe(true); + const props: RouteProps = secureRoute.find(Route).props(); + expect(props.strict).toBe(true); expect(wrapper.find(MyComponent).html()).toBe('
hello world
'); }); @@ -339,7 +347,8 @@ describe('', () => { ); const secureRoute = wrapper.find(SecureRoute); - expect(secureRoute.find(Route).props().sensitive).toBe(true); + const props: RouteProps = secureRoute.find(Route).props(); + expect(props.sensitive).toBe(true); expect(wrapper.find(MyComponent).html()).toBe('
hello world
'); }); diff --git a/test/jest/security.test.tsx b/test/jest/security.test.tsx index ecb53f6f..0fe02135 100644 --- a/test/jest/security.test.tsx +++ b/test/jest/security.test.tsx @@ -13,8 +13,7 @@ import * as React from 'react'; import { mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; -import { MemoryRouter, Router } from 'react-router-dom'; -import { createBrowserHistory } from 'history'; +import { MemoryRouter } from 'react-router-dom'; import Security from '../../src/Security'; import { useOktaAuth } from '../../src/OktaContext'; import * as pkg from '../../package.json'; @@ -22,6 +21,9 @@ import * as pkg from '../../package.json'; describe('', () => { let oktaAuth; let initialAuthState; + const restoreOriginalUri = async (_, url) => { + location.href = url; + }; beforeEach(() => { initialAuthState = { isInitialState: true @@ -40,7 +42,8 @@ describe('', () => { it('should set userAgent for oktaAuth', () => { const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; mount(); expect(oktaAuth.userAgent).toEqual(`${pkg.name}/${pkg.version} okta/okta-auth-js`); @@ -49,7 +52,8 @@ describe('', () => { it('should set default restoreOriginalUri callback in oktaAuth.options', () => { oktaAuth.options = {}; const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; mount(); expect(oktaAuth.options.restoreOriginalUri).toBeDefined(); @@ -57,7 +61,8 @@ describe('', () => { it('gets initial state from oktaAuth and exposes it on the context', () => { const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; const MyComponent = jest.fn().mockImplementation(() => { const oktaProps = useOktaAuth(); @@ -87,7 +92,8 @@ describe('', () => { callback(newAuthState); }); const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; const MyComponent = jest.fn() @@ -120,7 +126,8 @@ describe('', () => { it('should not call updateAuthState when in login redirect state', () => { oktaAuth.isLoginRedirect = jest.fn().mockImplementation(() => true); const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; mount( @@ -153,7 +160,8 @@ describe('', () => { callback(mockAuthStates[stateCount]); }); const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; const MyComponent = jest.fn() // first call @@ -195,7 +203,8 @@ describe('', () => { it('should accept a className prop and render a component using the className', () => { const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; const wrapper = mount( @@ -226,7 +235,8 @@ describe('', () => { isPending: false }; const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; const wrapper = mount( @@ -242,7 +252,8 @@ describe('', () => { isPending: false }; const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; const wrapper = mount( @@ -257,7 +268,8 @@ describe('', () => { isPending: true }; const mockProps = { - oktaAuth + oktaAuth, + restoreOriginalUri }; const wrapper = mount( @@ -268,66 +280,29 @@ describe('', () => { }); it('should render error if oktaAuth props is not provided', () => { + const mockProps = { + oktaAuth: null, + restoreOriginalUri + }; const wrapper = mount( - + ); expect(wrapper.find(Security).html()).toBe('

AuthSdkError: No oktaAuth instance passed to Security Component.

'); }); - }); - - describe('basename logic in default restoreOriginalUri implementation', () => { - it('removes basename from original URI before navigating', async () => { - const history = createBrowserHistory({ - basename: '/basename' - }); - const navigateSpy = jest.spyOn(history, 'replace'); - - renderRouterWithSecurity(history); - await oktaAuth.options.restoreOriginalUri(null, 'http://localhost/basename/profile?queryParam=1'); - expect(navigateSpy).toBeCalledWith('/profile?queryParam=1'); - }); - - it('handles nested basename path', async () => { - const history = createBrowserHistory({ - basename: '/siteRoot/app1' - }); - const navigateSpy = jest.spyOn(history, 'replace'); - - renderRouterWithSecurity(history); - await oktaAuth.options.restoreOriginalUri(null, 'http://localhost/siteRoot/app1/profile#anchor'); - expect(navigateSpy).toBeCalledWith('/profile#anchor'); - }); - - it("handles Router configuration with baseanme set to '/'", async () => { - const history = createBrowserHistory({ - basename: '/' - }); - const navigateSpy = jest.spyOn(history, 'replace'); - renderRouterWithSecurity(history); - await oktaAuth.options.restoreOriginalUri(null, 'http://localhost/profile?queryParam=1'); - expect(navigateSpy).toBeCalledWith('/profile?queryParam=1'); - }); - - it('handles Router configuration without basename property set', async () => { - const history = createBrowserHistory({}); - const navigateSpy = jest.spyOn(history, 'replace'); - - renderRouterWithSecurity(history); - await oktaAuth.options.restoreOriginalUri(null, 'http://localhost/profile?queryParam=1'); - expect(navigateSpy).toBeCalledWith('/profile?queryParam=1'); - }); - - const renderRouterWithSecurity = function (history) { - mount( - - - <> - - + it('should render error if restoreOriginalUri prop is not provided', () => { + const mockProps = { + oktaAuth, + restoreOriginalUri: null + }; + const wrapper = mount( + + + ); - }; + expect(wrapper.find(Security).html()).toBe('

AuthSdkError: No restoreOriginalUri callback passed to Security Component.

'); + }); }); });