diff --git a/cypress/e2e/permissions.cy.js b/cypress/e2e/permissions.cy.js
index cdc8aff8826..228a1030b3f 100644
--- a/cypress/e2e/permissions.cy.js
+++ b/cypress/e2e/permissions.cy.js
@@ -132,4 +132,18 @@ describe('Permissions', () => {
cy.contains('Role');
});
});
+
+ it('refreshes permissions after logging out and back in with a different user', () => {
+ 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');
+ });
});
diff --git a/docs/Admin.md b/docs/Admin.md
index 968aa048127..bfbf2d91e70 100644
--- a/docs/Admin.md
+++ b/docs/Admin.md
@@ -41,6 +41,7 @@ Here are all the props accepted by the component:
- [`theme`](#theme)
- [`layout`](#layout)
- [`loginPage`](#loginpage)
+- [`authCallbackPage`](#authcallbackpage)
- [`history`](#history)
- [`basename`](#basename)
- [`ready`](#ready)
@@ -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 = () => (
+
+ ...
+
+);
+```
+
+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.
diff --git a/docs/AuthProviderWriting.md b/docs/AuthProviderWriting.md
index 95b1040c8e7..653798aa875 100644
--- a/docs/AuthProviderWriting.md
+++ b/docs/AuthProviderWriting.md
@@ -395,6 +395,52 @@ React-admin doesn't use permissions by default, but it provides [the `usePermiss
[The Role-Based Access Control (RBAC) module](./AuthRBAC.md) allows fined-grained permissions in react-admin apps, and specifies a custom return format for `authProvider.getPermissions()`. Check [the RBAC documentation](./AuthRBAC.md#authprovider-methods) for more information.
+### `handleCallback`
+
+This is used when integrating a third party authentication service such as [Auth0](https://auth0.com/). React-admin provides a route at the `/auth-callback` path you can configure as the callback in the authentication service. After logging in using the authentication service page, users will be redirected to this page. The `/auth-callback` route will then call the AuthProvider `handleCallback` method where you can validate users are indeed authenticated.
+
+**Tip**: if you want to redirect users to the page they were on before logging in, you can store the location in localStorage under the key provided by the `PreviousLocationStorageKey` constant.
+
+Here's an example using Auth0:
+
+```js
+import { Auth0Client } from './Auth0Client';
+import { PreviousLocationStorageKey } from 'react-admin';
+
+export const authProvider = {
+ async checkAuth() {
+ const isAuthenticated = await client.isAuthenticated();
+ if (isAuthenticated) {
+ return;
+ }
+
+ localStorage.setItem(PreviousLocationStorageKey, window.location.href);
+
+ client.loginWithRedirect({
+ authorizationParams: {
+ redirect_uri: `${window.location.origin}/auth-callback`,
+ },
+ });
+ },
+ async handleCallback() {
+ const query = window.location.search;
+ // If we did receive the Auth0 parameters
+ if (query.includes('code=') && query.includes('state=')) {
+ try {
+ // Request the Auth0 client to validate them
+ await Auth0Client.handleRedirectCallback();
+ return;
+ } catch (error) {
+ console.log('error', error);
+ throw error;
+ }
+ }
+ throw new Error('Failed to handle login callback.');
+ },
+ ...
+}
+```
+
## Request Format
React-admin calls the `authProvider` methods with the following params:
@@ -407,6 +453,7 @@ React-admin calls the `authProvider` methods with the following params:
| `logout` | Log a user out | |
| `getIdentity` | Get the current user identity | |
| `getPermissions` | Get the current user credentials | `Object` whatever params passed to `usePermissions()` - empty for react-admin default routes |
+| `handleCallback` | Validate users after third party authentication service redirection | |
## Response Format
@@ -420,6 +467,7 @@ React-admin calls the `authProvider` methods with the following params:
| `logout` | Auth backend acknowledged logout | `string | false | void` route to redirect to after logout, defaults to `/login` |
| `getIdentity` | Auth backend returned identity | `{ id: string | number, fullName?: string, avatar?: string }` |
| `getPermissions` | Auth backend returned permissions | `Object | Array` free format - the response will be returned when `usePermissions()` is called |
+| `handleCallback` | User is authenticated | `void | { redirectTo?: string | boolean }` route to redirect to after login |
## Error Format
@@ -433,4 +481,5 @@ When the auth backend returns an error, the Auth Provider should return a reject
| `logout` | Auth backend failed to log the user out | `void` |
| `getIdentity` | Auth backend failed to return identity | `Object` free format - returned as `error` when `useGetIdentity()` is called |
| `getPermissions` | Auth backend failed to return permissions | `Object` free format - returned as `error` when `usePermissions()` is called |
+| `handleCallback` | Failed to authenticate users after redirection | `void | { redirectTo?: string, logoutOnFailure?: boolean, message?: string }` |
diff --git a/docs/Authentication.md b/docs/Authentication.md
index 37744f1124f..627eeffc5d5 100644
--- a/docs/Authentication.md
+++ b/docs/Authentication.md
@@ -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 `/auth-callback`. It will call the `AuthProvider.handleCallback` method that may validate the params received from the URL and redirect users to any page (the home page by default) afterwards.
+
+It's up to you to decide when to redirect users to the third party authentication service, for instance:
+* Directly in the `AuthProvider.checkAuth` method when users are not yet authenticated;
+* When users click a button on a [custom login page](#customizing-the-login-component)
+
+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}/auth-callback`,
+ },
+ });
+ },
+ async handleCallback() {
+ 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 `` components. If you want to allow anonymous access, you can set the `disableAuthentication` prop in the page components.
diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts
index fec06d41d5f..64f4fa8e8b5 100644
--- a/packages/ra-core/src/auth/index.ts
+++ b/packages/ra-core/src/auth/index.ts
@@ -15,6 +15,7 @@ export * from './types';
export * from './useAuthenticated';
export * from './useCheckAuth';
export * from './useGetIdentity';
+export * from './useHandleAuthCallback';
export {
AuthContext,
diff --git a/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx b/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx
new file mode 100644
index 00000000000..c9c1de72d4a
--- /dev/null
+++ b/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx
@@ -0,0 +1,167 @@
+import * as React from 'react';
+import expect from 'expect';
+import { render, screen, 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();
+ useHandleAuthCallback(
+ customError
+ ? {
+ onError: error => {
+ setError((error as Error).message);
+ },
+ }
+ : 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('Custom Error'));
+ }
+ return Promise.resolve();
+ },
+ getPermissions: () => Promise.reject('not authenticated'),
+ handleCallback: () => 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(
+
+
+
+
+
+
+
+ );
+ await waitFor(() => {
+ expect(redirect).toHaveBeenCalledWith('/');
+ });
+ });
+
+ it('should redirect to the provided route when the callback was successfully handled', async () => {
+ const history = createMemoryHistory({ initialEntries: ['/'] });
+ render(
+
+
+
+
+
+
+
+ );
+ 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(
+
+ Promise.reject(),
+ }}
+ >
+
+
+
+
+
+ );
+ 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(
+
+
+ Promise.reject({ redirectTo: '/test' }),
+ }}
+ >
+
+
+
+
+
+ );
+ await waitFor(() => {
+ expect(redirect).toHaveBeenCalledWith('/test');
+ });
+ });
+
+ it('should use custom useQuery options such as onError', async () => {
+ const history = createMemoryHistory({ initialEntries: ['/'] });
+ render(
+
+
+ Promise.reject(new Error('Custom Error')),
+ }}
+ >
+
+
+
+
+
+ );
+ await waitFor(() => {
+ screen.getByText('Custom Error');
+ });
+ expect(redirect).not.toHaveBeenCalledWith('/test');
+ });
+});
diff --git a/packages/ra-core/src/auth/useHandleAuthCallback.ts b/packages/ra-core/src/auth/useHandleAuthCallback.ts
new file mode 100644
index 00000000000..a4afd1551dc
--- /dev/null
+++ b/packages/ra-core/src/auth/useHandleAuthCallback.ts
@@ -0,0 +1,71 @@
+import { useQuery, UseQueryOptions } from 'react-query';
+import { useLocation } from 'react-router';
+import { useRedirect } from '../routing';
+import { AuthProvider, AuthRedirectResult } from '../types';
+import useAuthProvider from './useAuthProvider';
+import useLogout from './useLogout';
+
+/**
+ * This hook calls the `authProvider.handleCallback()` 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. handleCallback`.
+ *
+ * @returns An object containing { isLoading, data, error, refetch }.
+ */
+export const useHandleAuthCallback = (
+ options?: UseQueryOptions>
+) => {
+ 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', 'handleCallback'],
+ () => authProvider.handleCallback(),
+ {
+ retry: false,
+ onSuccess: data => {
+ // AuthProviders relying on a third party services redirect back to the app can't
+ // use the location state to store the path on which the user was before the login.
+ // So we support a fallback on the localStorage.
+ const previousLocation = localStorage.getItem(
+ PreviousLocationStorageKey
+ );
+ const redirectTo =
+ (data as AuthRedirectResult)?.redirectTo ??
+ previousLocation;
+
+ if (redirectTo === false) {
+ return;
+ }
+
+ redirect(redirectTo ?? defaultRedirectUrl);
+ },
+ onError: err => {
+ const { redirectTo = false, logoutOnFailure = true } = (err ??
+ {}) as AuthRedirectResult;
+
+ if (logoutOnFailure) {
+ logout({}, redirectTo);
+ }
+ if (redirectTo === false) {
+ return;
+ }
+
+ redirect(redirectTo);
+ },
+ ...options,
+ }
+ );
+};
+
+/**
+ * Key used to store the previous location in localStorage.
+ * Used by the useHandleAuthCallback hook to redirect the user to their previous location after a successful login.
+ */
+export const PreviousLocationStorageKey = '@react-admin/nextPathname';
diff --git a/packages/ra-core/src/core/CoreAdminUI.tsx b/packages/ra-core/src/core/CoreAdminUI.tsx
index 4b5c073934b..8462cf9162f 100644
--- a/packages/ra-core/src/core/CoreAdminUI.tsx
+++ b/packages/ra-core/src/core/CoreAdminUI.tsx
@@ -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
@@ -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',
@@ -70,6 +72,14 @@ export const CoreAdminUI = (props: CoreAdminUIProps) => {
{LoginPage !== false && LoginPage !== true ? (
) : null}
+
+ {LoginCallbackPage !== false && LoginCallbackPage !== true ? (
+
+ ) : null}
+
Promise;
getIdentity?: () => Promise;
getPermissions: (params: any) => Promise;
+ handleCallback?: () => Promise;
[key: string]: any;
};
+export type AuthRedirectResult = {
+ redirectTo?: string | false;
+ logoutOnFailure?: boolean;
+};
+
export type LegacyAuthProvider = (
type: AuthActionType,
params?: any
diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts
index 554ac87c748..af9f5e2ab7b 100644
--- a/packages/ra-language-english/src/index.ts
+++ b/packages/ra-language-english/src/index.ts
@@ -85,6 +85,8 @@ const englishMessages: TranslationMessages = {
message: {
about: 'About',
are_you_sure: 'Are you sure?',
+ auth_error:
+ 'A error occurred while validating the authentication token.',
bulk_delete_content:
'Are you sure you want to delete this %{name}? |||| Are you sure you want to delete these %{smart_count} items?',
bulk_delete_title:
@@ -99,6 +101,7 @@ const englishMessages: TranslationMessages = {
details: 'Details',
error:
"A client error occurred and your request couldn't be completed.",
+
invalid_form: 'The form is not valid. Please check for errors',
loading: 'The page is loading, just a moment please',
no: 'No',
diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts
index da748e24146..5f0df75fccc 100644
--- a/packages/ra-language-french/src/index.ts
+++ b/packages/ra-language-french/src/index.ts
@@ -87,6 +87,8 @@ const frenchMessages: TranslationMessages = {
message: {
about: 'Au sujet de',
are_you_sure: 'Êtes-vous sûr ?',
+ auth_error:
+ "Une erreur est survenue lors de la validation de votre jeton d'authentification.",
bulk_delete_content:
'Êtes-vous sûr(e) de vouloir supprimer cet élément ? |||| Êtes-vous sûr(e) de vouloir supprimer ces %{smart_count} éléments ?',
bulk_delete_title:
@@ -103,6 +105,7 @@ const frenchMessages: TranslationMessages = {
details: 'Détails',
error:
"En raison d'une erreur côté navigateur, votre requête n'a pas pu aboutir.",
+
invalid_form: "Le formulaire n'est pas valide.",
loading:
'La page est en cours de chargement, merci de bien vouloir patienter.',
diff --git a/packages/ra-ui-materialui/src/AdminUI.tsx b/packages/ra-ui-materialui/src/AdminUI.tsx
index c915358f1ea..30fd4258de6 100644
--- a/packages/ra-ui-materialui/src/AdminUI.tsx
+++ b/packages/ra-ui-materialui/src/AdminUI.tsx
@@ -9,7 +9,7 @@ import {
NotFound,
Notification,
} from './layout';
-import { Login } from './auth';
+import { Login, AuthCallback } from './auth';
export const AdminUI = ({ notification, ...props }: AdminUIProps) => (
@@ -27,5 +27,6 @@ AdminUI.defaultProps = {
catchAll: NotFound,
loading: LoadingPage,
loginPage: Login,
+ authCallbackPage: AuthCallback,
notification: Notification,
};
diff --git a/packages/ra-ui-materialui/src/auth/AuthCallback.tsx b/packages/ra-ui-materialui/src/auth/AuthCallback.tsx
new file mode 100644
index 00000000000..3432dac6db5
--- /dev/null
+++ b/packages/ra-ui-materialui/src/auth/AuthCallback.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+import { useHandleAuthCallback, useTimeout } from 'ra-core';
+import { Loading } from '..';
+import { AuthError } from './AuthError';
+
+/**
+ * A standalone page to be used in a route called by external authentication services (e.g. OAuth)
+ * after the user has been authenticated.
+ *
+ * Copy and adapt this component to implement your own login logic
+ * (e.g. to show a different waiting screen, start onboarding procedures, etc.).
+ *
+ * @example
+ * import MyAuthCallbackPage from './MyAuthCallbackPage';
+ * const App = () => (
+ *
+ * ...
+ *
+ * );
+ */
+export const AuthCallback = () => {
+ const { error } = useHandleAuthCallback();
+ const hasOneSecondPassed = useTimeout(1000);
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return hasOneSecondPassed ? : null;
+};
diff --git a/packages/ra-ui-materialui/src/auth/AuthError.stories.tsx b/packages/ra-ui-materialui/src/auth/AuthError.stories.tsx
new file mode 100644
index 00000000000..4c512914d63
--- /dev/null
+++ b/packages/ra-ui-materialui/src/auth/AuthError.stories.tsx
@@ -0,0 +1,37 @@
+import * as React from 'react';
+import { I18nContextProvider } from 'ra-core';
+import polyglotI18nProvider from 'ra-i18n-polyglot';
+import englishMessages from 'ra-language-english';
+import { AuthError } from './AuthError';
+import { defaultTheme } from '../defaultTheme';
+import { createTheme, ThemeProvider } from '@mui/material';
+
+export default { title: 'ra-ui-materialui/auth/AuthError' };
+
+const Wrapper = ({ children }) => (
+
+ englishMessages)}
+ >
+ {children}
+
+
+);
+
+export const Default = () => (
+
+
+
+);
+
+export const CustomError = () => (
+
+
+
+);
+
+export const CustomTitle = () => (
+
+
+
+);
diff --git a/packages/ra-ui-materialui/src/auth/AuthError.tsx b/packages/ra-ui-materialui/src/auth/AuthError.tsx
new file mode 100644
index 00000000000..fcc9839bdd9
--- /dev/null
+++ b/packages/ra-ui-materialui/src/auth/AuthError.tsx
@@ -0,0 +1,71 @@
+import * as React from 'react';
+import { styled, SxProps } from '@mui/material';
+import LockIcon from '@mui/icons-material/Lock';
+import PropTypes from 'prop-types';
+import { useTranslate } from 'ra-core';
+import { Button } from '../button';
+
+export const AuthError = (props: AuthErrorProps) => {
+ const {
+ className,
+ title = 'ra.page.error',
+ message = 'ra.message.auth_error',
+ ...rest
+ } = props;
+
+ const translate = useTranslate();
+ return (
+
+