Skip to content

Commit

Permalink
Add authenticated session to app server context (#3157)
Browse files Browse the repository at this point in the history
Signed-off-by: Pedro Ferreira <[email protected]>
  • Loading branch information
apedroferreira authored Feb 12, 2024
1 parent 8be217e commit 9406c7d
Show file tree
Hide file tree
Showing 18 changed files with 136 additions and 45 deletions.
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: [email protected]')).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;
}

0 comments on commit 9406c7d

Please sign in to comment.