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

Add /login-callback route and optional new authProvider.handleLoginCalback() method #8457

Merged
merged 23 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from 9 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
14 changes: 14 additions & 0 deletions cypress/e2e/permissions.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,18 @@ describe('Permissions', () => {
cy.contains('Role');
});
});

it.only('refreshes permissions after logging out and back in with a different user', () => {
djhi marked this conversation as resolved.
Show resolved Hide resolved
ShowPage.navigate();
ShowPage.logout();
LoginPage.login('login', 'password');
cy.contains('Posts');
cy.contains('Comments');
cy.contains('Users').should(el => expect(el).to.not.exist);
ShowPage.logout();
LoginPage.login('user', 'password');
cy.contains('Posts');
cy.contains('Comments');
cy.contains('Users');
});
});
19 changes: 19 additions & 0 deletions docs/Admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Here are all the props accepted by the component:
- [`theme`](#theme)
- [`layout`](#layout)
- [`loginPage`](#loginpage)
- [`loginCallbackPage`](#logincallbackpage)
djhi marked this conversation as resolved.
Show resolved Hide resolved
- [`history`](#history)
- [`basename`](#basename)
- [`ready`](#ready)
Expand Down Expand Up @@ -441,6 +442,24 @@ See The [Authentication documentation](./Authentication.md#customizing-the-login

**Tip**: Before considering writing your own login page component, please take a look at how to change the default [background image](./Theming.md#using-a-custom-login-page) or the [MUI theme](#theme). See the [Authentication documentation](./Authentication.md#customizing-the-login-component) for more details.

## `authCallbackPage`

Used for external authentication services callbacks, the `AuthCallback` page can be customized by passing a component of your own as the `authCallbackPage` prop. React-admin will display this component whenever the `/auth-callback` route is called.

```jsx
import MyAuthCallbackPage from './MyAuthCallbackPage';

const App = () => (
<Admin authCallbackPage={MyAuthCallbackPage}>
...
</Admin>
);
```

You can also disable it completely along with the `/auth-callback` route by passing `false` to this prop.

See The [Authentication documentation](./Authentication.md#handling-external-authentication-services-callbacks) for more details.

## ~~`history`~~

**Note**: This prop is deprecated. Check [the Routing chapter](./Routing.md) to see how to use a different router.
Expand Down
53 changes: 53 additions & 0 deletions docs/Authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,59 @@ Now the admin is secured: The user can be authenticated and use their credential

If you have a custom REST client, don't forget to add credentials yourself.

## Handling External Authentication Services Callbacks

When using external authentication services such as those implementing OAuth, you usually need a callback route. React-admin provides a default one at `/login-callback`. It will call the `AuthProvider.handleLoginCallback` method that may validate the params received from the URL and redirect users to any page (the home page by default) afterwards.
djhi marked this conversation as resolved.
Show resolved Hide resolved

It's up to you to decide when to redirect users to the third party authentication service, for instance:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit confusing. Could you elaborate?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get what's confusing

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why this section occurs here. It has nothing to do with the login callback

Copy link
Collaborator Author

@djhi djhi Dec 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I introduce a new dedicated page to third party authentication services ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at least a section in the Authentication introduction

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the authentication introduction page

- Directly in the `AuthProvider.checkAuth` method when users are not yet authenticated;
- After a user interaction on the login page;

For instance, here's what a simple authProvider for Auth0 might look like:

```js
import { Auth0Client } from './Auth0Client';

export const authProvider = {
async checkAuth() {
const isAuthenticated = await Auth0Client.isAuthenticated();
if (isAuthenticated) {
return;
}

Auth0Client.loginWithRedirect({
authorizationParams: {
redirect_uri: `${window.location.origin}/login-callback`,
},
});
},
async handleLoginCallback() {
const query = window.location.search;
if (query.includes('code=') && query.includes('state=')) {
try {
await Auth0Client.handleRedirectCallback();
return;
} catch (error) {
console.log('error', error);
throw error;
}
}
throw new Error('Failed to handle login callback.');
},
async logout() {
const isAuthenticated = await client.isAuthenticated();
if (isAuthenticated) {
// need to check for this as react-admin calls logout in case checkAuth failed
return client.logout({
returnTo: window.location.origin,
});
}
},
async login() => { /* Nothing to do here, this function will never be called */ },
...
}
```

## Allowing Anonymous Access

As long as you add an `authProvider`, react-admin restricts access to all the pages declared in the `<Resource>` components. If you want to allow anonymous access, you can set the `disableAuthentication` prop in the page components.
Expand Down
1 change: 1 addition & 0 deletions packages/ra-core/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './types';
export * from './useAuthenticated';
export * from './useCheckAuth';
export * from './useGetIdentity';
export * from './useHandleAuthCallback';
djhi marked this conversation as resolved.
Show resolved Hide resolved

export {
AuthContext,
Expand Down
165 changes: 165 additions & 0 deletions packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import * as React from 'react';
import expect from 'expect';
import { render, waitFor } from '@testing-library/react';
import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom';
import { QueryClientProvider, QueryClient } from 'react-query';
import { createMemoryHistory } from 'history';

import { useHandleAuthCallback } from './useHandleAuthCallback';
import AuthContext from './AuthContext';
import { useRedirect } from '../routing/useRedirect';
import useLogout from './useLogout';
import { AuthProvider } from '../types';

jest.mock('../routing/useRedirect');
jest.mock('./useLogout');

const redirect = jest.fn();
// @ts-ignore
useRedirect.mockImplementation(() => redirect);

const logout = jest.fn();
// @ts-ignore
useLogout.mockImplementation(() => logout);

const TestComponent = ({ customError }: { customError?: boolean }) => {
const [error, setError] = React.useState<string>();
useHandleAuthCallback(
customError
? {
onError: error => {
setError(error as string);
},
}
: undefined
);
return error ? <>{error}</> : null;
};

const authProvider: AuthProvider = {
login: () => Promise.reject('bad method'),
logout: () => {
return Promise.resolve();
},
checkAuth: params => (params.token ? Promise.resolve() : Promise.reject()),
checkError: params => {
if (params instanceof Error && params.message === 'denied') {
return Promise.reject(new Error('logout'));
}
return Promise.resolve();
},
getPermissions: () => Promise.reject('not authenticated'),
handleLoginCallback: () => Promise.resolve(),
};

const queryClient = new QueryClient();

describe('useHandleAuthCallback', () => {
afterEach(() => {
redirect.mockClear();
});

it('should redirect to the home route by default when the callback was successfully handled', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] });
render(
<HistoryRouter history={history}>
<AuthContext.Provider value={authProvider}>
<QueryClientProvider client={queryClient}>
<TestComponent />
</QueryClientProvider>
</AuthContext.Provider>
</HistoryRouter>
);
await waitFor(() => {
expect(redirect).toHaveBeenCalledWith('/');
});
});

it('should redirect to the provided route when the callback was successfully handled', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] });
render(
<HistoryRouter history={history}>
<AuthContext.Provider
value={{
...authProvider,
handleLoginCallback: () =>
Promise.resolve({ redirectTo: '/test' }),
}}
>
<QueryClientProvider client={queryClient}>
<TestComponent />
</QueryClientProvider>
</AuthContext.Provider>
</HistoryRouter>
);
await waitFor(() => {
expect(redirect).toHaveBeenCalledWith('/test');
});
});

it('should logout and not redirect to any page when the callback was not successfully handled', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] });
render(
<HistoryRouter history={history}>
<AuthContext.Provider
value={{
...authProvider,
handleLoginCallback: () => Promise.reject(),
}}
>
<QueryClientProvider client={queryClient}>
<TestComponent />
</QueryClientProvider>
</AuthContext.Provider>
</HistoryRouter>
);
await waitFor(() => {
expect(logout).toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
});

it('should redirect to the provided route when the callback was not successfully handled', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] });
render(
<HistoryRouter history={history}>
<AuthContext.Provider
value={{
...authProvider,
handleLoginCallback: () =>
Promise.reject({ redirectTo: '/test' }),
}}
>
<QueryClientProvider client={queryClient}>
<TestComponent />
</QueryClientProvider>
</AuthContext.Provider>
</HistoryRouter>
);
await waitFor(() => {
expect(redirect).toHaveBeenCalledWith('/test');
});
});

it('should use custom useQuery options such as onError', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] });
render(
<HistoryRouter history={history}>
<AuthContext.Provider
value={{
...authProvider,
handleLoginCallback: () =>
Promise.resolve({ redirectTo: '/test' }),
}}
>
<QueryClientProvider client={queryClient}>
<TestComponent customError />
</QueryClientProvider>
</AuthContext.Provider>
</HistoryRouter>
);
await waitFor(() => {
expect(redirect).toHaveBeenCalledWith('/test');
});
});
});
59 changes: 59 additions & 0 deletions packages/ra-core/src/auth/useHandleAuthCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useQuery, UseQueryOptions } from 'react-query';
import { useLocation } from 'react-router';
import { useRedirect } from '../routing';
import { AuthRedirectResult } from '../types';
import useAuthProvider from './useAuthProvider';
import useLogout from './useLogout';

/**
* This hook calls the `authProvider.handleLoginCallback()` method on mount. This is meant to be used in a route called
* by an external authentication service (e.g. Auth0) after the user has logged in.
* By default, it redirects to application home page upon success, or to the `redirectTo` location returned by `authProvider. handleLoginCallback`.
*
* @returns An object containing { isLoading, data, error, refetch }.
*/
export const useHandleAuthCallback = <
HandleLoginCallbackResult = AuthRedirectResult | void
>(
options?: UseQueryOptions<HandleLoginCallbackResult>
) => {
const authProvider = useAuthProvider();
const redirect = useRedirect();
const logout = useLogout();
const location = useLocation();
const locationState = location.state as any;
const nextPathName = locationState && locationState.nextPathname;
const nextSearch = locationState && locationState.nextSearch;
const defaultRedirectUrl = nextPathName ? nextPathName + nextSearch : '/';

return useQuery(
['auth', 'handleLoginCallback'],
() => authProvider.handleLoginCallback<HandleLoginCallbackResult>(),
{
retry: false,
onSuccess: data => {
const redirectTo = (data as AuthRedirectResult)?.redirectTo;

if (redirectTo === false) {
return;
}

redirect(data == null ? defaultRedirectUrl : redirectTo);
},
onError: err => {
const { redirectTo = false, logoutOnFailure = true } = (err ??
{}) as AuthRedirectResult;

if (logoutOnFailure) {
logout({}, redirectTo);
}
if (redirectTo === false) {
return;
}

redirect(redirectTo);
},
...options,
}
);
};
10 changes: 10 additions & 0 deletions packages/ra-core/src/core/CoreAdminUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface CoreAdminUIProps {
disableTelemetry?: boolean;
layout?: LayoutComponent;
loading?: LoadingComponent;
authCallbackPage?: ComponentType | boolean;
loginPage?: LoginComponent | boolean;
/**
* @deprecated use a custom layout instead
Expand All @@ -45,6 +46,7 @@ export const CoreAdminUI = (props: CoreAdminUIProps) => {
layout = DefaultLayout,
loading = Noop,
loginPage: LoginPage = false,
authCallbackPage: LoginCallbackPage = false,
menu, // deprecated, use a custom layout instead
ready = Ready,
title = 'React Admin',
Expand All @@ -70,6 +72,14 @@ export const CoreAdminUI = (props: CoreAdminUIProps) => {
{LoginPage !== false && LoginPage !== true ? (
<Route path="/login" element={createOrGetElement(LoginPage)} />
) : null}

{LoginCallbackPage !== false && LoginCallbackPage !== true ? (
<Route
path="/login-callback"
djhi marked this conversation as resolved.
Show resolved Hide resolved
element={createOrGetElement(LoginCallbackPage)}
/>
) : null}

<Route
path="/*"
element={
Expand Down
Loading