Expose a ~gitbook/auth/login URL to handle redirection to upstream auth with current location passed (#4113)

This commit is contained in:
spastorelli
2026-03-16 16:00:31 +01:00
committed by GitHub
parent f09ca60930
commit 4ac29817a2
11 changed files with 354 additions and 82 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "gitbook",
@@ -349,7 +348,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.169.0",
"@gitbook/api": "0.170.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -746,7 +745,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.169.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-kzJT8P5HCnqeYG3kFmBfvKMjQhTuUVmNV5Wvyh87QnoOA3odwh2C3qONpTmBA6cNE3ymDnxs8SJT9FG/qquRZQ=="],
"@gitbook/api": ["@gitbook/api@0.170.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RCW9tipg8CJqXPHrZ65wj/EwN4/Rmvy4uOZ8tez5B9NcM7C4vYJOBcgsBeGD4bB5I0YmH/uyZMno5zgIM8sOKw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.169.0",
"@gitbook/api": "0.170.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -0,0 +1,30 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
/**
* Redirect to the upstream auth provider login URL of site, or to the site root when not configured.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
const noLoginFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
if (!context.site.urls.login) {
return NextResponse.redirect(noLoginFallbackURL);
}
try {
const loginURL = new URL(context.site.urls.login);
const location = request.nextUrl.searchParams.get('location');
if (location) {
loginURL.searchParams.set('location', location);
}
return NextResponse.redirect(loginURL);
} catch (_error) {
return NextResponse.redirect(noLoginFallbackURL);
}
}
@@ -1,7 +1,10 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type React from 'react';
import { Button, type ButtonProps } from '../primitives';
import { SiteAuthLoginButton } from '../primitives/SiteAuthLoginLink';
import type { InlineProps } from './Inline';
import { InlineActionButton } from './InlineActionButton';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
@@ -58,21 +61,27 @@ export async function InlineLinkButton(
const href =
resolved?.href ??
(inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
const sharedProps: React.ComponentProps<typeof Button> = {
...buttonProps,
insights: {
type: 'link_click' as const,
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
},
href,
disabled: href === undefined,
};
const button = (
<Button
{...buttonProps}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
href={href}
disabled={href === undefined}
/>
);
const button =
href &&
context.contentContext &&
isSiteAuthLoginHref(context.contentContext.linker, href) ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
if (inline.data.ref && !resolved) {
return <NotFoundRefHoverCard context={context}>{button}</NotFoundRefHoverCard>;
@@ -1,3 +1,4 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type ContentRef,
@@ -7,6 +8,7 @@ import {
SiteInsightsLinkPosition,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -17,6 +19,11 @@ import {
DropdownMenu,
DropdownMenuItem,
} from '../primitives/DropdownMenu';
import {
SiteAuthLoginButton,
SiteAuthLoginDropdownMenuItem,
SiteAuthLoginLink,
} from '../primitives/SiteAuthLoginLink';
export async function HeaderLink(props: {
context: GitBookSiteContext;
@@ -44,6 +51,7 @@ export async function HeaderLink(props: {
title={link.title}
isDropdown
href={target?.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target.href)}
/>
)
}
@@ -68,6 +76,7 @@ export async function HeaderLink(props: {
title={link.title}
isDropdown={false}
href={target?.href}
isSiteAuthLoginHref={target ? isSiteAuthLoginHref(context.linker, target.href) : false}
/>
);
}
@@ -79,6 +88,7 @@ export type HeaderLinkNavItemProps = {
title: string;
href?: string;
isDropdown: boolean;
isSiteAuthLoginHref: boolean;
} & DropdownButtonProps<HTMLElement>;
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
@@ -99,7 +109,16 @@ function HeaderItemButton(
linkStyle: 'button-secondary' | 'button-primary';
}
) {
const { linkTarget, linkStyle, headerPreset, title, href, isDropdown, ...rest } = props;
const {
linkTarget,
linkStyle,
headerPreset,
title,
href,
isDropdown,
isSiteAuthLoginHref,
...rest
} = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
@@ -110,21 +129,25 @@ function HeaderItemButton(
assertNever(linkStyle);
}
})();
return (
<Button
href={href}
variant={variant}
size="medium"
insights={{
type: 'link_click',
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
}}
label={title}
{...rest}
/>
const sharedProps: React.ComponentProps<typeof Button> = {
href,
variant,
size: 'medium' as const,
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
label: title,
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
}
@@ -154,20 +177,28 @@ function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPrese
}
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props;
return (
<Link
href={href ?? '#'}
className={getHeaderLinkClassName({ headerPreset })}
insights={{
type: 'link_click',
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
}}
{...rest}
>
const { linkTarget, headerPreset, title, isDropdown, href, isSiteAuthLoginHref, ...rest } =
props;
const sharedProps = {
href: href ?? '#',
className: getHeaderLinkClassName({ headerPreset }),
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginLink {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</SiteAuthLoginLink>
) : (
<Link {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</Link>
@@ -204,18 +235,20 @@ async function SubHeaderLink(props: {
return null;
}
return (
<DropdownMenuItem
href={target.href}
insights={{
type: 'link_click',
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}}
>
{link.title}
</DropdownMenuItem>
const sharedProps = {
href: target.href,
insights: {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
},
};
return isSiteAuthLoginHref(context.linker, target.href) ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps}>{link.title}</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{link.title}</DropdownMenuItem>
);
}
@@ -1,3 +1,4 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type CustomizationContentLink,
@@ -19,6 +20,7 @@ import {
DropdownMenuSeparator,
DropdownSubMenu,
} from '../primitives/DropdownMenu';
import { SiteAuthLoginDropdownMenuItem } from '../primitives/SiteAuthLoginLink';
import styles from './headerLinks.module.css';
/**
@@ -84,6 +86,18 @@ async function MoreMenuLink(props: {
const { context, link } = props;
const target = link.to ? await resolveContentRef(link.to, context) : null;
const sharedProps = {
href: target?.href,
insights: link.to
? {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined,
};
return 'links' in link && link.links.length > 0 ? (
<DropdownSubMenu label={link.title}>
@@ -91,22 +105,11 @@ async function MoreMenuLink(props: {
return <MoreMenuLink key={index} {...props} link={subLink} />;
})}
</DropdownSubMenu>
) : (
<DropdownMenuItem
href={target?.href}
insights={
link.to
? {
type: 'link_click',
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined
}
>
) : isSiteAuthLoginHref(context.linker, target?.href) && sharedProps.href ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps} href={sharedProps.href}>
{link.title}
</DropdownMenuItem>
</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{link.title}</DropdownMenuItem>
);
}
@@ -0,0 +1,76 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import type React from 'react';
import { removeTrailingSlash } from '@/lib/paths';
import { Button, type ButtonProps } from './Button';
import { DropdownMenuItem } from './DropdownMenu';
import { Link, type LinkInsightsProps, type LinkProps } from './Link';
/**
* Enrich a site auth login link with the current location relative to the site URL.
*/
function useSiteAuthLoginHrefWithLocation(href: string) {
const rawPathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams?.toString();
const pathname = rawPathname ?? '/';
return useMemo(() => {
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
const siteBasePath = removeTrailingSlash(
resolved.pathname.replace(/\/~gitbook\/auth\/login\/?$/, '')
);
const locationPath =
siteBasePath && pathname.startsWith(`${siteBasePath}/`)
? pathname.slice(siteBasePath.length)
: pathname === siteBasePath
? '/'
: pathname;
resolved.searchParams.set(
'location',
`${locationPath}${currentSearch ? `?${currentSearch}` : ''}`
);
return href.startsWith('http')
? resolved.toString()
: `${resolved.pathname}${resolved.search}`;
}, [currentSearch, href, pathname]);
}
/**
* Link component that preserves the current page location through the auth login flow.
*/
export function SiteAuthLoginLink(props: LinkProps) {
const href = useSiteAuthLoginHrefWithLocation(props.href);
return <Link {...props} href={href} />;
}
/**
* Button variant of SiteAuthLoginLink.
*/
export function SiteAuthLoginButton(props: ButtonProps) {
const href = useSiteAuthLoginHrefWithLocation(props.href ?? '#');
return <Button {...props} href={href} />;
}
/**
* Dropdown menu item variant of SiteAuthLoginLink.
*/
export function SiteAuthLoginDropdownMenuItem(
props: {
href: string;
target?: React.HTMLAttributeAnchorTarget;
active?: boolean;
className?: string;
children: React.ReactNode;
leadingIcon?: React.ReactNode | string;
} & LinkInsightsProps
) {
const href = useSiteAuthLoginHrefWithLocation(props.href);
return <DropdownMenuItem {...props} href={href} />;
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'bun:test';
import { isSiteAuthLoginHref } from './auth-login-link';
import { createLinker, linkerForPublishedURL } from './links';
type SiteFixture = {
label: string;
publishedURL: string;
siteBasePath: string;
localhostLoginURL: string;
previewLoginURL: string;
publishedLoginURL: string;
};
const previewHost = '41ba7c7e-gitbook-open-v2-preview.gitbook.workers.dev';
const fixtures: SiteFixture[] = [
{
label: 'proxy sites',
publishedURL: 'https://gitbook.com/docs',
siteBasePath: '/url/gitbook.com/docs',
localhostLoginURL: 'https://localhost:3000/url/gitbook.com/docs/~gitbook/auth/login',
previewLoginURL: `https://${previewHost}/url/gitbook.com/docs/~gitbook/auth/login`,
publishedLoginURL: 'https://gitbook.com/docs/~gitbook/auth/login',
},
{
label: 'GitBook-hosted site with a path prefix',
publishedURL: 'https://gitbook.gitbook.io/test/',
siteBasePath: '/url/gitbook.gitbook.io/test',
localhostLoginURL: 'https://localhost:3000/url/gitbook.gitbook.io/test/~gitbook/auth/login',
previewLoginURL: `https://${previewHost}/url/gitbook.gitbook.io/test/~gitbook/auth/login`,
publishedLoginURL: 'https://gitbook.gitbook.io/test/~gitbook/auth/login',
},
{
label: 'custom domain at the root path',
publishedURL: 'https://docs.acme.org/',
siteBasePath: '/url/docs.acme.org',
localhostLoginURL: 'https://localhost:3000/url/docs.acme.org/~gitbook/auth/login',
previewLoginURL: `https://${previewHost}/url/docs.acme.org/~gitbook/auth/login`,
publishedLoginURL: 'https://docs.acme.org/~gitbook/auth/login',
},
];
function createURLModeSiteLinker(currentHost: string, siteBasePath: string, publishedURL: string) {
const linker = createLinker({
protocol: 'https:',
host: currentHost,
siteBasePath,
spaceBasePath: `${siteBasePath}/getting-started`,
});
return linkerForPublishedURL(linker, publishedURL);
}
describe('isSiteAuthLoginHref', () => {
describe.each(fixtures)('$label', (fixture) => {
it('matches the localhost absolute login URL for the current site', () => {
const linker = createURLModeSiteLinker(
'localhost:3000',
fixture.siteBasePath,
fixture.publishedURL
);
expect(isSiteAuthLoginHref(linker, fixture.localhostLoginURL)).toBe(true);
});
it('matches the workers preview absolute login URL for the current site', () => {
const linker = createURLModeSiteLinker(
previewHost,
fixture.siteBasePath,
fixture.publishedURL
);
expect(isSiteAuthLoginHref(linker, fixture.previewLoginURL)).toBe(true);
});
it('matches the published-site absolute login URL when served through preview', () => {
const linker = createURLModeSiteLinker(
previewHost,
fixture.siteBasePath,
fixture.publishedURL
);
expect(isSiteAuthLoginHref(linker, fixture.publishedLoginURL)).toBe(true);
});
});
});
@@ -0,0 +1,36 @@
import type { GitBookLinker } from './links';
import { removeTrailingSlash } from './paths';
/**
* Check if an href points to the current site's auth login route.
*/
export function isSiteAuthLoginHref(
linker: Pick<GitBookLinker, 'toPathInSite' | 'toAbsoluteURL' | 'toLinkForContent'>,
href?: string | null
) {
if (!href) {
return false;
}
const relativeLoginHref = linker.toPathInSite('~gitbook/auth/login');
const absoluteLoginHref = linker.toAbsoluteURL(relativeLoginHref);
const siteRelativeHref = URL.canParse(href) ? linker.toLinkForContent(href) : href;
return (
normalizeHref(href) === normalizeHref(relativeLoginHref) ||
normalizeHref(href) === normalizeHref(absoluteLoginHref) ||
normalizeHref(siteRelativeHref) === normalizeHref(relativeLoginHref)
);
}
/**
* Normalize an href so login-route comparisons ignore trailing slashes.
*/
function normalizeHref(href: string) {
if (URL.canParse(href)) {
const url = new URL(href);
return `${url.origin}${removeTrailingSlash(url.pathname)}`;
}
return removeTrailingSlash(href);
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { GitBookSiteContext } from '@/lib/context';
import type {
LocalizedString,
LocalizedTitle,
SiteSection,
SiteSectionGroup,
SiteSpace,
@@ -189,7 +189,7 @@ function findSiteSpaceByIdInSiteSpaces(
* Get the localized title for a site entity (SiteSection, SiteSectionGroup, or SiteSpace).
*/
export function getLocalizedTitle(
entity: { title: string; localizedTitle?: LocalizedString },
entity: { title: string; localizedTitle?: LocalizedTitle },
currentLanguage: TranslationLanguage | undefined
): string {
return getLocalizedField(entity.localizedTitle, currentLanguage) ?? entity.title;
@@ -199,7 +199,7 @@ export function getLocalizedTitle(
* Get the localized description for a site entity.
*/
export function getLocalizedDescription(
entity: { description?: string; localizedDescription?: LocalizedString },
entity: { description?: string; localizedDescription?: LocalizedTitle },
currentLanguage: TranslationLanguage | undefined
): string | undefined {
return getLocalizedField(entity.localizedDescription, currentLanguage) ?? entity.description;
@@ -209,7 +209,7 @@ export function getLocalizedDescription(
* Get a localized field value for the given language.
*/
function getLocalizedField(
localizedField: LocalizedString | undefined,
localizedField: LocalizedTitle | undefined,
currentLanguage: TranslationLanguage | undefined
): string | undefined {
if (localizedField && currentLanguage && localizedField[currentLanguage]) {
+2 -1
View File
@@ -681,8 +681,9 @@ function encodePathInSiteContent(
return { pathname, routeType: 'static' };
case '~gitbook/pdf':
case '~gitbook/search':
case '~gitbook/auth/login':
case '~scalar/proxy':
// PDF and search routes are always dynamic as they depend on the request.
// PDF, search and auth routes are always dynamic as they depend on the request.
return { pathname, routeType: 'dynamic' };
default: {
// If the pathname is a markdown file or the request is accepting markdown,