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

fix: logout, then redirect to configured url #1622

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
4 changes: 4 additions & 0 deletions packages/commons/core-utils/src/withDefaultProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export function withDefaultProtocol(endpoint: string, defaultProtocol = "https:/
const protocolRegex = /^[a-z]+:\/\//i;

if (!protocolRegex.test(endpoint)) {
if (endpoint === "localhost" || endpoint.startsWith("localhost:")) {
return `http://${endpoint}`;
}

return `${defaultProtocol}${endpoint}`;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/ui/app/src/hooks/useStandardProxyEnvironment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { once } from "lodash-es";
import { useBasePath, useFeatureFlags } from "../atoms";
import { useApiRoute } from "./useApiRoute";
Expand All @@ -12,10 +13,10 @@ export const getAppBuildwithfernCom = once((): string => {
// see: https://vercel.com/docs/projects/environment-variables/system-environment-variables#framework-environment-variables
if (process.env.NEXT_PUBLIC_VERCEL_ENV === "preview" || process.env.NEXT_PUBLIC_VERCEL_ENV === "development") {
// this mimics the behavior of hitting app.buildwithfern.com in a preview environment
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL ?? APP_BUILDWITHFERN_COM}`;
return withDefaultProtocol(process.env.NEXT_PUBLIC_VERCEL_URL ?? APP_BUILDWITHFERN_COM);
}

return `https://${APP_BUILDWITHFERN_COM}`;
return withDefaultProtocol(APP_BUILDWITHFERN_COM);
});

export function useStandardProxyEnvironment(): string {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/app/src/seo/getBreadcrumbList.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { DocsV1Read } from "@fern-api/fdr-sdk/client/types";
import type * as FernDocs from "@fern-api/fdr-sdk/docs";
import * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { JsonLd } from "@fern-ui/next-seo";
import urljoin from "url-join";
import { getFrontmatter } from "../mdx/frontmatter";

function toUrl(domain: string, slug: FernNavigation.Slug): string {
return urljoin(`https://${domain}`, slug);
return urljoin(withDefaultProtocol(domain), slug);
}

export function getBreadcrumbList(
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/docs-bundle/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { extractBuildId, extractNextDataPathname } from "@/server/extractNextDat
import { getPageRoute, getPageRouteMatch, getPageRoutePath } from "@/server/pageRoutes";
import { rewritePosthog } from "@/server/rewritePosthog";
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import type { 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";
Expand Down Expand Up @@ -97,7 +98,8 @@ export const middleware: NextMiddleware = async (request) => {
if (!isLoggedIn && authConfig?.type === "basic_token_verification") {
if (!withBasicTokenPublic(authConfig, pathname)) {
const destination = new URL(authConfig.redirect);
destination.searchParams.set("state", urlJoin(`https://${xFernHost}`, pathname));
destination.searchParams.set("state", urlJoin(withDefaultProtocol(xFernHost), pathname));
// TODO: validate allowlist of domains to prevent open redirects
return NextResponse.redirect(destination);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { signFernJWT } from "@/server/auth/FernJWT";
import { withSecureCookie } from "@/server/auth/withSecure";
import { safeUrl } from "@/server/safeUrl";
import { getWorkOS, getWorkOSClientId } from "@/server/workos";
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { FernUser } from "@fern-ui/fern-docs-auth";
import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config";
import { COOKIE_FERN_TOKEN, HEADER_X_FERN_HOST } from "@fern-ui/fern-docs-utils";
Expand All @@ -12,6 +14,7 @@ export const runtime = "edge";
function redirectWithLoginError(location: string, errorMessage: string): NextResponse {
const url = new URL(location);
url.searchParams.set("loginError", errorMessage);
// TODO: validate allowlist of domains to prevent open redirects
return NextResponse.redirect(url.toString());
}

Expand All @@ -26,7 +29,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {
const state = req.nextUrl.searchParams.get("state");
const error = req.nextUrl.searchParams.get("error");
const error_description = req.nextUrl.searchParams.get("error_description");
const redirectLocation = state ?? `https://${domain}/`;
const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req));

if (error != null) {
return redirectWithLoginError(redirectLocation, error_description ?? error);
Expand All @@ -52,6 +55,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {
nextUrl.host = req.headers.get(HEADER_X_FERN_HOST)!;
}

// TODO: validate allowlist of domains to prevent open redirects
return NextResponse.redirect(nextUrl);
}

Expand All @@ -73,6 +77,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {

const token = await signFernJWT(fernUser, user);

// TODO: validate allowlist of domains to prevent open redirects
const res = NextResponse.redirect(redirectLocation);
res.cookies.set(COOKIE_FERN_TOKEN, token, withSecureCookie());
return res;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { verifyFernJWTConfig } from "@/server/auth/FernJWT";
import { withSecureCookie } from "@/server/auth/withSecure";
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { safeUrl } from "@/server/safeUrl";
import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config";
import { COOKIE_FERN_TOKEN } from "@fern-ui/fern-docs-utils";
import { NextRequest, NextResponse } from "next/server";
Expand All @@ -10,6 +12,7 @@ export const runtime = "edge";
function redirectWithLoginError(location: string, errorMessage: string): NextResponse {
const url = new URL(location);
url.searchParams.set("loginError", errorMessage);
// TODO: validate allowlist of domains to prevent open redirects
return NextResponse.redirect(url.toString());
}

Expand All @@ -24,7 +27,7 @@ export default async function handler(req: NextRequest): Promise<NextResponse> {
// since we expect the callback to be redirected to, the token will be in the query params
const token = req.nextUrl.searchParams.get(COOKIE_FERN_TOKEN);
const state = req.nextUrl.searchParams.get("state");
const redirectLocation = state ?? `https://${domain}/`;
const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req));

if (edgeConfig?.type !== "basic_token_verification" || token == null) {
// eslint-disable-next-line no-console
Expand All @@ -35,6 +38,7 @@ export default async function handler(req: NextRequest): Promise<NextResponse> {
try {
await verifyFernJWTConfig(token, edgeConfig);

// TODO: validate allowlist of domains to prevent open redirects
const res = NextResponse.redirect(redirectLocation);
res.cookies.set(COOKIE_FERN_TOKEN, token, withSecureCookie());
return res;
Expand Down
12 changes: 10 additions & 2 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { safeUrl } from "@/server/safeUrl";
import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config";
import { COOKIE_ACCESS_TOKEN, COOKIE_FERN_TOKEN, COOKIE_REFRESH_TOKEN } from "@fern-ui/fern-docs-utils";
import { NextRequest, NextResponse } from "next/server";

Expand All @@ -7,8 +10,13 @@ export const runtime = "edge";
export default async function GET(req: NextRequest): Promise<NextResponse> {
const domain = getXFernHostEdge(req);

const authConfig = await getAuthEdgeConfig(domain);
const logoutUrl = authConfig?.type === "basic_token_verification" ? authConfig.logout : undefined;

const state = req.nextUrl.searchParams.get("state");
const redirectLocation = state ?? `https://${domain}/`;

const redirectLocation =
safeUrl(logoutUrl) ?? safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req));

const res = NextResponse.redirect(redirectLocation);
res.cookies.delete(COOKIE_FERN_TOKEN);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { getXFernHostNode } from "@/server/xfernhost/node";
import type { DocsV1Read } from "@fern-api/fdr-sdk/client/types";
import * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { NodeCollector } from "@fern-api/fdr-sdk/navigation";
import { assertNever } from "@fern-api/ui-core-utils";
import { assertNever, withDefaultProtocol } from "@fern-api/ui-core-utils";
import { COOKIE_FERN_TOKEN } from "@fern-ui/fern-docs-utils";
import { getFrontmatter } from "@fern-ui/ui";
import { Feed, Item } from "feed";
import { NextApiRequest, NextApiResponse } from "next";
import urlJoin from "url-join";

export const revalidate = 60 * 60 * 24;

Expand Down Expand Up @@ -43,7 +44,7 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA
return res.status(404).end();
}

const link = `https://${xFernHost}/${node.slug}`;
const link = urlJoin(withDefaultProtocol(xFernHost), node.slug);

const feed = new Feed({
id: link,
Expand Down Expand Up @@ -92,7 +93,7 @@ function toFeedItem(
): Item {
const item: Item = {
title: entry.title,
link: `https://${xFernHost}/${entry.slug}`,
link: urlJoin(withDefaultProtocol(xFernHost), entry.slug),
date: new Date(entry.date),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { COOKIE_EMAIL } from "@fern-ui/fern-docs-utils";
import { NextRequest, NextResponse } from "next/server";

Expand All @@ -6,7 +8,8 @@ export const runtime = "edge";
export default async function handler(req: NextRequest): Promise<NextResponse> {
const email = req.nextUrl.searchParams.get(COOKIE_EMAIL);

const res = NextResponse.redirect(new URL("/", req.url));
// TODO: validate allowlist of domains to prevent open redirects
const res = NextResponse.redirect(withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req)));

if (email) {
res.cookies.set({ name: COOKIE_EMAIL, value: email });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { signFernJWT } from "@/server/auth/FernJWT";
import { OAuth2Client } from "@/server/auth/OAuth2Client";
import { withSecureCookie } from "@/server/auth/withSecure";
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { safeUrl } from "@/server/safeUrl";
import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { FernUser, OryAccessTokenSchema } from "@fern-ui/fern-docs-auth";
import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config";
import { COOKIE_ACCESS_TOKEN, COOKIE_FERN_TOKEN, COOKIE_REFRESH_TOKEN } from "@fern-ui/fern-docs-utils";
Expand All @@ -12,6 +14,7 @@ export const runtime = "edge";
function redirectWithLoginError(location: string, errorMessage: string): NextResponse {
const url = new URL(location);
url.searchParams.set("loginError", errorMessage);
// TODO: validate allowlist of domains to prevent open redirects
return NextResponse.redirect(url.toString());
}

Expand All @@ -26,7 +29,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {
const state = req.nextUrl.searchParams.get("state");
const error = req.nextUrl.searchParams.get("error");
const error_description = req.nextUrl.searchParams.get("error_description");
const redirectLocation = state ?? `https://${domain}/`;
const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req));

if (error != null) {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -59,6 +62,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {
email: token.ext?.email,
};
const expires = token.exp == null ? undefined : new Date(token.exp * 1000);
// TODO: validate allowlist of domains to prevent open redirects
const res = NextResponse.redirect(redirectLocation);
res.cookies.set(COOKIE_FERN_TOKEN, await signFernJWT(fernUser), withSecureCookie({ expires }));
res.cookies.set(COOKIE_ACCESS_TOKEN, access_token, withSecureCookie({ expires }));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { withSecureCookie } from "@/server/auth/withSecure";
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { safeUrl } from "@/server/safeUrl";
import { getXFernHostEdge, getXFernHostHeaderFallbackOrigin } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { getAuthEdgeConfig } from "@fern-ui/fern-docs-edge-config";
import { NextRequest, NextResponse } from "next/server";
import { WebflowClient } from "webflow-api";
Expand All @@ -9,6 +11,7 @@ export const runtime = "edge";
function redirectWithLoginError(location: string, errorMessage: string): NextResponse {
const url = new URL(location);
url.searchParams.set("loginError", errorMessage);
// TODO: validate allowlist of domains to prevent open redirects
return NextResponse.redirect(url.toString());
}

Expand All @@ -23,7 +26,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {
const state = req.nextUrl.searchParams.get("state");
const error = req.nextUrl.searchParams.get("error");
const error_description = req.nextUrl.searchParams.get("error_description");
const redirectLocation = state ?? `https://${domain}/`;
const redirectLocation = safeUrl(state) ?? withDefaultProtocol(getXFernHostHeaderFallbackOrigin(req));

if (error != null) {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -53,6 +56,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {
code,
});

// TODO: validate allowlist of domains to prevent open redirects
const res = NextResponse.redirect(redirectLocation);
res.cookies.set("access_token", accessToken, withSecureCookie());
return res;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { getSeoDisabled } from "@fern-ui/fern-docs-edge-config";
import { NextRequest, NextResponse } from "next/server";
import urlJoin from "url-join";
Expand All @@ -8,7 +9,7 @@ export const runtime = "edge";
export default async function GET(req: NextRequest): Promise<NextResponse> {
const xFernHost = getXFernHostEdge(req);
const basePath = req.nextUrl.pathname.split("/robots.txt")[0] || "";
const sitemap = urlJoin(`https://${xFernHost}`, basePath, "/sitemap.xml");
const sitemap = urlJoin(withDefaultProtocol(xFernHost), basePath, "/sitemap.xml");

if (await getSeoDisabled(xFernHost)) {
return new NextResponse(`User-Agent: *\nDisallow: /\nSitemap: ${sitemap}`, { status: 200 });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FdrAPI } from "@fern-api/fdr-sdk";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { FernVenusApi, FernVenusApiClient } from "@fern-api/venus-api-sdk";
import { provideRegistryService } from "@fern-ui/ui";
import type { Redirect } from "next/types";
Expand All @@ -9,7 +10,7 @@ export async function getUnauthenticatedRedirect(xFernHost: string, path: string
const authorizationUrl = getAuthorizationUrl(
{
organization: await maybeGetWorkosOrganization(xFernHost),
state: urlJoin(`https://${xFernHost}`, path),
state: urlJoin(withDefaultProtocol(xFernHost), path),
},
xFernHost,
);
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/docs-bundle/src/server/safeUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { withDefaultProtocol } from "@fern-api/ui-core-utils";

export function safeUrl(url: string | null | undefined): string | undefined {
if (url == null) {
return undefined;
}

url = withDefaultProtocol(url);

try {
new URL(url);
return url;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return undefined;
}
}
12 changes: 12 additions & 0 deletions packages/ui/docs-bundle/src/server/xfernhost/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ export function getXFernHostEdge(req: NextRequest, useSearchParams = false): str

throw new Error("Could not determine xFernHost from request.");
}

// use this for testing auth-based redirects on development and preview environments
export function getXFernHostHeaderFallbackOrigin(req: NextRequest): string {
if (
process.env.NODE_ENV === "development" ||
process.env.VERCEL_ENV === "preview" ||
process.env.VERCEL_ENV === "development"
) {
return req.nextUrl.host;
}
return cleanHost(req.headers.get(HEADER_X_FERN_HOST)) ?? req.nextUrl.host;
}
1 change: 1 addition & 0 deletions packages/ui/fern-docs-auth/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({
secret: z.string(),
issuer: z.string(),
redirect: z.string(),
logout: z.string().optional(),

allowlist: z
.array(z.string(), {
Expand Down
Loading