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 authenticated session to app server context #3157

Merged
merged 67 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
973aa97
Add universal required email config for authentication
apedroferreira Jan 3, 2024
ba2399d
Must have at least 1 verified email in Github
apedroferreira Jan 3, 2024
ebcbf55
Merge remote-tracking branch 'upstream/master' into auth-required-email
apedroferreira Jan 3, 2024
08791c6
Address review comments
apedroferreira Jan 4, 2024
ef5731b
Merge remote-tracking branch 'upstream/master' into auth-required-email
apedroferreira Jan 4, 2024
39c0f00
Refactor (review comments)
apedroferreira Jan 5, 2024
d7fc87c
Small refactor
apedroferreira Jan 5, 2024
07015a0
Load env before imports
apedroferreira Jan 8, 2024
f730414
Add spacing to navigation
apedroferreira Jan 8, 2024
ebce000
Azure AD auth provider (without role mapping)
apedroferreira Jan 12, 2024
60f031d
Use just size property in icon
apedroferreira Jan 12, 2024
f58dcc9
Add role mapping
apedroferreira Jan 16, 2024
43c9af0
Update schemas
apedroferreira Jan 16, 2024
1c06826
Better name
apedroferreira Jan 16, 2024
f6fdf95
Fix Azure icon
apedroferreira Jan 16, 2024
761f0ed
Merge remote-tracking branch 'upstream/master' into auth-azure-ad-pro…
apedroferreira Jan 16, 2024
71ea8a7
Disable feature flag
apedroferreira Jan 16, 2024
9cffb6a
Self-review
apedroferreira Jan 16, 2024
8f150de
Merge remote-tracking branch 'upstream/master' into auth-azure-ad-pro…
apedroferreira Jan 16, 2024
f550a02
Fix page blocking logic and default page
apedroferreira Jan 16, 2024
f725a21
More fixes
apedroferreira Jan 16, 2024
7d921c2
Better signout experience
apedroferreira Jan 16, 2024
af6835a
[WIP] Authentication tests
apedroferreira Jan 4, 2024
8bcf78b
Simplify error mesage logic
apedroferreira Jan 5, 2024
4d8fc43
Auth test without restricted domains
apedroferreira Jan 5, 2024
11a6704
Much more better
apedroferreira Jan 5, 2024
ec4c161
Add note to try a better way next
apedroferreira Jan 5, 2024
e1acc08
Best possible test without creating test users with public credentials
apedroferreira Jan 8, 2024
4efa00f
Small refactor
apedroferreira Jan 8, 2024
a1ca40c
Add credentials provider for testing
apedroferreira Jan 23, 2024
488da7d
Fix some scenarios with missing secret and unnecessary requests
apedroferreira Jan 24, 2024
68f9de7
More fixes
apedroferreira Jan 24, 2024
9d6b82a
Fix CSRF bullshit, add test with authentication, sign in, sign out an…
apedroferreira Jan 25, 2024
188ef58
Add roles test
apedroferreira Jan 25, 2024
55b35e7
Update test/integration/auth/basic.spec.ts
apedroferreira Jan 26, 2024
9baabfe
Better function name
apedroferreira Jan 26, 2024
ba3b0e3
Forgot this
apedroferreira Jan 26, 2024
74e9cb9
Merge remote-tracking branch 'upstream/master' into auth-tests
apedroferreira Jan 31, 2024
9affd28
Continue merge
apedroferreira Jan 31, 2024
e7b2931
Add some refactors from other PR
apedroferreira Jan 31, 2024
27f8e3e
Disable feature flag
apedroferreira Jan 31, 2024
3d7ad43
Fix tests
apedroferreira Jan 31, 2024
833feb7
Update @auth/core
apedroferreira Jan 31, 2024
e079ac1
Run install
apedroferreira Jan 31, 2024
f799ce5
Lint fixins
apedroferreira Jan 31, 2024
d4869b1
Prettier
apedroferreira Jan 31, 2024
6f9c719
Revert @auth/core version
apedroferreira Jan 31, 2024
3b8a69e
Remove all temporary fixtures
apedroferreira Feb 1, 2024
3a3d59e
Remove more unwanted things
apedroferreira Feb 1, 2024
4445f9d
Update @auth/core, fix error message
apedroferreira Feb 1, 2024
b587343
Add logged-in user to context
apedroferreira Feb 1, 2024
6dae561
Fix type
apedroferreira Feb 1, 2024
3ebe46a
Remove feature flag changes
apedroferreira Feb 1, 2024
ec1d7fe
Merge remote-tracking branch 'upstream/master' into add-user-to-context
apedroferreira Feb 5, 2024
f2d4a65
Update docs more, change API a bit
apedroferreira Feb 8, 2024
3e786c9
Non-docs changes
apedroferreira Feb 8, 2024
935fd36
Better type name
apedroferreira Feb 8, 2024
bbe2270
Merge remote-tracking branch 'upstream/master' into add-user-to-context
apedroferreira Feb 8, 2024
b852709
Fix types
apedroferreira Feb 8, 2024
e35837f
Adjust copy
apedroferreira Feb 8, 2024
172d2c3
Use react router, fix Firefox test
apedroferreira Feb 8, 2024
80a5a60
Show ServerContextSession in separate table in docs, rename user to s…
apedroferreira Feb 9, 2024
d30af5e
Merge remote-tracking branch 'upstream/master' into add-user-to-context
apedroferreira Feb 9, 2024
6968018
Include user inside session instead
apedroferreira Feb 9, 2024
937e930
Adjustments
apedroferreira Feb 9, 2024
58141ef
Cover session in context in tests
apedroferreira Feb 9, 2024
cd1b59c
Merge remote-tracking branch 'upstream/master' into add-user-to-context
apedroferreira Feb 12, 2024
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
13 changes: 13 additions & 0 deletions docs/data/toolpad/concepts/custom-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,16 @@ export async function getData() {
return api.getData(token);
}
```

### Get the current authenticated session with `context.session`

If your Toolpad app has [authentication](/toolpad/concepts/authentication/) enabled, you can get data from the authenticated session, such as the logged-in user's `email`, `name` or `avatar`. Example:

```jsx
import { getContext } from '@mui/toolpad/server';

export async function getCurrentUserEmail() {
const { session } = getContext();
return session?.user.email;
}
```
22 changes: 17 additions & 5 deletions docs/data/toolpad/reference/api/get-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,26 @@ a `ServerContext` containing information on the context the backend function was

### ServerContext

This described a certain context under which a backend function was called.
This describes a certain context under which a backend function was called.

**Properties**

| Name | Type | Description |
| :---------- | :-------------------------------------- | :------------------------------------------------ |
| `cookies` | `Record<string, string>` | A dictionary mapping cookie name to cookie value. |
| `setCookie` | `(name: string, value: string) => void` | Use to set a cookie `name` with `value`. |
| Name | Type | Description |
| :---------- | :------------------------------------------- | :--------------------------------------------------------------------------- |
| `cookies` | `Record<string, string>` | A dictionary mapping cookie name to cookie value. |
| `setCookie` | `(name: string, value: string) => void` | Use to set a cookie `name` with `value`. |
| `session` | `{ user: ServerContextSessionUser } \| null` | Get current [authenticated](/toolpad/concepts/authentication/) session data. |

### ServerContextSessionUser

**Properties**

| Name | Type | Description |
| :-------- | :--------------- | :---------------------------------------------------------- |
| `name?` | `string \| null` | Logged-in user name. |
| `email?` | `string \| null` | Logged-in user email. |
| `avatar?` | `string \| null` | Logged-in user avatar image URL. |
| `roles` | `string[]` | Logged-in user [roles](/toolpad/concepts/rbac/) in Toolpad. |

## Usage

Expand Down
3 changes: 2 additions & 1 deletion packages/toolpad-app/src/runtime/ToolpadApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1662,7 +1662,8 @@ export function ToolpadAppProvider({
(window as any).toggleDevtools = () => toggleDevtools();
}, [toggleDevtools]);

const authContext = useAuth({ dom, basename, signInPagePath: `${basename}/signin` });
const authContext = useAuth({ dom, basename, signInPagePath: '/signin' });

const appHost = useNonNullableContext(AppHostContext);
const showPreviewHeader = shouldShowPreviewHeader(appHost);

Expand Down
12 changes: 8 additions & 4 deletions packages/toolpad-app/src/runtime/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import * as appDom from '@mui/toolpad-core/appDom';
import { useNonNullableContext } from '@mui/toolpad-utils/react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AppHostContext } from './AppHostContext';

const AUTH_API_PATH = '/api/auth';
Expand Down Expand Up @@ -52,10 +53,13 @@ export const AuthContext = React.createContext<AuthPayload>({
interface UseAuthInput {
dom: appDom.RenderTree;
basename: string;
signInPagePath?: string;
signInPagePath: string;
}

export function useAuth({ dom, basename, signInPagePath }: UseAuthInput): AuthPayload {
const location = useLocation();
const navigate = useNavigate();

const authProviders = React.useMemo(() => {
const app = appDom.getApp(dom);
const authProviderConfigs = app.attributes.authentication?.providers ?? [];
Expand Down Expand Up @@ -107,10 +111,10 @@ export function useAuth({ dom, basename, signInPagePath }: UseAuthInput): AuthPa
setSession(null);
setIsSigningOut(false);

if (!signInPagePath || window.location.pathname !== signInPagePath) {
window.location.href = `${basename}${AUTH_SIGNIN_PATH}`;
if (location.pathname !== signInPagePath) {
navigate(signInPagePath);
}
}, [basename, getCsrfToken, signInPagePath]);
}, [basename, getCsrfToken, location.pathname, navigate, signInPagePath]);

const getSession = React.useCallback(async () => {
setIsSigningIn(true);
Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-app/src/server/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export default class DataManager {
invariant(typeof pageName === 'string', 'pageName url param required');
invariant(typeof queryName === 'string', 'queryName url variable required');

const ctx = createServerContext(req, res);
const ctx = await createServerContext(req, res);
const result = await withContext(ctx, async () => {
return this.execQuery(pageName, queryName, req.body);
});
Expand Down
21 changes: 2 additions & 19 deletions packages/toolpad-app/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { AuthConfig, TokenSet } from '@auth/core/types';
import { OAuthConfig } from '@auth/core/providers';
import chalk from 'chalk';
import * as appDom from '@mui/toolpad-core/appDom';
import { JWT, getToken } from '@auth/core/jwt';
import { adaptRequestFromExpressToFetch } from '@mui/toolpad-utils/httpApiAdapters';
import { getUserToken } from '@mui/toolpad-core/auth';
import { asyncHandler } from '../utils/express';
import { adaptRequestFromExpressToFetch } from './httpApiAdapters';
import type { ToolpadProject } from './localMode';

const SKIP_VERIFICATION_PROVIDERS: appDom.AuthProvider[] = [
Expand All @@ -35,23 +35,6 @@ export async function getRequireAuthentication(project: ToolpadProject): Promise
return authProviders.length > 0;
}

export async function getUserToken(req: express.Request): Promise<JWT | null> {
let token = null;
if (process.env.TOOLPAD_AUTH_SECRET) {
const request = adaptRequestFromExpressToFetch(req);

// @TODO: Library types are wrong as salt should not be required, remove once fixed
// Github discussion: https://github.com/nextauthjs/next-auth/discussions/9133
// @ts-ignore
token = await getToken({
req: request,
secret: process.env.TOOLPAD_AUTH_SECRET,
});
}

return token;
}

function getMappedRoles(
roles: string[],
allRoles: string[],
Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-app/src/server/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function createRpcHandler(definition: MethodResolvers): express.RequestHa
let rawResult;
let error: Error | null = null;
try {
const ctx = createServerContext(req, res);
const ctx = await createServerContext(req, res);
rawResult = await withContext(ctx, async () => {
return method({ params, req, res });
});
Expand Down
2 changes: 2 additions & 0 deletions packages/toolpad-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"url": "https://github.com/mui/mui-toolpad/issues"
},
"dependencies": {
"@auth/core": "0.25.1",
"@mui/material": "5.15.10",
"@mui/toolpad-utils": "workspace:*",
"@tanstack/react-query": "5.18.1",
Expand All @@ -57,6 +58,7 @@
},
"devDependencies": {
"@types/cookie": "0.6.0",
"@types/express": "4.17.21",
"@types/invariant": "2.2.37",
"@types/react": "18.2.55",
"@types/react-is": "18.2.4",
Expand Down
20 changes: 20 additions & 0 deletions packages/toolpad-core/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type express from 'express';
import { JWT, getToken } from '@auth/core/jwt';
import { adaptRequestFromExpressToFetch } from '@mui/toolpad-utils/httpApiAdapters';

export async function getUserToken(req: express.Request): Promise<JWT | null> {
let token = null;
if (process.env.TOOLPAD_AUTH_SECRET) {
const request = adaptRequestFromExpressToFetch(req);

// @TODO: Library types are wrong as salt should not be required, remove once fixed
// Github discussion: https://github.com/nextauthjs/next-auth/discussions/9133
// @ts-ignore
token = await getToken({
req: request,
secret: process.env.TOOLPAD_AUTH_SECRET,
});
}

return token;
}
30 changes: 29 additions & 1 deletion packages/toolpad-core/src/serverRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import { IncomingMessage, ServerResponse } from 'node:http';
import * as cookie from 'cookie';
import { isWebContainer } from '@webcontainer/env';
import type express from 'express';
import { getUserToken } from './auth';

interface ServerContextSessionUser {
name?: string | null;
email?: string | null;
avatar?: string | null;
roles?: string[];
}

export interface ServerContext {
/**
Expand All @@ -12,6 +21,10 @@ export interface ServerContext {
* Use to set a cookie `name` with `value`.
*/
setCookie: (name: string, value: string) => void;
/**
* Data about current authenticated session.
*/
session: { user: ServerContextSessionUser } | null;
}

const contextStore = new AsyncLocalStorage<ServerContext>();
Expand All @@ -20,13 +33,28 @@ export function getServerContext(): ServerContext | undefined {
return contextStore.getStore();
}

export function createServerContext(req: IncomingMessage, res: ServerResponse): ServerContext {
export async function createServerContext(
req: IncomingMessage,
res: ServerResponse,
): Promise<ServerContext> {
const cookies = cookie.parse(req.headers.cookie || '');

const token = await getUserToken(req as express.Request);
const session = token && {
user: {
name: token.name,
email: token.email,
avatar: token.picture,
roles: token.roles,
},
};

return {
cookies,
setCookie(name, value) {
res.setHeader('Set-Cookie', cookie.serialize(name, value, { path: '/' }));
},
session,
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"pretty": true,
"preserveWatchOutput": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "public/serverModules.d.ts"]
"include": ["src/**/*.ts", "src/**/*.tsx", "public/serverModules.d.ts", "typings/**/*.d.ts"]
}
10 changes: 10 additions & 0 deletions packages/toolpad-core/typings/@auth.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export declare module '@auth/core/types' {
interface User {
roles: string[];
}
}
export declare module '@auth/core/jwt' {
interface JWT {
roles: string[];
}
}
1 change: 1 addition & 0 deletions packages/toolpad-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"yaml-diff-patch": "2.0.0"
},
"devDependencies": {
"@types/express": "4.17.21",
"@types/invariant": "2.2.37",
"@types/prettier": "2.7.3",
"@types/react": "18.2.55",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import express from 'express';
import type express from 'express';

export function encodeRequestBody(req: express.Request) {
const contentType = req.headers['content-type'];

if (typeof req.body === 'object' && contentType?.includes('application/x-www-form-urlencoded')) {
return Object.entries(req.body as Record<string, any>).reduce((acc, [key, value]) => {
const encKey = encodeURIComponent(key);
const encValue = encodeURIComponent(value);
return `${acc ? `${acc}&` : ''}${encKey}=${encValue}`;
}, '');
return Object.entries(req.body as Record<string, string | number | boolean>).reduce(
(acc, [key, value]) => {
const encKey = encodeURIComponent(key);
const encValue = encodeURIComponent(value);
return `${acc ? `${acc}&` : ''}${encKey}=${encValue}`;
},
'',
);
}

if (contentType?.includes('application/json')) {
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion test/integration/auth/domain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url));
test.use({
ignoreConsoleErrors: [
/Failed to load resource: the server responded with a status of 401 \(Unauthorized\)/,
/NetworkError when attempting to fetch resource./,
/The operation was aborted./,
],
});

Expand Down Expand Up @@ -41,7 +43,7 @@ test('Must be authenticated with valid domain to access app', async ({ page, req
// Sign in with valid domain
await tryCredentialsSignIn(page, 'mui', 'mui');
await expect(page).toHaveURL(/\/prod\/pages\/mypage/);
await expect(page.getByText('message: hello world')).toBeVisible();
await expect(page.getByText('my email: test@mui.com')).toBeVisible();

// Is not redirected when authenticated
await page.goto('/prod/pages/mypage');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ spec:
props:
value:
$$jsExpression: |
`message: ${hello.data.message}`
`my email: ${getMySession.data.user.email}`
queries:
- name: hello
- name: getMySession
query:
function: hello
function: getMySession
kind: local
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export async function hello() {
return { message: 'hello world' };
import { getContext } from '@mui/toolpad/server';

export async function getMySession() {
const context = getContext();
return context.session;
}
Loading