diff --git a/.changeset/scroll-container-overflow-state.md b/.changeset/scroll-container-overflow-state.md new file mode 100644 index 000000000..eda768fcd --- /dev/null +++ b/.changeset/scroll-container-overflow-state.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results). diff --git a/.changeset/webframe-page-context.md b/.changeset/webframe-page-context.md new file mode 100644 index 000000000..041df33bc --- /dev/null +++ b/.changeset/webframe-page-context.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-contentkit": patch +"gitbook": patch +--- + +Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims. diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index f8b7181a5..ae38528b4 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -53,6 +53,7 @@ runs: GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL + GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST @@ -68,6 +69,8 @@ runs: GITBOOK_RUNTIME: cloudflare GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }} GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }} + # Enable the sites OAuth consent screen everywhere but production, matching the OAuth server default. + GITBOOK_SITE_OAUTH_CONSENT_ENABLED: ${{ inputs.environment != 'production' && 'true' || 'false' }} shell: bash - name: Upload the DO worker diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index b796c5a59..d6171290a 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -55,6 +55,7 @@ runs: GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL + GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST @@ -84,6 +85,8 @@ runs: VERCEL_PROJECT_ID: ${{ inputs.vercelProject }} GITBOOK_RUNTIME: vercel GITBOOK_HEAD_SHA: ${{ inputs.headSha }} + # Enable the sites OAuth consent screen everywhere but production, matching the OAuth server default. + GITBOOK_SITE_OAUTH_CONSENT_ENABLED: ${{ inputs.environment != 'production' && 'true' || 'false' }} - name: Deploy Project Artifacts to Vercel id: deploy shell: bash diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts index 8e4d60707..8ccdcda75 100644 --- a/packages/gitbook/e2e/customers.spec.ts +++ b/packages/gitbook/e2e/customers.spec.ts @@ -282,11 +282,6 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://vimeo.com', tests: [{ name: 'Home', url: '/legal' }], }, - { - name: 'help.platipomiru.com', - contentBaseURL: 'https://help.platipomiru.com', - tests: [{ name: 'Home', url: '/' }], - }, { name: 'help.aikido.dev', contentBaseURL: 'https://help.aikido.dev', diff --git a/packages/gitbook/next.config.mjs b/packages/gitbook/next.config.mjs index 992281a11..e093cfd77 100644 --- a/packages/gitbook/next.config.mjs +++ b/packages/gitbook/next.config.mjs @@ -46,6 +46,8 @@ const nextConfig = { GITBOOK_API_URL: process.env.GITBOOK_API_URL, GITBOOK_APP_URL: process.env.GITBOOK_APP_URL, GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL, + GITBOOK_SITE_OAUTH_SIGNING_SECRET: process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET, + GITBOOK_SITE_OAUTH_CONSENT_ENABLED: process.env.GITBOOK_SITE_OAUTH_CONSENT_ENABLED, GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL, GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST, diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/layout.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/layout.tsx new file mode 100644 index 000000000..7e6161251 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/layout.tsx @@ -0,0 +1,20 @@ +import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; +import { CustomizationRootLayout } from '@/components/RootLayout/CustomizationRootLayout'; +import { getThemeFromMiddleware } from '@/lib/middleware'; + +/** + * Layout for the sites OAuth consent screen. + */ +export default async function Layout({ + params, + children, +}: React.PropsWithChildren<{ params: Promise }>) { + const { context } = await getDynamicSiteContext(await params); + const forcedTheme = await getThemeFromMiddleware(); + + return ( + + {children} + + ); +} diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/page.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/page.tsx new file mode 100644 index 000000000..218f4afe7 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/oauth2/v1/[siteId]/authorize/page.tsx @@ -0,0 +1,67 @@ +import { cookies, headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import { + type RouteLayoutParams, + getDynamicSiteContext, + getSiteURLDataFromParams, +} from '@/app/utils'; +import { ConsentError, ConsentScreen } from '@/components/SiteOAuthConsent'; +import { withLeadingSlash, withTrailingSlash } from '@/lib/paths'; +import { + SiteOAuthConsentError, + isSitesOAuthConsentEnabled, + startSiteOAuthConsent, +} from '@/lib/site-oauth'; +import { getVisitorToken } from '@/lib/visitors'; + +// The consent screen depends on the request (visitor, one-time interaction) and must never be cached. +export const dynamic = 'force-dynamic'; + +type PageParams = RouteLayoutParams & { siteId: string }; + +/** + * Render the sites OAuth consent screen for a post-login authorize resume. + */ +export default async function Page(props: { + params: Promise; + searchParams: Promise<{ gb_oauth_state?: string }>; +}) { + if (!isSitesOAuthConsentEnabled()) { + notFound(); + } + + const params = await props.params; + const searchParams = await props.searchParams; + const { siteId } = params; + + const { context } = await getDynamicSiteContext(params); + const siteBasePath = withTrailingSlash( + withLeadingSlash(getSiteURLDataFromParams(params).siteBasePath) + ); + const authorizeURL = new URL( + `${siteBasePath}~gitbook/oauth2/v1/${siteId}/authorize`, + context.linker.toAbsoluteURL('/') + ); + const visitorToken = getVisitorToken({ + cookies: (await cookies()).getAll(), + headers: await headers(), + url: authorizeURL, + }); + const jwtToken = visitorToken?.token; + const interactionId = searchParams.gb_oauth_state; + + if (!interactionId || !jwtToken) { + return ; + } + + try { + const consent = await startSiteOAuthConsent({ siteId, interactionId, jwtToken }); + return ; + } catch (error) { + if (error instanceof SiteOAuthConsentError) { + return ; + } + throw error; + } +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx index 47d6104cf..c044638d8 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx @@ -6,6 +6,7 @@ import { type GitBookLinker, createLinker } from '@/lib/links'; import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client'; import { useRouter } from 'next/navigation'; import React from 'react'; +import type { WebframePageContext } from './adaptive'; type ContentKitProps = React.ComponentProps>; @@ -17,18 +18,20 @@ export type WebframeLinkerData = Pick< /** * ContentKit wrapper for integration blocks that expose client-only capabilities to webframes: - * navigation to other pages, and adaptive visitor claims (only when the integration is allowed to - * access them). + * the current page, navigation to other pages, and adaptive visitor claims (only when the + * integration is allowed to access them). */ export function ContentKitWithClientContext( props: ContentKitProps & { /** Whether visitor claims may be exposed to the webframe (integration scope gated). */ canAccessVisitorClaims: boolean; + /** Current page to inject into the webframe, or `null` when unknown. */ + page: WebframePageContext | null; /** Data to rebuild the site linker, used to resolve webframe navigation requests. */ linkerData: WebframeLinkerData; } ) { - const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props; + const { canAccessVisitorClaims, page, linkerData, ...contentKitProps } = props; const router = useRouter(); const { onNavigationClick } = React.useContext(NavigationStatusContext); @@ -56,6 +59,7 @@ export function ContentKitWithClientContext( getVisitorContext: canAccessVisitorClaims ? () => ({ visitor: visitorClaims?.visitor ?? null }) : undefined, + getPageContext: page ? () => ({ page }) : undefined, navigate: ({ path, anchor }) => { // Resolve the requested path relative to the site root so a webframe can navigate // to any section or space within the site (and nowhere outside it). @@ -63,7 +67,7 @@ export function ContentKitWithClientContext( navigateTo(linker.toPathInSite(path) + suffix); }, }), - [canAccessVisitorClaims, visitorClaims, linker, navigateTo] + [canAccessVisitorClaims, visitorClaims, page, linker, navigateTo] ); return ; diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 1443cc2c2..1d599549c 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -10,7 +10,7 @@ import { ContentKitWithClientContext, type WebframeLinkerData, } from './ContentKitWithClientContext'; -import { integrationBlockContainsWebframe } from './adaptive'; +import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive'; import { contentKitServerContext } from './contentkit'; import { fetchSafeIntegrationUI } from './render'; import { renderIntegrationUi } from './server-actions'; @@ -77,8 +77,11 @@ export async function IntegrationBlock(props: BlockProps diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts index 11dbae41f..20108b89e 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'bun:test'; import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api'; -import { integrationBlockContainsWebframe } from './adaptive'; +import type { GitBookAnyContext } from '@/lib/context'; +import { createLinker } from '@/lib/links'; +import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive'; const webframe: ContentKitWebFrame = { type: 'webframe', @@ -38,3 +40,47 @@ describe('integrationBlockContainsWebframe', () => { expect(integrationBlockContainsWebframe(output)).toBe(true); }); }); + +describe('getWebframePageContext', () => { + it('returns null when the context has no page', () => { + const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext; + expect(getWebframePageContext(context)).toBeNull(); + }); + + it('resolves the page path relative to the site root, including the section slug', () => { + const context = { + page: { + id: 'page-1', + path: 'guides/getting-started', + title: 'Getting started', + slug: 'getting-started', + }, + // Site served at /docs, with the page's space mounted under the `api` section. + linker: createLinker({ siteBasePath: '/docs/', spaceBasePath: '/docs/api/' }), + } as unknown as GitBookAnyContext; + + expect(getWebframePageContext(context)).toEqual({ + id: 'page-1', + path: 'api/guides/getting-started', + title: 'Getting started', + }); + }); + + it('leaves the path unprefixed when the space is served at the site root', () => { + const context = { + page: { + id: 'page-2', + path: 'guides/getting-started', + title: 'Getting started', + slug: 'getting-started', + }, + linker: createLinker({ siteBasePath: '/', spaceBasePath: '/' }), + } as unknown as GitBookAnyContext; + + expect(getWebframePageContext(context)).toEqual({ + id: 'page-2', + path: 'guides/getting-started', + title: 'Getting started', + }); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts index 9adeb2686..77c5732a7 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts @@ -1,3 +1,4 @@ +import type { GitBookAnyContext } from '@/lib/context'; import type { ContentKitDescendantElement, ContentKitRenderOutput, @@ -7,9 +8,19 @@ import type { type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper; +/** + * Current page exposed to a webframe through the client-only webframe state. + */ +export type WebframePageContext = { + id: string; + /** Path of the page relative to the site root (includes the section and variant). */ + path: string; + title: string; +}; + /** * Whether an integration block's output contains a webframe that can consume client-only context - * (navigation and/or visitor claims). + * (navigation, visitor claims and/or the current page). */ export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean { if (output.type === 'complete') { @@ -19,6 +30,31 @@ export function integrationBlockContainsWebframe(output: ContentKitRenderOutput) return doesContentKitElementContainWebframe(output.element); } +/** + * Extract the current page to expose to a webframe, or `null` when it is unknown + * (e.g. a non-page context, or reusable content resolved from another source). + * + * The exposed `path` is resolved relative to the site root — so it carries the section and + * variant, unlike the space-relative `page.path` — matching how `@webframe.navigate` resolves a + * path. A webframe can pass `page.path` straight back to the navigate action. + */ +export function getWebframePageContext( + contentContext: GitBookAnyContext +): WebframePageContext | null { + if (!('page' in contentContext) || !contentContext.page) { + return null; + } + + const { linker } = contentContext; + const { id, path, title } = contentContext.page; + + return { + id, + path: linker.toRelativePathInSite(linker.toPathInSpace(path)), + title, + }; +} + /** * Check whether a ContentKit element tree contains a webframe element. */ diff --git a/packages/gitbook/src/components/SiteOAuthConsent/ConsentForm.tsx b/packages/gitbook/src/components/SiteOAuthConsent/ConsentForm.tsx new file mode 100644 index 000000000..d0f1d6c0d --- /dev/null +++ b/packages/gitbook/src/components/SiteOAuthConsent/ConsentForm.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { Button } from '@/components/primitives/Button'; +import { Checkbox } from '@/components/primitives/Checkbox'; +import { tcls } from '@/lib/tailwind'; + +import { type SubmitConsentInput, submitSiteOAuthConsent } from './actions'; + +/** + * Site's OAuth consent form to present to the user the client's information requesting access to the site's MCP. + */ +export function ConsentForm(props: { + siteId: string; + consentSessionId: string; + /** Whether the OAuth server recognizes the client as verified. */ + verified: boolean; +}) { + const { siteId, consentSessionId, verified } = props; + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(); + const [trusted, setTrusted] = useState(false); + + // Unverified clients can only be approved once the visitor explicitly acknowledges they trust + // the app. The OAuth server re-checks this, so it can't be bypassed by tampering with the client. + const canApprove = verified || trusted; + + const decide = (decision: SubmitConsentInput['decision']) => { + setError(undefined); + startTransition(async () => { + const result = await submitSiteOAuthConsent({ + siteId, + consentSessionId, + decision, + trusted, + }); + if ('redirectURL' in result) { + // Full-page navigation to the client's (external) redirect URI. + window.location.href = result.redirectURL; + } else { + setError(result.error); + } + }); + }; + + return ( +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ {verified ? ( + + ) : ( + + )} + +
+ + +
+
+
+ ); +} diff --git a/packages/gitbook/src/components/SiteOAuthConsent/ConsentScreen.tsx b/packages/gitbook/src/components/SiteOAuthConsent/ConsentScreen.tsx new file mode 100644 index 000000000..3d1ee9bbd --- /dev/null +++ b/packages/gitbook/src/components/SiteOAuthConsent/ConsentScreen.tsx @@ -0,0 +1,203 @@ +import { Icon } from '@gitbook/icons'; + +import { StyledLink } from '@/components/primitives/StyledLink'; +import type { SiteOAuthConsentStart } from '@/lib/site-oauth'; +import { tcls } from '@/lib/tailwind'; + +import { ConsentForm } from './ConsentForm'; + +/** + * Consent screen shown to a visitor when an MCP client requests authorization to a published site. + */ +export function ConsentScreen(props: { + siteId: string; + siteTitle: string; + consent: SiteOAuthConsentStart; +}) { + const { siteId, siteTitle, consent } = props; + const { client, redirectUri, consentSessionId } = consent; + const redirectParts = parseRedirectURI(redirectUri); + + return ( + +
+ {/* Client identity */} +
+ {client.logoUri ? ( + + ) : ( + + + + )} + +
+
+

{client.name}

+ +
+ {client.uri ? ( + + Website + + + ) : null} +
+
+ + {/* Request statement — toned down, with the client and site names emphasized. */} +

+ {client.name} wants to + access {siteTitle} MCP{' '} + on your behalf. +

+ + {/* Redirect URI, shown in full with the destination host emphasized. */} +
+ + After approving, an authorization code will be sent to: + +
+ + + {redirectParts ? ( + <> + {redirectParts.prefix} + + {redirectParts.host} + + {redirectParts.rest} + + ) : ( + {redirectUri} + )} + +
+
+ + {client.verified ? null : ( +
+ +
+ + GitBook has not verified this client + + + Only approve if you recognize this application and trust it with + access to {siteTitle}. + +
+
+ )} +
+ + {/* Footer: trust acknowledgement + decision */} +
+ +
+
+ ); +} + +/** + * Centered, branded card shell shared by the consent screen and its error state. + */ +function ConsentCard(props: { children: React.ReactNode }) { + return ( +
+
+ {props.children} +
+
+ ); +} + +/** + * Error state shown when the consent flow cannot be started (e.g. a refreshed or expired link). + */ +export function ConsentError(props: { title?: string; message?: string }) { + const { + title = 'This authorization link has expired', + message = 'Please start the sign-in again from the application.', + } = props; + + return ( + +
+ + + +

{title}

+

{message}

+
+
+ ); +} + +/** + * Split a redirect URI so the destination host (the trust-relevant part) can be emphasized while + * the scheme and path are shown muted. Returns null if the URI can't be parsed. + */ +function parseRedirectURI(uri: string): { prefix: string; host: string; rest: string } | null { + try { + const url = new URL(uri); + return { + prefix: `${url.protocol}//`, + host: url.host, + rest: `${url.pathname}${url.search}${url.hash}`, + }; + } catch { + return null; + } +} + +/** + * Inline verified/unverified indicator shown next to the client name. + */ +function ClientTrustBadge(props: { verified: boolean }) { + const { verified } = props; + + return ( + + + {verified ? 'Verified' : 'Unverified'} + + ); +} diff --git a/packages/gitbook/src/components/SiteOAuthConsent/actions.ts b/packages/gitbook/src/components/SiteOAuthConsent/actions.ts new file mode 100644 index 000000000..4ed6d96e7 --- /dev/null +++ b/packages/gitbook/src/components/SiteOAuthConsent/actions.ts @@ -0,0 +1,39 @@ +'use server'; + +import { type SiteOAuthConsentDecision, submitSiteOAuthConsentDecision } from '@/lib/site-oauth'; + +export type SubmitConsentInput = { + siteId: string; + consentSessionId: string; + decision: SiteOAuthConsentDecision; + trusted: boolean; +}; + +export type SubmitConsentResult = { redirectURL: string } | { error: string }; + +/** + * Server action to submit the consent decision to the sites OAuth server's `consent/decision` endpoint. + */ +export async function submitSiteOAuthConsent( + input: SubmitConsentInput +): Promise { + const { siteId, consentSessionId, decision, trusted } = input; + + if (!siteId || !consentSessionId || (decision !== 'approve' && decision !== 'deny')) { + return { error: 'Invalid request. Please start again from the application.' }; + } + + try { + const { redirectURL } = await submitSiteOAuthConsentDecision({ + siteId, + consentSessionId, + decision, + trusted, + }); + return { redirectURL }; + } catch (_error) { + return { + error: 'We could not complete the authorization. The request may have expired — please start again from the application.', + }; + } +} diff --git a/packages/gitbook/src/components/SiteOAuthConsent/index.ts b/packages/gitbook/src/components/SiteOAuthConsent/index.ts new file mode 100644 index 000000000..f7aaddeef --- /dev/null +++ b/packages/gitbook/src/components/SiteOAuthConsent/index.ts @@ -0,0 +1 @@ +export { ConsentScreen, ConsentError } from './ConsentScreen'; diff --git a/packages/gitbook/src/components/hooks/useScrollOverflow.ts b/packages/gitbook/src/components/hooks/useScrollOverflow.ts new file mode 100644 index 000000000..48664255d --- /dev/null +++ b/packages/gitbook/src/components/hooks/useScrollOverflow.ts @@ -0,0 +1,104 @@ +'use client'; + +import * as React from 'react'; +import { useScrollListener } from './useScrollListener'; + +/** + * Track the scroll position and overflow amount of a scrollable container, + * keeping them in sync with scroll events, resizes, and content changes. + */ +export function useScrollOverflow( + orientation: 'horizontal' | 'vertical', + containerRef: React.RefObject +) { + const [scrollPosition, setScrollPosition] = React.useState(0); + const [scrollSize, setScrollSize] = React.useState(0); + + const measure = React.useCallback(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const scrollDimension = + orientation === 'horizontal' ? container.scrollWidth : container.scrollHeight; + const clientDimension = + orientation === 'horizontal' ? container.clientWidth : container.clientHeight; + + setScrollSize(Math.max(scrollDimension - clientDimension - 1, 0)); + setScrollPosition( + orientation === 'horizontal' ? container.scrollLeft : container.scrollTop + ); + }, [orientation, containerRef]); + + useScrollListener(measure, containerRef); + + // Measure synchronously on mount (and when the container/orientation changes), so + // initial overflow is detected without waiting for a resize/scroll event. Subsequent + // content changes are picked up by the observers below instead of re-measuring on + // every render. + React.useLayoutEffect(() => { + measure(); + }, [measure]); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + // Children can overflow (or stop overflowing) without the container itself + // changing size, so we observe the direct children in addition to the container, + // and re-register observers as children are added/removed. + let frame: number | null = null; + const scheduleMeasure = () => { + if (frame !== null) { + return; + } + frame = requestAnimationFrame(() => { + frame = null; + measure(); + }); + }; + + const ro = new ResizeObserver(scheduleMeasure); + ro.observe(container); + for (const child of Array.from(container.children)) { + ro.observe(child); + } + + const mo = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // Only re-register direct children with the resize observer; descendants + // deeper in the tree are covered by their parent's resize/mutation handling. + if (mutation.target !== container) { + continue; + } + for (const node of Array.from(mutation.addedNodes)) { + if (node instanceof Element) { + ro.observe(node); + } + } + for (const node of Array.from(mutation.removedNodes)) { + if (node instanceof Element) { + ro.unobserve(node); + } + } + } + scheduleMeasure(); + }); + // Also watch descendants (subtree/characterData) so text/content changes deeper in + // the tree that grow or shrink scrollHeight/scrollWidth still trigger a re-measure. + mo.observe(container, { childList: true, subtree: true, characterData: true }); + + return () => { + if (frame !== null) { + cancelAnimationFrame(frame); + } + ro.disconnect(); + mo.disconnect(); + }; + }, [measure, containerRef]); + + return { scrollPosition, scrollSize }; +} diff --git a/packages/gitbook/src/components/primitives/ScrollContainer.tsx b/packages/gitbook/src/components/primitives/ScrollContainer.tsx index 8c70b2d03..1bc07addb 100644 --- a/packages/gitbook/src/components/primitives/ScrollContainer.tsx +++ b/packages/gitbook/src/components/primitives/ScrollContainer.tsx @@ -3,7 +3,7 @@ import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import * as React from 'react'; -import { useScrollListener } from '../hooks/useScrollListener'; +import { useScrollOverflow } from '../hooks/useScrollOverflow'; import { Button, type ButtonProps } from './Button'; /** @@ -56,50 +56,9 @@ export function ScrollContainer(props: ScrollContainerProps) { const containerRef = React.useRef(null); - const [scrollPosition, setScrollPosition] = React.useState(0); - const [scrollSize, setScrollSize] = React.useState(0); - const language = useLanguage(); - useScrollListener(() => { - const container = containerRef.current; - if (!container) { - return; - } - - setScrollSize( - orientation === 'horizontal' - ? container.scrollWidth - container.clientWidth - 1 - : container.scrollHeight - container.clientHeight - 1 - ); - - setScrollPosition( - orientation === 'horizontal' ? container.scrollLeft : container.scrollTop - ); - }, containerRef); - - React.useEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - - // Update max scroll position using resize observer - const ro = new ResizeObserver((entries) => { - const [entry] = entries; - if (entry) { - setScrollSize( - orientation === 'horizontal' - ? entry.target.scrollWidth - entry.target.clientWidth - 1 - : entry.target.scrollHeight - entry.target.clientHeight - 1 - ); - } - }); - - ro.observe(container); - - return () => ro.disconnect(); - }, [orientation]); + const { scrollPosition, scrollSize } = useScrollOverflow(orientation, containerRef); React.useEffect(() => { const container = containerRef.current; diff --git a/packages/gitbook/src/lib/env/globals.ts b/packages/gitbook/src/lib/env/globals.ts index 92970e753..b7d97778b 100644 --- a/packages/gitbook/src/lib/env/globals.ts +++ b/packages/gitbook/src/lib/env/globals.ts @@ -132,6 +132,14 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN; */ export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null; +/** + * Shared secret used to sign server-to-server requests to the sites OAuth server consent endpoints. + * This must match the sites OAuth provider signing secret (`functionsConfig.sitesOAuth.signingSecret` + * in gitbook-x); it is a dedicated secret and must not be confused with `GITBOOK_SECRET`. + */ +export const GITBOOK_SITE_OAUTH_SIGNING_SECRET = + process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET ?? null; + function enforceEnum(key: string, value: string, enumValues: T[]): T { if (!enumValues.includes(value as T)) { throw new Error( diff --git a/packages/gitbook/src/lib/site-oauth/flag.ts b/packages/gitbook/src/lib/site-oauth/flag.ts new file mode 100644 index 000000000..25acf282a --- /dev/null +++ b/packages/gitbook/src/lib/site-oauth/flag.ts @@ -0,0 +1,32 @@ +/** + * Whether GBO should render the sites OAuth consent screen (instead of forwarding the post-login + * resume to the OAuth server). This must be coordinated with the OAuth server so GBO renders consent + * exactly when the server expects it. + * + * Kept in its own module (free of `node:crypto`/`server-only`) so it can be imported from the edge + * middleware. It is never imported into a client bundle. + */ +export function isSitesOAuthConsentEnabled(): boolean { + const override = process.env.GITBOOK_SITE_OAUTH_CONSENT_ENABLED; + if (override !== undefined) { + return override === 'true'; + } + return process.env.NODE_ENV === 'development'; +} + +/** + * Interaction id the OAuth server puts on the post-login resume URL. Its presence marks a resume + * that GBO should render consent for (rather than forward to the OAuth server). + */ +export const SITE_OAUTH_STATE_PARAM = 'gb_oauth_state'; + +/** + * Whether GBO should render the consent screen for a request hitting the + * `~gitbook/oauth2/v1/:siteId/authorize` forwarder, rather than forwarding it to the OAuth server. + * + * Render only when consent is enabled AND this is a post-login resume (carries the interaction id); + * everything else forwards, preserving the legacy behavior. + */ +export function shouldRenderSiteOAuthConsent(searchParams: URLSearchParams): boolean { + return isSitesOAuthConsentEnabled() && searchParams.has(SITE_OAUTH_STATE_PARAM); +} diff --git a/packages/gitbook/src/lib/site-oauth/index.ts b/packages/gitbook/src/lib/site-oauth/index.ts new file mode 100644 index 000000000..426916794 --- /dev/null +++ b/packages/gitbook/src/lib/site-oauth/index.ts @@ -0,0 +1,136 @@ +import 'server-only'; + +import { createHmac } from 'node:crypto'; + +import { GITBOOK_OAUTH_SERVER_URL, GITBOOK_SITE_OAUTH_SIGNING_SECRET } from '@/lib/env'; + +export { + SITE_OAUTH_STATE_PARAM, + isSitesOAuthConsentEnabled, + shouldRenderSiteOAuthConsent, +} from './flag'; + +/** + * Details about the OAuth client requesting authorization, as returned by the OAuth server. The + * `name` and `uri` are client-supplied and must be treated as untrusted when rendered. + */ +export type SiteOAuthConsentClient = { + name: string; + uri?: string; + logoUri?: string; + verified: boolean; + verifiedName?: string; +}; + +/** + * Result of a successful `consent/start` call: everything GBO needs to render the consent screen. + */ +export type SiteOAuthConsentStart = { + consentSessionId: string; + client: SiteOAuthConsentClient; + scopes: string[]; + redirectUri: string; +}; + +/** + * A visitor's decision on a consent request. + */ +export type SiteOAuthConsentDecision = 'approve' | 'deny'; + +/** + * Error thrown when a server-to-server call to the OAuth server consent endpoint fails. It carries + * the upstream HTTP status so callers can distinguish an expired/consumed session (400) from an + * authentication problem (401). + */ +export class SiteOAuthConsentError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'SiteOAuthConsentError'; + this.status = status; + } +} + +/** + * Start a consent session with the OAuth server for a post-login authorize resume. + * + * This is single-use: it consumes the pending interaction session on the OAuth server, so it must be + * called exactly once per consent render. + */ +export async function startSiteOAuthConsent(args: { + siteId: string; + interactionId: string; + jwtToken: string; +}): Promise { + const { siteId, interactionId, jwtToken } = args; + return postToConsentEndpoint(siteId, 'consent/start', { + interactionId, + jwtToken, + }); +} + +/** + * Submit the visitor's decision to the OAuth server and get back the absolute URL to send the + * visitor's browser to (the client's redirect URI with an auth code on approve, or an access_denied + * redirect on deny). + */ +export async function submitSiteOAuthConsentDecision(args: { + siteId: string; + consentSessionId: string; + decision: SiteOAuthConsentDecision; + trusted: boolean; +}): Promise<{ redirectURL: string }> { + const { siteId, consentSessionId, decision, trusted } = args; + return postToConsentEndpoint<{ redirectURL: string }>(siteId, 'consent/decision', { + consentSessionId, + decision, + // Only forward the trust acknowledgement when the visitor actually gave it; the server + // re-checks and rejects an unverified client approved without it. + ...(trusted ? { trusted: true } : {}), + }); +} + +/** + * Sign and POST a JSON body to a site OAuth server consent endpoint, returning the parsed response. + * + * The shared-secret signature matches the OAuth server's schem and is sent as the + * `x-gitbook-signature` / `x-gitbook-timestamp` headers. + */ +async function postToConsentEndpoint( + siteId: string, + endpoint: 'consent/start' | 'consent/decision', + body: unknown +): Promise { + if (!GITBOOK_SITE_OAUTH_SIGNING_SECRET) { + throw new SiteOAuthConsentError('Missing sites OAuth signing secret', 500); + } + + const rawBody = JSON.stringify(body); + const timestamp = Math.floor(Date.now() / 1000); + const signature = createHmac('sha256', GITBOOK_SITE_OAUTH_SIGNING_SECRET) + .update(`${siteId}:${timestamp}:${rawBody}`) + .digest('hex'); + + const url = new URL(GITBOOK_OAUTH_SERVER_URL); + url.pathname += `/${encodeURIComponent(siteId)}/${endpoint}`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-gitbook-signature': signature, + 'x-gitbook-timestamp': String(timestamp), + }, + body: rawBody, + cache: 'no-store', + }); + + if (!response.ok) { + throw new SiteOAuthConsentError( + `OAuth server ${endpoint} responded with ${response.status}`, + response.status + ); + } + + return (await response.json()) as T; +} diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index d5faa030d..d2edaee43 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -36,6 +36,7 @@ import { getPreviewRequestIdentifier, isPreviewRequest, } from '@/lib/preview'; +import { shouldRenderSiteOAuthConsent } from '@/lib/site-oauth/flag'; import { type ResponseCookies, getPathScopedCookieName, @@ -194,10 +195,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { if (siteOAuthAuthorizeMatch) { const siteId = siteOAuthAuthorizeMatch.pathname.groups.siteId; - const siteOAuthAuthorizeURL = new URL(oauthServerURL); - siteOAuthAuthorizeURL.pathname += `/${siteId}/authorize`; - siteOAuthAuthorizeURL.search = siteOAuthAuthorizeMatch.search.input.replace('?', ''); - return NextResponse.redirect(siteOAuthAuthorizeURL.toString()); + + // When the consent flow is enabled, GBO renders the consent screen for the post-login resume + // (recognized by the `gb_oauth_state` interaction id the OAuth server puts on the resume URL) + // instead of forwarding. We fall through to the normal site routing, which rewrites the + // request to the `~gitbook/oauth2/v1/[siteId]/authorize` route that renders consent. + // + // Otherwise we forward to the OAuth server exactly as before (legacy path). + if (!shouldRenderSiteOAuthConsent(siteRequestURL.searchParams)) { + const siteOAuthAuthorizeURL = new URL(oauthServerURL); + siteOAuthAuthorizeURL.pathname += `/${siteId}/authorize`; + siteOAuthAuthorizeURL.search = siteOAuthAuthorizeMatch.search.input.replace('?', ''); + return NextResponse.redirect(siteOAuthAuthorizeURL.toString()); + } } // @@ -541,6 +551,21 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { response.headers.set('cache-control', 'public, max-age=0, must-revalidate'); } + // The sites OAuth consent screen carries a security decision: lock it down so it can't be + // framed, cached, or leak the client's redirect URI via the Referer header. + if (pathname.match(/^~gitbook\/oauth2\/v1\/[^/]+\/authorize$/)) { + response.headers.set( + 'content-security-policy', + getContentSecurityPolicy().replace( + /frame-ancestors[^;]*;/, + "frame-ancestors 'none';" + ) + ); + response.headers.set('x-frame-options', 'DENY'); + response.headers.set('referrer-policy', 'no-referrer'); + response.headers.set('cache-control', 'no-store'); + } + return writeResponseCookies(response, cookies); }; @@ -731,6 +756,11 @@ function encodePathInSiteContent( return { pathname }; } + // The sites OAuth consent screen is rendered dynamically per request (client details, visitor). + if (pathname.match(/^~gitbook\/oauth2\/v1\/[^/]+\/authorize$/)) { + return { pathname, routeType: 'dynamic' }; + } + // If the pathname is a RSS feed (/.../rss.xml), we rewrite it to ~gitbook/rss/:pathname const rssMatch = pathname.match(RSS_PATH_REGEX); if (rssMatch) { diff --git a/packages/react-contentkit/src/ElementWebframe.tsx b/packages/react-contentkit/src/ElementWebframe.tsx index 9ff9f58de..0f4a3a423 100644 --- a/packages/react-contentkit/src/ElementWebframe.tsx +++ b/packages/react-contentkit/src/ElementWebframe.tsx @@ -159,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps { const abort = { cancelled: false }; sendWebframeState({ @@ -231,10 +231,14 @@ function resolveWebframeState( } /** - * Resolve the optional client-only contexts (visitor claims) to merge into the webframe state. + * Resolve the optional client-only contexts (visitor claims, current page) + * to merge into the webframe state. */ async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) { - return await Promise.all([clientContext?.getVisitorContext?.()]); + return await Promise.all([ + clientContext?.getVisitorContext?.(), + clientContext?.getPageContext?.(), + ]); } /** diff --git a/packages/react-contentkit/src/context.ts b/packages/react-contentkit/src/context.ts index fcff8f10a..c50a1abd6 100644 --- a/packages/react-contentkit/src/context.ts +++ b/packages/react-contentkit/src/context.ts @@ -17,6 +17,16 @@ export type ContentKitRenderUpdate = Partial< Pick >; +/** + * The current page exposed to a webframe through the client-only webframe state. + */ +export type ContentKitWebframePage = { + id: string; + /** Path of the page relative to the site root. */ + path: string; + title: string; +}; + export type ContentKitClientContextData = { /** * Client-only visitor claims, merged into the webframe state. @@ -28,6 +38,15 @@ export type ContentKitClientContextData = { | undefined | Promise | null | undefined>; + /** + * Client-only current-page context, merged into the webframe state. + */ + getPageContext?: () => + | { page: ContentKitWebframePage } + | null + | undefined + | Promise<{ page: ContentKitWebframePage } | null | undefined>; + /** * Navigate the host page to another page, in response to a webframe `@webframe.navigate` * action. The destination is addressed by `path` (resolved against the site base path); the