Protecting a Multi-tenant Next.js client/server app with PropelAuth

Protecting a Multi-tenant Next.js client/server app with PropelAuth

In this tutorial, we'll show you how to create a multi-tenant (or B2B) Next.js 14 app that creates digital coupons on demand using OpenAI's API. OpenAI will create a coupon image using a discount percentage entered by the user along with their assigned grocery store name. We'll use PropelAuth, a B2B/multi-tenant authentication provider, to ensure only authorized users can create coupons. Out-of-the-box, it provides account sign up, login, logout functionality and we’ll use their roles and permissions system to verify the user is in the correct role before allowing them to create a coupon.

You can find the complete code on GitHub. Here's what the complete app looks like:

For background, imagine a grocery store corporation. They often own multiple chains consisting of different store brands. Suppose the marketing team wants to create on-demand digital coupons for a particular chain. They want to offer an internal company-wide solution, but given a coupon's monetary savings and potential for abuse, they'll need to ensure that only authorized team members can create coupons.

The app we’ll create consists of a Next.js frontend used for generating coupons and a Next.js backend API used for communicating with OpenAI. Let's start by creating the coupon generation app.

Create the Digital Coupon App

For this app, we'll use Next.js 14. Create a new project:

npx create-next-app@14

Feel free to select the options that best suit you. I selected TypeScript, App Router, and a src directory for this tutorial's companion app, but not Tailwind. With the app created, build and start development mode:

npm run build
npm run dev

The app can now be viewed at localhost:3000. Open up src/app/page.tsx next, where we'll put the main functionality. Begin with the coupon generation markup consisting of a simple form that prompts for the grocery store name and the discount percentage to use in the generation of a new coupon image:

const [discount, setDiscount] = useState(0);
const [couponImage, setCouponImage] = useState(null);
const [isImageGenerating, setIsImageGenerating] = useState(false);

return <>
    <h2>Digital Coupon Generation</h2>
    <div>
        <form onSubmit={createNewCoupon}>
            <p>
                <label htmlFor="store">Grocery Store: </label>
                <input type="text" id="store" />
            </p>
            <p>
                <label htmlFor="discount">Discount (%): </label>
                <input 
                    type="text" value={discount} id="discount"
                    onChange={(e) => setDiscount(Number(e.target.value))}  
                />
            </p>
            <button type="submit">Generate Coupon</button>
        </form>
        {couponImage && <img src={couponImage} style={{ height: '400px' }}/>}
        {isImageGenerating && <p>Generating coupon image...</p>}
    </div>
</>

Next, add a createNewCoupon function that calls our Next.js backend API (which will call OpenAI's API soon). Pass the grocery store name and the discount to the API in the request body.

const createNewCoupon = async (event: React.FormEvent) => {
  event.preventDefault();
  try {
      setIsImageGenerating(true);
      setCouponImage(null);
      const response =
          await fetch(`/api/coupons`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    discount: discount
                })
            })
      const data = await response.json()
      setCouponImage(data.image);
  } finally {
      setIsImageGenerating(false);
  }
}

With the frontend code in place, let's implement the backend API within our Next.js app using Route Handlers for simplicity.

Generate Coupon Images with OpenAI's API

First, install the OpenAI SDK for Node.js:

npm install openai

Under the src/app folder, create two nested folders: api/coupons. This is our Next.js API endpoint. We'll execute calls to OpenAI server-side to protect our OpenAI API key. The API operates on a "pay as you go" usage model. As of the time of writing, you must add a minimum of $5 to your account before using it, so we definitely don't want the key to fall into the wrong hands!

Create route.ts under the coupons folder. Import OpenAI so we can use their image generation capabilities, then extract the discount percentage from the request body. Set grocery store to a string placeholder for now.

// src/app/api/coupons/route.ts
import { NextResponse } from 'next/server'
import OpenAI from 'openai';

export async function POST(request: Request) {
  const body = await request.json();
  const discount = body.discount;
  const groceryStore = "orgName";
}

Next, call the OpenAI image generation service, keeping the prompt simple: a coupon for ${discount}% off any purchase at ${groceryStore}, with code ${couponCode}. Choose the DALL·E 3 model since the image quality is better, and select a size that mimics a classic horizontally printed coupon. Finally, extract the URL of the generated image and send it back in the API response.

const openai = new OpenAI();
const couponCode = generateCouponCode()

const image = await openai.images.generate({
	model: 'dall-e-3',
	size: "1792x1024",
	prompt: `a coupon for ${discount}% off any purchase at ${groceryStore}, with code ${couponCode}`
});

saveCouponCodeToDb(groceryStore, couponCode)
const imageUrl = image.data[0].url;
return NextResponse.json({ image: imageUrl })

The complete code looks like:

import { NextResponse } from 'next/server'
import OpenAI from 'openai';

export async function POST(request: Request) {
	const body = await request.json();
	const discount = body.discount;
    const groceryStore = "orgName";
  
	const openai = new OpenAI();
    const couponCode = generateCouponCode()
	const image = await openai.images.generate({
		model: 'dall-e-3',
		size: "1792x1024",
		prompt: 
        `a coupon for ${discount}% off any purchase at ${groceryStore}, with code ${couponCode}`
	});

	saveCouponCodeToDb(groceryStore, couponCode)
	const imageUrl = image.data[0].url;
	return NextResponse.json({ image: imageUrl })
}

Store the OpenAI Key in an Environment File

The last step in creating the coupon generation app is placing our OpenAI API key into an environment file. Create a .env file at the project's root, then add OPENAI_API_KEY= along with the API key value. Note: Be careful committing this file to source control! In a public repository, the key will be leaked.

Generate a Coupon

We have a working solution now! The app sends an API request to our backend, generates an image using OpenAI, and displays it to the end user. Here it is in action:

There's just one (major) problem: anyone at the company (or the public) can use the app!

Securing the App with PropelAuth

To secure the app quickly, we'll integrate PropelAuth, a B2B user authentication platform with easy integration and straightforward APIs for developers. Head on over to propelauth.com and create a free account. Afterward, navigate to the Frontend Quickstart in their docs. Choose "Next.js App Router" as the frontend and backend framework and follow the setup guide. It'll walk you through creating a project, setting up signup, login, and account pages, and installing and configuring PropelAuth in our Next.js app.

Go ahead, I'll wait! Come back here after completing the middleware setup step.

Note: Before continuing, ensure you have created an Organization within the PropelAuth dashboard.

In the PropelAuth dashboard, navigate to Organization Settings then under Membership Settings toggle on “User must be in at least one org” and set max number of orgs per user to “1.”

This will make sure that users must be in an organization before they are able to use your product.

Limiting Public Access with Basic Authentication

One of the great things about PropelAuth is not only how much time it saves you but also the security peace of mind it provides. To make this app feel more "complete" as well as limit public access, let's use their hosted sign-up and login pages as the first step in protecting our application.

Head back to page.tsx and import PropelAuth's library. Use the provided React hooks to retrieve user information and access login/logout functionality. We'll also retrieve the signed-in user's organization to which they belong.

// src/app/page.tsx
import {useUser, useRedirectFunctions, useLogoutFunction} from "@propelauth/nextjs/client";

export default function Home() {
	const {loading, user} = useUser()
	const {
        redirectToSignupPage, redirectToLoginPage, redirectToAccountPage
    } = useRedirectFunctions()
	const logoutFn = useLogoutFunction()
	const org = user?.getActiveOrg()

	// ... snip
}

You’ll notice the use of getActiveOrg() - this is to get the user’s active organization. Over in api/auth/[slug]/route.ts, implement the getDefaultActiveOrgId function that executes automatically for us after the user logs in. You have full control over what the active org is. Since earlier in this tutorial we configured the Organization Settings to require a user to be in one org (and only one), we can simply return that one org:

// api/auth/[slug]/route.ts
const routeHandlers = getRouteHandlers({
  postLoginRedirectPathFn: (req: NextRequest) => {
    return "/"
  },
  getDefaultActiveOrgId: (req: NextRequest, user: UserFromToken) => {
    return user.getOrgs()[0].orgId;
  },
})

Next, update the code to show a loading message when the authentication process is taking place. If the user object exists (aka an employee is signed-in), display the user's email and organization they are a part of. Finally, display Account and Logout buttons.

return (
  <div className={styles.page}>
    <main className={styles.main}>
      { loading ? <div>Loading...</div> : null }
      { user ?
        <div>
          <p>You are logged in as {user.email}</p>
          <p>You are in organization: {org!.orgName}</p>

          <button onClick={() => redirectToAccountPage()}>Account</button>
          <button onClick={logoutFn}>Logout</button>
          
// snip

Further down, in the else statement signifying the user hasn't signed in yet, explain that they need to sign in to use the app and provide the appropriate Login and Signup buttons.

:
<div>
  <p>You are not logged in</p>
  <button onClick={() => redirectToLoginPage()}>Login</button>
  <button onClick={() => redirectToSignupPage()}>Signup</button>
</div>

Thanks to PropelAuth, we now have a working signup and login workflow with minimal custom code required. Log into your PropelAuth account now to see the user's info displayed:

The coupon generation functionality has been placed behind a login screen but is available to all employees and the API is publicly accessible as well. Let's change that.

Limiting Employee Access with PropelAuth Roles

PropelAuth offers multiple ways to secure an app via orgs, roles, and permissions. In this example, let's use a role to define what users within each organization (grocery store) can do.

By default, PropelAuth gives us three roles: Owner, Admin, and Member. In this case, only Owners and Admins (aka grocery store managers) should be able to create new coupons. Once again, PropelAuth makes this easy. Use the isAtLeastRole function to state that users must at least be an Admin or Owner to access coupon generation:

{ user ?       
  // snip...
  <div>
    <h2>Digital Coupon Generation</h2>

    { org?.isAtLeastRole("Admin") ?
      // snip: coupon code generation
	  : <p>You must be a Store Manager or Owner to create a coupon.</p> 
    }
  </div>

One last detail before we're done! Our image generation API needs to know the employee's grocery store name in order to insert it into the coupon image. Employees should only be able to generate coupons for stores where they are employed. In the store text field, automatically fill in the organization name and disable editing:

// page.tsx
<label htmlFor="store">Grocery Store: </label>
<input type="text" placeholder={org?.orgName} disabled={true} id="store" />

Here's how the app looks for a "Member" employee now:

Securing the Coupons API Endpoint

We’ve protected the frontend but still need to protect the coupons API endpoint. First, add a call to getUser() at the beginning of the request. If the user object is undefined, that means authentication has failed and we can reject the request.

export async function POST(request: Request) {
  const user = await getUser();
  if (!user) {
    return NextResponse.json({ 
        message: "You must be logged in to generate a coupon." 
      }, 
      { status: 401 }
    );
  }

// ... snip ...

Next, we want to ensure the user is a store manager or owner, just like we implemented on the frontend. Access the user’s active org, then check that their role is at least Admin:

const activeOrg = user.getActiveOrg();
if (!activeOrg || !activeOrg.isAtLeastRole("Admin")) { 
  return NextResponse.json({ 
      message: "You need to be a Store Manager or Owner to create a coupon." 
    }, 
    { status: 403 }
  );
}

As a final step, change the grocery store from a placeholder string to the active organization name:

const groceryStore = activeOrg.orgName;

Congrats! We've created a complete Next.js 14 frontend and backend API that safeguards access to coupon generation using PropelAuth's end-to-end user authentication services.

What's Next?

There are several paths to take with this grocery store app. We could add additional login methods in just a few button clicks, go deeper with authorization using permissions, and more. Happy building (and securing)!