mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 10:33:22 +00:00
Clean up old forward resume authorize to sites oauth server (#4436)
This commit is contained in:
+4
-12
@@ -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.
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user