Skip to content

Commit

Permalink
feat: add withAuthenticationRequired
Browse files Browse the repository at this point in the history
  • Loading branch information
pamapa committed Dec 15, 2023
1 parent 72d4585 commit 0a81bcc
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 0 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (<div>Private</div>);

export default withAuthenticationRequired(PrivateRoute, {
onRedirecting: () => (<div>Redirecting to the login page...</div>)
});
```
### 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.
Expand Down
10 changes: 10 additions & 0 deletions docs/react-oidc-context.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ export const useAuth: () => AuthContextProps;
// @public
export function withAuth<P>(Component: React_2.ComponentType<P>): React_2.ComponentType<Omit<P, keyof AuthContextProps>>;

// @public
export const withAuthenticationRequired: <P extends object>(Component: React_2.ComponentType<P>, options?: WithAuthenticationRequiredProps) => React_2.FC<P>;

// @public (undocumented)
export interface WithAuthenticationRequiredProps {
onBeforeSignin?: () => Promise<void> | void;
OnRedirecting?: () => JSX.Element;
signinRedirectArgs?: SigninRedirectArgs;
}

// (No @packageDocumentation comment for this package)

```
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export type { AuthState } from "./AuthState";
export * from "./useAuth";
export { hasAuthParams } from "./utils";
export * from "./withAuth";
export * from "./withAuthenticationRequired";
65 changes: 65 additions & 0 deletions src/withAuthenticationRequired.tsx
Original file line number Diff line number Diff line change
@@ -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> | 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 = <P extends object>(
Component: React.ComponentType<P>,
options: WithAuthenticationRequiredProps = {},
): React.FC<P> => {
const { OnRedirecting = (): JSX.Element => <></>, onBeforeSignin, signinRedirectArgs } = options;
const displayName = `withAuthenticationRequired(${Component.displayName || Component.name})`;
const C: React.FC<P> = (props) => {
const auth = useAuth();

React.useEffect(() => {
if (hasAuthParams() ||
auth.isLoading || auth.activeNavigator || auth.isAuthenticated) {
return;
}
void (async (): Promise<void> => {
onBeforeSignin && await onBeforeSignin();
await auth.signinRedirect(signinRedirectArgs);
})();
}, [
auth.isLoading,
auth.isAuthenticated,
onBeforeSignin,
signinRedirectArgs,
]);

return auth.isAuthenticated ? <Component {...props} /> : OnRedirecting();
};

C.displayName = displayName;

return C;
};
183 changes: 183 additions & 0 deletions test/withAuthenticationRequired.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AuthProvider {...settingsStub}>
<WrappedComponent />
</AuthProvider>,
);

// 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(
<AuthProvider {...settingsStub}>
<WrappedComponent />
</AuthProvider>,
);
});

// 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(
<AuthProvider {...settingsStub}>
<WrappedComponent />
</AuthProvider>,
);
});

// 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(
<AuthProvider {...settingsStub}>
<WrappedComponent />
</AuthProvider>,
);

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(
<AuthProvider {...settingsStub}>
<WrappedComponent />
</AuthProvider>,
);

// 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 => (
<div>
{foo}
<AuthProvider {...settingsStub}>
<WrappedComponent />
</AuthProvider>
</div>
);

// act
const { rerender } = render(<App foo={1} />);
await waitFor(() =>
expect(signinRedirectMock).toHaveBeenCalled(),
);
signinRedirectMock.mockClear();
rerender(<App foo={2} />);

// assert
await waitFor(() =>
expect(signinRedirectMock).not.toHaveBeenCalled(),
);
});
});

0 comments on commit 0a81bcc

Please sign in to comment.