Skip to content

Commit

Permalink
fix: logout, then redirect to configured url (#1622)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Oct 9, 2024
1 parent 4d3f594 commit a7d3e59
Show file tree
Hide file tree
Showing 16 changed files with 90 additions and 20 deletions.
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 @@ -71,6 +75,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
7 changes: 4 additions & 3 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts
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 @@ -57,6 +60,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 @@ -32,6 +32,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

0 comments on commit a7d3e59

Please sign in to comment.