From 9406c7d7e27c92063643bd9b4c8e2c8390caff10 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:47:46 +0000 Subject: [PATCH] Add authenticated session to app server context (#3157) Signed-off-by: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> --- .../data/toolpad/concepts/custom-functions.md | 13 ++++++++ .../data/toolpad/reference/api/get-context.md | 22 ++++++++++---- .../toolpad-app/src/runtime/ToolpadApp.tsx | 3 +- packages/toolpad-app/src/runtime/useAuth.ts | 12 +++++--- .../toolpad-app/src/server/DataManager.ts | 2 +- packages/toolpad-app/src/server/auth.ts | 21 ++----------- packages/toolpad-app/src/server/rpc.ts | 2 +- packages/toolpad-core/package.json | 2 ++ packages/toolpad-core/src/auth.ts | 20 +++++++++++++ packages/toolpad-core/src/serverRuntime.ts | 30 ++++++++++++++++++- packages/toolpad-core/tsconfig.json | 2 +- packages/toolpad-core/typings/@auth.d.ts | 10 +++++++ packages/toolpad-utils/package.json | 1 + .../src}/httpApiAdapters.ts | 15 ++++++---- pnpm-lock.yaml | 9 ++++++ test/integration/auth/domain.spec.ts | 4 ++- .../toolpad/pages/mypage/page.yml | 6 ++-- .../toolpad/resources/functions.ts | 7 +++-- 18 files changed, 136 insertions(+), 45 deletions(-) create mode 100644 packages/toolpad-core/src/auth.ts create mode 100644 packages/toolpad-core/typings/@auth.d.ts rename packages/{toolpad-app/src/server => toolpad-utils/src}/httpApiAdapters.ts (75%) diff --git a/docs/data/toolpad/concepts/custom-functions.md b/docs/data/toolpad/concepts/custom-functions.md index 63e892c9f18..06ec53a778f 100644 --- a/docs/data/toolpad/concepts/custom-functions.md +++ b/docs/data/toolpad/concepts/custom-functions.md @@ -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; +} +``` diff --git a/docs/data/toolpad/reference/api/get-context.md b/docs/data/toolpad/reference/api/get-context.md index 19dfcd53bd3..87ef42af696 100644 --- a/docs/data/toolpad/reference/api/get-context.md +++ b/docs/data/toolpad/reference/api/get-context.md @@ -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` | 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` | 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 diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 5419de9167e..8fe00542852 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -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); diff --git a/packages/toolpad-app/src/runtime/useAuth.ts b/packages/toolpad-app/src/runtime/useAuth.ts index dc7dcb87abc..6d5469152ee 100644 --- a/packages/toolpad-app/src/runtime/useAuth.ts +++ b/packages/toolpad-app/src/runtime/useAuth.ts @@ -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'; @@ -52,10 +53,13 @@ export const AuthContext = React.createContext({ 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 ?? []; @@ -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); diff --git a/packages/toolpad-app/src/server/DataManager.ts b/packages/toolpad-app/src/server/DataManager.ts index e7462af911c..4c9c4d7c37e 100644 --- a/packages/toolpad-app/src/server/DataManager.ts +++ b/packages/toolpad-app/src/server/DataManager.ts @@ -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); }); diff --git a/packages/toolpad-app/src/server/auth.ts b/packages/toolpad-app/src/server/auth.ts index 19a8f8f525d..347f5f3f3b7 100644 --- a/packages/toolpad-app/src/server/auth.ts +++ b/packages/toolpad-app/src/server/auth.ts @@ -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[] = [ @@ -35,23 +35,6 @@ export async function getRequireAuthentication(project: ToolpadProject): Promise return authProviders.length > 0; } -export async function getUserToken(req: express.Request): Promise { - 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[], diff --git a/packages/toolpad-app/src/server/rpc.ts b/packages/toolpad-app/src/server/rpc.ts index 397c4fa5b3c..8c5b6ddde12 100644 --- a/packages/toolpad-app/src/server/rpc.ts +++ b/packages/toolpad-app/src/server/rpc.ts @@ -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 }); }); diff --git a/packages/toolpad-core/package.json b/packages/toolpad-core/package.json index 3f86bd27065..2a2ab0a964c 100644 --- a/packages/toolpad-core/package.json +++ b/packages/toolpad-core/package.json @@ -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", @@ -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", diff --git a/packages/toolpad-core/src/auth.ts b/packages/toolpad-core/src/auth.ts new file mode 100644 index 00000000000..b251dfed760 --- /dev/null +++ b/packages/toolpad-core/src/auth.ts @@ -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 { + 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; +} diff --git a/packages/toolpad-core/src/serverRuntime.ts b/packages/toolpad-core/src/serverRuntime.ts index ac77998f0fe..d9424c4ad2e 100644 --- a/packages/toolpad-core/src/serverRuntime.ts +++ b/packages/toolpad-core/src/serverRuntime.ts @@ -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 { /** @@ -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(); @@ -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 { 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, }; } diff --git a/packages/toolpad-core/tsconfig.json b/packages/toolpad-core/tsconfig.json index 048b93c6e52..73456ce6435 100644 --- a/packages/toolpad-core/tsconfig.json +++ b/packages/toolpad-core/tsconfig.json @@ -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"] } diff --git a/packages/toolpad-core/typings/@auth.d.ts b/packages/toolpad-core/typings/@auth.d.ts new file mode 100644 index 00000000000..2dd16da613d --- /dev/null +++ b/packages/toolpad-core/typings/@auth.d.ts @@ -0,0 +1,10 @@ +export declare module '@auth/core/types' { + interface User { + roles: string[]; + } +} +export declare module '@auth/core/jwt' { + interface JWT { + roles: string[]; + } +} diff --git a/packages/toolpad-utils/package.json b/packages/toolpad-utils/package.json index b466ec12220..80c894658ce 100644 --- a/packages/toolpad-utils/package.json +++ b/packages/toolpad-utils/package.json @@ -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", diff --git a/packages/toolpad-app/src/server/httpApiAdapters.ts b/packages/toolpad-utils/src/httpApiAdapters.ts similarity index 75% rename from packages/toolpad-app/src/server/httpApiAdapters.ts rename to packages/toolpad-utils/src/httpApiAdapters.ts index 84007964088..72f1cc30c34 100644 --- a/packages/toolpad-app/src/server/httpApiAdapters.ts +++ b/packages/toolpad-utils/src/httpApiAdapters.ts @@ -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).reduce((acc, [key, value]) => { - const encKey = encodeURIComponent(key); - const encValue = encodeURIComponent(value); - return `${acc ? `${acc}&` : ''}${encKey}=${encValue}`; - }, ''); + return Object.entries(req.body as Record).reduce( + (acc, [key, value]) => { + const encKey = encodeURIComponent(key); + const encValue = encodeURIComponent(value); + return `${acc ? `${acc}&` : ''}${encKey}=${encValue}`; + }, + '', + ); } if (contentType?.includes('application/json')) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bbad2f6108..6ca1c04fe83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -899,6 +899,9 @@ importers: packages/toolpad-core: dependencies: + '@auth/core': + specifier: 0.25.1 + version: 0.25.1 '@mui/material': specifier: 5.15.10 version: 5.15.10(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0) @@ -945,6 +948,9 @@ importers: '@types/cookie': specifier: 0.6.0 version: 0.6.0 + '@types/express': + specifier: 4.17.21 + version: 4.17.21 '@types/invariant': specifier: 2.2.37 version: 2.2.37 @@ -982,6 +988,9 @@ importers: specifier: 2.0.0 version: 2.0.0 devDependencies: + '@types/express': + specifier: 4.17.21 + version: 4.17.21 '@types/invariant': specifier: 2.2.37 version: 2.2.37 diff --git a/test/integration/auth/domain.spec.ts b/test/integration/auth/domain.spec.ts index 22a3d1d97b5..1de9c69d3d4 100644 --- a/test/integration/auth/domain.spec.ts +++ b/test/integration/auth/domain.spec.ts @@ -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./, ], }); @@ -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'); diff --git a/test/integration/auth/fixture-domain/toolpad/pages/mypage/page.yml b/test/integration/auth/fixture-domain/toolpad/pages/mypage/page.yml index 31ebc4c0340..d58fd663f57 100644 --- a/test/integration/auth/fixture-domain/toolpad/pages/mypage/page.yml +++ b/test/integration/auth/fixture-domain/toolpad/pages/mypage/page.yml @@ -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 diff --git a/test/integration/auth/fixture-domain/toolpad/resources/functions.ts b/test/integration/auth/fixture-domain/toolpad/resources/functions.ts index 7951b807142..3a54984c879 100644 --- a/test/integration/auth/fixture-domain/toolpad/resources/functions.ts +++ b/test/integration/auth/fixture-domain/toolpad/resources/functions.ts @@ -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; }