diff --git a/README.md b/README.md index 4f10467b..34dee639 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,21 @@ export const getPosts = createAsyncThunk( ) ``` +### Protect a route + +Secure a route component by using the withAuthenticationRequired higher-order component. If a user attempts to access this route without authentication, they will be redirected to the login page. + +```jsx +import React from 'react'; +import { withAuthenticationRequired } from "react-oidc-context"; + +const PrivateRoute = () => (
Private
); + +export default withAuthenticationRequired(PrivateRoute, { + onRedirecting: () => (
Redirecting to the login page...
) +}); +``` + ### Adding event listeners The underlying [`UserManagerEvents`](https://authts.github.io/oidc-client-ts/classes/UserManagerEvents.html) instance can be imperatively managed with the `useAuth` hook. diff --git a/docs/react-oidc-context.api.md b/docs/react-oidc-context.api.md index 7bc5cab5..727c551e 100644 --- a/docs/react-oidc-context.api.md +++ b/docs/react-oidc-context.api.md @@ -98,6 +98,16 @@ export const useAuth: () => AuthContextProps; // @public export function withAuth

(Component: React_2.ComponentType

): React_2.ComponentType>; +// @public +export const withAuthenticationRequired:

(Component: React_2.ComponentType

, options?: WithAuthenticationRequiredProps) => React_2.FC

; + +// @public (undocumented) +export interface WithAuthenticationRequiredProps { + onBeforeSignin?: () => Promise | void; + OnRedirecting?: () => JSX.Element; + signinRedirectArgs?: SigninRedirectArgs; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/src/index.ts b/src/index.ts index 99d806af..56718b02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,4 @@ export type { AuthState } from "./AuthState"; export * from "./useAuth"; export { hasAuthParams } from "./utils"; export * from "./withAuth"; +export * from "./withAuthenticationRequired"; diff --git a/src/withAuthenticationRequired.tsx b/src/withAuthenticationRequired.tsx new file mode 100644 index 00000000..6656b684 --- /dev/null +++ b/src/withAuthenticationRequired.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import type { SigninRedirectArgs } from "oidc-client-ts"; + +import { useAuth } from "./useAuth"; +import { hasAuthParams } from "./utils"; + +/** + * @public + */ +export interface WithAuthenticationRequiredProps { + /** + * Show a message when redirected to the signin page. + */ + OnRedirecting?: () => JSX.Element; + + /** + * Allows executing logic before the user is redirected to the signin page. + */ + onBeforeSignin?: () => Promise | void; + + /** + * Pass additional signin redirect arguments. + */ + signinRedirectArgs?: SigninRedirectArgs; +} + +/** + * A public higher-order component to protect accessing not public content. When you wrap your components in this higher-order + * component and an anonymous user visits your component, they will be redirected to the login page; after logging in, they + * will return to the page from which they were redirected. + * + * @public + */ +export const withAuthenticationRequired =

( + Component: React.ComponentType

, + options: WithAuthenticationRequiredProps = {}, +): React.FC

=> { + const { OnRedirecting = (): JSX.Element => <>, onBeforeSignin, signinRedirectArgs } = options; + const displayName = `withAuthenticationRequired(${Component.displayName || Component.name})`; + const C: React.FC

= (props) => { + const auth = useAuth(); + + React.useEffect(() => { + if (hasAuthParams() || + auth.isLoading || auth.activeNavigator || auth.isAuthenticated) { + return; + } + void (async (): Promise => { + onBeforeSignin && await onBeforeSignin(); + await auth.signinRedirect(signinRedirectArgs); + })(); + }, [ + auth.isLoading, + auth.isAuthenticated, + onBeforeSignin, + signinRedirectArgs, + ]); + + return auth.isAuthenticated ? : OnRedirecting(); + }; + + C.displayName = displayName; + + return C; +}; diff --git a/test/withAuthenticationRequired.test.tsx b/test/withAuthenticationRequired.test.tsx new file mode 100644 index 00000000..8a0c7eaf --- /dev/null +++ b/test/withAuthenticationRequired.test.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; + +import { AuthProvider, withAuthenticationRequired, type AuthContextProps } from "../src"; +import * as useAuthModule from "../src/useAuth"; + +const settingsStub = { authority: "authority", client_id: "client", redirect_uri: "redirect" }; + +describe("withAuthenticationRequired", () => { + it("should block access to a private component when not authenticated", async () => { + // arrange + const useAuthMock = jest.spyOn(useAuthModule, "useAuth"); + const authContext = { isLoading: false, isAuthenticated: false } as AuthContextProps; + const signinRedirectMock = jest.fn().mockResolvedValue(undefined); + authContext.signinRedirect = signinRedirectMock; + useAuthMock.mockReturnValue(authContext); + + const MyComponent = (): JSX.Element => <>Private; + const WrappedComponent = withAuthenticationRequired(MyComponent); + + // act + render( + + + , + ); + + // assert + await waitFor(() => + expect(signinRedirectMock).toHaveBeenCalled(), + ); + expect(screen.queryByText("Private")).toBeNull(); + }); + + it("should allow access to a private component when authenticated", async () => { + // arrange + const useAuthMock = jest.spyOn(useAuthModule, "useAuth"); + const authContext = { isLoading: false, isAuthenticated: true } as AuthContextProps; + const signinRedirectMock = jest.fn().mockResolvedValue(undefined); + authContext.signinRedirect = signinRedirectMock; + useAuthMock.mockReturnValue(authContext); + + const MyComponent = (): JSX.Element => <>Private; + const WrappedComponent = withAuthenticationRequired(MyComponent); + + // act + act(() => { + render( + + + , + ); + }); + + // assert + await waitFor(() => + expect(signinRedirectMock).not.toHaveBeenCalled(), + ); + await screen.findByText("Private"); + }); + + it("should show a custom redirecting message when not authenticated", async () => { + // arrange + const useAuthMock = jest.spyOn(useAuthModule, "useAuth"); + const authContext = { isLoading: false, isAuthenticated: false } as AuthContextProps; + const signinRedirectMock = jest.fn().mockResolvedValue(undefined); + authContext.signinRedirect = signinRedirectMock; + useAuthMock.mockReturnValue(authContext); + + const MyComponent = (): JSX.Element => <>Private; + const OnRedirecting = (): JSX.Element => <>Redirecting; + const WrappedComponent = withAuthenticationRequired(MyComponent, { + OnRedirecting, + }); + + // act + act(() => { + render( + + + , + ); + }); + + // assert + await screen.findByText("Redirecting"); + }); + + it("should call onBeforeSignin before signinRedirect", async () => { + // arrange + const useAuthMock = jest.spyOn(useAuthModule, "useAuth"); + const authContext = { isLoading: false, isAuthenticated: false } as AuthContextProps; + const signinRedirectMock = jest.fn().mockResolvedValue(undefined); + authContext.signinRedirect = signinRedirectMock; + useAuthMock.mockReturnValue(authContext); + + const MyComponent = (): JSX.Element => <>Private; + const onBeforeSigninMock = jest.fn(); + const WrappedComponent = withAuthenticationRequired(MyComponent, { + onBeforeSignin: onBeforeSigninMock, + }); + + // act + render( + + + , + ); + + await waitFor(() => + expect(onBeforeSigninMock).toHaveBeenCalled(), + ); + + await waitFor(() => + expect(signinRedirectMock).toHaveBeenCalled(), + ); + }); + + it("should pass additional options on to signinRedirect", async () => { + // arrange + const useAuthMock = jest.spyOn(useAuthModule, "useAuth"); + const authContext = { isLoading: false, isAuthenticated: false } as AuthContextProps; + const signinRedirectMock = jest.fn().mockResolvedValue(undefined); + authContext.signinRedirect = signinRedirectMock; + useAuthMock.mockReturnValue(authContext); + + const MyComponent = (): JSX.Element => <>Private; + const WrappedComponent = withAuthenticationRequired(MyComponent, { + signinRedirectArgs: { + redirect_uri: "foo", + }, + }); + + // act + render( + + + , + ); + + // assert + await waitFor(() => + expect(signinRedirectMock).toHaveBeenCalledWith( + expect.objectContaining({ + redirect_uri: "foo", + }), + ), + ); + }); + + it("should call signinRedirect only once even if parent state changes", async () => { + // arrange + const useAuthMock = jest.spyOn(useAuthModule, "useAuth"); + const authContext = { isLoading: false, isAuthenticated: false } as AuthContextProps; + const signinRedirectMock = jest.fn().mockResolvedValue(undefined); + authContext.signinRedirect = signinRedirectMock; + useAuthMock.mockReturnValue(authContext); + + const MyComponent = (): JSX.Element => <>Private; + const WrappedComponent = withAuthenticationRequired(MyComponent); + const App = ({ foo }: { foo: number }): JSX.Element => ( +

+ {foo} + + + +
+ ); + + // act + const { rerender } = render(); + await waitFor(() => + expect(signinRedirectMock).toHaveBeenCalled(), + ); + signinRedirectMock.mockClear(); + rerender(); + + // assert + await waitFor(() => + expect(signinRedirectMock).not.toHaveBeenCalled(), + ); + }); +});