Skip to content

Commit

Permalink
Merge pull request #8457 from marmelab/login-callback
Browse files Browse the repository at this point in the history
Add login-callback hook and authProvider optional new `handleLoginCalback` method
  • Loading branch information
fzaninotto authored Dec 12, 2022
2 parents 989a9cc + d57138e commit e54d433
Show file tree
Hide file tree
Showing 19 changed files with 548 additions and 4 deletions.
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
49 changes: 49 additions & 0 deletions docs/AuthProviderWriting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 }` |
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 `/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 `<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';

export {
AuthContext,
Expand Down
167 changes: 167 additions & 0 deletions packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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(
<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() {
return 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.reject(new Error('Custom Error')),
}}
>
<QueryClientProvider client={queryClient}>
<TestComponent customError />
</QueryClientProvider>
</AuthContext.Provider>
</HistoryRouter>
);
await waitFor(() => {
screen.getByText('Custom Error');
});
expect(redirect).not.toHaveBeenCalledWith('/test');
});
});
Loading

0 comments on commit e54d433

Please sign in to comment.