Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

backport add with authentication required #1380

Merged
merged 1 commit into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -111,6 +111,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";
60 changes: 60 additions & 0 deletions src/withAuthenticationRequired.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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, auth]);

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(),
);
});
});
Loading