diff --git a/packages/ui/app/src/auth/OAuth2Client.ts b/packages/ui/app/src/auth/OAuth2Client.ts index 2d00078391..ffa99f5402 100644 --- a/packages/ui/app/src/auth/OAuth2Client.ts +++ b/packages/ui/app/src/auth/OAuth2Client.ts @@ -16,16 +16,15 @@ export class OAuth2Client { private readonly environment: string; private readonly scope: string | undefined; private readonly jwks: string | undefined; + private readonly redirectUri?: string; - constructor( - config: AuthEdgeConfigOAuth2Ory, - private readonly redirect_uri?: string, - ) { + constructor(config: AuthEdgeConfigOAuth2Ory) { this.clientId = config.clientId; this.clientSecret = config.clientSecret; this.environment = config.environment; this.scope = config.scope; this.jwks = config.jwks; + this.redirectUri = config.redirectUri; } public async getToken(code: string): Promise<OAuthTokenResponse> { @@ -34,8 +33,8 @@ export class OAuth2Client { form.append("client_secret", this.clientSecret); form.append("grant_type", "authorization_code"); form.append("client_id", this.clientId); - if (this.redirect_uri != null) { - form.append("redirect_uri", this.redirect_uri); + if (this.redirectUri != null) { + form.append("redirect_uri", this.redirectUri); } const response = await fetch(urlJoin(this.environment, "/token"), { @@ -55,8 +54,8 @@ export class OAuth2Client { form.append("client_secret", this.clientSecret); form.append("grant_type", "refresh_token"); form.append("client_id", this.clientId); - if (this.redirect_uri != null) { - form.append("redirect_uri", this.redirect_uri); + if (this.redirectUri != null) { + form.append("redirect_uri", this.redirectUri); } const response = await fetch(urlJoin(this.environment, "/token"), { @@ -74,8 +73,8 @@ export class OAuth2Client { const url = new URL(urlJoin(this.environment, "/auth")); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", this.clientId); - if (this.redirect_uri != null) { - url.searchParams.set("redirect_uri", this.redirect_uri); + if (this.redirectUri != null) { + url.searchParams.set("redirect_uri", this.redirectUri); } if (state != null) { url.searchParams.set("state", state); diff --git a/packages/ui/app/src/auth/getApiKeyInjectionConfig.ts b/packages/ui/app/src/auth/getApiKeyInjectionConfig.ts index 23331b9c6e..7291f31628 100644 --- a/packages/ui/app/src/auth/getApiKeyInjectionConfig.ts +++ b/packages/ui/app/src/auth/getApiKeyInjectionConfig.ts @@ -34,7 +34,7 @@ export async function getAPIKeyInjectionConfig( ): Promise<APIKeyInjectionConfig> { const config = await getAuthEdgeConfig(domain); if (config?.type === "oauth2" && config.partner === "ory" && config["api-key-injection-enabled"]) { - const client = new OAuth2Client(config, `https://${domain}/api/auth/callback`); + const client = new OAuth2Client(config); const tokens = cookies != null ? await client.getOrRefreshAccessTokenEdge(cookies) : undefined; if (tokens != null) { @@ -71,7 +71,7 @@ export async function getAPIKeyInjectionConfigNode( ): Promise<APIKeyInjectionConfig> { const config = await getAuthEdgeConfig(domain); if (config?.type === "oauth2" && config.partner === "ory" && config["api-key-injection-enabled"]) { - const client = new OAuth2Client(config, `https://${domain}/api/auth/callback`); + const client = new OAuth2Client(config); const tokens = cookies != null ? await client.getOrRefreshAccessTokenNode(cookies) : undefined; if (tokens != null) { diff --git a/packages/ui/app/src/auth/getAuthEdgeConfig.ts b/packages/ui/app/src/auth/getAuthEdgeConfig.ts index 6f5df55d67..a75f2e1521 100644 --- a/packages/ui/app/src/auth/getAuthEdgeConfig.ts +++ b/packages/ui/app/src/auth/getAuthEdgeConfig.ts @@ -1,3 +1,4 @@ +import { captureMessage } from "@sentry/nextjs"; import { get } from "@vercel/edge-config"; import { AuthEdgeConfig, AuthEdgeConfigSchema } from "./types"; @@ -8,8 +9,17 @@ export async function getAuthEdgeConfig(currentDomain: string): Promise<AuthEdge const toRet = domainToTokenConfigMap?.[currentDomain]; if (toRet != null) { - // if the config is present, it should be valid - return AuthEdgeConfigSchema.parse(toRet); + const config = AuthEdgeConfigSchema.safeParse(toRet); + + // if the config is present, it should be valid. + // if it's malformed, custom auth for this domain will not work and may leak docs to the public. + if (!config.success) { + // eslint-disable-next-line no-console + console.error(config.error); + captureMessage(`Could not parse AuthEdgeConfigSchema for ${currentDomain}`, "fatal"); + } + + return config.data; } return; diff --git a/packages/ui/app/src/auth/types.ts b/packages/ui/app/src/auth/types.ts index c8b429598b..a4af571830 100644 --- a/packages/ui/app/src/auth/types.ts +++ b/packages/ui/app/src/auth/types.ts @@ -17,6 +17,7 @@ export const AuthEdgeConfigOAuth2OrySchema = z.object({ scope: z.optional(z.string()), clientId: z.string(), clientSecret: z.string(), + redirectUri: z.string().optional(), "api-key-injection-enabled": z.optional(z.boolean()), }); export const AuthEdgeConfigOAuth2WebflowSchema = z.object({ @@ -25,6 +26,7 @@ export const AuthEdgeConfigOAuth2WebflowSchema = z.object({ scope: z.optional(z.union([z.string(), z.array(z.string())])), clientId: z.string(), clientSecret: z.string(), + redirectUri: z.string().optional(), }); export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({ diff --git a/packages/ui/app/src/playground/PlaygroundAuthorizationForm.tsx b/packages/ui/app/src/playground/PlaygroundAuthorizationForm.tsx index 79fc57225c..0a25f70d76 100644 --- a/packages/ui/app/src/playground/PlaygroundAuthorizationForm.tsx +++ b/packages/ui/app/src/playground/PlaygroundAuthorizationForm.tsx @@ -396,7 +396,7 @@ export function PlaygroundAuthorizationFormCard({ } url.searchParams.set("state", state.toString()); - if (apiKeyInjection.partner === "ory") { + if (apiKeyInjection.partner === "ory" || apiKeyInjection.partner === "webflow") { const redirect_uri = urlJoin(window.location.origin, callbackApiRoute); url.searchParams.set("redirect_uri", redirect_uri); } diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts index 1ffbd38f54..ef4dbd3212 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts @@ -8,7 +8,6 @@ import { withSecureCookie, } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; -import urlJoin from "url-join"; import { WebflowClient } from "webflow-api"; import type { OauthScope } from "webflow-api/api/types/OAuthScope"; @@ -27,12 +26,10 @@ export default async function handler(req: NextRequest): Promise<NextResponse<AP authenticated: false, url: WebflowClient.authorizeURL({ clientId: edgeConfig.clientId, - - // TODO: subpaths will not work - // redirectUri: `https://${domain}/api/fern-docs/oauth/webflow/callback`, + redirectUri: edgeConfig.redirectUri, // note: this is not validated - scope: (edgeConfig.scope as OauthScope | OauthScope[]) ?? "authorized_user:read", + scope: (edgeConfig.scope as OauthScope | OauthScope[]) ?? [], }), }); } @@ -59,7 +56,7 @@ export default async function handler(req: NextRequest): Promise<NextResponse<AP let exp = expires; if (edgeConfig != null && edgeConfig.type === "oauth2" && edgeConfig.partner === "ory") { - const oauthClient = new OAuth2Client(edgeConfig, urlJoin(`https://${domain}/api/auth/callback`)); + const oauthClient = new OAuth2Client(edgeConfig); const token = OryAccessTokenSchema.parse(await oauthClient.decode(access_token)); exp = token.exp == null ? undefined : new Date(token.exp * 1000); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts index 990c50037d..f2a6fe86ef 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts @@ -15,13 +15,14 @@ export default async function GET(req: NextRequest): Promise<NextResponse> { if (req.method !== "GET") { return new NextResponse(null, { status: 405 }); } + const domain = getXFernHostEdge(req); // The authorization code returned by AuthKit const code = req.nextUrl.searchParams.get("code"); 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 ?? req.nextUrl.origin; + const redirectLocation = state ?? `https://${domain}/`; if (error != null) { return redirectWithLoginError(redirectLocation, error_description ?? error); @@ -31,7 +32,6 @@ export default async function GET(req: NextRequest): Promise<NextResponse> { return redirectWithLoginError(redirectLocation, "Couldn't login, please try again"); } - const domain = getXFernHostEdge(req); const config = await getAuthEdgeConfig(domain); if (config != null && config.type === "oauth2" && config.partner === "ory") { diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts index c06e2dae8d..76e181e775 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts @@ -1,10 +1,13 @@ +import { getXFernHostEdge } from "@/server/xfernhost/edge"; import { NextRequest, NextResponse } from "next/server"; export const runtime = "edge"; export default async function GET(req: NextRequest): Promise<NextResponse> { + const domain = getXFernHostEdge(req); + const state = req.nextUrl.searchParams.get("state"); - const redirectLocation = state ?? req.nextUrl.origin; + const redirectLocation = state ?? `https://${domain}/`; const res = NextResponse.redirect(redirectLocation); res.cookies.delete("fern_token"); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts index 9f9724fd71..50753196d1 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts @@ -8,7 +8,6 @@ import { withSecureCookie, } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; -import urlJoin from "url-join"; export const runtime = "edge"; @@ -23,11 +22,13 @@ export default async function GET(req: NextRequest): Promise<NextResponse> { return new NextResponse(null, { status: 405 }); } + const domain = getXFernHostEdge(req); + const code = req.nextUrl.searchParams.get("code"); 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 ?? req.nextUrl.origin; + const redirectLocation = state ?? `https://${domain}/`; if (error != null) { return redirectWithLoginError(redirectLocation, error_description ?? error); @@ -37,14 +38,13 @@ export default async function GET(req: NextRequest): Promise<NextResponse> { return redirectWithLoginError(redirectLocation, "Couldn't login, please try again"); } - const domain = getXFernHostEdge(req); const config = await getAuthEdgeConfig(domain); if (config == null || config.type !== "oauth2" || config.partner !== "ory") { return redirectWithLoginError(redirectLocation, "Couldn't login, please try again"); } - const oauthClient = new OAuth2Client(config, urlJoin(`https://${domain}`, req.nextUrl.pathname)); + const oauthClient = new OAuth2Client(config); try { const { access_token, refresh_token } = await oauthClient.getToken(code); const token = OryAccessTokenSchema.parse(await oauthClient.decode(access_token)); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts index 9a34aefd80..bd99c5736d 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts @@ -16,11 +16,13 @@ export default async function GET(req: NextRequest): Promise<NextResponse> { return new NextResponse(null, { status: 405 }); } + const domain = getXFernHostEdge(req); + const code = req.nextUrl.searchParams.get("code"); 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 ?? req.nextUrl.origin; + const redirectLocation = state ?? `https://${domain}/`; if (error != null) { return redirectWithLoginError(redirectLocation, error_description ?? error); @@ -30,7 +32,6 @@ export default async function GET(req: NextRequest): Promise<NextResponse> { return redirectWithLoginError(redirectLocation, "Couldn't login, please try again"); } - const domain = getXFernHostEdge(req); const config = await getAuthEdgeConfig(domain); if (config == null || config.type !== "oauth2" || config.partner === "ory") { @@ -41,6 +42,7 @@ export default async function GET(req: NextRequest): Promise<NextResponse> { const accessToken = await WebflowClient.getAccessToken({ clientId: config.clientId, clientSecret: config.clientSecret, + redirectUri: config.redirectUri, code, });