diff --git a/.changeset/nasty-apricots-wash.md b/.changeset/nasty-apricots-wash.md new file mode 100644 index 000000000..fc9c8b3b5 --- /dev/null +++ b/.changeset/nasty-apricots-wash.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-contentkit': minor +'gitbook': minor +--- + +Send redirectOnError param to getPublishedContent when token is pulled from cookie diff --git a/bun.lockb b/bun.lockb index 55de3ded1..1b543f8ea 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/gitbook/e2e/pages.spec.ts b/packages/gitbook/e2e/pages.spec.ts index 7d545604d..85609eeaf 100644 --- a/packages/gitbook/e2e/pages.spec.ts +++ b/packages/gitbook/e2e/pages.spec.ts @@ -6,20 +6,25 @@ import { CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationLocale, + CustomizationSidebarBackgroundStyle, + CustomizationSidebarListStyle, CustomizationThemeMode, SiteCustomizationSettings, } from '@gitbook/api'; -import { test, expect, Page } from '@playwright/test'; +import { test, expect, Page, BrowserContext } from '@playwright/test'; import deepMerge from 'deepmerge'; import jwt from 'jsonwebtoken'; import rison from 'rison'; import { DeepPartial } from 'ts-essentials'; +import { getVisitorAuthCookieName, getVisitorAuthCookieValue } from '@/lib/visitor-auth'; + import { getContentTestURL } from '../tests/utils'; interface Test { name: string; url: string; // URL to visit for testing + cookies?: Parameters[0]; run?: (page: Page) => Promise; // The test to run fullPage?: boolean; // Whether the test should be fullscreened during testing screenshot?: false; // Should a screenshot be stored @@ -867,6 +872,60 @@ const testCases: TestsCase[] = [ }, ], }, + { + name: 'Visitor Auth - Site (redirects to fallback/auth URL)', + baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/`, + tests: [ + { + name: 'Redirect to fallback on invalid token pulled from cookie', + url: '', + screenshot: false, + cookies: (() => { + const basePath = '/va-site-redirects-fallback/'; + const invalidToken = jwt.sign( + { + name: 'gitbook-open-tests', + }, + 'invalidKey', + { + expiresIn: '24h', + }, + ); + return [ + { + name: getVisitorAuthCookieName(basePath), + value: getVisitorAuthCookieValue(basePath, invalidToken), + httpOnly: true, + }, + ]; + })(), + run: async (page) => { + await expect(page).toHaveURL(/https:\/\/www.google.com/); + }, + }, + { + name: 'Show error message when invalid token is passed to url', + screenshot: false, + url: (() => { + const token = jwt.sign( + { + name: 'gitbook-open-tests', + }, + 'invalidKey', + { + expiresIn: '24h', + }, + ); + return `?jwt_token=${token}`; + })(), + run: async (page) => { + await expect(page.locator('pre')).toContainText( + 'Error while validating the JWT token. Reason: The token signature is invalid.', + ); + }, + }, + ], + }, { name: 'Languages', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/', @@ -1112,9 +1171,18 @@ for (const testCase of testCases) { test.describe(testCase.name, () => { for (const testEntry of testCase.tests) { const testFn = testEntry.only ? test.only : test; - testFn(testEntry.name, async ({ page, baseURL }) => { + testFn(testEntry.name, async ({ page, baseURL, context }) => { const contentUrl = new URL(testEntry.url, testCase.baseUrl); const url = getContentTestURL(contentUrl.toString(), baseURL); + if (testEntry.cookies) { + await context.addCookies( + testEntry.cookies.map((cookie) => ({ + ...cookie, + domain: new URL(url).host, + path: '/', + })), + ); + } await page.goto(url); if (testEntry.run) { await testEntry.run(page); @@ -1152,6 +1220,10 @@ function getCustomizationURL(partial: DeepPartial): s font: CustomizationFont.Inter, background: CustomizationBackground.Plain, icons: CustomizationIconsStyle.Regular, + sidebar: { + background: CustomizationSidebarBackgroundStyle.Default, + list: CustomizationSidebarListStyle.Default, + }, }, internationalization: { locale: CustomizationLocale.En, diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index f359a16ae..e508d3287 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -16,7 +16,7 @@ "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static" }, "dependencies": { - "@gitbook/api": "^0.81.0", + "@gitbook/api": "^0.83.0", "@gitbook/cache-do": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", "@gitbook/icons": "workspace:*", diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index b96f4dbca..e9f30bcc6 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -214,6 +214,7 @@ export const getPublishedContentByUrl = cache({ get: async ( url: string, visitorAuthToken: string | undefined, + redirectOnError: boolean | undefined, options: CacheFunctionOptions, ) => { try { @@ -221,6 +222,7 @@ export const getPublishedContentByUrl = cache({ { url, visitorAuthToken, + redirectOnError, }, { signal: options.signal, diff --git a/packages/gitbook/src/lib/visitor-auth.ts b/packages/gitbook/src/lib/visitor-auth.ts index ef52ade8e..6a1023687 100644 --- a/packages/gitbook/src/lib/visitor-auth.ts +++ b/packages/gitbook/src/lib/visitor-auth.ts @@ -12,6 +12,16 @@ export type VisitorAuthCookieValue = { token: string; }; +export function isVisitorAuthTokenFromCookies( + visitorAuthToken: NonNullable>, +) { + return ( + typeof visitorAuthToken !== 'string' && + 'basePath' in visitorAuthToken && + 'token' in visitorAuthToken + ); +} + /** * Get the visitor authentication token for the request. This token can either be in the * query parameters or stored as a cookie. diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 2dfa807f2..3a15a3789 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -27,6 +27,7 @@ import { getVisitorAuthCookieName, getVisitorAuthCookieValue, getVisitorAuthToken, + isVisitorAuthTokenFromCookies, normalizeVisitorAuthURL, } from '@/lib/visitor-auth'; @@ -685,14 +686,21 @@ async function lookupSiteByAPI( `lookup content for url "${url.toString()}", with ${lookup.urls.length} alternatives`, ); + // When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow + // redirecting when the token is invalid as we could be dealing with stale token stored in the cookie. + // For example when the VA backend signature has changed but the token stored in the cookie is not yet expired. + const redirectOnError = + typeof visitorAuthToken !== 'undefined' && isVisitorAuthTokenFromCookies(visitorAuthToken); + const result = await race(lookup.urls, async (alternative, { signal }) => { const data = await getPublishedContentByUrl( alternative.url, typeof visitorAuthToken === 'undefined' ? undefined - : typeof visitorAuthToken === 'string' - ? visitorAuthToken - : visitorAuthToken.token, + : isVisitorAuthTokenFromCookies(visitorAuthToken) + ? visitorAuthToken.token + : visitorAuthToken, + typeof visitorAuthToken === 'undefined' ? undefined : redirectOnError, { signal, }, diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index 82b92e432..c8f9b8c10 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "classnames": "^2.5.1", - "@gitbook/api": "^0.77.0", + "@gitbook/api": "^0.83.0", "assert-never": "^1.2.1" }, "peerDependencies": {