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 2 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
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.

## `loginCallbackPage`

If you want to customize the LoginCallback page, pass a component of your own as the `loginCallbackPage` prop. React-admin will display this component whenever the `/login-callback` route is called.
djhi marked this conversation as resolved.
Show resolved Hide resolved

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

const App = () => (
<Admin loginCallbackPage={MyLoginCallbackPage}>
...
</Admin>
);
```

You can also disable it completely along with the `/login-callback` route by passing `false` to this prop.
djhi marked this conversation as resolved.
Show resolved Hide resolved

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
11 changes: 11 additions & 0 deletions docs/Authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ What's an `authProvider`? Just like a `dataProvider`, an `authProvider` is an ob
const authProvider = {
// send username and password to the auth server and get back credentials
login: params => Promise.resolve(),
// validate authentication from an external service (e.g. OAuth)
handleLoginCallback: () => Promise.resolve(),
djhi marked this conversation as resolved.
Show resolved Hide resolved
// when the dataProvider returns an error, check if this is an authentication error
checkError: error => Promise.resolve(),
// when the user navigates, make sure that their credentials are still valid
Expand Down Expand Up @@ -82,6 +84,15 @@ 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;
- After a user interaction on the login page;

## 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 './useHandleLoginCallback';

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

import { useHandleLoginCallback } from './useHandleLoginCallback';
import AuthContext from './AuthContext';

import { BasenameContextProvider } from '../routing';
import { useRedirect } from '../routing/useRedirect';
import { AuthProvider } from '../types';

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

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

const TestComponent = ({ customError }: { customError?: boolean }) => {
const [error, setError] = React.useState<string>();
useHandleLoginCallback(
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('useHandleLoginCallback', () => {
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 redirect to the home route by default 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(redirect).toHaveBeenCalledWith('/');
});
});

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');
});
});

it('should take basename into account when redirecting to home route', async () => {
const history = createMemoryHistory({ initialEntries: ['/foo'] });
render(
<HistoryRouter history={history}>
<BasenameContextProvider basename="/foo">
<AuthContext.Provider value={authProvider}>
<QueryClientProvider client={queryClient}>
<TestComponent />
</QueryClientProvider>
</AuthContext.Provider>
</BasenameContextProvider>
</HistoryRouter>
);
await waitFor(() => {
expect(redirect).toHaveBeenCalledWith('/foo/');
});
});
});
57 changes: 57 additions & 0 deletions packages/ra-core/src/auth/useHandleLoginCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useQuery, UseQueryOptions } from 'react-query';
import { useBasename, useRedirect } from '../routing';
import { removeDoubleSlashes } from '../routing/useCreatePath';
import { AuthRedirectResult } from '../types';
import useAuthProvider from './useAuthProvider';

/**
* This hook calls the `authProvider.handleLoginCallback()` method. This is meant to be used in a route called
djhi marked this conversation as resolved.
Show resolved Hide resolved
* by an external authentication service (e.g. Auth0) after the user has logged in.
* By default, it redirects to the `redirectTo` location, home page if undefined.
djhi marked this conversation as resolved.
Show resolved Hide resolved
*
* @returns The result of the `handleLoginCallback` call. Destructure as { isLoading, data, error, refetch }.
djhi marked this conversation as resolved.
Show resolved Hide resolved
*/
export const useHandleLoginCallback = <
HandleLoginCallbackResult = AuthRedirectResult | void
>(
options?: UseQueryOptions<HandleLoginCallbackResult>
) => {
const authProvider = useAuthProvider();
const redirect = useRedirect();
const basename = useBasename();

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

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

redirect(
djhi marked this conversation as resolved.
Show resolved Hide resolved
data == null || redirectTo === true
? removeDoubleSlashes(`${basename}/`)
djhi marked this conversation as resolved.
Show resolved Hide resolved
: redirectTo
);
},
onError: err => {
const redirectTo = (err as AuthRedirectResult)?.redirectTo;

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

redirect(
err == null || redirectTo === true
? removeDoubleSlashes(`${basename}/`)
Copy link
Member

Choose a reason for hiding this comment

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

This will likely trigger an infinite loop if the home page requires auth. I don't think we should redirect by default on failure.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If the home page requires auth, users will either be redirected back to the third party auth page or to the login page, won't they?

Copy link
Member

Choose a reason for hiding this comment

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

right, but since they are already authenticated, the third-party auth page will redirect them to the callback URL, therefore endless loop

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should we logout first then? Something else?

: 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;
loginCallbackPage?: LoginComponent | 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,
loginCallbackPage: 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
useEffect,
useState,
} from 'react';
import { useLogout, usePermissions, useAuthState } from '../auth';
import { useLogout, usePermissions } from '../auth';
import { useSafeSetState } from '../util';
import {
AdminChildren,
Expand Down Expand Up @@ -79,7 +79,6 @@ const useRoutesAndResourcesFromChildren = (
// We need to know right away wether some resources were declared to correctly
// initialize the status at the next stop
const doLogout = useLogout();
const { authenticated } = useAuthState();
djhi marked this conversation as resolved.
Show resolved Hide resolved
const [
routesAndResources,
setRoutesAndResources,
Expand Down Expand Up @@ -142,7 +141,6 @@ const useRoutesAndResourcesFromChildren = (
updateFromChildren();
}
}, [
authenticated,
children,
doLogout,
isLoading,
Expand Down
5 changes: 5 additions & 0 deletions packages/ra-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,14 @@ export type AuthProvider = {
checkError: (error: any) => Promise<void>;
getIdentity?: () => Promise<UserIdentity>;
getPermissions: (params: any) => Promise<any>;
handleLoginCallback?: <
djhi marked this conversation as resolved.
Show resolved Hide resolved
HandleLoginCallbackResult = AuthRedirectResult
>() => Promise<HandleLoginCallbackResult | void>;
[key: string]: any;
};

export type AuthRedirectResult = { redirectTo?: string | boolean };

export type LegacyAuthProvider = (
type: AuthActionType,
params?: any
Expand Down
3 changes: 2 additions & 1 deletion packages/ra-ui-materialui/src/AdminUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
NotFound,
Notification,
} from './layout';
import { Login } from './auth';
import { Login, LoginCallback } from './auth';

export const AdminUI = ({ notification, ...props }: AdminUIProps) => (
<ScopedCssBaseline enableColorScheme>
Expand All @@ -27,5 +27,6 @@ AdminUI.defaultProps = {
catchAll: NotFound,
loading: LoadingPage,
loginPage: Login,
loginCallbackPage: LoginCallback,
djhi marked this conversation as resolved.
Show resolved Hide resolved
notification: Notification,
};
Loading