Login session in Next.auth with JWT

Hi. This is the sixteenth part of the diary about developing the “Programmers’ diary” blog. The open source code of this project is on https://github.com/TheProgrammersDiary. The fifteenth part: https://hashnode.programmersdiary.com/implementing-jwt-token-authorization-in-fe.

The first diary entry contains explanation on why this project was done: https://medium.com/@vievaldas/developing-a-website-with-microservices-part-1-the-idea-fe6e0a7a96b5.

Next entry:

—————

2023-11-26:

OK, we are able to sign up, login and send a request which requires authorization. However, it would be cool to manage the login session. We can use next-auth library. [Note from the future: I understood later that Redux can handle my needs better and next-auth was removed. However, you can check how I tried implementing login, logout and showing username with next-auth.]

Let’s run: npm install next-auth. According to next-auth docs https://next-auth.js.org/configuration/options#nextauth_secret you need to setup NEXTAUTH_URL and NEXTAUTH_SECRET. To generate secret value, run openssl rand -base64 32 in the command prompt (if you don’t have openssl command you will need to install openssl). In frontend root folder create an .env file with content:

NEXTAUTH_URL="<http://localhost:3000>"
NEXTAUTH_SECRET="generated secret"

Having this, add api/auth/[…nextauth]/route.ts:

import { NextAuthOptions } from "next-auth";
import NextAuth from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";

export const authOptions: NextAuthOptions = {
  pages: {
    signIn: "/login",
  },
  session: {
    strategy: "jwt",
  },
  providers: [
    CredentialsProvider({
      credentials: {
        username: {
          label: "username",
          type: "input"
        },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        return {id: "-1", name: credentials.username};
      },
    }),
  ],
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

Then, in login page, change onSubmit function:

async function onSubmit(data, event) {
    event.preventDefault();
    const body = {"username": data.username, "password": data.password};
    await fetch(
      "<http://localhost:8080/users/login>",
      { 
        method: "POST",
        body: JSON.stringify(body),
        headers: { "Content-Type": "application/json" },
        credentials: "include",
      }
    )
    .then(response => response.json())
    .then(response => {
      signIn('credentials', { 
        callbackUrl: '/',
        username: response.username,
        expiration: response.expirationDate
      });
    });
  }

On login user will be redirected to the main page. Also we will be able to show user his username.

In BE login endpoint instead of returning expirationDate we also return a username. We do this because JWT will be stored in HttpOnly cookie and JavaScript won’t be able to read it so we need to provide data we need to read as response body:

@PostMapping("/login")
public void login(@RequestBody User user, HttpServletResponse response) throws IOException {
    Authentication authentication = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword())
    );
    SecurityContextHolder.getContext().setAuthentication(authentication);
    JwtToken token = JwtToken.create(authentication, blacklistedJwtTokenRepository);
    ResponseCookie cookie = ResponseCookie.from("jwt", token.retrieve())
            .httpOnly(true)
            //.secure(true) //TODO: uncomment then HTTPS is enabled.
.maxAge(Duration.ofMinutes(10))
            .path("/")
            .build();
    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    response.getWriter().println(
            new ObjectMapper()
                    .createObjectNode()
                    .put("username", token.username())
                    .put("expirationDate", token.expirationDate().toString())
                    .toPrettyString()
    );
}

Now what we have this, we want the session be available at whetever page we need. Our layout.tsx is a server-side component which is passed to every page. So we can instruct the page to return NextAuthProvider containing the session:

import { getServerSession } from "next-auth";
import NextAuthProvider from "./NextAuthProvider"
import React from "react"
import { authOptions } from "./api/auth/[...nextauth]/route";

export default async function RootLayout({
    children,
  }: {
    children: React.ReactNode
  }) {
    const session = await getServerSession(authOptions);
    return (
      <html lang="en">
        <NextAuthProvider session={session}>
          <body>{children}</body>
        </NextAuthProvider>
      </html>
    )
  }

app/NextAuthProvider.tsx file:

'use client'

import { SessionProvider } from "next-auth/react"

export default function NextAuthProvider ({
  children,
  session
}: {
  children: React.ReactNode
  session: any
}): React.ReactNode {
  return <SessionProvider session={session}>
    {children}
  </SessionProvider>
}

You might be asking, why do we need this file, we could just replace NextAuthProvider with Session provider in layout. The problem is that SessionProvider needs to be on the client side. However, the layout.tsx is on the server side. By calling NextAuthProvider inside layout.tsx we are able to get client session.

Now we can check if user is logged in, so let’s update app/page.tsx:

  1. add import { useSession } from "next-auth/react";

  2. const { data: session } = useSession(); // first line in Page function.

  3. <div>{session ? (<p>Logged in as {session?.user.name}</p>) : (<p>Logged out</p>)}</div> Add this somewhere to return statement.

Now if you go to /sign_up, create an account, go to /login and enter correct credentials, on the main page you will see Logged in as Petras if your username is Petras.

Now let’s do logout. Add /app/page/logout.tsx:

"use client"

import { useEffect } from 'react';
import { signOut } from 'next-auth/react';

export default function Logout() {
    useEffect(() => {
        const effect = async () => {
            await fetch(
                "<http://localhost:8080/users/logout>",
                {
                    method: "POST",
                    credentials: "include",
                }
            ).then(_ => {
                signOut({callbackUrl: '/'});
            });
        }
        effect();
    }, []);
}

The signOut method will only signOut us from next-auth functionality. It won’t delete HttpOnly jwt cookie. Also, it cannot be deleted since it’s HttpOnly and is not accessible from JavaScript (only user using the browser’s developer console could delete it). Signing out in FE is OK, since we can show user that he is not logged in. We also need to sign out form BE. However, BE does not store the JWT tokens. Yet it has the way to decrypt them (by using the secret JWT key we generate as application launches), so if non-expired JWT token is passed to BE it will be considered valid. To sign out from BE, we can blacklist the JWT token and add it to the in memory store. We could store it in the database, however that defeats the purpose of JWT token, because they were designed to be efficient by use of encryption without requiring to call the database. We can use an in memory database instead. [Note from the future: we actually don't need this super security. Instead of having token blacklisting we will be able to use long-lived refresh tokens and short-lived tokens. More about that in the upcoming diary entries.]

In BE let’s start from UserController:

@PostMapping("/logout")
public void logout(HttpServletRequest request) {
    JwtToken
            .existing(request, blacklistedJwtTokenRepository)
            .ifPresentOrElse(
                    blacklistedJwtTokenRepository::blacklistToken,
                    () -> {
                        throw new RuntimeException("Possible security issue. Logout is missing jwt token.");
                    }
            );
}

To access logout method user needs to be authorized. If he’s not, something is probably wrong, so I throw the exception which can be seen in logs.

Then we need this in-memory repo:

@Repository
public class BlacklistedJwtTokenRepository {
    private final Map<String, Date> blacklistedJwtTokenWithExpirationDate = new HashMap<>();

    public void blacklistToken(JwtToken token) {
        blacklistedJwtTokenWithExpirationDate.put(token.retrieve(), token.expirationDate());
    }

    public boolean isTokenBlacklisted(String jwt) {
        return blacklistedJwtTokenWithExpirationDate.containsKey(jwt);
    }

    @Scheduled(fixedRate = 10 * 60 * 1000)
    public void removeExpiredTokens() {
        blacklistedJwtTokenWithExpirationDate
                .entrySet()
                .removeIf(tokenWithDate -> tokenWithDate.getValue().before(new Date()));
    }
}

We add invalid JWT tokens to a hashmap to efficiently check JWT token validity when a request comes.

We also remove expired jwt tokens from blacklistRepository every 10 minutes (since if token is expired it will be automatically rejected and we don’t need to store it as blacklisted anymore). This removal will work as scheduled job. For it to work, we need to enable it in BlogApplication.java by adding @EnableScheduling.

After that, a little bit refactoring is needed. JwtUtils class was deleted, instead JwtToken class was created:

public final class JwtToken {
    private static final Logger logger= LoggerFactory.getLogger(JwtToken.class);
    private static final SecretKey key= Jwts.SIG.HS256.key().build();
    private static final intjwtExpirationMs = 1000 * 60 * 10;

    private final String token;
    private final BlacklistedJwtTokenRepository blacklistedJwtTokenRepository;

    private JwtToken(String token, BlacklistedJwtTokenRepository blacklistedJwtTokenRepository) {
        this.token = token;
        this.blacklistedJwtTokenRepository = blacklistedJwtTokenRepository;
    }

    public static JwtToken create(
            Authentication authentication, BlacklistedJwtTokenRepository blacklistedJwtTokenRepository
    ) {
        return new JwtToken(
                Jwts
                        .builder()
                        .subject(((UserDetailsImpl) authentication.getPrincipal()).getUsername())
                        .issuedAt(new Date())
                        .expiration(new Date((new Date()).getTime() +jwtExpirationMs))
                        .signWith(key)
                        .compact(),
                blacklistedJwtTokenRepository
        );
    }

    public static Optional<JwtToken> existing(
            HttpServletRequest request, BlacklistedJwtTokenRepository blacklistedJwtTokenRepository
    ) {
        returnparseJwt(request).map(token -> new JwtToken(token, blacklistedJwtTokenRepository));
    }

    private static Optional<String> parseJwt(HttpServletRequest request) {
        return Optional
                .ofNullable(request.getHeader("Authorization"))
                .map(JwtToken::parseJwtFromHeader)
                .orElse(parseJwtFromCookies(request.getCookies()));
    }

    private static Optional<String> parseJwtFromHeader(String token) {
        if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
            return Optional.of(token.substring(7));
        }
        return Optional.empty();
    }

    private static Optional<String> parseJwtFromCookies(Cookie[] cookies) {
        if(cookies == null) {
            return Optional.empty();
        }
        return Arrays
                .stream(cookies)
                .filter(cookie -> cookie.getName().equals("jwt"))
                .findFirst()
                .map(Cookie::getValue);
    }

    public String retrieve() {
        return token;
    }

    public String username() {
        return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getSubject();
    }

    public Date expirationDate() {
        return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getExpiration();
    }

    public boolean tokenIsValid() {
        try {
            Jwts.parser().verifyWith(key).build().parse(token);
            return !blacklistedJwtTokenRepository.isTokenBlacklisted(token);
        } catch(ExpiredJwtException | MalformedJwtException | SecurityException | IllegalArgumentException e) {
logger.error("Exception while trying to validate JWT token: {}", e.getMessage());
            return false;
        }
    }
}

In tokenIsValid method we check if token is valid by decrypting it with key, however we also check if it’s not blacklisted. If it is, it will be rejected.

JwtFilter is also refactored:

public class JwtFilter extends OncePerRequestFilter {
    private static final Loggerlogger= LoggerFactory.getLogger(JwtFilter.class);
    @Autowired
    private UserDetailsServiceImpl userDetailsService;
    @Autowired
    private BlacklistedJwtTokenRepository blacklistedJwtTokenRepository;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            JwtToken.existing(request, blacklistedJwtTokenRepository).ifPresent(
                    token -> {
                        if (token.tokenIsValid()) {
                            UserDetails userDetails = userDetailsService.loadUserByUsername(token.username());
                            UsernamePasswordAuthenticationToken authentication =
                                    new UsernamePasswordAuthenticationToken(
                                            userDetails,
                                            null,
                                            userDetails.getAuthorities()
                                    );
                            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                            SecurityContextHolder.getContext().setAuthentication(authentication);
                        }
                    }
            );

        } catch (Exception e) {
logger.error("Cannot set user authentication: ", e);
        }
        filterChain.doFilter(request, response);
    }
}

Now you can create a post, login, write a comment, logout, and try to write a comment again. And you will see you can’t write a comment again, although you still have the jwt cookie in browser. That’s because you are logged out from BE and the token is blacklisted.

So we have implemented authentication session in FE.

This update resulted in lots of failing tests on Github. You can check the commit history on Github https://github.com/TheProgrammersDiary/Frontend/commits/main/ by searching for 2023-11-26 commits.

—————

Thanks for reading.

The project logged in this diary is open source, so if you would like to code or suggest changes, please visit https://github.com/TheProgrammersDiary.

You can check out the website: https://www.programmersdiary.com/.

More diary entries coming soon.

Update: next diary entry: https://hashnode.programmersdiary.com/programmers-diary-project-design-updates.