From 580d186ddd64131ce5d1214f43b1f55ea113f265 Mon Sep 17 00:00:00 2001 From: spastorelli Date: Thu, 23 Jul 2026 14:08:56 +0200 Subject: [PATCH] Clean up old forward resume authorize to sites oauth server (#4436) --- .../oauth2/v1/[siteId]/authorize/page.tsx | 16 +--- packages/gitbook/src/lib/site-oauth/flag.ts | 92 ------------------- packages/gitbook/src/lib/site-oauth/index.ts | 7 -- packages/gitbook/src/middleware.ts | 32 +------ 4 files changed, 5 insertions(+), 142 deletions(-) delete mode 100644 packages/gitbook/src/lib/site-oauth/flag.ts 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 index ebe5b5318..233c513cb 100644 --- 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 @@ -8,11 +8,7 @@ import { } from '@/app/utils'; import { ConsentError, ConsentScreen } from '@/components/SiteOAuthConsent'; import { withLeadingSlash, withTrailingSlash } from '@/lib/paths'; -import { - SiteOAuthConsentError, - startSiteOAuthConsent, - verifySiteOAuthConsentMarker, -} from '@/lib/site-oauth'; +import { SiteOAuthConsentError, 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. @@ -25,18 +21,14 @@ type PageParams = RouteLayoutParams & { siteId: string }; */ export default async function Page(props: { params: Promise; - searchParams: Promise<{ gb_oauth_state?: string; gb_consent?: string }>; + searchParams: Promise<{ gb_oauth_state?: string }>; }) { const params = await props.params; const searchParams = await props.searchParams; const { siteId } = params; - const consentVerified = await verifySiteOAuthConsentMarker({ - siteId, - interactionId: searchParams.gb_oauth_state, - signature: searchParams.gb_consent, - }); - if (!consentVerified) { + // Only a post-login resume (carrying the interaction id) legitimately reaches this route. + if (!searchParams.gb_oauth_state) { notFound(); } diff --git a/packages/gitbook/src/lib/site-oauth/flag.ts b/packages/gitbook/src/lib/site-oauth/flag.ts deleted file mode 100644 index e8cd4a14b..000000000 --- a/packages/gitbook/src/lib/site-oauth/flag.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Query param on the post-login resume URL carrying the OAuth server's *signed* consent marker - * (`?gb_oauth_state=&gb_consent=`). The signature proves the OAuth server - * has consent enabled for this authorization; GBO verifies it before rendering the consent screen so - * a visitor can't forge the flag on the resume URL. When it's absent or fails verification, GBO - * forwards the resume to the OAuth server exactly as in the legacy path. - * - * Kept in its own module (free of `node:crypto`/`server-only`, verifying with async Web Crypto) so - * it can be imported and awaited from the edge middleware. It is never imported into a client bundle. - */ -export const SITE_OAUTH_CONSENT_PARAM = 'gb_consent'; - -/** - * Interaction id the OAuth server puts on the post-login resume URL. It is part of the signed - * consent marker and is passed to the consent endpoints to identify the pending authorization. - */ -export const SITE_OAUTH_STATE_PARAM = 'gb_oauth_state'; - -const encoder = new TextEncoder(); - -/** - * 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. - * - * The OAuth server signals this per request via the signed consent marker; GBO verifies it against - * the site and interaction, making the OAuth server the single source of truth for whether consent - * is enabled. Without a valid marker, GBO forwards (legacy). - */ -export function shouldRenderSiteOAuthConsent( - siteId: string | undefined, - searchParams: URLSearchParams -): Promise { - return verifySiteOAuthConsentMarker({ - siteId, - interactionId: searchParams.get(SITE_OAUTH_STATE_PARAM), - signature: searchParams.get(SITE_OAUTH_CONSENT_PARAM), - }); -} - -/** - * Verify the OAuth server's signed consent marker. The marker is the hex HMAC-SHA256 of - * `consent-enabled::` keyed with the shared sites-OAuth signing secret (the - * same secret used to sign the server-to-server consent requests). Returns true only when the - * signature matches, using a constant-time comparison. - */ -export async function verifySiteOAuthConsentMarker(args: { - siteId: string | undefined; - interactionId: string | null | undefined; - signature: string | null | undefined; -}): Promise { - const { siteId, interactionId, signature } = args; - const secret = process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET; - if (!secret || !siteId || !interactionId || !signature) { - return false; - } - - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - const digest = await crypto.subtle.sign( - 'HMAC', - key, - encoder.encode(`consent-enabled:${siteId}:${interactionId}`) - ); - - return timingSafeEqualHex(bufferToHex(digest), signature); -} - -function bufferToHex(buffer: ArrayBuffer): string { - return Array.from(new Uint8Array(buffer)) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join(''); -} - -/** - * Constant-time comparison of two hex signatures of equal length (the HMAC-SHA256 hex is always 64 - * chars, so the length check doesn't leak anything sensitive). - */ -function timingSafeEqualHex(a: string, b: string): boolean { - if (a.length !== b.length) { - return false; - } - let mismatch = 0; - for (let index = 0; index < a.length; index++) { - mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index); - } - return mismatch === 0; -} diff --git a/packages/gitbook/src/lib/site-oauth/index.ts b/packages/gitbook/src/lib/site-oauth/index.ts index f4aa7e384..e3d17d9e4 100644 --- a/packages/gitbook/src/lib/site-oauth/index.ts +++ b/packages/gitbook/src/lib/site-oauth/index.ts @@ -4,13 +4,6 @@ import { createHmac } from 'node:crypto'; import { GITBOOK_OAUTH_SERVER_URL, GITBOOK_SITE_OAUTH_SIGNING_SECRET } from '@/lib/env'; -export { - SITE_OAUTH_CONSENT_PARAM, - SITE_OAUTH_STATE_PARAM, - shouldRenderSiteOAuthConsent, - verifySiteOAuthConsentMarker, -} 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. diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 70f5c2349..5d4453d37 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -26,7 +26,7 @@ import { normalizeRequestURL, throwIfDataError, } from '@/lib/data'; -import { GITBOOK_OAUTH_SERVER_URL, isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env'; +import { isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env'; import { getImageResizingContextId } from '@/lib/images'; import { MiddlewareHeaders } from '@/lib/middleware'; import { @@ -41,7 +41,6 @@ import { getPreviewRequestIdentifier, isPreviewRequest, } from '@/lib/preview'; -import { shouldRenderSiteOAuthConsent } from '@/lib/site-oauth/flag'; import { type ResponseCookies, getPathScopedCookieName, @@ -191,35 +190,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { return serveVisitorClaimsDataRequest(request, siteRequestURL); } - // Handler that forwards redirections from upstream auth provider during a site's OAuth /authorize session - // back to the site's OAuth server. - const oauthServerURL = new URL(GITBOOK_OAUTH_SERVER_URL); - const siteOAuthAuthorizeMatch = new URLPattern({ - pathname: `*/~gitbook/${oauthServerURL.pathname.substring(1)}/:siteId/authorize`, - }).exec(siteRequestURL.toString()); - - // TODO-RND-12161: Clean this up once the consent flow is fully shipped (drop this legacy - // forward — GBO should then always render consent for these resumes). - if (siteOAuthAuthorizeMatch) { - const siteId = siteOAuthAuthorizeMatch.pathname.groups.siteId; - - // When the OAuth server has consent enabled, it stamps the post-login resume URL with a signed - // `gb_consent` marker. If it verifies, GBO renders the consent screen by falling through to the - // normal site consent page routing. - // - // Otherwise (no marker or it fails verification) we forward to the OAuth server as before. - const renderConsent = await shouldRenderSiteOAuthConsent( - siteId, - siteRequestURL.searchParams - ); - if (!renderConsent) { - const siteOAuthAuthorizeURL = new URL(oauthServerURL); - siteOAuthAuthorizeURL.pathname += `/${siteId}/authorize`; - siteOAuthAuthorizeURL.search = siteOAuthAuthorizeMatch.search.input.replace('?', ''); - return NextResponse.redirect(siteOAuthAuthorizeURL.toString()); - } - } - // // Detect and extract the visitor authentication token from the request //