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

Create Site Preview JWT in the API #2554

Merged
merged 7 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/cold-guests-train.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change needs a changeset and this change should also be added to the migration guide for projects that are not on v7 yet

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset will be added.

I added another commit to mitigate the effort of handling the breaking change: dea0a1d

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset: 5d8c99a

NEXT_PUBLIC_SITE_LANGUAGES=en,de
NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE=en
NEXT_PUBLIC_SITE_PAGES_DOMAIN=secondary
Expand Down
1 change: 1 addition & 0 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imho this should be a mutation, not a query:

  • it creates something new (although not stored, so it strictly speaking does not mutate a database or something)
  • it should never be cached by anything, that might even result into security issues

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so too

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could call it createSitePreviewJwt or generateSitePreviewJwt

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the request is polled (due to expiration time of jwt), this is not possible for mutations. Furthermore, useMutation does not provide refetch, which is used when changing the preview settings.

Caching however is disabled by fetchPolicy: "network-only".

Changing to a mutation requires more handwork to simulate polling and refetching. Do you still think a mutation is necessary?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caching however is disabled

I was thinking about a future cache solution we might add

You ave good arguments, leave the query!

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!
Expand Down
2 changes: 2 additions & 0 deletions demo/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions demo/api/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) {
secret: envVars.FILE_UPLOADS_DOWNLOAD_SECRET,
},
},
sitePreviewSecret: envVars.SITE_PREVIEW_SECRET,
};
}

Expand Down
5 changes: 5 additions & 0 deletions demo/api/src/config/environment-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
30 changes: 23 additions & 7 deletions packages/admin/cms-admin/src/preview/site/SitePreview.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -98,6 +100,7 @@ function SitePreview({ resolvePath, logo = <CometColor sx={{ fontSize: 32 }} />
const newShowOnlyVisible = !showOnlyVisible;
setShowOnlyVisible(String(newShowOnlyVisible));
setIframePath(sitePath); //reload iframe with new settings
refetch();
};

const siteLink = `${siteConfig.url}${sitePath}`;
Expand All @@ -113,13 +116,26 @@ function SitePreview({ resolvePath, logo = <CometColor sx={{ fontSize: 32 }} />
}
});

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<GQLSitePreviewJwtQuery>(
gql`
query SitePreviewJwt($scope: JSONObject!, $path: String!, $includeInvisible: Boolean!) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One downside to a mutation would be that it can't be queried like this here. But we could create it before even navigating to the site preview.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this cause a location reset when a user stays too long in the site preview?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it does. Do we ignore it? (as this only happens after 24 hours)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's 24 hours, do we even need polling?

Copy link
Contributor Author

@fraxachun fraxachun Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expiry date of the JWT (which is stored in the site as cookie) is validated with every request. If we don't reload the SitePreview every click would result in an error. The problem occurs when a user leaves the tab opened for more than 24 hours and then continues navigation in the preview.

},
);
if (error) throw new Error(error.message);
if (!data) return <div />;

const initialPageUrl = `${siteConfig.sitePreviewApiUrl}?${new URLSearchParams({ jwt: data.sitePreviewJwt }).toString()}`;

return (
<Root>
Expand Down
2 changes: 2 additions & 0 deletions packages/api/cms-api/generate-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -113,6 +114,7 @@ async function generateSchema(): Promise<void> {
GenerateAltTextResolver,
GenerateImageTitleResolver,
FileUploadsResolver,
SitePreviewResolver,
]);

await writeFile("schema.gql", printSchema(schema));
Expand Down
1 change: 1 addition & 0 deletions packages/api/cms-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/api/cms-api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ type Query {
userPermissionsAvailableContentScopes: [JSONObject!]!
fileUploadForTypesGenerationDoNotUse: FileUpload!
azureAiTranslate(input: AzureAiTranslationInput!): String!
sitePreviewJwt(scope: JSONObject!, path: String!, includeInvisible: Boolean!): String!
}

input RedirectScopeInput {
Expand Down
2 changes: 2 additions & 0 deletions packages/api/cms-api/src/page-tree/page-tree.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
18 changes: 17 additions & 1 deletion packages/api/cms-api/src/page-tree/page-tree.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -30,6 +31,7 @@ interface PageTreeModuleOptions {
Documents: Type<DocumentInterface>[];
Scope?: Type<ScopeInterface>;
reservedPaths?: string[];
sitePreviewSecret?: string;
}

@Global()
Expand All @@ -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,
Expand Down Expand Up @@ -90,6 +99,13 @@ export class PageTreeModule {
PageTreeNodeDocumentEntityInfoService,
PageTreeNodeDocumentEntityScopeService,
InternalLinkBlockTransformerService,
{
provide: SITE_PREVIEW_CONFIG,
useValue: {
secret: options.sitePreviewSecret,
},
},
SitePreviewResolver,
],
exports: [
PageTreeService,
Expand Down
36 changes: 36 additions & 0 deletions packages/api/cms-api/src/page-tree/site-preview.resolver.ts
Original file line number Diff line number Diff line change
@@ -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,
johnnyomair marked this conversation as resolved.
Show resolved Hide resolved
@Args("path") path: string,
@Args("includeInvisible") includeInvisible: boolean,
johnnyomair marked this conversation as resolved.
Show resolved Hide resolved
): Promise<string> {
return new SignJWT({
scope,
path,
previewData: {
includeInvisible,
},
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1 day")
.sign(new TextEncoder().encode(this.config.secret));
}
}
65 changes: 18 additions & 47 deletions packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

Expand All @@ -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);
}

/**
Expand All @@ -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<SitePreviewParams | null> {
const previewScopeSigningKey = getPreviewScopeSigningKey();

if (!options.skipDraftModeCheck) {
if (!draftMode().isEnabled) return null;
}

const cookie = cookies().get("__comet_preview");
if (cookie) {
const { payload } = await jwtVerify<SitePreviewParams>(cookie.value, new TextEncoder().encode(previewScopeSigningKey));
return payload;
return verifySitePreviewJwt(cookie.value);
}
return null;
}

export async function verifySitePreviewJwt(jwt: string): Promise<SitePreviewParams> {
if (!process.env.SITE_PREVIEW_SECRET) {
throw new Error("SITE_PREVIEW_SECRET environment variable is required.");
}
const data = await jwtVerify<SitePreviewParams>(jwt, new TextEncoder().encode(process.env.SITE_PREVIEW_SECRET));
return data.payload;
}
Loading
Loading