Handling 3rd party auth (Clerk) over REST API

default discord avatar
isaackoz12 months ago
4 1

I have my front end (Next.js w/ app dir) at example.com and PayloadCMS on admin.example.com.

On my front end I'm using Clerk for user authentication. I'm trying to figure out the "best" way to architect both Payload and Next to seamlessly work together. In Payload I have a "Clients" collection containing all the users information. I set up hooks to automatically create/delete Clerk users and assign the clerk user Id to a read only field in the Client. The problem I'm trying to solve is how to make requests in Nextjs to Payload.

I see from the docs there are two ways to integeate with third-party services/apis:

  1. Create a user for the third-party app, and log in each time to receive a token before you attempt to access any protected actions
  2. Enable API key support for the Collection, where you can generate a non-expiring API key per user in the collection

Here are my two attempts at doing each.

  1. Create a custom Passport Strategy and verify requests in Payload - I tried this one initially, but it seemed too complicated and I wasn't sure if I was doing it right. This is what I came up with for my clerkStrategy. Note: I have never worked with Passport js before, so this was new to me and I'm 100% certain I'm doing this wrong (but somehow it still worked). I haven't figured out how to add query requests and only get data for that specific user.

PayloadCMS -> clerkStrategy.ts

import { Strategy as CustomStrategy } from "passport-custom";
import { sessions } from "@clerk/clerk-sdk-node";

const getCookiesAsCollection = function (
	rawCookie: string
): Record<string, string> {
	const cookies: Record<string, string> = {};
	rawCookie &&
		rawCookie.split(";").forEach(function (cookie: string) {
			const parts: RegExpMatchArray | null = cookie.match(/(.*?)=(.*)$/);
			if (parts && parts.length) {
				cookies[parts[1].trim()] = (parts[2] || "").trim();
			}
		});
	return cookies;
};

export class ClerkStrategy extends CustomStrategy {
	name: string;
	constructor() {
		super(async (req, done) => {
			try {
				const authHeader =
					req.headers.authorization?.split(" ")[1] || "";
				const sessionId = authHeader as string;
				const sessionToken = getCookiesAsCollection(
					req.headers.cookie as string
				)["__session"] as string;

				const session = await sessions.verifySession(
					sessionId,
					sessionToken
				);
				console.log(session);

				if (session && session.status === "active") {
					done(null, { id: session.userId });
				} else {
					done(null, false);
				}
			} catch (err) {
				console.log(err);
				done(null, false);
			}
		});

		this.name = "clerk";
	}
}

This is calling it from client-side, but concept should be the same if I were to use route handlers or server components.
NextJS Frontend -> page.tsx

"use client";

import Image from "next/image";
import { UserButton, useSession } from "@clerk/nextjs";

export default function Home() {
	const session = useSession();

	const handleClick = async () => {
		const sessionId = session.session?.id || "";
		const res = await fetch("http://localhost:3000/api/restraunts/", {
			headers: {
				"Content-Type": "application/json",
				Authorization: `SessionId ${sessionId}`,
			},
			credentials: "include",
		});
		const data = await res.json();
		console.log(data);
	};

	return (
		<main className="flex min-h-screen flex-col items-center justify-center p-24">
			<UserButton />
			<div>Hello world</div>
			<button
				className="bg-red-400  rounded-xl px-4 py-2 text-black hover:scale-105 transition"
				onClick={handleClick}
			>
				Fetch Data
			</button>
		</main>
	);
}
  1. Create an admin API key to use in a route handler in NextJS - With this method, I include the Clerks UserID in the query in the route handler so it looks something like this.
    /api/orders/route.ts
import qs from 'qs';
import { currentUser } from '@clerk/nextjs';


export async function getOrders () {
  const user = await currentUser();
  if (!user) {
    return new Response('Unauthorized', { status: 401 });
  }

  const stringifiedQuery = qs.stringify({
    where: { clerkId: { equals: user.id } },
  }, { addQueryPrefix: true });

  const response = await fetch(`http://localhost:3000/api/orders${stringifiedQuery}`,{
    headers: {
        Authorization: `admin API-Key ${process.env.PAYLOAD_API_KEY}`
    }
  });
  // Continue to handle the response below...
}

This approach feels a lot more simple but I'm still not sure if this is the right way. Since the req.user would be the Admin for every call, would using access controls not be possible? I'm assuming with this approach I would use queries like above and handle access in the route handler. And furthermore, would there be a way to set the Client user to req.user?

I'm just looking for some feedback as to the pros/cons of each approach and/or if I'm on the right track. It's a little overwhelming and I don't want to overthink it, but I also don't want to be going about this the wrong way. Any feedback is helpful. Thank you!

  • Selected Answer
    default discord avatar
    isaackoz10 months ago

    Yes I did! My approach is probably less than ideal, but it works perfectly for what I'm doing. Ideally one would use a custom passport strategy (which I sort of had going above), but I wasn't able to fully get it. My approach has been working very well in production though:

    This util will simply take the Clerk session token passed in the request auth header (or will attempt to get from cookies if no auth header) and decode/verify it using Clerk backend. (make sure CLERK_SECRET_KEY env variable is set).

    // src/util/verifyClerkUser.ts
    import { sessions } from "@clerk/clerk-sdk-node";
    import { decodeJwt } from "@clerk/clerk-sdk-node";
    import { PayloadRequest } from "payload/types";
    
    export default async function VerifyClerkAuthUser({
    	req,
    }: {
    	req: PayloadRequest;
    }) {
    	let token: string;
    	if (
    		req.headers.authorization === undefined ||
    		req.headers.authorization === null
    	) {
    		// try to get token from cookie
    		if (req.headers.cookie) {
    			const cookies = getCookies(req);
    			console.log(cookies);
    			token = cookies.__session;
    		} else {
    			return { user: null, status: 401 };
    		}
    	} else {
    		token = req.headers.authorization.toString().split(" ")[1];
    	}
    
    	try {
    		if (token) {
    			const decodeInfo = decodeJwt(token);
    			const sessionId = decodeInfo.payload.sid;
    			const verifiedSession = await sessions.verifySession(
    				sessionId,
    				token
    			);
    			if (
    				verifiedSession &&
    				verifiedSession.userId &&
    				verifiedSession.status === "active"
    			) {
    				return {
    					user: verifiedSession.userId,
    					status: 200,
    				};
    			} else {
    				return {
    					user: null,
    					status: 401,
    				};
    			}
    		} else {
    			return {
    				user: null,
    				status: 401,
    			};
    		}
    	} catch (err) {
    		console.log(err);
    		return {
    			user: null,
    			status: 500,
    		};
    	}
    }
    
    function getCookies(req: PayloadRequest) {
    	const cookies: { [key: string]: string } = {};
    	req.headers &&
    		req.headers.cookie &&
    		req.headers.cookie.split(";").forEach((cookie) => {
    			const parts = cookie.match(/(.*?)=(.*)$/);
    			if (parts) {
    				cookies[parts[1].trim()] = (parts[2] || "").trim();
    			}
    		});
    	return cookies;
    }
    

    Then in my routes I simply pass the req to our function

    // GET /api/user
    import { PayloadHandler } from "payload/config";
    import { Subscription } from "payload/generated-types";
    import VerifyClerkAuthUser from "../util/verifyClerkUser";
    
    const getUserInfo: PayloadHandler = async (req, res): Promise<void> => {
    	const { user, payload } = req;
    	const clerkAuthUser = await VerifyClerkAuthUser({ req });
    	if (!clerkAuthUser || !clerkAuthUser.user || clerkAuthUser.status !== 200) {
    		res.status(401).json({ message: "Unauthorized" });
    		return;
    	}
            // you might want to move below into your util function above, but I didn't because we might not always want to get the user
    	try {
    		const {
    			docs: [payloadClient],
    		} = await payload.find({
    			collection: "clients",
    			where: {
    				clerkID: {
    					equals: (await clerkAuthUser).user,
    				},
    			},
    		});
            // rest of code...
    

    And finally in your frontend or wherever (below is in a client component in Next.Js)

    import { useAuth } from "@clerk/nextjs";
    
    // ...
    const { sessionId, getToken } = useAuth();
    
    const response = await fetch(
    			process.env.NEXT_PUBLIC_CMS_URL + "/api/user",
    			{
    				method: "GET",
    				headers: {
    					Authorization: `Bearer ${await getToken()}`,
    				},
    			}
    		);
    
    		const data = await response.json();
    

    Clerk has a way to get the session token from backend too using import { auth } from "@clerk/nextjs"; so that you can hit the same endpoint securely.

  • default discord avatar
    MichaelFrieze10 months ago

    Payload CMS with Clerk seems like a great idea. Did you end up answering your own questions and getting Clerk to work nicely with Payload?

    1 reply
    default discord avatar
    isaackoz10 months ago

    Yes I did! My approach is probably less than ideal, but it works perfectly for what I'm doing. Ideally one would use a custom passport strategy (which I sort of had going above), but I wasn't able to fully get it. My approach has been working very well in production though:

    This util will simply take the Clerk session token passed in the request auth header (or will attempt to get from cookies if no auth header) and decode/verify it using Clerk backend. (make sure CLERK_SECRET_KEY env variable is set).

    // src/util/verifyClerkUser.ts
    import { sessions } from "@clerk/clerk-sdk-node";
    import { decodeJwt } from "@clerk/clerk-sdk-node";
    import { PayloadRequest } from "payload/types";
    
    export default async function VerifyClerkAuthUser({
    	req,
    }: {
    	req: PayloadRequest;
    }) {
    	let token: string;
    	if (
    		req.headers.authorization === undefined ||
    		req.headers.authorization === null
    	) {
    		// try to get token from cookie
    		if (req.headers.cookie) {
    			const cookies = getCookies(req);
    			console.log(cookies);
    			token = cookies.__session;
    		} else {
    			return { user: null, status: 401 };
    		}
    	} else {
    		token = req.headers.authorization.toString().split(" ")[1];
    	}
    
    	try {
    		if (token) {
    			const decodeInfo = decodeJwt(token);
    			const sessionId = decodeInfo.payload.sid;
    			const verifiedSession = await sessions.verifySession(
    				sessionId,
    				token
    			);
    			if (
    				verifiedSession &&
    				verifiedSession.userId &&
    				verifiedSession.status === "active"
    			) {
    				return {
    					user: verifiedSession.userId,
    					status: 200,
    				};
    			} else {
    				return {
    					user: null,
    					status: 401,
    				};
    			}
    		} else {
    			return {
    				user: null,
    				status: 401,
    			};
    		}
    	} catch (err) {
    		console.log(err);
    		return {
    			user: null,
    			status: 500,
    		};
    	}
    }
    
    function getCookies(req: PayloadRequest) {
    	const cookies: { [key: string]: string } = {};
    	req.headers &&
    		req.headers.cookie &&
    		req.headers.cookie.split(";").forEach((cookie) => {
    			const parts = cookie.match(/(.*?)=(.*)$/);
    			if (parts) {
    				cookies[parts[1].trim()] = (parts[2] || "").trim();
    			}
    		});
    	return cookies;
    }
    

    Then in my routes I simply pass the req to our function

    // GET /api/user
    import { PayloadHandler } from "payload/config";
    import { Subscription } from "payload/generated-types";
    import VerifyClerkAuthUser from "../util/verifyClerkUser";
    
    const getUserInfo: PayloadHandler = async (req, res): Promise<void> => {
    	const { user, payload } = req;
    	const clerkAuthUser = await VerifyClerkAuthUser({ req });
    	if (!clerkAuthUser || !clerkAuthUser.user || clerkAuthUser.status !== 200) {
    		res.status(401).json({ message: "Unauthorized" });
    		return;
    	}
            // you might want to move below into your util function above, but I didn't because we might not always want to get the user
    	try {
    		const {
    			docs: [payloadClient],
    		} = await payload.find({
    			collection: "clients",
    			where: {
    				clerkID: {
    					equals: (await clerkAuthUser).user,
    				},
    			},
    		});
            // rest of code...
    

    And finally in your frontend or wherever (below is in a client component in Next.Js)

    import { useAuth } from "@clerk/nextjs";
    
    // ...
    const { sessionId, getToken } = useAuth();
    
    const response = await fetch(
    			process.env.NEXT_PUBLIC_CMS_URL + "/api/user",
    			{
    				method: "GET",
    				headers: {
    					Authorization: `Bearer ${await getToken()}`,
    				},
    			}
    		);
    
    		const data = await response.json();
    

    Clerk has a way to get the session token from backend too using import { auth } from "@clerk/nextjs"; so that you can hit the same endpoint securely.

Star on GitHub

Star

Chat on Discord

Discord

online

Can't find what you're looking for?

Get help straight from the Payload team with an Enterprise License.