diff --git a/.changeset/cold-guests-train.md b/.changeset/cold-guests-train.md new file mode 100644 index 0000000000..315f9c7787 --- /dev/null +++ b/.changeset/cold-guests-train.md @@ -0,0 +1,11 @@ +--- +"@comet/cms-admin": minor +"@comet/cms-site": minor +"@comet/cms-api": minor +--- + +Create site preview JWT in the API + +With this change the site preview can be deployed unprotected. Authentication is made via a JWT created in the API and validated in the site. A separate domain for the site preview is still necessary. + +BREAKING: this update of Comet v7 requires to have set sitePreviewSecret (which has to be the same value like possibly already set for site). Please refer to https://github.com/vivid-planet/comet-starter/pull/371 for more information on how to upgrade. diff --git a/.env b/.env index 4ab8e2fcbc..4462d4f917 100644 --- a/.env +++ b/.env @@ -66,6 +66,7 @@ NEXT_PUBLIC_API_URL=$API_URL # site-pages SITE_PAGES_PORT=3001 SITE_PAGES_URL=http://localhost:$SITE_PAGES_PORT +SITE_PREVIEW_SECRET=zPoURKLHPcMnV7E9 NEXT_PUBLIC_SITE_LANGUAGES=en,de NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE=en NEXT_PUBLIC_SITE_PAGES_DOMAIN=secondary diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 780ceb3b10..d792ba463b 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -750,6 +750,7 @@ type Query { pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]! paginatedPageTreeNodes(scope: PageTreeNodeScopeInput!, category: String, sort: [PageTreeNodeSort!], documentType: String, offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability! + sitePreviewJwt(scope: JSONObject!, path: String!, includeInvisible: Boolean!): String! redirects(scope: RedirectScopeInput!, query: String, type: RedirectGenerationType, active: Boolean, sortColumnName: String, sortDirection: SortDirection! = ASC): [Redirect!]! @deprecated(reason: "Use paginatedRedirects instead. Will be removed in the next version.") paginatedRedirects(scope: RedirectScopeInput!, search: String, filter: RedirectFilter, sort: [RedirectSort!], offset: Int! = 0, limit: Int! = 25): PaginatedRedirects! redirect(id: ID!): Redirect! diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts index 2f950521bf..2b59960fe2 100644 --- a/demo/api/src/app.module.ts +++ b/demo/api/src/app.module.ts @@ -117,7 +117,9 @@ export class AppModule { Documents: [Page, Link, PredefinedPage], Scope: PageTreeNodeScope, reservedPaths: ["/events"], + sitePreviewSecret: config.sitePreviewSecret, }), + RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }), BlobStorageModule.register({ backend: config.blob.storage, diff --git a/demo/api/src/config/config.ts b/demo/api/src/config/config.ts index 6aef9da589..6499d573ce 100644 --- a/demo/api/src/config/config.ts +++ b/demo/api/src/config/config.ts @@ -88,6 +88,7 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) { secret: envVars.FILE_UPLOADS_DOWNLOAD_SECRET, }, }, + sitePreviewSecret: envVars.SITE_PREVIEW_SECRET, }; } diff --git a/demo/api/src/config/environment-variables.ts b/demo/api/src/config/environment-variables.ts index 173cc2d993..5842b401e9 100644 --- a/demo/api/src/config/environment-variables.ts +++ b/demo/api/src/config/environment-variables.ts @@ -135,4 +135,9 @@ export class EnvironmentVariables { @IsString() @MinLength(16) FILE_UPLOADS_DOWNLOAD_SECRET: string; + + @IsString() + @MinLength(16) + @ValidateIf(() => process.env.NODE_ENV === "production") + SITE_PREVIEW_SECRET: string; } diff --git a/packages/admin/cms-admin/src/preview/site/SitePreview.tsx b/packages/admin/cms-admin/src/preview/site/SitePreview.tsx index 4c93a29566..18782ef62f 100644 --- a/packages/admin/cms-admin/src/preview/site/SitePreview.tsx +++ b/packages/admin/cms-admin/src/preview/site/SitePreview.tsx @@ -1,3 +1,4 @@ +import { gql, useQuery } from "@apollo/client"; import { CometColor, Domain, DomainLocked } from "@comet/admin-icons"; import { Grid, Tooltip, Typography } from "@mui/material"; import { ReactNode, useCallback, useState } from "react"; @@ -14,6 +15,7 @@ import { VisibilityToggle } from "../common/VisibilityToggle"; import { SitePrevewIFrameLocationMessage, SitePreviewIFrameMessageType } from "./iframebridge/SitePreviewIFrameMessage"; import { useSitePreviewIFrameBridge } from "./iframebridge/useSitePreviewIFrameBridge"; import { OpenLinkDialog } from "./OpenLinkDialog"; +import { GQLSitePreviewJwtQuery } from "./SitePreview.generated"; import { ActionsContainer, LogoWrapper, Root, SiteInformation, SiteLink, SiteLinkWrapper } from "./SitePreview.sc"; //TODO v4 remove RouteComponentProps @@ -98,6 +100,7 @@ function SitePreview({ resolvePath, logo = const newShowOnlyVisible = !showOnlyVisible; setShowOnlyVisible(String(newShowOnlyVisible)); setIframePath(sitePath); //reload iframe with new settings + refetch(); }; const siteLink = `${siteConfig.url}${sitePath}`; @@ -113,13 +116,26 @@ function SitePreview({ resolvePath, logo = } }); - const initialPageUrl = `${siteConfig.sitePreviewApiUrl}?${new URLSearchParams({ - scope: JSON.stringify(scope), - path: iframePath, - settings: JSON.stringify({ - includeInvisible: showOnlyVisible ? false : true, - }), - }).toString()}`; + const { data, error, refetch } = useQuery( + gql` + query SitePreviewJwt($scope: JSONObject!, $path: String!, $includeInvisible: Boolean!) { + sitePreviewJwt(scope: $scope, path: $path, includeInvisible: $includeInvisible) + } + `, + { + fetchPolicy: "network-only", + variables: { + scope, + path: iframePath, + includeInvisible: showOnlyVisible ? false : true, + }, + pollInterval: 1000 * 60 * 60 * 24, // due to expiration time of jwt + }, + ); + if (error) throw new Error(error.message); + if (!data) return
; + + const initialPageUrl = `${siteConfig.sitePreviewApiUrl}?${new URLSearchParams({ jwt: data.sitePreviewJwt }).toString()}`; return ( diff --git a/packages/api/cms-api/generate-schema.ts b/packages/api/cms-api/generate-schema.ts index 226f4830d0..6485126516 100644 --- a/packages/api/cms-api/generate-schema.ts +++ b/packages/api/cms-api/generate-schema.ts @@ -30,6 +30,7 @@ import { FileLicensesResolver } from "./src/dam/files/file-licenses.resolver"; import { createFilesResolver } from "./src/dam/files/files.resolver"; import { createFoldersResolver } from "./src/dam/files/folders.resolver"; import { FileUploadsResolver } from "./src/file-uploads/file-uploads.resolver"; +import { SitePreviewResolver } from "./src/page-tree/site-preview.resolver"; import { RedirectInputFactory } from "./src/redirects/dto/redirect-input.factory"; import { RedirectEntityFactory } from "./src/redirects/entities/redirect-entity.factory"; import { AzureAiTranslatorResolver } from "./src/translation/azure-ai-translator.resolver"; @@ -113,6 +114,7 @@ async function generateSchema(): Promise { GenerateAltTextResolver, GenerateImageTitleResolver, FileUploadsResolver, + SitePreviewResolver, ]); await writeFile("schema.gql", printSchema(schema)); diff --git a/packages/api/cms-api/package.json b/packages/api/cms-api/package.json index 2b6d173386..998285a0eb 100644 --- a/packages/api/cms-api/package.json +++ b/packages/api/cms-api/package.json @@ -60,6 +60,7 @@ "graphql-parse-resolve-info": "^4.13.0", "graphql-scalars": "^1.23.0", "hasha": "^5.2.2", + "jose": "^5.2.4", "jsonwebtoken": "^8.5.1", "jszip": "^3.10.1", "jwks-rsa": "^3.0.0", diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 3f2b294870..0696e73317 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -379,6 +379,7 @@ type Query { userPermissionsAvailableContentScopes: [JSONObject!]! fileUploadForTypesGenerationDoNotUse: FileUpload! azureAiTranslate(input: AzureAiTranslationInput!): String! + sitePreviewJwt(scope: JSONObject!, path: String!, includeInvisible: Boolean!): String! } input RedirectScopeInput { diff --git a/packages/api/cms-api/src/page-tree/page-tree.constants.ts b/packages/api/cms-api/src/page-tree/page-tree.constants.ts index 2cc32955fd..55684eeb94 100644 --- a/packages/api/cms-api/src/page-tree/page-tree.constants.ts +++ b/packages/api/cms-api/src/page-tree/page-tree.constants.ts @@ -5,3 +5,5 @@ export const PAGE_TREE_ENTITY = "PageTreeNode"; export const PAGE_TREE_CONFIG = "PageTreeConfig"; export const defaultReservedPaths = ["/admin", "/preview"]; + +export const SITE_PREVIEW_CONFIG = "SitePreviewConfig"; diff --git a/packages/api/cms-api/src/page-tree/page-tree.module.ts b/packages/api/cms-api/src/page-tree/page-tree.module.ts index 36a1db6737..23c752b3bc 100644 --- a/packages/api/cms-api/src/page-tree/page-tree.module.ts +++ b/packages/api/cms-api/src/page-tree/page-tree.module.ts @@ -11,11 +11,12 @@ import { DocumentSubscriberFactory } from "./document-subscriber"; import { PageTreeNodeBaseCreateInput, PageTreeNodeBaseUpdateInput } from "./dto/page-tree-node.input"; import { AttachedDocument } from "./entities/attached-document.entity"; import { PageTreeNodeBase } from "./entities/page-tree-node-base.entity"; -import { defaultReservedPaths, PAGE_TREE_CONFIG, PAGE_TREE_ENTITY, PAGE_TREE_REPOSITORY } from "./page-tree.constants"; +import { defaultReservedPaths, PAGE_TREE_CONFIG, PAGE_TREE_ENTITY, PAGE_TREE_REPOSITORY, SITE_PREVIEW_CONFIG } from "./page-tree.constants"; import { PageTreeService } from "./page-tree.service"; import { PageTreeNodeDocumentEntityInfoService } from "./page-tree-node-document-entity-info.service"; import { PageTreeNodeDocumentEntityScopeService } from "./page-tree-node-document-entity-scope.service"; import { PageTreeReadApiService } from "./page-tree-read-api.service"; +import { SitePreviewResolver } from "./site-preview.resolver"; import type { PageTreeNodeInterface, ScopeInterface } from "./types"; import { PageExistsConstraint } from "./validators/page-exists.validator"; @@ -30,6 +31,7 @@ interface PageTreeModuleOptions { Documents: Type[]; Scope?: Type; reservedPaths?: string[]; + sitePreviewSecret?: string; } @Global() @@ -42,6 +44,13 @@ export class PageTreeModule { throw new Error(`PageTreeModule: Your PageTreeNode entity must be named ${PAGE_TREE_ENTITY}`); } + // TODO v8: Make sitePreviewSecret mandatory and remove this error + if (!options.sitePreviewSecret) { + throw new Error( + "BREAKING: this update of Comet v7 requires to have set sitePreviewSecret (which has to be the same value like possibly already set for site). Please refer to https://github.com/vivid-planet/comet-starter/pull/371 for more information on how to upgrade.", + ); + } + const PageTreeResolver = createPageTreeResolver({ PageTreeNode, Documents, @@ -90,6 +99,13 @@ export class PageTreeModule { PageTreeNodeDocumentEntityInfoService, PageTreeNodeDocumentEntityScopeService, InternalLinkBlockTransformerService, + { + provide: SITE_PREVIEW_CONFIG, + useValue: { + secret: options.sitePreviewSecret, + }, + }, + SitePreviewResolver, ], exports: [ PageTreeService, diff --git a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts new file mode 100644 index 0000000000..16680500e8 --- /dev/null +++ b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts @@ -0,0 +1,36 @@ +import { Inject } from "@nestjs/common"; +import { Args, Query, Resolver } from "@nestjs/graphql"; +import { GraphQLJSONObject } from "graphql-scalars"; +import { SignJWT } from "jose"; + +import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; +import { ContentScope } from "../user-permissions/interfaces/content-scope.interface"; +import { SITE_PREVIEW_CONFIG } from "./page-tree.constants"; + +export type SitePreviewConfig = { + secret: string; +}; + +@Resolver() +export class SitePreviewResolver { + constructor(@Inject(SITE_PREVIEW_CONFIG) private readonly config: SitePreviewConfig) {} + + @Query(() => String) + @RequiredPermission("pageTree") + async sitePreviewJwt( + @Args("scope", { type: () => GraphQLJSONObject }) scope: ContentScope, + @Args("path") path: string, + @Args("includeInvisible") includeInvisible: boolean, + ): Promise { + return new SignJWT({ + scope, + path, + previewData: { + includeInvisible, + }, + }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1 day") + .sign(new TextEncoder().encode(this.config.secret)); + } +} diff --git a/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts b/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts index be6813ba19..5d1c8646b9 100644 --- a/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts +++ b/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts @@ -1,12 +1,10 @@ import "server-only"; -import { jwtVerify, SignJWT } from "jose"; +import { jwtVerify } from "jose"; import { cookies, draftMode } from "next/headers"; import { redirect } from "next/navigation"; import { type NextRequest } from "next/server"; -import { GraphQLFetch } from "../graphQLFetch/graphQLFetch"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Scope = Record; @@ -15,56 +13,24 @@ export type SitePreviewData = { }; export type SitePreviewParams = { scope: Scope; + path: string; previewData?: SitePreviewData; }; -function getPreviewScopeSigningKey() { - if (!process.env.SITE_PREVIEW_SECRET && process.env.NODE_ENV === "production") { - throw new Error("SITE_PREVIEW_SECRET environment variable is required in production mode"); - } - return process.env.SITE_PREVIEW_SECRET || "secret"; -} - -export async function sitePreviewRoute(request: NextRequest, graphQLFetch: GraphQLFetch) { - const previewScopeSigningKey = getPreviewScopeSigningKey(); +export async function sitePreviewRoute(request: NextRequest, _graphQLFetch: unknown /* deprecated: remove argument in v8 */) { const params = request.nextUrl.searchParams; - const settingsParam = params.get("settings"); - const scopeParam = params.get("scope"); - if (!settingsParam || !scopeParam) { - throw new Error("Missing settings or scope parameter"); + const jwt = params.get("jwt"); + if (!jwt) { + throw new Error("Missing jwt parameter"); } - const previewData = JSON.parse(settingsParam); - const scope = JSON.parse(scopeParam); - - const { currentUser } = await graphQLFetch<{ currentUser: { permissionsForScope: string[] } }, { scope: Scope }>( - ` - query CurrentUserPermissionsForScope($scope: JSONObject!) { - currentUser { - permissionsForScope(scope: $scope) - } - } - `, - { scope }, - { - headers: { - authorization: request.headers.get("authorization") || "", - }, - }, - ); - if (!currentUser.permissionsForScope.includes("pageTree")) { - return new Response("Preview is not allowed", { - status: 403, - }); - } + const data = await verifySitePreviewJwt(jwt); - const data: SitePreviewParams = { scope, previewData }; - const token = await new SignJWT(data).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode(previewScopeSigningKey)); - cookies().set("__comet_preview", token); + cookies().set("__comet_preview", jwt); draftMode().enable(); - return redirect(params.get("path") || "/"); + return redirect(data.path); } /** @@ -73,16 +39,21 @@ export async function sitePreviewRoute(request: NextRequest, graphQLFetch: Graph * @return If SitePreview is active the current preview settings */ export async function previewParams(options: { skipDraftModeCheck: boolean } = { skipDraftModeCheck: false }): Promise { - const previewScopeSigningKey = getPreviewScopeSigningKey(); - if (!options.skipDraftModeCheck) { if (!draftMode().isEnabled) return null; } const cookie = cookies().get("__comet_preview"); if (cookie) { - const { payload } = await jwtVerify(cookie.value, new TextEncoder().encode(previewScopeSigningKey)); - return payload; + return verifySitePreviewJwt(cookie.value); } return null; } + +export async function verifySitePreviewJwt(jwt: string): Promise { + if (!process.env.SITE_PREVIEW_SECRET) { + throw new Error("SITE_PREVIEW_SECRET environment variable is required."); + } + const data = await jwtVerify(jwt, new TextEncoder().encode(process.env.SITE_PREVIEW_SECRET)); + return data.payload; +} diff --git a/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts b/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts index aa8e268c63..f6447e3739 100644 --- a/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts +++ b/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts @@ -1,50 +1,23 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { Scope, SitePreviewParams } from "../SitePreviewUtils"; +import { verifySitePreviewJwt } from "../SitePreviewUtils"; -type GraphQLClient = { - setHeader(key: string, value: string): unknown; - request(document: string, variables?: Variables): Promise; -}; - -async function legacyPagesRouterSitePreviewApiHandler(req: NextApiRequest, res: NextApiResponse, graphQLClient: GraphQLClient) { +async function legacyPagesRouterSitePreviewApiHandler( + req: NextApiRequest, + res: NextApiResponse, + _graphQLClient: unknown /* deprecated: remove argument in v8 */, +) { const params = req.query; - const settingsParam = params.settings; - const scopeParam = params.scope; - - if (typeof settingsParam !== "string" || typeof scopeParam !== "string") { - throw new Error("Missing settings or scope parameter"); - } - - if (Array.isArray(params.path)) { - res.status(400).json({ error: "Parameter 'path' can't be an array" }); - return; - } - - const previewData = JSON.parse(settingsParam); - const scope = JSON.parse(scopeParam); - - graphQLClient.setHeader("authorization", req.headers?.authorization ?? ""); + const jwt = params.jwt; - const { currentUser } = await graphQLClient.request<{ currentUser: { permissionsForScope: string[] } }, { scope: Scope }>( - ` - query CurrentUserPermissionsForScope($scope: JSONObject!) { - currentUser { - permissionsForScope(scope: $scope) - } - } - `, - { scope }, - ); - if (!currentUser.permissionsForScope.includes("pageTree")) { - res.status(403).json({ error: "Preview is not allowed" }); - return; + if (typeof jwt !== "string") { + throw new Error("Missing jwt parameter"); } - const data: SitePreviewParams = { scope, previewData }; + const data = await verifySitePreviewJwt(jwt); res.setPreviewData(data); - res.redirect(params.path ?? "/"); + res.redirect(data.path); } export { legacyPagesRouterSitePreviewApiHandler }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ecfb2597f..727b885d72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2351,6 +2351,9 @@ importers: hasha: specifier: ^5.2.2 version: 5.2.2 + jose: + specifier: ^5.2.4 + version: 5.6.3 jsonwebtoken: specifier: ^8.5.1 version: 8.5.1