Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: anonymous filtering in jwt token #1626

Merged
merged 2 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,36 @@
import structuredClone from "@ungap/structured-clone";
import { DeepReadonly } from "ts-essentials";
import { FernNavigation } from "../..";
import { bfs } from "../../utils/traversers/bfs";
import { prunetree } from "../../utils/traversers/prunetree";
import { mutableDeleteChild } from "./deleteChild";
import { hasChildren } from "./hasChildren";
import { mutableUpdatePointsTo } from "./updatePointsTo";

/**
* @param root the root node of the navigation tree
* @param keep a function that returns true if the node should be kept
* @param hide a function that returns true if the node should be hidden
* @returns a new navigation tree with only the nodes that should be kept
*/
export function pruneNavigationTree<ROOT extends FernNavigation.NavigationNode>(
root: DeepReadonly<ROOT>,
keep: (node: FernNavigation.NavigationNode) => boolean,
keep?: (node: FernNavigation.NavigationNode) => boolean,
hide?: (node: FernNavigation.NavigationNodeWithMetadata) => boolean,
): ROOT | undefined {
const clone = structuredClone(root) as ROOT;
return mutablePruneNavigationTree(clone, keep);
return mutablePruneNavigationTree(clone, keep, hide);
}

function mutablePruneNavigationTree<ROOT extends FernNavigation.NavigationNode>(
root: ROOT,
keep: (node: FernNavigation.NavigationNode) => boolean,
keep: (node: FernNavigation.NavigationNode) => boolean = () => true,
hide: (node: FernNavigation.NavigationNodeWithMetadata) => boolean = () => false,
): ROOT | undefined {
const [result] = prunetree(root, {
predicate: keep,
getChildren: FernNavigation.getChildren,
getPointer: (node) => node.id,
deleter: mutableDeleteChild,

// after deletion, if the node no longer has any children, we can delete the parent node too
// but only if the parent node is NOT a visitable page
// shouldDeleteParent: (parent: FernNavigation.NavigationNodeParent) =>
// !hasChildren(parent) && !FernNavigation.isPage(parent),
});

if (result == null) {
Expand All @@ -42,5 +40,16 @@ function mutablePruneNavigationTree<ROOT extends FernNavigation.NavigationNode>(
// since the tree has been pruned, we need to update the pointsTo property
mutableUpdatePointsTo(result);

// other operations
bfs(
result,
(node) => {
if (FernNavigation.hasMarkdown(node) && hide(node)) {
node.hidden = true;
}
},
FernNavigation.getChildren,
);

return result;
}
4 changes: 2 additions & 2 deletions packages/ui/docs-bundle/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-tr
import { NextRequest, NextResponse, type NextMiddleware } from "next/server";
import urlJoin from "url-join";
import { verifyFernJWTConfig } from "./server/auth/FernJWT";
import { withBasicTokenPublic } from "./server/withBasicTokenPublic";
import { withBasicTokenAnonymous } from "./server/withBasicTokenAnonymous";

const API_FERN_DOCS_PATTERN = /^(?!\/api\/fern-docs\/).*(\/api\/fern-docs\/)/;
const CHANGELOG_PATTERN = /\.(rss|atom)$/;
Expand Down Expand Up @@ -96,7 +96,7 @@ export const middleware: NextMiddleware = async (request) => {
* redirect to the custom auth provider
*/
if (!isLoggedIn && authConfig?.type === "basic_token_verification") {
if (!withBasicTokenPublic(authConfig, pathname)) {
if (!withBasicTokenAnonymous(authConfig, pathname)) {
const destination = new URL(authConfig.redirect);
destination.searchParams.set("state", urlJoin(withDefaultProtocol(xFernHost), pathname));
// TODO: validate allowlist of domains to prevent open redirects
Expand Down
12 changes: 8 additions & 4 deletions packages/ui/docs-bundle/src/server/DocsLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { AuthEdgeConfig } from "@fern-ui/fern-docs-auth";
import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config";
import { AuthProps, withAuthProps } from "./authProps";
import { loadWithUrl } from "./loadWithUrl";
import { pruneWithBasicTokenPublic } from "./withBasicTokenPublic";
import { pruneWithBasicTokenAnonymous, pruneWithBasicTokenAuthed } from "./withBasicTokenAnonymous";

interface DocsLoaderFlags {
isBatchStreamToggleDisabled: boolean;
Expand Down Expand Up @@ -104,10 +104,14 @@ export class DocsLoader {
// if the user is not authenticated, and the page requires authentication, prune the navigation tree
// to only show pages that are allowed to be viewed without authentication.
// note: the middleware will not show this page at all if the user is not authenticated.
if (node && authConfig?.type === "basic_token_verification" && !auth) {
if (node) {
try {
// TODO: store this in cache
node = pruneWithBasicTokenPublic(authConfig, node);
if (authConfig?.type === "basic_token_verification") {
// TODO: store this in cache
node = !auth
? pruneWithBasicTokenAnonymous(authConfig, node)
: pruneWithBasicTokenAuthed(authConfig, node);
}
} catch (e) {
// TODO: sentry
// eslint-disable-next-line no-console
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
import { NodeId, PageId, Slug, Url } from "@fern-api/fdr-sdk/navigation";
import { withBasicTokenPublic, withBasicTokenPublicCheck } from "../withBasicTokenPublic";
import { withBasicTokenAnonymous, withBasicTokenAnonymousCheck } from "../withBasicTokenAnonymous";

describe("withBasicTokenPublic", () => {
describe("withBasicTokenAnonymous", () => {
it("should deny the request if the allowlist is empty", () => {
expect(withBasicTokenPublic({}, "/public")).toBe(false);
expect(withBasicTokenPublic({ allowlist: [] }, "/public")).toBe(false);
expect(withBasicTokenAnonymous({}, "/public")).toBe(false);
expect(withBasicTokenAnonymous({ allowlist: [] }, "/public")).toBe(false);
});

it("should allow the request to pass through if the path is in the allowlist", () => {
expect(withBasicTokenPublic({ allowlist: ["/public"] }, "/public")).toBe(true);
expect(withBasicTokenAnonymous({ allowlist: ["/public"] }, "/public")).toBe(true);
});

it("should allow the request to pass through if the path matches a regex in the allowlist", () => {
expect(withBasicTokenPublic({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(true);
expect(withBasicTokenAnonymous({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(true);
});

it("should allow the request to pass through if the path matches a path expression in the allowlist", () => {
expect(withBasicTokenPublic({ allowlist: ["/public/:id"] }, "/public/123")).toBe(true);
expect(withBasicTokenAnonymous({ allowlist: ["/public/:id"] }, "/public/123")).toBe(true);
});

it("should not allow the request to pass through if the path is not in the allowlist", () => {
expect(withBasicTokenPublic({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(false);
expect(withBasicTokenPublic({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(false);
expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(false);
expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(false);
});

it("shouuld respect denylist before allowlist", () => {
expect(withBasicTokenPublic({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(false);
expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(false);
});

it("should never deny external links", () => {
expect(
withBasicTokenPublicCheck({ denylist: ["/(.*)"] })({
withBasicTokenAnonymousCheck({ denylist: ["/(.*)"] })({
type: "link",
url: Url("https://example.com"),
title: "External url",
Expand All @@ -42,7 +42,7 @@ describe("withBasicTokenPublic", () => {

it("should prune childless non-leaf nodes", () => {
expect(
withBasicTokenPublicCheck({ allowlist: ["/public"] })({
withBasicTokenAnonymousCheck({ allowlist: ["/public"] })({
type: "section",
title: "Public",
children: [],
Expand All @@ -61,7 +61,7 @@ describe("withBasicTokenPublic", () => {

it("should not prune childless non-leaf nodes that have content", () => {
expect(
withBasicTokenPublicCheck({ allowlist: ["/public"] })({
withBasicTokenAnonymousCheck({ allowlist: ["/public"] })({
type: "section",
title: "Public",
children: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AuthEdgeConfig } from "@fern-ui/fern-docs-auth";
import { COOKIE_FERN_TOKEN } from "@fern-ui/fern-docs-utils";
import type { NextRequest } from "next/server";
import { withBasicTokenPublic } from "../withBasicTokenPublic";
import { withBasicTokenAnonymous } from "../withBasicTokenAnonymous";
import { verifyFernJWT } from "./FernJWT";

export async function checkViewerAllowedEdge(auth: AuthEdgeConfig | undefined, req: NextRequest): Promise<number> {
Expand All @@ -16,7 +16,7 @@ export async function checkViewerAllowedPathname(
fernToken: string | undefined,
): Promise<number> {
if (auth?.type === "basic_token_verification") {
if (withBasicTokenPublic(auth, pathname)) {
if (withBasicTokenAnonymous(auth, pathname)) {
return 200;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { matchPath } from "@fern-ui/fern-docs-utils";
* @param pathname pathname of the request to check
* @returns true if the request is allowed to pass through, false otherwise
*/
export function withBasicTokenPublic(
auth: Pick<AuthEdgeConfigBasicTokenVerification, "allowlist" | "denylist">,
export function withBasicTokenAnonymous(
auth: Pick<AuthEdgeConfigBasicTokenVerification, "allowlist" | "denylist" | "anonymous">,
pathname: string,
): boolean {
// if the path is in the denylist, deny the request
Expand All @@ -17,7 +17,10 @@ export function withBasicTokenPublic(
}

// if the path is in the allowlist, allow the request to pass through
if (auth.allowlist?.find((path) => matchPath(path, pathname))) {
if (
auth.allowlist?.find((path) => matchPath(path, pathname)) ||
auth.anonymous?.find((path) => matchPath(path, pathname))
) {
return true;
}

Expand All @@ -28,12 +31,12 @@ export function withBasicTokenPublic(
/**
* @internal visibleForTesting
*/
export function withBasicTokenPublicCheck(
auth: Pick<AuthEdgeConfigBasicTokenVerification, "allowlist" | "denylist">,
export function withBasicTokenAnonymousCheck(
auth: Pick<AuthEdgeConfigBasicTokenVerification, "allowlist" | "denylist" | "anonymous">,
): (node: NavigationNode) => boolean {
return (node: NavigationNode) => {
if (isPage(node)) {
return withBasicTokenPublic(auth, `/${node.slug}`);
return withBasicTokenAnonymous(auth, `/${node.slug}`);
} else if (!isLeaf(node) && getChildren(node).length === 0) {
return false;
}
Expand All @@ -42,8 +45,8 @@ export function withBasicTokenPublicCheck(
};
}

export function pruneWithBasicTokenPublic(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode {
const result = utils.pruneNavigationTree(node, withBasicTokenPublicCheck(auth));
export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode {
const result = utils.pruneNavigationTree(node, withBasicTokenAnonymousCheck(auth));

// TODO: handle this more gracefully
if (result == null) {
Expand All @@ -52,3 +55,19 @@ export function pruneWithBasicTokenPublic(auth: AuthEdgeConfigBasicTokenVerifica

return result;
}

export function pruneWithBasicTokenAuthed(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode {
const result = utils.pruneNavigationTree(
node,
// do not delete any nodes
() => true,
// hide nodes that are in the anonymous list
(n) => auth.anonymous?.find((path) => matchPath(path, `/${n.slug}`)) != null,
);

if (result == null) {
throw new Error("Failed to prune navigation tree");
}

return result;
}
13 changes: 12 additions & 1 deletion packages/ui/docs-bundle/src/server/withInitialProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,17 @@ export async function withInitialProps({
}
}

// prune the navigation tree to remove hidden nodes, unless it is the current node
const sidebar =
node.sidebar != null
? FernNavigation.utils.pruneNavigationTree(node.sidebar, (n) => {
if (FernNavigation.hasMetadata(n) && n.hidden) {
return n.id === node.node.id;
}
return true;
})
: undefined;

const props: ComponentProps<typeof DocsPage> = {
baseUrl: docs.baseUrl,
layout: docs.definition.config.layout,
Expand Down Expand Up @@ -251,7 +262,7 @@ export async function withInitialProps({
),
currentVersionId: node.currentVersion?.versionId,
versions,
sidebar: node.sidebar,
sidebar,
trailingSlash: isTrailingSlashEnabled(),
},
featureFlags,
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/fern-docs-auth/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({
description: "List of pages (regexp allowed) that are private and require authentication",
})
.optional(),
anonymous: z
.array(z.string(), {
description:
"List of pages (regexp allowed) that are public and do not require authentication, but are hidden when the user is authenticated",
})
.optional(),
});

export const AuthEdgeConfigSchema = z.union([
Expand Down
Loading