ProNextJS
    Loading
    lesson

    Token Variation of the External Systems Architecture

    Jack HerringtonJack Herrington

    This approach uses a service token to handle authentication between a Next.js client and an external API. We'll set up a proxy in Next.js and use a bearer token strategy to make authenticated requests.

    the token architecture

    Setting up the Next.js Proxy

    First, we'll look at the apps/external-token/next.config.mjs file:

    /** @type {import('next').NextConfig} */
    const nextConfig = {
      transpilePackages: ["@repo/ui"],
      rewrites: async () => {
        return [
          {
            source: "/rest/:path*",
            destination: "http://localhost:5002/:path*",
          },
        ];
      },
    };
    
    export default nextConfig;
    

    This configures a rewrite rule in the Next.js server to proxy requests made to /rest to the external API.

    In this setup, any request hitting /rest on the Next.js server will be forwarded to http://localhost:5002, effectively acting as a proxy to our external API.

    Homepage

    The homepage uses next-auth to manage user sessions and store the service token. Upon successful authentication, we'll fetch a token from our authentication logic.

    // inside page.tsx
    
    import { auth } from "@/auth";
    import { Todo } from "@repo/todos";
    
    import AuthButton from "@/components/AuthButton.server";
    
    import TodoList from "./TodoList";
    import { SessionProvider } from "next-auth/react";
    
    const REST_API = process.env.REST_API;
    
    export default async function Home() {
      const session = await auth();
    
      const priorities: string[] = await fetch(`${REST_API}/priorities`).then(
        (resp) => resp.json()
      );
    
      const todos: Todo[] = session?.user
        ? await fetch(`${REST_API}/todos`, {
            cache: "no-cache",
            headers: {
              Authorization: `Bearer ${session.user.token}`,
            },
          }).then((resp) => resp.json())
        : [];
    
      return (
        <main>
          <AuthButton />
          {session?.user && (
            <SessionProvider session={session}>
              <TodoList todos={todos} priorities={priorities} />
            </SessionProvider>
          )}
        </main>
      );
    }
    

    The auth Package

    Here's what the auth package looks like:

    // inside packages/auth/index.ts
    
    import { decodeJwt, SignJWT } from "jose";
    
    export const SECRET = "simple-secret";
    
    export function getUserToken(user: string) {
      return `token:${user}`;
    }
    
    export function getUserFromUserToken(token: string) {
      return token.replace("token:", "");
    }
    
    export async function encodeJWT(token: Record<string, any>) {
      return await new SignJWT(token)
        .setProtectedHeader({ alg: "HS256" })
        .sign(new TextEncoder().encode(SECRET.toString()));
    }
    
    export async function decodeJWT<Payload>(
      token?: string
    ): Promise<Payload | null> {
      return token ? decodeJwt(token?.toString()) : null;
    }
    
    export function validateUser(credentials: {
      username: string;
      password: string;
    }) {
      const users = [
        {
          id: "test-user-1",
          userName: "test1",
          name: "Test 1",
          password: "pass",
          email: "test1@donotreply.com",
        },
        {
          id: "test-user-2",
          userName: "test2",
          name: "Test 2",
          password: "pass",
          email: "test2@donotreply.com",
        },
      ];
      const user = users.find(
        (user) =>
          user.userName === credentials.username &&
          user.password === credentials.password
      );
      return user ? { id: user.id, name: user.name, email: user.email } : null;
    }
    

    In addition to setting the user id on the session, we also set a token that is given to us from getUserToken. This token is used to authenticate requests to the external API.

    Again, note that the token is not secure and is only used for demonstration purposes!

    The TodoList Component

    Similar to before, the TodoList component fetches the to-do list and priorities from the external API. It uses the Authorization header with the bearer token to authenticate the request.

    // inside TodoList.tsx
    
    "use client";
    import { useState } from "react";
    import { useSession } from "next-auth/react";
    
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from "@/components/ui/select";
    import { Input } from "@/components/ui/input";
    import { Checkbox } from "@/components/ui/checkbox";
    import { Button } from "@/components/ui/button";
    
    import { Todo } from "@repo/todos";
    
    export default function TodoList({
      priorities,
      todos: initialTodos,
    }: {
      priorities: string[];
      todos: Todo[];
    }) {
      const [todos, setTodos] = useState<Todo[]>(initialTodos);
      const [priority, setPriority] = useState<string>(priorities[0]);
      const [title, setTitle] = useState<string>("");
    
      const { data: session } = useSession();
    
      const updateTodos = async () => {
        const res = await fetch("/rest/todos", {
          cache: "no-cache",
          headers: {
            Authorization: `Bearer ${session?.user?.token}`,
          },
        });
        setTodos(await res.json());
      };
    
      const onSubmit = async () => {
        await fetch("/rest/todo", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${session?.user?.token}`,
          },
          body: JSON.stringify({
            title,
            priority,
            completed: false,
          }),
        });
        updateTodos();
      };
    
      const onSetCompleted = async (id: string, completed: boolean) => {
        await fetch(`/rest/todo/${id}`, {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${session?.user?.token}`,
          },
          body: JSON.stringify({ completed }),
        });
        updateTodos();
      };
    
      const onDelete = async (id: string) => {
        await fetch(`/rest/todo/${id}`, {
          method: "DELETE",
          headers: {
            Authorization: `Bearer ${session?.user?.token}`,
          },
        });
        updateTodos();
      };
    
      return (
        <div className="mt-5">
          {todos && (
            <>
              <ul>
                {todos?.map((todo) => (
                  <li key={todo.id} className="flex gap-2 items-center mb-3">
                    <Checkbox
                      checked={todo.completed}
                      onClick={() => onSetCompleted(todo.id, !todo.completed)}
                    />
                    <div className="flex-grow">{todo.title}</div>
                    <Button variant="destructive" onClick={() => onDelete(todo.id)}>
                      Delete
                    </Button>
                  </li>
                ))}
              </ul>
            </>
          )}
          <div className="flex gap-2">
            <Select value={priority} onValueChange={(v) => setPriority(v)}>
              <SelectTrigger className="w-[180px]">
                <SelectValue placeholder="Priority" />
              </SelectTrigger>
              <SelectContent>
                {priorities.map((priority) => (
                  <SelectItem value={priority} key={priority}>
                    {priority}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Input
              placeholder="Todo"
              value={title}
              onChange={(evt) => setTitle(evt.target.value)}
            />
            <Button onClick={onSubmit}>Submit</Button>
          </div>
        </div>
      );
    }
    

    After each mutation (like adding or completing a to-do), we should revalidate the Next.js page to reflect the changes. To do this, we can use a webhook to trigger revalidation on the server.

    Webhook for Revalidation

    Over in api/callback/route.ts on our Next.js server, we define a route to handle the webhook:

    // inside api/callback/route.ts
    
    import { revalidatePath } from "next/cache";
    import { NextResponse } from "next/server";
    
    export async function POST(req: Request) {
      revalidatePath("/");
      return NextResponse.json({ success: true });
    }
    

    When our external API receives a change, it can trigger this webhook.

    This ensures the Next.js app always reflects the latest state from the API.

    Security Considerations

    It is important to note that this architecture method exposes the access token to the client-side JavaScript. This is in contrast to other examples we've looked at that use HTTP-only cookies that are not accessible to the client.

    A more secure approach would be to handle token-based communication entirely on the server side. In this setup, the client would talk to the Next.js server, then the Next.js server would have access to the Bearer token on the server. It would make the request, then send the data back to the client.

    Transcript