Autenticació i gestió de la sessió amb NextJs

Roger Boixader Güell

19/2/2023

Un dels problemes més recurrents que ens trobem quan creem aplicacions, és la gestió de la sessió. En aquest article explicarem com implementar aquesta gestió amb dues llibreries diferents fent servir el famework de React NextJs. Explicarem les parts més importants però no farem un tutorial pas a pas de tot el codi, podreu trobar tot el codi en aquest repositori.

Utilitzarem la llibreria next-auth i iron-session. Per poder seguir correctament l'article és necessari tenir coneixements de JavaScript, React i NextJs. Abans de començar a explicar com hem fet les implementacions és recomanable llegir la documentació de les dues llibreries, ja que si no serà més complicat seguir tot el procediment, pel fet que no explicarem en detall totes les funcionalitats de les llibreries.

L'objectiu és poder crear la mateixa aplicació amb les dues llibreries, creant una api comuna, on els components i les pàgines seran les mateixes per les dues aplicacions i on hi haurà les modificacions serà en com obtenim i gestionem la sessió en cada cas.

En el nostre cas utilitzarem un servidor fet amb guillotina, ja que ens permet poder aixecar i crear una aplicació amb qüestió de minuts i fer l'inici de sessió. Podeu trobar més informació de com instal·lar la guillotina i crear usuaris amb els articles anteriors de Guillotina: El marc de referència (framework) de Python amb AsyncIO. (I) i Guillotina: La nostra primera aplicació (II).

La nostra aplicació serà una pàgina pública (home page), una pàgina privada (private page), una pàgina d'inici de sessió (login page) i un menú on hi haurà informació diferent en funció de si hem iniciat sessió o no.

Els nostres casos d'ús seràn:

  • Fer l'inici de la sessió
  • Mostrar al menú si l'usuari ha iniciat sessió, la seva informació i un botó per poder tancar la sessió (logout).
  • Si intentem accedir a la pàgina privada, i no hem iniciat sessió, farem una redirecció cap a la pàgina d'inici de sessió.
  • Si intentem accedir a la pàgina d'inici de sessió i ja hem iniciat sessió, farem una redirecció a la pàgina pública.
  • Si naveguem a qualsevol pàgina, comprovarem si la sessió ha caducat. En cas que hagi caducat, actualitzarem la informació del menú, en cas que no hagi caducat, però faltin N minuts perquè caduqui la sessió, farem una actualització de la sessió.

Per realitzar aquests casos d'ús farem servir el context de React per gestionar la sessió, i un hook que tindrà la mateixa API en les dues aplicacions. En l'aplicació d'exemple he creat dues aplicacions separades per facilitar la comprensió del codi de cada una. Tot i que els components i les pàgines són iguals en les dues.

Aquí podem veure el codi del nostre hook, on hi tenim la funció per realitzar l'inici de la sessió ( diferent en funció de la llibreria que utilitzem), la funció per tancar la sessió, i la informació de l'usuari actual que ha iniciat sessió, si és el cas. Com podem veure la informació de l'usuari ens ho proporciona el context i nomès implementem les funcions necessaries per iniciar i tancar sessió.

import { LoginErrorResponse } from "@/errors/errors";
import { AuthContext } from "context/AuthContext";

import { useRouter } from "next/router";
import { useContext } from "react";

export interface CredentialsData {
  username: string;
  password: string;
}
export function useAuth() {
  // Client only
  const { user, token, clear, update } = useContext(AuthContext);
  const router = useRouter();
  async function login(data: CredentialsData) {
   // implement your own login function
  }

  async function logout() {
    // implement your own logout function
  }

  return {
    token: token,
    user,
    isLogged: user !== undefined,
    login,
    logout,
  };
}

Podeu consultar com utilitzem el hook en la pàgina d'inici de sessió i en el menú en els següents enllaços:

Ara que hem vist com podem utilitzar el hook en qualsevol punt de l'aplicació on necessitem informació de l'usuari, o el token per poder obtenir informació privada d'aquest, anem a veure com implementem la gestió en cada una de les dues llibreries.

Implementació Next-auth

Per fer l'implementació amb la llibreria de next-auth primer hem d'implementar en el nostre servidor de l'aplicació de Nextjs les funcions necessaries per fer el login i obtenir la informació de la sessió. Seguint la seva documentació nosaltres utilitzarem el proveïdor de Credentials i un JSON Web Token ja que no estem fent l'inici de sessió amb cap plataforma com podria ser GitHub, Facebook, Gmail etc.

// pages/api/auth/[...nextauth].ts
import {
  SECONDS_LEFT_EXPIRE_TOKEN,
  SESSION_TTL_IN_SECONDS,
} from "@/core/constants";
import NextAuth, { AuthOptions, Session, User } from "next-auth";
import { JWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";

async function refreshAccessToken(jsonWebToken: JWT) {
  try {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_GUILLOTINA}@login-renew`,
      {
        body: new URLSearchParams({}),
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
          Authorization: `Bearer ${jsonWebToken.accessToken}`,
        },
        method: "POST",
      }
    );
    const tokenGuillotina = await response.json();
    if (!response.ok) {
      throw tokenGuillotina;
    }

    return {
      ...jsonWebToken,
      accessToken: tokenGuillotina.token,
      expires: JSON.parse(
        Buffer.from(tokenGuillotina.token.split(".")[1], "base64").toString()
      ).exp,
    };
  } catch (error) {
    return {
      ...jsonWebToken,
      error: "RefreshAccessTokenError",
    };
  }
}

const providers = [
  Credentials({
    name: "Credentials",
    credentials: {
      username: { label: "Username", type: "text" },
      password: { label: "Password", type: "password" },
    },
    authorize: async (credentials) => {
      const loginResponse = await fetch(
        `${process.env.NEXT_PUBLIC_GUILLOTINA}@login`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            password: credentials?.password ?? "",
            username: credentials?.username ?? "",
          }),
        }
      );

      if (loginResponse.ok) {
        const loginResponseData = await loginResponse.json();
        const userResponse = await fetch(
          `${process.env.NEXT_PUBLIC_GUILLOTINA}users/${credentials?.username}`,
          {
            method: "GET",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${loginResponseData.token}`,
            },
          }
        );
        let userData = null;
        if (userResponse.ok) {
          userData = await userResponse.json();
        }

        return {
          ...loginResponseData,
          user: userData,
        };
      } else {
        return null;
      }
    },
  }),
];

const callbacks = {
  async jwt({ token, user }: { token: JWT; user?: User }) {
    if (user) {
      return {
        accessToken: user.token,
        expires: JSON.parse(
          Buffer.from(user.token.split(".")[1], "base64").toString()
        ).exp,
        user: user.user,
      };
    }
    const currentTime = new Date().getTime() / 1000;
    if (
      currentTime < (token?.expires ?? 0) &&
      currentTime + SECONDS_LEFT_EXPIRE_TOKEN > (token?.expires ?? 0)
    ) {
      return refreshAccessToken(token);
    }

    if (currentTime < (token?.expires ?? 0)) {
      return token;
    }

    return {};
  },
  async session({ session, token }: { session: Session; token: JWT }) {
    if (token && Object.keys(token).length > 0) {
      session.user = token.user;
      session.accessToken = token.accessToken;
      session.expires = token.expires;
      return session;
    }
    return {} as Session;
  },
};

export const authOptions: AuthOptions = {
  providers,
  callbacks,
  pages: {
    signIn: "/login",
  },
  secret: process.env.NEXTAUTH_SECRET,
  session: {
    maxAge: SESSION_TTL_IN_SECONDS, 
  },
};

export default NextAuth(authOptions);

Aquí el que estem fent és en la funció authorize obtenir el token d'inici de sessió de guillotina i seguidament obtenir la informació de l'usuari. El callback jwt s'executarà cada vegada que consultem la informació de la sessió, i aquesta informació la llegirà el callback session que serà l'encarregat de retornar la informació cap al client.

El callback jwt rep dos paràmetres, el primer hi haurà la informació del token que tenim actualment guardat en sessió i en el segon hi haurà la informació de l'usuari que només hi serà just després d'haver fet l'inici de sessió.

Si tenim la informació de l'usuari, crearem l'objecte del token. En cas contrari serà quan comprovarem si el token ja ha expirat, si és així retornarem sessió buida, si no ha expirat retornarem la informació i si falten menys de N segons per expirar, l'actualitzarem.

En les opcions, li definirem quina és la durada de la sessió, que hauria de coincidir amb el pàrametre que configurem a guillotina. També li definim quina és la nostra pàgina d'inici de sessió, ja que l'utilitzarem en la protecció de rutes més endevant.

Un cop tenim implementades les funcions necessaries per obtenir la informació de la sessió implementarem el nostre context de react per obtenir aquesta informació, i utilitzar-la en l'aplicació.

// context/AuthContext.tsx
import { Session } from "next-auth";
import { getSession } from "next-auth/react";
import { createContext, useEffect, useState } from "react";
import { GuillotinaUser } from "types/guillotina";

export type UserContextType = {
  user: GuillotinaUser | undefined;
  token: string | undefined;
  clear: () => void;
  update: () => void;
};

interface AuthContextProviderProps {
  children: React.ReactNode;
  session: Session;
}

export const AuthContext = createContext<UserContextType>({
  user: undefined,
  token: undefined,
  clear: () => {},
  update: () => {},
});

export function AuthContextProvider({
  children,
  session: sessionProps,
}: AuthContextProviderProps) {
  const [session, setSession] = useState<Session | undefined>(
    sessionProps ?? undefined
  );
  useEffect(() => {
    if (sessionProps && "user" in sessionProps) {
      setSession(sessionProps);
    } else {
      clear();
    }
  }, [sessionProps]);

  async function update() {
    const session = await getSession();
    if (session && "user" in session) {
      setSession(session);
    } else {
      clear();
    }
  }

  function clear() {
    setSession(undefined);
  }

  return (
    <AuthContext.Provider
      value={{
        user: session?.user,
        token: session?.accessToken,
        clear,
        update,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

La idea és que nosaltres en cada pàgina de l'aplicació de NextJs, llegirem la sessió i l'enviarem com a props per poder-la obtenir en el context, i d'aquesta manera actualitzar l'estat que farem servir per llegir en els components. La funció update, actualitza manualment la informació de la sessió, necessari per exemple després de fer l'acció d'inici de sessió (login).

Aquí podem veure com enviarem la informació de la sessió des de cada pàgina de Nextjs.

export const getServerSideProps: GetServerSideProps = async (context) => {
  return {
    props: {
      session: await getServerSession(context.req, context.res, authOptions),
    },
  };
};

La llibreria de next-auth, ja ens implementa un middelware de NextJs que ens permet bloquejar quines pàgines són privades i quines no. Si intentem accedir a una pàgina privada i la sessió no està iniciada, el middleware ens farà una redirecció cap a la pàgina d'inici de sessió que li hem definit en les opcions anteriorment.

Resumint, amb la llibreria next-auth necessitem implementar les funcions per obtenir la informació de la sessió i fer l'inici de sessió en el servidor. Aquesta informació la llegim des del servidor en l'aplicació de Nextjs que enviem cap al client. En el client gràcies al context de react llegim aquesta informació i l'enviem cap als components, que en funció de si hem iniciat sessió o no podran pintar una informació o un altre. I també podrem consultar informació privada de l'usuari en el cas que hàgim iniciat sessió.

Podeu trobar tot el codi de l'aplicació en el següent enllaç

Implementació Iron session

En el cas de la llibreria iron-session la idea és la mateixa que amb la llibreria next-auth, tot i que la implementació varia una mica.

Aquí no tenim una sola pàgina en el nostre servidor que implementa les funcions necessàries per obtenir la sessió, iniciar-la o tancar-la. Si no que tenim 3 pàgines separades.

// pages/api/login.ts

import { sessionOptions } from "@/lib/sessionOptions";
import { UserSession } from "@/types/session";
import { withIronSessionApiRoute } from "iron-session/next";

import { NextApiRequest, NextApiResponse } from "next";

export default withIronSessionApiRoute(loginRoute, sessionOptions);

async function loginRoute(req: NextApiRequest, res: NextApiResponse) {
  const { username, password } = await req.body;
  try {
    // Get token from guillotina
    let user = null;
    let userData = null;
    try {
      const loginResponse = await fetch(
        `${process.env.NEXT_PUBLIC_GUILLOTINA}@login`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            password: password ?? "",
            username: username ?? "",
          }),
        }
      );
      if (!loginResponse.ok) {
        res.status(401);
        res.json({});
        return;
      }
      const loginResponseData = await loginResponse.json();
      const userResponse = await fetch(
        `${process.env.NEXT_PUBLIC_GUILLOTINA}users/${username}`,
        {
          method: "GET",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${loginResponseData.token}`,
          },
        }
      );
      if (userResponse.ok) {
        userData = await userResponse.json();
      }
      user = {
        accessToken: loginResponseData.token,
        user: userData,
        expires: JSON.parse(
          Buffer.from(
            loginResponseData.token.split(".")[1],
            "base64"
          ).toString()
        ).exp,
      } as UserSession;
      req.session.data = user;
      await req.session.save();
    } catch (err) {
      console.error(err);
    }

    res.json(user);
  } catch (error) {
    res.status(500).json({ message: (error as Error).message });
  }
}
// pages/api/logout.ts

import { sessionOptions } from "@/lib/sessionOptions";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";

export default withIronSessionApiRoute(logoutRoute, sessionOptions);

function logoutRoute(req: NextApiRequest, res: NextApiResponse) {
  req.session.destroy();
  res.send({ ok: true });
}
// pages/api/session.ts

import { sessionOptions } from "@/lib/sessionOptions";
import { getSession } from "@/services/session";
import { UserSession } from "@/types/session";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";

export default withIronSessionApiRoute(userRoute, sessionOptions);

async function userRoute(
  req: NextApiRequest,
  res: NextApiResponse<UserSession>
) {
  const sessionData = await getSession(req.session.data);
  if (sessionData) {
    req.session.data = sessionData;
    await req.session.save();
    res.json(sessionData);
    return;
  } else {
    req.session.destroy();
    res.status(401);
    res.json({});
  }
}

Cada pàgina és un endpoint en el nostre servidor de l'aplicació de Nextjs que utilitzarem en el client. En aquest cas l'actualització de la sessió si és necessaria ho farem en l'endpoint session però també ho farem en el servidor abans d'intentar accedir a les pàgines.

En aquest cas no farem servir el middleware de Nextjs, ja que quan vaig intentar fer la implementació no funcionava correctament la redirecció, i en comptes de fer-ho en el middleware ho haurem de fer en cada pàgina privada que tinguem de Nextjs.

NOTA: Cal dir que quan NextJs 13 hagi implementat d'una manera més estable el nou directori app aquesta implementació canviarà i se simplificarà.

Llavors les nostres pàgines privades, implementaríem en el servidor el següent codi:

export const getServerSideProps = withSessionSsr(async function (context) {
  const propsSecurePage = await securePage(context, "/login");
  if ("redirect" in propsSecurePage) {
    return propsSecurePage;
  }

  // Do something to get data from our server
  return propsSecurePage;
});

I en les pàgines públiques:

export const getServerSideProps = withSessionSsr(async function (context) {
  const sessionData = await getSessionSSR(context);
  if (sessionData) {
    return {
      props: {
        session: sessionData,
      },
    };
  }
  return {
    props: {
      session: {},
    },
  };
});

La funcio securePage internament utilitza la funció getSessionSSR, que a la vegada aquesta utilitza la funció getSession que utilitzem en l'endpoint session per obtenir la informació i és l'encarregada d'actualitzar-la si és necessari per al temps d'expiració que li queda al token:

// services/session
import { SECONDS_LEFT_EXPIRE_TOKEN } from "@/core/constants";
import { UserSession } from "@/types/session";
import { refreshAccessToken } from "./refreshToken";

export async function getSession(
  sessionData: UserSession | undefined
): Promise<UserSession | null> {
  const currentTime = new Date().getTime() / 1000;
  if (
    currentTime < (sessionData?.expires ?? 0) &&
    currentTime + SECONDS_LEFT_EXPIRE_TOKEN > (sessionData?.expires ?? 0)
  ) {
    try {
      const data = await refreshAccessToken(sessionData!);
      return data;
    } catch (err) {
      return null;
    }
  }

  if (currentTime < (sessionData?.expires ?? 0)) {
    return sessionData!;
  } else {
    return null;
  }
}

Un cop enviem la informació de la sessió cap al client la implementació del context de react és quasi el mateix que en la llibreria next-auth. Amb la diferència que per obtenir la informació de la sessió, utilitzarem l'endpoint directament que hem creat de session.

import { CustomError } from "@/types/global";
import { UserSession } from "@/types/session";
import { createContext, useEffect, useState } from "react";
import { GuillotinaUser } from "types/guillotina";

export type UserContextType = {
  user: GuillotinaUser | undefined;
  token: string | undefined;
  clear: () => void;
  update: () => void;
};

interface AuthContextProviderProps {
  children: React.ReactNode;
  session: UserSession;
}

export const AuthContext = createContext<UserContextType>({
  user: undefined,
  token: undefined,
  clear: () => {},
  update: () => {},
});

const fetcher = async (url: string) => {
  const res = await fetch(url);

  // If the status code is not in the range 200-299,
  // we still try to parse and throw it.
  if (!res.ok) {
    const error = new Error(
      "An error occurred while fetching the data."
    ) as CustomError;
    // Attach extra info to the error object.
    error.info = await res.json();
    error.status = res.status;
    throw error;
  }

  return res.json();
};

export function AuthContextProvider({
  children,
  session: sessionProp,
}: AuthContextProviderProps) {
  const [session, setSession] = useState<UserSession | undefined>(sessionProp);

  useEffect(() => {
    if (sessionProp && "user" in sessionProp) {
      setSession(sessionProp);
    } else {
      clear();
    }
  }, [sessionProp]);

  async function update() {
    const session = await fetcher("/api/session");
    if (session && "user" in session) {
      setSession(session);
    } else {
      clear();
    }
  }

  function clear() {
    setSession(undefined);
  }

  return (
    <AuthContext.Provider
      value={{
        user: session?.user,
        token: session?.accessToken,
        clear,
        update,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

Amb aquesta llibreria li definirem en les opcions, el nom de la cookie que crearem per mantenir la sessió, el temps d'expiració de la sessió i una contrasenya.

export const sessionOptions = {
  cookieName: "local-example-app",
  password: "wpwQZktHxFMZN3V4hbDbux1WYjPKr9M6",
  // secure: true should be used in production (HTTPS) but can't be used in development (HTTP)
  cookieOptions: {
    secure: process.env.SESSION_SECURE === "active",
    ttl: 20,
  },
};

Podeu trobar tot el codi de l'aplicació en el següent enllaç

Testos

Finalment, en la carpeta e2e hi tenim la implementació dels testos fets amb playwright que ens permetrà validar el comportament, i en el cas de refactoritzar el codi, tenir la seguretat que no trenquem el funcionament esperat de l'aplicació.

En cada carpeta hi ha un arxiu .env-template, que hi ha definides les variables d'entorn necessaries per arrancar els projectes, i per provar-ho tot complet també serà necessari tenir una guillotina corrent.