diff --git a/README.md b/README.md index fd0d6c0..b8233ef 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ create a `route.ts` or `route.js` [route handler](https://nextjs.org/docs/app/ ```ts // app/api/enable-draft/route.ts|js -export { enableDraftHandler as GET } from "@contentful/nextjs-toolkit/app-router" +export { enableDraftHandler as GET } from "@contentful/vercel-nextjs-toolkit/app-router" ``` @@ -68,7 +68,7 @@ If your NextJs project is using [Pages Router](https://nextjs.org/docs/pages), c ```ts // pages/api/enable-draft.ts|js -export { enableDraftHandler as handler } from "@contentful/nextjs-toolkit/pages-router"; +export { enableDraftHandler as default } from "@contentful/vercel-nextjs-toolkit/pages-router"; ``` diff --git a/lib/app-router/handlers/app.ts b/lib/app-router/handlers/app.ts deleted file mode 100644 index 99b25f9..0000000 --- a/lib/app-router/handlers/app.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { draftMode } from 'next/headers'; -import { redirect } from 'next/navigation'; -import { NextRequest } from 'next/server'; - -export async function enableDraftHandler( - request: NextRequest, -): Promise { - const { - origin: base, - path, - host, - bypassToken: bypassTokenFromQuery, - } = parseRequestUrl(request.url); - - // if we're in development, we don't need to check for a bypass token, and we can just enable draft mode - if (process.env.NODE_ENV === 'development') { - draftMode().enable(); - const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); - return redirect(redirectUrl); - } - - let bypassToken: string; - let aud: string; - - if (bypassTokenFromQuery) { - bypassToken = bypassTokenFromQuery; - aud = host; - } else { - // if x-vercel-protection-bypass not provided in query, we defer to parsing the _vercel_jwt cookie - // which bundlees the bypass token value in its payload - let vercelJwt: VercelJwt; - try { - vercelJwt = parseVercelJwtCookie(request); - } catch (e) { - if (!(e instanceof Error)) throw e; - return new Response( - 'Missing or malformed bypass authorization token in _vercel_jwt cookie', - { status: 401 }, - ); - } - bypassToken = vercelJwt.bypass; - aud = vercelJwt.aud; - } - - if (bypassToken !== process.env.VERCEL_AUTOMATION_BYPASS_SECRET) { - return new Response( - 'The bypass token you are authorized with does not match the bypass secret for this deployment. You might need to redeploy or go back and try the link again.', - { status: 403 }, - ); - } - - if (aud !== host) { - return new Response( - `The bypass token you are authorized with is not valid for this host (${host}). You might need to redeploy or go back and try the link again.`, - { status: 403 }, - ); - } - - if (!path) { - return new Response('Missing required value for query parameter `path`', { - status: 400, - }); - } - - draftMode().enable(); - - const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); - redirect(redirectUrl); -} - -interface VercelJwt { - bypass: string; - aud: string; - iat: number; - sub: string; -} - -const parseVercelJwtCookie = (request: NextRequest): VercelJwt => { - const vercelJwtCookie = request.cookies.get('_vercel_jwt'); - if (!vercelJwtCookie) throw new Error('`_vercel_jwt` cookie not set'); - - const base64Payload = vercelJwtCookie.value.split('.')[1]; - if (!vercelJwtCookie) throw new Error('Malformed `_vercel_jwt` cookie value'); - - const base64 = base64Payload.replace('-', '+').replace('_', '/'); - const payload = atob(base64); - const vercelJwt = JSON.parse(payload); - - assertVercelJwt(vercelJwt); - - return vercelJwt; -}; - -function assertVercelJwt(value: object): asserts value is VercelJwt { - const vercelJwt = value as VercelJwt; - if (typeof vercelJwt.bypass !== 'string') - throw new TypeError("'bypass' property in VercelJwt is not a string"); - if (typeof vercelJwt.aud !== 'string') - throw new TypeError("'aud' property in VercelJwt is not a string"); - if (typeof vercelJwt.sub !== 'string') - throw new TypeError("'sub' property in VercelJwt is not a string"); - if (typeof vercelJwt.iat !== 'number') - throw new TypeError("'iat' property in VercelJwt is not a number"); -} - -const parseRequestUrl = ( - requestUrl: string, -): { - origin: string; - host: string; - path: string; - bypassToken: string; -} => { - const { searchParams, origin, host } = new URL(requestUrl); - - const rawPath = searchParams.get('path') || ''; - const bypassToken = searchParams.get('x-vercel-protection-bypass') || ''; - - // to allow query parameters to be passed through to the redirected URL, the original `path` should already be - // URI encoded, and thus must be decoded here - const path = decodeURIComponent(rawPath); - - return { origin, path, host, bypassToken }; -}; - -const buildRedirectUrl = ({ - path, - base, - bypassTokenFromQuery, -}: { - path: string; - base: string; - bypassTokenFromQuery?: string; -}): string => { - const redirectUrl = new URL(path, base); - - // if the bypass token is provided in the query, we assume Vercel has _not_ already set the actual - // token that bypasses authentication. thus we provided it here, on the redirect - if (bypassTokenFromQuery) { - redirectUrl.searchParams.set( - 'x-vercel-protection-bypass', - bypassTokenFromQuery, - ); - redirectUrl.searchParams.set('x-vercel-set-bypass-cookie', 'samesitenone'); - } - - return redirectUrl.toString(); -}; diff --git a/lib/app-router/handlers/app.spec.ts b/lib/app-router/handlers/enable-draft.spec.ts similarity index 98% rename from lib/app-router/handlers/app.spec.ts rename to lib/app-router/handlers/enable-draft.spec.ts index 59a53b0..141bb71 100644 --- a/lib/app-router/handlers/app.spec.ts +++ b/lib/app-router/handlers/enable-draft.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { redirect } from 'next/navigation'; import { NextRequest } from 'next/server'; -import { enableDraftHandler as GET } from './app'; +import { enableDraftHandler as GET } from './enable-draft'; vi.mock('next/navigation', () => { return { diff --git a/lib/app-router/handlers/enable-draft.ts b/lib/app-router/handlers/enable-draft.ts new file mode 100644 index 0000000..4755a15 --- /dev/null +++ b/lib/app-router/handlers/enable-draft.ts @@ -0,0 +1,73 @@ +import { draftMode } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; +import { buildRedirectUrl, parseRequestUrl } from '../../utils/url'; +import { getVercelJwtCookie, parseVercelJwtCookie } from '../../utils/vercelJwt'; +import { type VercelJwt } from '../../types'; + +export async function enableDraftHandler( + request: NextRequest, +): Promise { + const { + origin: base, + path, + host, + bypassToken: bypassTokenFromQuery, + } = parseRequestUrl(request.url); + + // if we're in development, we don't need to check for a bypass token, and we can just enable draft mode + if (process.env.NODE_ENV === 'development') { + draftMode().enable(); + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + return redirect(redirectUrl); + } + + let bypassToken: string; + let aud: string; + + if (bypassTokenFromQuery) { + bypassToken = bypassTokenFromQuery; + aud = host; + } else { + // if x-vercel-protection-bypass not provided in query, we defer to parsing the _vercel_jwt cookie + // which bundlees the bypass token value in its payload + let vercelJwt: VercelJwt + try { + const vercelJwtCookie = getVercelJwtCookie(request) + vercelJwt = parseVercelJwtCookie(vercelJwtCookie); + } catch (e) { + if (!(e instanceof Error)) throw e; + return new Response( + 'Missing or malformed bypass authorization token in _vercel_jwt cookie', + { status: 401 }, + ); + } + bypassToken = vercelJwt.bypass; + aud = vercelJwt.aud; + } + + if (bypassToken !== process.env.VERCEL_AUTOMATION_BYPASS_SECRET) { + return new Response( + 'The bypass token you are authorized with does not match the bypass secret for this deployment. You might need to redeploy or go back and try the link again.', + { status: 403 }, + ); + } + + if (aud !== host) { + return new Response( + `The bypass token you are authorized with is not valid for this host (${host}). You might need to redeploy or go back and try the link again.`, + { status: 403 }, + ); + } + + if (!path) { + return new Response('Missing required value for query parameter `path`', { + status: 400, + }); + } + + draftMode().enable(); + + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + redirect(redirectUrl); +} diff --git a/lib/app-router/handlers/index.ts b/lib/app-router/handlers/index.ts index 665a3d9..bd09407 100644 --- a/lib/app-router/handlers/index.ts +++ b/lib/app-router/handlers/index.ts @@ -1 +1 @@ -export * from './app'; +export * from './enable-draft'; diff --git a/lib/index.ts b/lib/index.ts deleted file mode 100644 index bb5cecb..0000000 --- a/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './app-router'; diff --git a/lib/pages-router/handlers/enable-draft.spec.ts b/lib/pages-router/handlers/enable-draft.spec.ts new file mode 100644 index 0000000..bf63424 --- /dev/null +++ b/lib/pages-router/handlers/enable-draft.spec.ts @@ -0,0 +1,133 @@ +import { MockInstance, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableDraftHandler as handler } from './enable-draft'; +import { makeNextApiRequest } from '../../../test/helpers'; + +vi.mock('next/navigation', () => { + return { + redirect: vi.fn(), + }; +}); + +const makeNextApiResponse = (): NextApiResponse => (nextApiResponseMock as NextApiResponse) + +const nextApiResponseMock: Partial = { + status(_code: number) { return this as NextApiResponse }, + send(_bodyString: string) { }, + redirect(_statusCode: number | string, _url?: string) { return this as NextApiResponse }, + setDraftMode() { return this as NextApiResponse }, +} + +interface ApiResponseSpy { + status: MockInstance + send: MockInstance + redirect: MockInstance + setDraftMode: MockInstance +} + + +describe('handler', () => { + const bypassToken = 'kByQez2ke5Jl4ulCY6kxQrpFMp1UIohs'; + const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`; + const response = makeNextApiResponse() + + // based on a real vercel token + const vercelJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJieXBhc3MiOiJrQnlRZXoya2U1Smw0dWxDWTZreFFycEZNcDFVSW9ocyIsImF1ZCI6InZlcmNlbC1hcHAtcm91dGVyLWludGVncmF0aW9ucy1sbDl1eHdiNGYudmVyY2VsLmFwcCIsImlhdCI6MTcxMzgwOTE2Nywic3ViIjoicHJvdGVjdGlvbi1ieXBhc3MtYXV0b21hdGlvbiJ9.ktyaHnYQXj-3dDnEn0ZVYkwpnQt1gc2sZ6qrgg3GIOs'; + let request: NextApiRequest = makeNextApiRequest(url); + let apiResponseSpy: ApiResponseSpy + request.cookies['_vercel_jwt'] = vercelJwt; + + beforeEach(() => { + apiResponseSpy = { + redirect: vi.spyOn(nextApiResponseMock, 'redirect'), + status: vi.spyOn(nextApiResponseMock, 'status'), + send: vi.spyOn(nextApiResponseMock, 'send'), + setDraftMode: vi.spyOn(nextApiResponseMock, 'setDraftMode') + } + vi.stubEnv('VERCEL_AUTOMATION_BYPASS_SECRET', bypassToken); + }); + + it('redirects safely to the provided path, without passing through the token and bypass cookie query params', async () => { + const result = await handler(request, response); + expect(result).to.be.undefined; + expect(apiResponseSpy.setDraftMode).toHaveBeenCalledWith({ enable: true }); + expect(apiResponseSpy.redirect).toHaveBeenCalledWith( + 'https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/blogs/my-cat', + ); + }); + + describe('when the path is missing', () => { + beforeEach(() => { + const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft`; + request = makeNextApiRequest(url); + request.cookies['_vercel_jwt'] = vercelJwt; + }); + + it('returns a response with status 400', async () => { + await handler(request, response); + expect(apiResponseSpy.status).toHaveBeenCalledWith(400); + expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String)); + }); + }); + + describe('when aud in token mismatches domain of request', () => { + beforeEach(() => { + const url = `https://vercel-app-router-integrations-foobar.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`; + request = makeNextApiRequest(url); + request.cookies['_vercel_jwt'] = vercelJwt; + }); + + it('returns a response with status 403', async () => { + await handler(request, response); + expect(apiResponseSpy.status).toHaveBeenCalledWith(403); + expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String)); + }); + }); + + describe('when the _vercel_jwt cookie is missing', () => { + beforeEach(() => { + const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`; + request = makeNextApiRequest(url); + }); + + it('returns a response with status 401', async () => { + await handler(request, response); + expect(apiResponseSpy.status).toHaveBeenCalledWith(401); + expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String)); + }); + + describe('when a x-vercel-protection-bypass token is provided as a query param', () => { + beforeEach(() => { + const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat&x-vercel-protection-bypass=${bypassToken}`; + request = makeNextApiRequest(url); + }); + + it('redirects safely to the provided path AND passes through the token and bypass cookie query params', async () => { + const result = await handler(request, response); + expect(result).to.be.undefined; + expect(apiResponseSpy.setDraftMode).toHaveBeenCalledWith({ enable: true }); + expect(apiResponseSpy.redirect).toHaveBeenCalledWith( + `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/blogs/my-cat?x-vercel-protection-bypass=${bypassToken}&x-vercel-set-bypass-cookie=samesitenone`, + ); + }); + }); + }); + + describe('when the bypass token is wrong', () => { + beforeEach(() => { + const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`; + const tokenWithBadBypass = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJieXBhc3MiOiJiYWQtYnlwYXNzLXRva2VuIiwiYXVkIjoidmVyY2VsLWFwcC1yb3V0ZXItaW50ZWdyYXRpb25zLWxsOXV4d2I0Zi52ZXJjZWwuYXBwIiwiaWF0IjoxNzEzODA5MTY3LCJzdWIiOiJwcm90ZWN0aW9uLWJ5cGFzcy1hdXRvbWF0aW9uIn0=.ktyaHnYQXj-3dDnEn0ZVYkwpnQt1gc2sZ6qrgg3GIOs'; + request = makeNextApiRequest(url); + request.cookies['_vercel_jwt'] = tokenWithBadBypass; + }); + + it('returns a response with status 403', async () => { + await handler(request, response); + expect(apiResponseSpy.status).toHaveBeenCalledWith(403); + expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String)); + }); + }); +}); + diff --git a/lib/pages-router/handlers/enable-draft.ts b/lib/pages-router/handlers/enable-draft.ts new file mode 100644 index 0000000..6edbdb3 --- /dev/null +++ b/lib/pages-router/handlers/enable-draft.ts @@ -0,0 +1,76 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'; +import { buildRedirectUrl, parseNextApiRequest } from '../../utils/url'; +import { parseVercelJwtCookie} from '../../utils/vercelJwt'; +import { type VercelJwt } from '../../types'; + +export const enableDraftHandler: NextApiHandler = async ( + request: NextApiRequest, + response: NextApiResponse +): Promise => { + const { + origin: base, + path, + host, + bypassToken: bypassTokenFromQuery, + } = parseNextApiRequest(request); + + // if we're in development, we don't need to check for a bypass token, and we can just enable draft mode + if (process.env.NODE_ENV === 'development') { + response.setDraftMode({ enable: true }) + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + response.redirect(redirectUrl) + return + } + + let bypassToken: string; + let aud: string; + + if (bypassTokenFromQuery) { + bypassToken = bypassTokenFromQuery; + aud = host; + } else { + // if x-vercel-protection-bypass not provided in query, we defer to parsing the _vercel_jwt cookie + // which bundlees the bypass token value in its payload + let vercelJwt: VercelJwt; + try { + const vercelJwtCookie = request.cookies['_vercel_jwt'] + if (!vercelJwtCookie) throw new Error('`_vercel_jwt` cookie not set'); + vercelJwt = parseVercelJwtCookie(vercelJwtCookie); + } catch (e) { + if (!(e instanceof Error)) throw e; + response.status(401).send( + 'Missing or malformed bypass authorization token in _vercel_jwt cookie' + ) + return + } + bypassToken = vercelJwt.bypass; + aud = vercelJwt.aud; + } + + if (bypassToken !== process.env.VERCEL_AUTOMATION_BYPASS_SECRET) { + response.status(403).send( + 'The bypass token you are authorized with does not match the bypass secret for this deployment. You might need to redeploy or go back and try the link again.' + ) + return + } + + if (aud !== host) { + response.status(403).send( + `The bypass token you are authorized with is not valid for this host (${host}). You might need to redeploy or go back and try the link again.`, + ) + return + } + + if (!path) { + response.status(400).send( + 'Missing required value for query parameter `path`' + ) + return + } + + response.setDraftMode({ enable: true }) + + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + response.redirect(redirectUrl) + return +} diff --git a/lib/pages-router/handlers/index.ts b/lib/pages-router/handlers/index.ts new file mode 100644 index 0000000..bd09407 --- /dev/null +++ b/lib/pages-router/handlers/index.ts @@ -0,0 +1 @@ +export * from './enable-draft'; diff --git a/lib/pages-router/index.ts b/lib/pages-router/index.ts new file mode 100644 index 0000000..80619c2 --- /dev/null +++ b/lib/pages-router/index.ts @@ -0,0 +1,2 @@ +export * from './handlers'; + diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..6649473 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,6 @@ +export interface VercelJwt { + bypass: string; + aud: string; + iat: number; + sub: string; +} diff --git a/lib/utils/url.spec.ts b/lib/utils/url.spec.ts new file mode 100644 index 0000000..60b2048 --- /dev/null +++ b/lib/utils/url.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { buildRedirectUrl, parseNextApiRequest, parseRequestUrl } from './url'; +import { makeNextApiRequest } from '../../test/helpers'; + +const requestUrl = `https://my.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat&x-vercel-protection-bypass=foo`; + +describe('parseNextApiRequest', () => { + const request = makeNextApiRequest(requestUrl); + + it('returns correct parsed values', () => { + const result = parseNextApiRequest(request) + expect(result).toHaveProperty('origin', 'https://my.vercel.app') + expect(result).toHaveProperty('host', 'my.vercel.app') + expect(result).toHaveProperty('bypassToken', 'foo') + expect(result).toHaveProperty('path', '/blogs/my-cat') + }) +}) + +describe('parseRequestUrl', () => { + it('returns correct parsed values', () => { + const result = parseRequestUrl(requestUrl) + expect(result).toHaveProperty('origin', 'https://my.vercel.app') + expect(result).toHaveProperty('host', 'my.vercel.app') + expect(result).toHaveProperty('bypassToken', 'foo') + expect(result).toHaveProperty('path', '/blogs/my-cat') + }) +}) + +describe('buildRedirectUrl', () => { + const path = '/blogs/my-cat' + const base = 'https://my.vercel.app' + const bypassTokenFromQuery = 'bypass-token-from-query' + + it('returns correct redirect URL', () => { + const result = buildRedirectUrl({path, base, bypassTokenFromQuery}) + expect(result).toEqual('https://my.vercel.app/blogs/my-cat?x-vercel-protection-bypass=bypass-token-from-query&x-vercel-set-bypass-cookie=samesitenone') + }) +}) diff --git a/lib/utils/url.ts b/lib/utils/url.ts new file mode 100644 index 0000000..8b0171a --- /dev/null +++ b/lib/utils/url.ts @@ -0,0 +1,62 @@ +import { NextApiRequest } from "next"; + +interface ParsedRequestUrl { + origin: string; + host: string; + path: string; + bypassToken: string; +} + +export const parseNextApiRequest = ( + request: NextApiRequest +): ParsedRequestUrl => { + const hostHeader = request.headers.host + if (!hostHeader) throw new Error('missing `host` header from request') + + const protocol = request.headers['x-forwarded-proto'] || 'https' + const requestUrl = request.url && new URL(request.url, `${protocol}://${hostHeader}`).toString() + + const { origin, path, host, bypassToken } = parseRequestUrl(requestUrl) + return { origin, path, host, bypassToken }; +} + +export const parseRequestUrl = ( + requestUrl: string | undefined, +): ParsedRequestUrl => { + if (!requestUrl) throw new Error('missing `url` value in request') + const { searchParams, origin, host } = new URL(requestUrl); + + const rawPath = searchParams.get('path') || ''; + const bypassToken = searchParams.get('x-vercel-protection-bypass') || ''; + + // to allow query parameters to be passed through to the redirected URL, the original `path` should already be + // URI encoded, and thus must be decoded here + const path = decodeURIComponent(rawPath); + + return { origin, path, host, bypassToken }; +}; + +export const buildRedirectUrl = ({ + path, + base, + bypassTokenFromQuery, +}: { + path: string; + base: string; + bypassTokenFromQuery?: string; +}): string => { + const redirectUrl = new URL(path, base); + + // if the bypass token is provided in the query, we assume Vercel has _not_ already set the actual + // token that bypasses authentication. thus we provided it here, on the redirect + if (bypassTokenFromQuery) { + redirectUrl.searchParams.set( + 'x-vercel-protection-bypass', + bypassTokenFromQuery, + ); + redirectUrl.searchParams.set('x-vercel-set-bypass-cookie', 'samesitenone'); + } + + return redirectUrl.toString(); +}; + diff --git a/lib/utils/vercelJwt.spec.ts b/lib/utils/vercelJwt.spec.ts new file mode 100644 index 0000000..e7bc43d --- /dev/null +++ b/lib/utils/vercelJwt.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { getVercelJwtCookie, parseVercelJwtCookie } from './vercelJwt'; +import { NextRequest } from 'next/server'; + +describe('getVercelJwtCookie', () => { + const url = 'http://example.com' + const request = new NextRequest(url); + request.cookies.set('_vercel_jwt', 'vercel-jwt'); + + it('returns the _vercel_jwt cookie', () => { + const result = getVercelJwtCookie(request) + expect(result).toEqual('vercel-jwt') + }) +}) + +describe('parseVercelJwtCookie', () => { + const vercelJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJieXBhc3MiOiJrQnlRZXoya2U1Smw0dWxDWTZreFFycEZNcDFVSW9ocyIsImF1ZCI6InZlcmNlbC1hcHAtcm91dGVyLWludGVncmF0aW9ucy1sbDl1eHdiNGYudmVyY2VsLmFwcCIsImlhdCI6MTcxMzgwOTE2Nywic3ViIjoicHJvdGVjdGlvbi1ieXBhc3MtYXV0b21hdGlvbiJ9.ktyaHnYQXj-3dDnEn0ZVYkwpnQt1gc2sZ6qrgg3GIOs'; + + it('returns the _vercel_jwt cookie', () => { + const result = parseVercelJwtCookie(vercelJwt) + expect(result).toHaveProperty('aud', 'vercel-app-router-integrations-ll9uxwb4f.vercel.app') + expect(result).toHaveProperty('bypass', 'kByQez2ke5Jl4ulCY6kxQrpFMp1UIohs') + expect(result).toHaveProperty('iat', 1713809167) + expect(result).toHaveProperty('sub', 'protection-bypass-automation') + }) +}) diff --git a/lib/utils/vercelJwt.ts b/lib/utils/vercelJwt.ts new file mode 100644 index 0000000..44a34ec --- /dev/null +++ b/lib/utils/vercelJwt.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; +import { type VercelJwt } from "../types"; + +export const getVercelJwtCookie = (request: NextRequest): string => { + const vercelJwtCookie = request.cookies.get('_vercel_jwt'); + if (!vercelJwtCookie) throw new Error('`_vercel_jwt` cookie not set'); + return vercelJwtCookie.value; +} + +export const parseVercelJwtCookie = (vercelJwtCookie: string): VercelJwt => { + const base64Payload = vercelJwtCookie.split('.')[1]; + if (!base64Payload) throw new Error('Malformed `_vercel_jwt` cookie value'); + + const base64 = base64Payload.replace('-', '+').replace('_', '/'); + const payload = atob(base64); + const vercelJwt = JSON.parse(payload); + + assertVercelJwt(vercelJwt); + + return vercelJwt; +}; + +function assertVercelJwt(value: object): asserts value is VercelJwt { + const vercelJwt = value as VercelJwt; + if (typeof vercelJwt.bypass !== 'string') + throw new TypeError("'bypass' property in VercelJwt is not a string"); + if (typeof vercelJwt.aud !== 'string') + throw new TypeError("'aud' property in VercelJwt is not a string"); + if (typeof vercelJwt.sub !== 'string') + throw new TypeError("'sub' property in VercelJwt is not a string"); + if (typeof vercelJwt.iat !== 'number') + throw new TypeError("'iat' property in VercelJwt is not a number"); +} diff --git a/package.json b/package.json index 9971f3e..629d058 100644 --- a/package.json +++ b/package.json @@ -9,20 +9,17 @@ "module": "./dist/index.js", "typings": "./dist/index.d.ts", "type": "module", - "files": [ - "dist", - "app-router" - ], + "files": [ "dist" ], "exports": { - ".": { - "import": "./dist/index.js", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" - }, "./app-router": { - "import": "./dist/app-router/handlers/app.js", - "require": "./dist/app-router/handlers/app.js", - "types": "./dist/app-router/handlers/app.d.ts" + "import": "./dist/app-router.js", + "require": "./dist/app-router.cjs", + "types": "./dist/app-router/index.d.ts" + }, + "./pages-router": { + "import": "./dist/pages-router.js", + "require": "./dist/pages-router.cjs", + "types": "./dist/pages-router/index.d.ts" } }, "repository": { @@ -34,7 +31,7 @@ }, "scripts": { "prepack": "npm run build", - "build": "tsc --p ./tsconfig-build.json && vite build", + "build": "tsc && vite build", "test": "vitest" }, "homepage": "https://github.com/contentful/vercel-nextjs-toolkit#readme", diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..1568966 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,17 @@ +import { NextApiRequest } from "next" + +export const makeNextApiRequest = (url: string): NextApiRequest => { + // need to recreate a realistic NextApiRequest which includes the values + // we'll use when parsing the URL in production code + const { protocol, host } = new URL(url) + const path = '/' + url.split('/').slice(3).join('/') + const request = { + url: path, + cookies: {}, + headers: { + host, + 'x-forwarded-proto': protocol.slice(0, -1) + } + } as unknown as NextApiRequest + return request +} diff --git a/tsconfig-build.json b/tsconfig-build.json deleted file mode 100644 index e0a84f7..0000000 --- a/tsconfig-build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["lib"] -} diff --git a/tsconfig.json b/tsconfig.json index efa4736..2be5093 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "lib"], + "include": ["lib", "test"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 5eff1cd..a19d51c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,18 +6,18 @@ import pkg from './package.json'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - dts({ - include: ['lib'], - }), - ], + plugins: [dts({ + exclude: ["lib/**/*.spec.ts", "test"], + })], build: { ssr: true, minify: false, lib: { - entry: resolve(__dirname, 'lib/index.ts'), - formats: ['es'], - fileName: () => 'index.js', + entry: { + "app-router": resolve(__dirname, 'lib/app-router/index.ts'), + "pages-router": resolve(__dirname, 'lib/pages-router/index.ts'), + }, + formats: ['es', 'cjs'] }, rollupOptions: { external: [...Object.keys(pkg.peerDependencies)],