Skip to content

Commit

Permalink
Create Site Preview JWT in the API (#2554)
Browse files Browse the repository at this point in the history
Allows having the Site Preview unauthenticated

## Description

Before: the JWT with the preview scope and setting was generated in the
site. To check if the current user is allowed to preview the given scope
the site had to make a request to the API and therefore needed the
access token of the current user. With this setup the site has to be
behind an authproxy.

Now: the JWT is generated by the API and submitted to the site. This way
the site can be public.

## BREAKING
The API now requires the SITE_PREVIEW_SECRET environment variable. To
make it explicit to add it this env now is also mandatory for local
development.
  • Loading branch information
fraxachun authored Oct 2, 2024
1 parent 64049c1 commit 671e2b2
Show file tree
Hide file tree
Showing 16 changed files with 135 additions and 93 deletions.
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
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!
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!) {
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 <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,
@Args("path") path: string,
@Args("includeInvisible") includeInvisible: boolean,
): 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

0 comments on commit 671e2b2

Please sign in to comment.