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

init: webflow oauth integration #1589

Merged
merged 7 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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: 2 additions & 2 deletions packages/ui/app/src/auth/OAuth2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { JWTPayload, createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
import { NextApiRequestCookies } from "next/dist/server/api-utils";
import type { NextRequest } from "next/server";
import urlJoin from "url-join";
import { AuthEdgeConfigOAuth2, OAuthTokenResponse, OAuthTokenResponseSchema } from "./types";
import { AuthEdgeConfigOAuth2Ory, OAuthTokenResponse, OAuthTokenResponseSchema } from "./types";

interface TokenInfo {
access_token: string;
Expand All @@ -18,7 +18,7 @@ export class OAuth2Client {
private readonly jwks: string | undefined;

constructor(
config: AuthEdgeConfigOAuth2,
config: AuthEdgeConfigOAuth2Ory,
private readonly redirect_uri?: string,
) {
this.clientId = config.clientId;
Expand Down
7 changes: 5 additions & 2 deletions packages/ui/app/src/auth/getApiKeyInjectionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ export type APIKeyInjectionConfig =
| APIKeyInjectionConfigEnabledUnauthorized
| APIKeyInjectionConfigEnabledAuthorized;

// TODO: since this is for ORY (rightbrain) only, lets refactor
export async function getAPIKeyInjectionConfig(
domain: string,
cookies?: NextRequest["cookies"],
state?: string,
): Promise<APIKeyInjectionConfig> {
const config = await getAuthEdgeConfig(domain);
if (config?.type === "oauth2" && config["api-key-injection-enabled"]) {
if (config?.type === "oauth2" && config.partner === "ory" && config["api-key-injection-enabled"]) {
const client = new OAuth2Client(config, `https://${domain}/api/auth/callback`);
const tokens = cookies != null ? await client.getOrRefreshAccessTokenEdge(cookies) : undefined;

Expand Down Expand Up @@ -61,13 +62,15 @@ export async function getAPIKeyInjectionConfig(
enabled: false,
};
}

// TODO: since this is for ORY (rightbrain) only, lets refactor
export async function getAPIKeyInjectionConfigNode(
domain: string,
cookies?: NextApiRequestCookies,
state?: string,
): Promise<APIKeyInjectionConfig> {
const config = await getAuthEdgeConfig(domain);
if (config?.type === "oauth2" && config["api-key-injection-enabled"]) {
if (config?.type === "oauth2" && config.partner === "ory" && config["api-key-injection-enabled"]) {
const client = new OAuth2Client(config, `https://${domain}/api/auth/callback`);
const tokens = cookies != null ? await client.getOrRefreshAccessTokenNode(cookies) : undefined;

Expand Down
19 changes: 16 additions & 3 deletions packages/ui/app/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const FernUserSchema = z.object({

export type FernUser = z.infer<typeof FernUserSchema>;

export const AuthEdgeConfigOAuth2Schema = z.object({
export const AuthEdgeConfigOAuth2OrySchema = z.object({
type: z.literal("oauth2"),
partner: z.literal("ory"),
environment: z.string(),
Expand All @@ -19,6 +19,13 @@ export const AuthEdgeConfigOAuth2Schema = z.object({
clientSecret: z.string(),
"api-key-injection-enabled": z.optional(z.boolean()),
});
export const AuthEdgeConfigOAuth2WebflowSchema = z.object({
type: z.literal("oauth2"),
partner: z.literal("webflow"),
scope: z.optional(z.union([z.string(), z.array(z.string())])),
clientId: z.string(),
clientSecret: z.string(),
});

export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({
type: z.literal("basic_token_verification"),
Expand All @@ -27,10 +34,16 @@ export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({
redirect: z.string(),
});

export const AuthEdgeConfigSchema = z.union([AuthEdgeConfigOAuth2Schema, AuthEdgeConfigBasicTokenVerificationSchema]);
export const AuthEdgeConfigSchema = z.union([
AuthEdgeConfigOAuth2OrySchema,
AuthEdgeConfigOAuth2WebflowSchema,
AuthEdgeConfigBasicTokenVerificationSchema,
AuthEdgeConfigBasicTokenVerificationSchema,
]);

export type AuthEdgeConfig = z.infer<typeof AuthEdgeConfigSchema>;
export type AuthEdgeConfigOAuth2 = z.infer<typeof AuthEdgeConfigOAuth2Schema>;
export type AuthEdgeConfigOAuth2Ory = z.infer<typeof AuthEdgeConfigOAuth2OrySchema>;
export type AuthEdgeConfigOAuth2Webflow = z.infer<typeof AuthEdgeConfigOAuth2WebflowSchema>;
export type AuthEdgeConfigBasicTokenVerification = z.infer<typeof AuthEdgeConfigBasicTokenVerificationSchema>;

export const OAuthTokenResponseSchema = z.object({
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/docs-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"lint": "pnpm lint:eslint && pnpm lint:style"
},
"dependencies": {
"@fern-ui/fern-docs-utils": "workspace:*",
"@algolia/requester-fetch": "^4.24.0",
"@aws-sdk/client-s3": "^3.335.0",
"@aws-sdk/s3-request-presigner": "^3.574.0",
Expand All @@ -36,6 +35,7 @@
"@fern-ui/components": "workspace:*",
"@fern-ui/core-utils": "workspace:*",
"@fern-ui/fdr-utils": "workspace:*",
"@fern-ui/fern-docs-utils": "workspace:*",
"@fern-ui/search-utils": "workspace:*",
"@fern-ui/ui": "workspace:*",
"@sentry/nextjs": "^8.30.0",
Expand All @@ -59,6 +59,7 @@
"ts-essentials": "^10.0.1",
"url-join": "5.0.0",
"uuid": "^9.0.0",
"webflow-api": "^2.4.2",
"zod": "^3.23.8"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,43 @@ import {
} 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";

export const runtime = "edge";

export default async function handler(req: NextRequest): Promise<NextResponse<APIKeyInjectionConfig>> {
const domain = getXFernHostEdge(req);
const edgeConfig = await getAuthEdgeConfig(domain);

// assume that if the edge config is set for webflow, api key injection is always enabled
if (edgeConfig?.type === "oauth2" && edgeConfig.partner === "webflow") {
const accessToken = req.cookies.get("access_token")?.value;
if (accessToken == null) {
return NextResponse.json({
enabled: true,
authenticated: false,
url: WebflowClient.authorizeURL({
clientId: edgeConfig.clientId,

// TODO: subpaths will not work
// redirectUri: `https://${domain}/api/fern-docs/oauth/webflow/callback`,

// note: this is not validated
scope: (edgeConfig.scope as OauthScope | OauthScope[]) ?? "authorized_user:read",
}),
});
}
return NextResponse.json({
enabled: true,
authenticated: true,
access_token: accessToken,
});
}

const fern_token = req.cookies.get("fern_token")?.value;

const config = await getAPIKeyInjectionConfig(domain, req.cookies);
const edgeConfig = await getAuthEdgeConfig(domain);
const response = NextResponse.json(config);

if (config.enabled && config.authenticated) {
Expand Down
46 changes: 12 additions & 34 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { getWorkOS, getWorkOSClientId } from "@/server/workos";
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import {
FernUser,
OAuth2Client,
OryAccessTokenSchema,
getAuthEdgeConfig,
signFernJWT,
withSecureCookie,
} from "@fern-ui/ui/auth";
import { FernUser, getAuthEdgeConfig, signFernJWT, withSecureCookie } from "@fern-ui/ui/auth";
import { NextRequest, NextResponse } from "next/server";
import urlJoin from "url-join";

export const runtime = "edge";

Expand All @@ -20,6 +12,10 @@ function redirectWithLoginError(location: string, errorMessage: string): NextRes
}

export default async function GET(req: NextRequest): Promise<NextResponse> {
if (req.method !== "GET") {
return new NextResponse(null, { status: 405 });
}

// The authorization code returned by AuthKit
const code = req.nextUrl.searchParams.get("code");
const state = req.nextUrl.searchParams.get("state");
Expand All @@ -39,31 +35,13 @@ export default async function GET(req: NextRequest): Promise<NextResponse> {
const config = await getAuthEdgeConfig(domain);

if (config != null && config.type === "oauth2" && config.partner === "ory") {
const oauthClient = new OAuth2Client(config, urlJoin(`https://${domain}`, req.nextUrl.pathname));
try {
const { access_token, refresh_token } = await oauthClient.getToken(code);
const token = OryAccessTokenSchema.parse(await oauthClient.decode(access_token));
const fernUser: FernUser = {
type: "user",
partner: "ory",
name: token.ext?.name,
email: token.ext?.email,
};
const expires = token.exp == null ? undefined : new Date(token.exp * 1000);
const res = NextResponse.redirect(redirectLocation);
res.cookies.set("fern_token", await signFernJWT(fernUser), withSecureCookie({ expires }));
res.cookies.set("access_token", access_token, withSecureCookie({ expires }));
if (refresh_token != null) {
res.cookies.set("refresh_token", refresh_token, withSecureCookie({ expires }));
} else {
res.cookies.delete("refresh_token");
}
return res;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return redirectWithLoginError(redirectLocation, "Couldn't login, please try again");
}
const nextUrl = req.nextUrl.clone();
nextUrl.pathname = nextUrl.pathname.replace(
"/api/fern-docs/auth/callback",
"/api/fern-docs/oauth/ory/callback",
);
// Permanent GET redirect to the Ory callback endpoint
return NextResponse.redirect(nextUrl, { status: 301 });
}

try {
Expand Down
13 changes: 3 additions & 10 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { getAuthEdgeConfig } from "@fern-ui/ui/auth";
import { NextRequest, NextResponse } from "next/server";

export const runtime = "edge";

export default async function GET(req: NextRequest): Promise<NextResponse> {
const domain = getXFernHostEdge(req);
const config = await getAuthEdgeConfig(domain);

const state = req.nextUrl.searchParams.get("state");
const redirectLocation = state ?? req.nextUrl.origin;

const res = NextResponse.redirect(redirectLocation);
if (config != null && config.type === "oauth2" && config.partner === "ory") {
res.cookies.delete("fern_token");
res.cookies.delete("access_token");
res.cookies.delete("refresh_token");
}
res.cookies.delete("fern_token");
res.cookies.delete("access_token");
res.cookies.delete("refresh_token");
return res;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import {
FernUser,
OAuth2Client,
OryAccessTokenSchema,
getAuthEdgeConfig,
signFernJWT,
withSecureCookie,
} from "@fern-ui/ui/auth";
import { NextRequest, NextResponse } from "next/server";
import urlJoin from "url-join";

export const runtime = "edge";

function redirectWithLoginError(location: string, errorMessage: string): NextResponse {
const url = new URL(location);
url.searchParams.set("loginError", errorMessage);
return NextResponse.redirect(url.toString());
}

export default async function GET(req: NextRequest): Promise<NextResponse> {
if (req.method !== "GET") {
return new NextResponse(null, { status: 405 });
}

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;

if (error != null) {
return redirectWithLoginError(redirectLocation, error_description ?? error);
}

if (typeof code !== "string") {
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));
try {
const { access_token, refresh_token } = await oauthClient.getToken(code);
const token = OryAccessTokenSchema.parse(await oauthClient.decode(access_token));
const fernUser: FernUser = {
type: "user",
partner: "ory",
name: token.ext?.name,
email: token.ext?.email,
};
const expires = token.exp == null ? undefined : new Date(token.exp * 1000);
const res = NextResponse.redirect(redirectLocation);
res.cookies.set("fern_token", await signFernJWT(fernUser), withSecureCookie({ expires }));
res.cookies.set("access_token", access_token, withSecureCookie({ expires }));
if (refresh_token != null) {
res.cookies.set("refresh_token", refresh_token, withSecureCookie({ expires }));
} else {
res.cookies.delete("refresh_token");
}
return res;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return redirectWithLoginError(redirectLocation, "Couldn't login, please try again");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getXFernHostEdge } from "@/server/xfernhost/edge";
import { getAuthEdgeConfig, withSecureCookie } from "@fern-ui/ui/auth";
import { NextRequest, NextResponse } from "next/server";
import { WebflowClient } from "webflow-api";

export const runtime = "edge";

function redirectWithLoginError(location: string, errorMessage: string): NextResponse {
const url = new URL(location);
url.searchParams.set("loginError", errorMessage);
return NextResponse.redirect(url.toString());
}

export default async function GET(req: NextRequest): Promise<NextResponse> {
if (req.method !== "GET") {
return new NextResponse(null, { status: 405 });
}

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;

if (error != null) {
return redirectWithLoginError(redirectLocation, error_description ?? error);
}

if (typeof code !== "string") {
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");
}

try {
const accessToken = await WebflowClient.getAccessToken({
clientId: config.clientId,
clientSecret: config.clientSecret,
code,
});

const res = NextResponse.redirect(redirectLocation);
res.cookies.set("access_token", accessToken, withSecureCookie());
return res;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return redirectWithLoginError(redirectLocation, "Couldn't login, please try again");
}
}
Loading
Loading