Skip to content

Commit

Permalink
fix: require authentication on all endpoints (#1697)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Oct 24, 2024
1 parent 5c3e64b commit 9d3eff1
Show file tree
Hide file tree
Showing 54 changed files with 658 additions and 419 deletions.
1 change: 1 addition & 0 deletions .github/workflows/healthcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ env:
VERCEL_ORG_ID: team_6FKOM5nw037hv8g2mTk3gaH7
VERCEL_PROJECT_ID: prj_QX3venU6jwRUmdt8ArfL8AU5r1d4
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
FERN_TOKEN: ${{ secrets.FERN_TOKEN }}

jobs:
run:
Expand Down
2 changes: 1 addition & 1 deletion fern/apis/fdr/definition/api/v1/read/__package__.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ imports:

service:
base-path: /registry/api
auth: false
auth: true
audiences:
- read
endpoints:
Expand Down
2 changes: 1 addition & 1 deletion fern/apis/fdr/definition/docs/v1/read/__package__.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ imports:

service:
base-path: /registry/docs
auth: false
auth: true
audiences:
- read
endpoints:
Expand Down
4 changes: 2 additions & 2 deletions fern/apis/fdr/definition/docs/v2/read/__package__.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ imports:

service:
base-path: /v2/registry/docs
auth: false
auth: true
audiences:
- read
endpoints:
Expand Down Expand Up @@ -37,6 +37,7 @@ service:
- rootCommons.UnauthorizedError

getPrivateDocsForUrl:
availability: deprecated
method: POST
auth: true
path: /private/load-with-url
Expand All @@ -52,7 +53,6 @@ service:

listAllDocsUrls:
method: GET
auth: true
path: /urls/list
request:
name: ListAllDocsUrlsRequest
Expand Down
2 changes: 1 addition & 1 deletion packages/configs/tsconfig/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noImplicitThis": false,
"noImplicitThis": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ describe("slugjoin", () => {
expect(slugjoin("a", "b", "c", "d")).toBe("a/b/c/d");
expect(slugjoin("a ", " b", "c/ ", "", " / d", "e ")).toBe("a/b/c/ d/e");
expect(slugjoin("a", " ", " ", " / / ", "e")).toBe("a/ /e");
});

it("should join slugs from messy params", () => {
expect(slugjoin()).toBe("");
expect(slugjoin(undefined)).toBe("");
expect(slugjoin(null)).toBe("");
expect(slugjoin(null, null, "a")).toBe("a");
expect(slugjoin("a")).toBe("a");
expect(slugjoin(["a", "b"])).toBe("a/b");
expect(slugjoin(undefined, ["a", "b"], null, ["c"], "d")).toBe("a/b/c/d");
});
});
13 changes: 6 additions & 7 deletions packages/fdr-sdk/src/navigation/versions/latest/slugjoin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { isNonNullish } from "@fern-api/ui-core-utils";
import urljoin from "url-join";
import { Slug } from ".";

// normalizes slug parts and joins them with a single slash
export function slugjoin(...parts: string[]): Slug {
return Slug(
urljoin(parts.map((part) => part.trim()))
.replaceAll("//*", "/")
.replace(/^\//, "")
.replace(/\/$/, ""),
);
export function slugjoin(...parts: (string | string[] | null | undefined)[]): Slug {
const slugArray = parts
.filter(isNonNullish)
.flatMap((part) => (typeof part === "string" ? [part.trim()] : part.map((part) => part.trim())));
return Slug(urljoin(slugArray).replaceAll("//*", "/").replace(/^\//, "").replace(/\/$/, ""));
}
2 changes: 1 addition & 1 deletion packages/healthchecks/src/rules/runRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function getAllRules(): Rule[] {
return [new AllPagesLoadRule(), new SearchSlugsCorrectRule()];
}

const FDR_CLIENT = new FdrClient({ environment: "https://registry.buildwithfern.com" });
const FDR_CLIENT = new FdrClient({ environment: "https://registry.buildwithfern.com", token: process.env.FERN_TOKEN });

export async function runRules({ url }: { url: string }): Promise<RuleResult[]> {
const rules = getAllRules();
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ export type { ProxyRequest, ProxyResponse, SerializableFile, SerializableFormDat
export { resolveDocsContent } from "./resolver/resolveDocsContent";
export { getBreadcrumbList } from "./seo/getBreadcrumbList";
export { getSeoProps } from "./seo/getSeoProp";
export { getRegistryServiceWithToken, provideRegistryService } from "./services/registry";
export { provideRegistryService } from "./services/registry";
export { renderThemeStylesheet } from "./themes/stylesheet/renderThemeStylesheet";
export { getGitHubInfo, getGitHubRepo } from "./util/github";
14 changes: 10 additions & 4 deletions packages/ui/app/src/services/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ function getEnvironment() {
return process.env.NEXT_PUBLIC_FDR_ORIGIN ?? "https://registry.buildwithfern.com";
}

export const provideRegistryService = once(() => new FdrClient({ environment: getEnvironment() }));

export function getRegistryServiceWithToken(token: string): FdrClient {
return new FdrClient({ environment: getEnvironment(), token });
function getFernToken() {
const fernToken = process.env.FERN_TOKEN;
if (!fernToken) {
throw new Error("FERN_TOKEN is not set");
}
return fernToken;
}

export const provideRegistryService = once(
() => new FdrClient({ environment: getEnvironment(), token: getFernToken() }),
);
131 changes: 10 additions & 121 deletions packages/ui/docs-bundle/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
import { extractBuildId, extractNextDataPathname } from "@/server/extractNextDataPathname";
import { getNextDataPageRoute, getPageRoute } from "@/server/pageRoutes";
import { extractNextDataPathname } from "@/server/extractNextDataPathname";
import { rewritePosthog } from "@/server/rewritePosthog";
import { getDocsDomainEdge } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import type { AuthEdgeConfig, FernUser } from "@fern-ui/fern-docs-auth";
import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config";
import { COOKIE_FERN_TOKEN } from "@fern-ui/fern-docs-utils";
import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-trailing-slash";
import { NextRequest, NextResponse, type NextMiddleware } from "next/server";
import urlJoin from "url-join";
import { verifyFernJWTConfig } from "./server/auth/FernJWT";
import { withBasicTokenAnonymous } from "./server/withBasicTokenAnonymous";
import { NextResponse, type NextMiddleware } from "next/server";
import { withMiddlewareAuth } from "./server/withMiddlewareAuth";
import { withMiddlewareRewrite } from "./server/withMiddlewareRewrite";
import { withPathname } from "./server/withPathname";

const API_FERN_DOCS_PATTERN = /^(?!\/api\/fern-docs\/).*(\/api\/fern-docs\/)/;
const CHANGELOG_PATTERN = /\.(rss|atom)$/;

export const middleware: NextMiddleware = async (request) => {
const xFernHost = getDocsDomainEdge(request);
const search = request.nextUrl.search;

const withPathname = (pathname: string): string => {
return `${request.nextUrl.origin}${pathname}${search}`;
};

let pathname = extractNextDataPathname(removeTrailingSlash(request.nextUrl.pathname));

/**
Expand All @@ -37,7 +24,7 @@ export const middleware: NextMiddleware = async (request) => {
return NextResponse.json({}, { status: 404 });
}

let response = NextResponse.rewrite(withPathname(pathname), { request: { headers } });
let response = NextResponse.rewrite(withPathname(request, pathname), { request: { headers } });

if (pathname === request.nextUrl.pathname) {
response = NextResponse.next({ request: { headers } });
Expand All @@ -52,15 +39,15 @@ export const middleware: NextMiddleware = async (request) => {
*/
if (pathname.endsWith("/robots.txt")) {
pathname = "/api/fern-docs/robots.txt";
return NextResponse.rewrite(withPathname(pathname));
return NextResponse.rewrite(withPathname(request, pathname));
}

/**
* Rewrite sitemap.xml
*/
if (pathname.endsWith("/sitemap.xml")) {
pathname = "/api/fern-docs/sitemap.xml";
return NextResponse.rewrite(withPathname(pathname));
return NextResponse.rewrite(withPathname(request, pathname));
}

/**
Expand All @@ -75,7 +62,7 @@ export const middleware: NextMiddleware = async (request) => {
*/
if (pathname.match(API_FERN_DOCS_PATTERN)) {
pathname = request.nextUrl.pathname.replace(API_FERN_DOCS_PATTERN, "/api/fern-docs/");
return NextResponse.rewrite(withPathname(pathname));
return NextResponse.rewrite(withPathname(request, pathname));
}

/**
Expand All @@ -90,97 +77,7 @@ export const middleware: NextMiddleware = async (request) => {
return NextResponse.rewrite(String(url));
}

const fernToken = request.cookies.get(COOKIE_FERN_TOKEN);
let authConfig: AuthEdgeConfig | undefined;

try {
authConfig = await getAuthEdgeConfig(xFernHost);
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to get auth config", e);
}

let fernUser: FernUser | undefined;

// TODO: check if the site is SSO protected, and if so, redirect to the SSO provider
if (fernToken != null) {
try {
fernUser = await verifyFernJWTConfig(fernToken?.value, authConfig);
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to verify fern_token", e);
}
}

const isLoggedIn = fernUser != null;

/**
* if using custom auth (e.g. qlty, propexo, etc), and the user is not authenticated,
* redirect to the custom auth provider
*/
if (!isLoggedIn && authConfig?.type === "basic_token_verification") {
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
return NextResponse.redirect(destination);
}
}

/**
* error=true is a hack to force dynamic rendering when `_error.ts` is rendered.
*
* This is because: sometimes SSR'd markdown content throws an error during rendering,
* and we want to show a partially errored page to the user.
*/
const hasError = request.nextUrl.searchParams.get("error") === "true";

/**
* There are two types of pages in the docs bundle:
* - static = SSG pages
* - dynamic = SSR pages (because fern_token is present or there is an error)
*/
const isDynamic = isLoggedIn || hasError;

/**
* Mock the /_next/data/... request to the corresponding page route
*/
if (request.nextUrl.pathname.includes("/_next/data/")) {
const buildId = getBuildId(request);

const headers = new Headers(request.headers);
headers.set("x-nextjs-data", "1");

const rewrittenPathname = pathname;

pathname = getPageRoute(!isDynamic, xFernHost, rewrittenPathname);

// NOTE: skipMiddlewareUrlNormalize=true must be set for this to work
// if the request is not in the /_next/data/... path, we need to rewrite to the full /_next/data/... path
if (!request.nextUrl.pathname.startsWith("/_next/data/") && process.env.NODE_ENV === "development") {
pathname = getNextDataPageRoute(!isDynamic, buildId, xFernHost, rewrittenPathname);
}

let response = NextResponse.rewrite(withPathname(pathname), { request: { headers } });

if (pathname === request.nextUrl.pathname) {
response = NextResponse.next({ request: { headers } });
}

/**
* Add x-matched-path header so the client can detect original path (despite a forward-proxy nextjs middleware rewriting to it)
*/
response.headers.set("x-matched-path", getNextDataPageRoute(!isDynamic, buildId, xFernHost, rewrittenPathname));

return response;
}

/**
* Rewrite all other requests to /static/[domain]/[[...slug]] or /dynamic/[domain]/[[...slug]]
*/

pathname = getPageRoute(!isDynamic, xFernHost, pathname);
return NextResponse.rewrite(withPathname(pathname));
return withMiddlewareAuth(request, pathname, withMiddlewareRewrite(request, pathname));
};

export const config = {
Expand All @@ -200,11 +97,3 @@ export const config = {
"/((?!api/fern-docs|_next/static|_next/image|_vercel|favicon.ico).*)",
],
};

function getBuildId(req: NextRequest): string {
return (
req.nextUrl.buildId ??
extractBuildId(req.nextUrl.pathname) ??
(process.env.NODE_ENV === "development" ? "development" : "")
);
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import { getDocsDomainNode } from "@/server/xfernhost/node";
import { getAuthStateNode } from "@/server/auth/getAuthStateNode";
import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition";
import { getFeatureFlags } from "@fern-ui/fern-docs-edge-config";
import { ApiDefinitionLoader } from "@fern-ui/fern-docs-server";
import { getMdxBundler } from "@fern-ui/ui/bundlers";
import { NextApiHandler, NextApiResponse } from "next";

const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse<ApiDefinition.ApiDefinition>) => {
const xFernHost = getDocsDomainNode(req);
const { api, endpoint } = req.query;
if (req.method !== "GET" || typeof api !== "string" || typeof endpoint !== "string") {
res.status(400).end();
return;
}

const flags = await getFeatureFlags(xFernHost);
// TODO: this auth needs to be more granular: the user should only have access to this api definition if
// - the api definition belongs to this org
// - the user has view access to the the api definition based on their audience
const authState = await getAuthStateNode(req);

if (!authState.ok) {
return res.status(authState.authed ? 403 : 401).end();
}

const flags = await getFeatureFlags(authState.host);

// TODO: pass in other tsx/mdx files to serializeMdx options
const engine = flags.useMdxBundler ? "mdx-bundler" : "next-mdx-remote";
const serializeMdx = await getMdxBundler(engine);

// TODO: authenticate the request in FDR
const apiDefinition = await ApiDefinitionLoader.create(xFernHost, ApiDefinition.ApiDefinitionId(api))
const apiDefinition = await ApiDefinitionLoader.create(authState.host, ApiDefinition.ApiDefinitionId(api))
.withFlags(flags)
.withMdxBundler(serializeMdx, engine)
.withPrune({ type: "endpoint", endpointId: ApiDefinition.EndpointId(endpoint) })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import { getDocsDomainNode } from "@/server/xfernhost/node";
import { getAuthStateNode } from "@/server/auth/getAuthStateNode";
import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition";
import { getFeatureFlags } from "@fern-ui/fern-docs-edge-config";
import { ApiDefinitionLoader } from "@fern-ui/fern-docs-server";
import { getMdxBundler } from "@fern-ui/ui/bundlers";
import { NextApiHandler, NextApiResponse } from "next";

const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse<ApiDefinition.ApiDefinition>) => {
const xFernHost = getDocsDomainNode(req);
const { api, webhook } = req.query;
if (req.method !== "GET" || typeof api !== "string" || typeof webhook !== "string") {
res.status(400).end();
return;
}

const flags = await getFeatureFlags(xFernHost);
// TODO: this auth needs to be more granular: the user should only have access to this api definition if
// - the api definition belongs to this org
// - the user has view access to the the api definition based on their audience
const authState = await getAuthStateNode(req);

if (!authState.ok) {
return res.status(authState.authed ? 403 : 401).end();
}

const flags = await getFeatureFlags(authState.host);

// TODO: pass in other tsx/mdx files to serializeMdx options
const engine = flags.useMdxBundler ? "mdx-bundler" : "next-mdx-remote";
const serializeMdx = await getMdxBundler(engine);

// TODO: authenticate the request in FDR
const apiDefinition = await ApiDefinitionLoader.create(xFernHost, ApiDefinition.ApiDefinitionId(api))
const apiDefinition = await ApiDefinitionLoader.create(authState.host, ApiDefinition.ApiDefinitionId(api))
.withFlags(flags)
.withMdxBundler(serializeMdx, engine)
.withPrune({ type: "webhook", webhookId: ApiDefinition.WebhookId(webhook) })
Expand Down
Loading

0 comments on commit 9d3eff1

Please sign in to comment.