logo My Digital Garden

NestJS 13 series - authentication

By James Kolean on Nov 24, 2023
Source repository: https://gitlab.com/jameskolean/next-explore
Demo: https://next-explore-liart.vercel.app/
JavaScriptNext
banner

This iteration is not a complete authentication solution. It only looks at the session. NextJS recommends Iron-Session for this. As luck would have it, the latest release fully supports NextJS 13.

We need a bunch of stuff to make this work:

  • Login page
  • Middleware
  • Iron-session setup
  • API
  • Server-side example
  • Client-side example
  • Hooks

Let’s get started

Login Page

Here is a simple login page and form. An actual login form will include a password.

// src/app/[lng]/login/page.tsx
import {
    Metadata
} from "next";
import {
    Form
} from "./form";

export const metadata: Metadata = {
    title: "Login",
};

export default function AppRouterRedirect() {
    return ( <
        main >
        <
        h1 > Login < /h1> <
        div >
        <
        Form / >
        <
        /div> < /
        main >
    );
}
"use client";
// src/app/[lng]/login/form.tsx
import {
    useEffect,
    useState
} from "react";
import {
    SessionData
} from "@/session";
import {
    defaultSession
} from "@/session";

export function Form() {
    const [session, setSession] = useState < SessionData > (defaultSession);
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {
        fetch("/api/session")
            .then((res) => res.json())
            .then((session) => {
                setSession(session);
                setIsLoading(false);
            });
    }, []);

    if (isLoading) {
        return <p > Loading... < /p>;
    }

    if (session.isLoggedIn) {
        return ( <
            >
            <
            p >
            Logged in user: < strong > {
                session.username
            } < /strong> < /
            p > <
            LogoutButton / >
            <
            />
        );
    }

    return <LoginForm / > ;
}

function LoginForm() {
    return ( <
        form action = "/api/session"
        method = "POST" >
        <
        label className = "block text-lg" >
        <
        span > Username < /span> <
        input type = "text"
        name = "username"
        placeholder = ""
        defaultValue = "James"
        required
        // for demo purposes, disabling autocomplete 1password here
        // autoComplete="off"
        // data-1p-ignore
        /
        >
        <
        /label> <
        div >
        <
        input type = "submit"
        value = "Login" / >
        <
        /div> < /
        form >
    );
}

function LogoutButton() {
    return ( <
        p >
        <
        a href = "/api/session?action=logout" > Logout < /a> < /
        p >
    );
}

Middleware

Next, we should direct users to the Login page if they are not logged in. Middleware will do this.

// src/middlewares/withSession.ts
import {
    NextFetchEvent,
    NextRequest
} from "next/server";
import {
    MiddlewareFactory
} from "./middlewareFactory";
import {
    cookies
} from "next/headers";
import {
    getIronSession
} from "iron-session";
import {
    cookieName as i18nCookieName
} from "@/app/i18n/settings";
import {
    SessionData,
    sessionOptions
} from "@/session";

export const withSession: MiddlewareFactory = (next) => {
    return async (request: NextRequest, _next: NextFetchEvent) => {
        const session = await getIronSession < SessionData > (
            cookies(),
            sessionOptions
        );
        const lng = request.cookies.get(i18nCookieName)?.value;

        if (!session.isLoggedIn && !request.nextUrl.pathname.endsWith("login")) {
            const redirectTo = `/${lng}/login`;
            return Response.redirect(`${request.nextUrl.origin}${redirectTo}`, 302);
        }
        return next(request, _next);
    };
};

Iron-session setup

We can add some utility functions now. The critical export is the SessionOptions.

// src/session/index.ts
import {
    SessionOptions
} from "iron-session";

export interface SessionData {
    username: string;
    isLoggedIn: boolean;
}
export const sessionOptions: SessionOptions = {
    // should come from env
    password: "complex_password_at_least_32_characters_long",
    cookieName: "iron-session",
    cookieOptions: {
        // secure only works in `https` environments
        // if your localhost is not on `https`, then use: `secure: process.env.NODE_ENV === "production"`
        secure: process.env.NODE_ENV === "production",
        // secure: true,
    },
};
export const defaultSession: SessionData = {
    username: "",
    isLoggedIn: false,
};

export function sleep(ms: number) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

API

Now, let’s create the API endpoint that the login form calls. The actual API would make a DB or API call to authenticate. We don’t care about that step for now.

Note: this does the logout too. Again, a production solution will need to reset other resources on logout.

// src/app/api/login/route.ts
import {
    NextRequest
} from "next/server";
import {
    cookies
} from "next/headers";
import {
    getIronSession
} from "iron-session";
import {
    defaultSession,
    sessionOptions
} from "@/session";
import {
    redirect
} from "next/navigation";
import {
    sleep,
    SessionData
} from "@/session";

export async function POST(request: NextRequest) {
    const session = await getIronSession < SessionData > (cookies(), sessionOptions);
    const formData = await request.formData();
    session.isLoggedIn = true;
    session.username = (formData.get("username") as string) ?? "No username";
    await session.save();

    // simulate looking up the user in db
    await sleep(250);

    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
    // not using redirect() yet: https://github.com/vercel/next.js/issues/51592#issuecomment-1810212676
    return Response.redirect(`${request.nextUrl.origin}/en/`, 303);
}

export async function GET(request: NextRequest) {
    const session = await getIronSession < SessionData > (cookies(), sessionOptions);

    const action = new URL(request.url).searchParams.get("action");
    if (action === "logout") {
        session.destroy();
        return redirect("/login");
    }

    // simulate looking up the user in db
    await sleep(250);

    if (session.isLoggedIn !== true) {
        return Response.json(defaultSession);
    }

    return Response.json(session);
}

Server side example

Let’s add the session information to a page. We can do this in the base Layout.

// src/app/[lng]/layout.tsx
import type {
    Metadata
} from "next";
import {
    Inter
} from "next/font/google";
import "./globals.css";
import {
    dir
} from "i18next";
import {
    PropsWithChildren
} from "react";
import {
    languages
} from "@/app/i18n/settings";
import {
    getIronSession
} from "iron-session";
import {
    SessionData,
    sessionOptions
} from "@/session";
import {
    cookies
} from "next/headers";

export async function generateStaticParams() {
    return languages.map((lng) => ({
        lng
    }));
}
const inter = Inter({
    subsets: ["latin"]
});

export const metadata: Metadata = {
    title: "Next Explorer",
    description: "App to explore Next 13",
};
interface Props {
    children: React.ReactNode;

    params: {
        lng: string;
    };
}
export default async function RootLayout(props: PropsWithChildren < Props > ) {
    const session = await getIronSession < SessionData > (cookies(), sessionOptions);
    return ( <
        html lang = {
            props.params.lng
        }
        dir = {
            dir(props.params.lng)
        } >
        <
        head / >
        <
        body className = {
            inter.className
        } >
        <
        div > {
            !!session && !!session.username && ( <
                div style = {
                    {
                        display: "flex",
                        gap: "2rem",
                        alignItems: "center"
                    }
                } >
                Hello {
                    session.username
                } <
                LogoutButton / >
                <
                /div>
            )
        } <
        /div> <
        div > {
            props.children
        } < /div> < /
        body > <
        /html>
    );
}

function LogoutButton() {
    return ( <
        p >
        <
        a href = "/api/session?action=logout" > Logout < /a> < /
        p >
    );
}

Now we can see who we logged in as and log out.

Client-side example

Now, let’s try to use session information in a client component. Here is the client component. Just add it to the second page.

"use client";
// src/app/[lng]/(protected)/second-page/ClientSessionPanel.tsx
import useSession from "@/session/use-session";
import {
    useEffect
} from "react";
import {
    useRouter
} from "next/navigation";

interface Props {
    lng: string;
}
export default function ClientSessionPanel(props: Props) {
    const {
        session,
        isLoading
    } = useSession();
    const router = useRouter();
    // I don't think we need this, the middleware should be enough.
    useEffect(() => {
        if (!isLoading && !session.isLoggedIn) {
            router.replace(`/${props.lng}/login`);
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [isLoading, session.isLoggedIn, router]);

    if (isLoading) {
        return <p > Loading... < /p>;
    }

    return ( <
        div >
        <
        p >
        Hello < strong > {
            session.username
        }! < /strong> < /
        p > <
        /div>
    );
}

Hooks

And finally, the hook used by the client component looks like this.

// src/session/use-session.ts
import useSWR from "swr";
import {
    SessionData,
    defaultSession
} from "@/session";
import useSWRMutation from "swr/mutation";

const sessionApiRoute = "/api/session";

async function fetchJson < JSON = unknown > (
    input: RequestInfo,
    init ? : RequestInit
): Promise < JSON > {
    return fetch(input, {
        headers: {
            accept: "application/json",
            "content-type": "application/json",
        },
        ...init,
    }).then((res) => res.json());
}

function doLogin(url: string, {
    arg
}: {
    arg: string
}) {
    return fetchJson < SessionData > (url, {
        method: "POST",
        body: JSON.stringify({
            username: arg
        }),
    });
}

function doLogout(url: string) {
    return fetchJson < SessionData > (url, {
        method: "DELETE",
    });
}

export default function useSession() {
    const {
        data: session,
        isLoading
    } = useSWR(
        sessionApiRoute,
        fetchJson < SessionData > , {
            fallbackData: defaultSession,
        }
    );

    const {
        trigger: login
    } = useSWRMutation(sessionApiRoute, doLogin, {
        // the login route already provides the updated information, no need to revalidate
        revalidate: false,
    });
    const {
        trigger: logout
    } = useSWRMutation(sessionApiRoute, doLogout);

    return {
        session,
        logout,
        login,
        isLoading
    };
}
© Copyright 2023 Digital Garden cultivated by James Kolean.