Clean up old forward resume authorize to sites oauth server (#4436)

This commit is contained in:
spastorelli
2026-07-23 14:08:56 +02:00
committed by GitHub
parent 038008c853
commit 580d186ddd
4 changed files with 5 additions and 142 deletions
@@ -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<PageParams>;
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();
}
@@ -1,92 +0,0 @@
/**
* Query param on the post-login resume URL carrying the OAuth server's *signed* consent marker
* (`?gb_oauth_state=<interactionId>&gb_consent=<signature>`). 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<boolean> {
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:<siteId>:<interactionId>` 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<boolean> {
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;
}
@@ -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.
+1 -31
View File
@@ -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
//