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 14 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('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');
});
});
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)
- [`authCallbackPage`](#authcallbackpage)
- [`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
30 changes: 30 additions & 0 deletions docs/AuthProviderWriting.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,33 @@ 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 `/login-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 `/login-callback` route will then call the AuthProvider `handleCallback` method where you can validate users are indeed authenticated. Here's an example using Auth0:

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

export const authProvider = {
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:
Expand All @@ -407,6 +434,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

Expand All @@ -420,6 +448,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

Expand All @@ -433,4 +462,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 }` |

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.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:
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;
* 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}/login-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 `<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'),
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(
<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,
handleCallback: () =>
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,
handleCallback: () => 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,
handleCallback: () =>
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,
handleCallback: () =>
Promise.resolve({ redirectTo: '/test' }),
}}
>
<QueryClientProvider client={queryClient}>
<TestComponent customError />
</QueryClientProvider>
</AuthContext.Provider>
</HistoryRouter>
);
await waitFor(() => {
expect(redirect).toHaveBeenCalledWith('/test');
});
});
slax57 marked this conversation as resolved.
Show resolved Hide resolved
});
Loading