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 => (
+
+ );
+
+ // act
+ const { rerender } = render();
+ await waitFor(() =>
+ expect(signinRedirectMock).toHaveBeenCalled(),
+ );
+ signinRedirectMock.mockClear();
+ rerender();
+
+ // assert
+ await waitFor(() =>
+ expect(signinRedirectMock).not.toHaveBeenCalled(),
+ );
+ });
+});