Skip to content

Commit

Permalink
add extraScriptProps to ApolloNextAppProvider for "nonce" (#160)
Browse files Browse the repository at this point in the history
to support for passing a nonce to the rehydration script

---------

Co-authored-by: Lenz Weber-Tronic <[email protected]>
  • Loading branch information
josh-feldman and phryneas authored Jan 5, 2024
1 parent bb6bee5 commit e466b37
Show file tree
Hide file tree
Showing 13 changed files with 318 additions and 66 deletions.
1 change: 1 addition & 0 deletions integration-test/.env.local
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SECRET={"alg":"A256CBC","ext":true,"k":"yZrgd8urZnVpOSjC22EUNIDJZbmvEL4xApt_eraMsaU","key_ops":["encrypt","decrypt"],"kty":"oct"}
3 changes: 2 additions & 1 deletion integration-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
"@types/react-dom": "18.2.6",
"graphql": "^16.7.1",
"graphql-tag": "^2.12.6",
"next": "^14.0.0",
"next": "^14.0.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"ssr-only-secrets": "^0.0.5",
"typescript": "5.1.3"
},
"devDependencies": {
Expand Down
17 changes: 14 additions & 3 deletions integration-test/src/app/cc/ApolloWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,26 @@ import { SchemaLink } from "@apollo/client/link/schema";
import { loadErrorMessages, loadDevMessages } from "@apollo/client/dev";
import { setVerbosity } from "ts-invariant";
import { delayLink } from "@/shared/delayLink";
import { schema } from "../graphql/route";
import { schema } from "../graphql/schema";

import { useSSROnlySecret } from "ssr-only-secrets";

setVerbosity("debug");
loadDevMessages();
loadErrorMessages();

export function ApolloWrapper({ children }: React.PropsWithChildren<{}>) {
export function ApolloWrapper({
children,
nonce,
}: React.PropsWithChildren<{ nonce?: string }>) {
const actualNonce = useSSROnlySecret(nonce, "SECRET");
return (
<ApolloNextAppProvider makeClient={makeClient}>
<ApolloNextAppProvider
makeClient={makeClient}
extraScriptProps={{
nonce: actualNonce,
}}
>
{children}
</ApolloNextAppProvider>
);
Expand Down
27 changes: 27 additions & 0 deletions integration-test/src/app/cc/dynamic/dynamic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,31 @@ test.describe("CC dynamic", () => {
await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
});
});
test.describe("useSuspenseQuery with a nonce", () => {
test("invalid: logs an error", async ({ page, blockRequest }) => {
await page.goto(
"http://localhost:3000/cc/dynamic/useSuspenseQuery?nonce=invalid",
{
waitUntil: "commit",
}
);

const messagePromise = page.waitForEvent("console");
const message = await messagePromise;
expect(message.text()).toMatch(
/^Refused to execute inline script because it violates the following Content Security Policy/
);
});
test("valid: does not log an error", async ({ page, blockRequest }) => {
await page.goto(
"http://localhost:3000/cc/dynamic/useSuspenseQuery?nonce=8IBTHwOdqNKAWeKl7plt8g==",
{
waitUntil: "commit",
}
);

const messagePromise = page.waitForEvent("console");
expect(messagePromise).rejects.toThrow(/waiting for event \"console\"/);
});
});
});
13 changes: 11 additions & 2 deletions integration-test/src/app/cc/dynamic/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { headers } from "next/headers";
import { ApolloWrapper } from "../ApolloWrapper";
import { cloakSSROnlySecret } from "ssr-only-secrets";

export default function Layout({ children }: React.PropsWithChildren) {
export default async function Layout({ children }: React.PropsWithChildren) {
// force this into definitely rendering on the server, and dynamically
console.log(headers().toString().substring(0, 0));
return children;
const nonce = headers().get("x-nonce-param") ?? undefined;
return (
<ApolloWrapper
nonce={nonce ? await cloakSSROnlySecret(nonce, "SECRET") : undefined}
>
{children}
</ApolloWrapper>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApolloWrapper } from "./ApolloWrapper";
import { ApolloWrapper } from "../ApolloWrapper";

export default async function Layout({
children,
Expand Down
49 changes: 1 addition & 48 deletions integration-test/src/app/graphql/route.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,6 @@
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { ApolloServer } from "@apollo/server";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { gql } from "graphql-tag";

const typeDefs = gql`
type Product {
id: String!
title: String!
}
type Query {
products: [Product!]!
}
`;

const resolvers = {
Query: {
products: async () => [
{
id: "product:5",
title: "Soft Warm Apollo Beanie",
},
{
id: "product:2",
title: "Stainless Steel Water Bottle",
},
{
id: "product:3",
title: "Athletic Baseball Cap",
},
{
id: "product:4",
title: "Baby Onesies",
},
{
id: "product:1",
title: "The Apollo T-Shirt",
},
{
id: "product:6",
title: "The Apollo Socks",
},
],
},
};

export const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
import { schema } from "./schema";

const server = new ApolloServer({
schema,
Expand Down
48 changes: 48 additions & 0 deletions integration-test/src/app/graphql/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { makeExecutableSchema } from "@graphql-tools/schema";
import gql from "graphql-tag";

const typeDefs = gql`
type Product {
id: String!
title: String!
}
type Query {
products: [Product!]!
}
`;

const resolvers = {
Query: {
products: async () => [
{
id: "product:5",
title: "Soft Warm Apollo Beanie",
},
{
id: "product:2",
title: "Stainless Steel Water Bottle",
},
{
id: "product:3",
title: "Athletic Baseball Cap",
},
{
id: "product:4",
title: "Baby Onesies",
},
{
id: "product:1",
title: "The Apollo T-Shirt",
},
{
id: "product:6",
title: "The Apollo Socks",
},
],
},
};

export const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
2 changes: 1 addition & 1 deletion integration-test/src/app/rsc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { setVerbosity } from "ts-invariant";
import { delayLink } from "@/shared/delayLink";
import { SchemaLink } from "@apollo/client/link/schema";

import { schema } from "../graphql/route";
import { schema } from "../graphql/schema";

setVerbosity("debug");
loadDevMessages();
Expand Down
33 changes: 33 additions & 0 deletions integration-test/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
const nonce = request.nextUrl.searchParams.get("nonce");
if (nonce) {
// we set a fixed nonce here so we can test correct and incorrect nonce values
const validNonce = "8IBTHwOdqNKAWeKl7plt8g==";
// 'unsafe-eval' for `react-refresh`
const contentSecurityPolicyHeaderValue = `default-src 'self'; script-src 'nonce-${validNonce}' 'strict-dynamic' 'unsafe-eval';`;

const requestHeaders = new Headers(request.headers);
// valid nonce for next
requestHeaders.set("x-nonce", validNonce);
// potentially invalid nonce for `ApolloNextAppProvider`
requestHeaders.set("x-nonce-param", nonce);
requestHeaders.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue
);

const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue
);

return response;
}
}
18 changes: 13 additions & 5 deletions package/src/ssr/ApolloNextAppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
ApolloClient,
ApolloProvider as _ApolloProvider,
} from "@apollo/client";
import { RehydrationContextProvider } from "./RehydrationContext";
import {
HydrationContextOptions,
RehydrationContextProvider,
} from "./RehydrationContext";

export const ApolloClientSingleton = Symbol.for("ApolloClientSingleton");

Expand All @@ -16,9 +19,12 @@ declare global {
export const ApolloNextAppProvider = ({
makeClient,
children,
}: React.PropsWithChildren<{
makeClient: () => ApolloClient<any>;
}>) => {
...hydrationContextOptions
}: React.PropsWithChildren<
{
makeClient: () => ApolloClient<any>;
} & HydrationContextOptions
>) => {
const clientRef = React.useRef<ApolloClient<any>>();

if (typeof window !== "undefined") {
Expand All @@ -31,7 +37,9 @@ export const ApolloNextAppProvider = ({

return (
<_ApolloProvider client={clientRef.current}>
<RehydrationContextProvider>{children}</RehydrationContextProvider>
<RehydrationContextProvider {...hydrationContextOptions}>
{children}
</RehydrationContextProvider>
</_ApolloProvider>
);
};
29 changes: 26 additions & 3 deletions package/src/ssr/RehydrationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,34 @@ const ApolloRehydrationContext = React.createContext<
RehydrationContextValue | undefined
>(undefined);

export interface HydrationContextOptions {
extraScriptProps?: ScriptProps;
}

type SerializableProps<T> = Pick<
T,
{
[K in keyof T]: T[K] extends string | number | boolean | undefined | null
? K
: never;
}[keyof T]
>;

type ScriptProps = SerializableProps<
React.ScriptHTMLAttributes<HTMLScriptElement>
>;

export const RehydrationContextProvider = ({
children,
}: React.PropsWithChildren) => {
extraScriptProps,
}: React.PropsWithChildren<HydrationContextOptions>) => {
const client = useApolloClient();
const rehydrationContext = React.useRef<RehydrationContextValue>();
if (typeof window == "undefined") {
if (!rehydrationContext.current) {
rehydrationContext.current = buildApolloRehydrationContext();
rehydrationContext.current = buildApolloRehydrationContext({
extraScriptProps,
});
}
if (client instanceof NextSSRApolloClient) {
client.setRehydrationContext(rehydrationContext.current);
Expand Down Expand Up @@ -62,7 +82,9 @@ export function useRehydrationContext(): RehydrationContextValue | undefined {
return rehydrationContext;
}

function buildApolloRehydrationContext(): RehydrationContextValue {
function buildApolloRehydrationContext({
extraScriptProps,
}: HydrationContextOptions): RehydrationContextValue {
const rehydrationContext: RehydrationContextValue = {
currentlyInjected: false,
transportValueData: {},
Expand Down Expand Up @@ -109,6 +131,7 @@ function buildApolloRehydrationContext(): RehydrationContextValue {
rehydrationContext.incomingBackgroundQueries = [];
return (
<script
{...extraScriptProps}
dangerouslySetInnerHTML={{
__html,
}}
Expand Down
Loading

0 comments on commit e466b37

Please sign in to comment.