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 ( + +
+

{translate(title, { _: title })}

+
{translate(message, { _: message })}
+ +
+
+ ); +}; + +AuthError.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + message: PropTypes.string, +}; + +export interface AuthErrorProps { + className?: string; + title?: string; + message?: string; + sx?: SxProps; +} + +const PREFIX = 'RaAuthError'; + +export const AuthErrorClasses = { + root: `${PREFIX}-root`, + message: `${PREFIX}-message`, +}; + +const Root = styled('div', { + name: PREFIX, + overridesResolver: (props, styles) => styles.root, +})(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + [theme.breakpoints.up('md')]: { + height: '100%', + }, + [theme.breakpoints.down('xl')]: { + height: '100vh', + marginTop: '-3em', + }, + + [`& .${AuthErrorClasses.message}`]: { + textAlign: 'center', + fontFamily: 'Roboto, sans-serif', + opacity: 0.5, + margin: '0 1em', + }, +})); diff --git a/packages/ra-ui-materialui/src/auth/index.ts b/packages/ra-ui-materialui/src/auth/index.ts index 511a87e1323..6302c0b3326 100644 --- a/packages/ra-ui-materialui/src/auth/index.ts +++ b/packages/ra-ui-materialui/src/auth/index.ts @@ -1,3 +1,5 @@ +export * from './AuthCallback'; +export * from './AuthError'; export * from './Login'; export * from './LoginForm'; export * from './Logout'; diff --git a/packages/react-admin/src/Admin.tsx b/packages/react-admin/src/Admin.tsx index 71f64c776a1..65ffeee7069 100644 --- a/packages/react-admin/src/Admin.tsx +++ b/packages/react-admin/src/Admin.tsx @@ -103,6 +103,7 @@ export const Admin = (props: AdminProps) => { layout, loading, loginPage, + authCallbackPage, menu, // deprecated, use a custom layout instead notification, queryClient, @@ -139,6 +140,7 @@ export const Admin = (props: AdminProps) => { title={title} loading={loading} loginPage={loginPage} + authCallbackPage={authCallbackPage} notification={notification} requireAuth={requireAuth} ready={ready}