Unable to successfully log out from next-auth using the Keycloak provider

I am currently using next-auth with my Next.js application to handle authentication. Here is how I have configured it:

....
export default NextAuth({
  // Configure one or more authentication providers
  providers: [
    KeycloakProvider({
      id: 'my-keycloack-2',
      name: 'my-keycloack-2',
      clientId: process.env.NEXTAUTH_CLIENT_ID,
      clientSecret: process.env.NEXTAUTH_CLIENT_SECRET,
      issuer: process.env.NEXTAUTH_CLIENT_ISSUER,
      profile: (profile) => ({
        ...profile,
        id: profile.sub
      })
    })
  ],
....

Authentication is functioning correctly, but I am facing an issue where logging out using the next-auth signOut function does not work as expected. The next-auth session gets destroyed, however, Keycloak continues to maintain its session.

Answer №1

During my investigation, I stumbled upon a discussion on Reddit that addresses the same issue here.

Here's how I resolved it.

I created a custom logout function:

  const logout = async (): Promise<void> => {
    const {
      data: { path }
    } = await axios.get('/api/auth/logout');
    await signOut({ redirect: false });
    window.location.href = path;
  };

Additionally, I set up an API endpoint to retrieve the path required to destroy the Keycloak session /api/auth/logout:

export default (req, res) => {
  const path = `${process.env.NEXTAUTH_CLIENT_ISSUER}/protocol/openid-connect/logout? 
                redirect_uri=${encodeURIComponent(process.env.NEXTAUTH_URL)}`;

  res.status(200).json({ path });
};

UPDATE

In recent versions of Keycloak (currently at version 19.*.* -> here), the redirect URI has become more intricate:

export default (req, res) => {

  const session = await getSession({ req });

  let path = `${process.env.NEXTAUTH_CLIENT_ISSUER}/protocol/openid-connect/logout? 
                post_logout_redirect_uri=${encodeURIComponent(process.env.NEXTAUTH_URL)}`;

if(session?.id_token) {
  path = path + `&id_token_hint=${session.id_token}`
} else {
  path = path + `&client_id=${process.env.NEXTAUTH_CLIENT_ID}`
}

  res.status(200).json({ path });
};

Keep in mind that either the client_id or id_token_hint parameter must be included if the post_logout_redirect_uri is included.

Answer №2

It seems like I encountered a similar issue, but instead of creating a new route, I opted to extend the signOut event to handle the necessary request for keycloak:

import NextAuth, { type AuthOptions } from "next-auth"
import KeycloakProvider, { type KeycloakProfile } from "next-auth/providers/keycloak"
import { type JWT } from "next-auth/jwt";
import { type OAuthConfig } from "next-auth/providers";


declare module 'next-auth/jwt' {
  interface JWT {
    id_token?: string;
    provider?: string;
  }
}


export const authOptions: AuthOptions = {
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOAK_CLIENT_ID || "keycloak_client_id",
      clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "keycloak_client_secret",
      issuer: process.env.KEYCLOAK_ISSUER || "keycloak_url",
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.id_token = account.id_token
        token.provider = account.provider
      }
      return token
    },
  },
  events: {
    async signOut({ token }: { token: JWT }) {
      if (token.provider === "keycloak") {
        const issuerUrl = (authOptions.providers.find(p => p.id === "keycloak") as OAuthConfig<KeycloakProfile>).options!.issuer!
        const logOutUrl = new URL(`${issuerUrl}/protocol/openid-connect/logout`)
        logOutUrl.searchParams.set("id_token_hint", token.id_token!)
        await fetch(logOutUrl);
      }
    },
  }
}

export default NextAuth(authOptions)

Moreover, by including id_token_hint in the request, it eliminates the need for users to click logOut twice.

Answer №3

My approach took a different direction from the discussion thread mentioned here.

I wasn't fond of the numerous redirects occurring in my application or the idea of creating a new endpoint solely for managing the "post-logout handshake."

Instead, I opted to directly include the id_token in the original JWT token generated. Additionally, I attached a function named doFinalSignoutHandshake to the events.signOut, which automates a GET request to the keycloak service endpoint and logs out the user's session automatically.

This method allows me to retain all existing application flows and utilize the standard signOut method provided by next-auth without requiring any specific modifications on the front end.

Since this is written in TypeScript, I extended the JWT definition to incorporate the new values (this step may not be necessary in vanilla JS).

// located under /types/next-auth.d.ts in your project
// Most editors will merge the definitions automatically
declare module "next-auth/jwt" {
    interface JWT {
        provider: string;
        id_token: string;
    }
}

Below is my implementation of /pages/api/[...nextauth.ts]

import axios, { AxiosError } from "axios";
import NextAuth from "next-auth";
import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";

// I defined this outside of the initial setup so
// that I wouldn't need to keep copying the
// process.env.KEYCLOAK_* values everywhere
const keycloak = KeycloakProvider({
    clientId: process.env.KEYCLOAK_CLIENT_ID,
    clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
    issuer: process.env.KEYCLOAK_ISSUER,
});

// This function handles the final handshake for the keycloak provider
// The way it's structured could potentially work for other providers as well
async function doFinalSignoutHandshake(jwt: JWT) {
    const { provider, id_token } = jwt;

    if (provider == keycloak.id) {
        try {
            // Add the id_token_hint to the query string
            const params = new URLSearchParams();
            params.append('id_token_hint', id_token);
            const { status, statusText } = await axios.get(`${keycloak.options.issuer}/protocol/openid-connect/logout?${params.toString()}`);

            // The response body should confirm that the user has been logged out
            console.log("Completed post-logout handshake", status, statusText);
        }
        catch (e: any) {
            console.error("Unable to perform post-logout handshake", (e as AxiosError)?.code || e)
        }
    }
}

export default NextAuth({
    secret: process.env.NEXTAUTH_SECRET,
    providers: [
        keycloak
    ],
    callbacks: {
        jwt: async ({ token, user, account, profile, isNewUser }) => {
            if (account) {
                // Copy the expiry from the original keycloak token
                // Overrides the settings in NextAuth.session
                token.exp = account.expires_at;
                token.id_token = account.id_token;
                // Update includes the "provider" property
                token.provider = account.provider;
            }

            return token;
        }
    },
    events: {
        signOut: ({ session, token }) => doFinalSignoutHandshake(token)
    }
});

Answer №4

signOut only removes session cookies but doesn't terminate the user's session on the provider.

  1. access the GET /logout endpoint of the provider to end the user's session
  2. perform signOut() to clear session cookies, only if step 1 was successful

Execution:
Presumption: assuming you store the user's idToken in the session object obtained from the session callback

  1. develop a idempotent endpoint (PUT) on the server side for making the GET request to the provider
    create file:
    pages/api/auth/signoutprovider.js
import { authOptions } from "./[...nextauth]";
import { getServerSession } from "next-auth";

export default async function signOutProvider(req, res) {
  if (req.method === "PUT") {
    const session = await getServerSession(req, res, authOptions);
    if (session?.idToken) {
      try {
        // terminate the user's session on the provider
        await axios.get("<your-issuer>/protocol/openid-connect/logout", { params: id_token_hint: session.idToken });
        res.status(200).json(null);
      }
      catch (error) {
        res.status(500).json(null);
      }
    } else {  
      // give 200 status if user is not signed in
      res.status(200).json(null);
    }
  }
}
  1. enclose signOut within a function, employ this function for signing out a user across your application
import { signOut } from "next-auth/react";

export async function theRealSignOut(args) {
  try {
    await axios.put("/api/auth/signoutprovider", null);
    // execute signOut only if PUT was successful
    return await signOut(args);
  } catch (error) {
    // <display a notification prompting the user to retry signout>
    throw error;
  }
}

Note: theRealSignOut is solely applicable on the client side since it internally uses signOut.

Refer to Keycloak docs for logout

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Stop the Material UI Datagrid link column from redirecting onRowClicked

I am currently utilizing a Material UI Datagrid to showcase some data. Within one of the columns, there is a link that, when clicked, opens a URL in a new tab. I am facing an issue where I want to navigate to another page upon clicking on a row, but do not ...

How can you trigger useEffect using a context variable as a dependency in React?

Struggling to get a useEffect hook to trigger when its dependency is a useContext variable. The Context contains an "update" variable that changes, but the effect doesn't fire as expected. import { useEffect, useState, useContext } from "react" import ...

Redirecting with NextAuth on a t3 technology stack

Recently, I set up a new T3 app using the NextJs app router. With NextAuth configured and some providers added, everything appears to be in order. However, once I log in, I keep getting redirected to a link that leads to a non-existent page: http://localho ...

Initiate an interactive pathway using @

Within my NextJS application, utilizing the pages router, there is a specific file called @[username].tsx. This format mirrors YouTube's user profile links and resides in the pages folder. Despite this setup, attempting to access /@lordsarcastic resul ...

Guide to sending API requests from a React client to an Express server hosted on Heroku

I've been grappling with deploying a Twitch-like application using a combination of react, redux, node media server, and json server module to Heroku. One roadblock I keep hitting is when attempting to establish a connection between my react client an ...

React - The issue with my form lies in submitting blank data due to the declaration of variables 'e' and 'data' which are ultimately left unused

Currently, I'm working on incorporating a form using the react-hook-form library. Despite following the documentation and utilizing the handleSubmit function along with a custom Axios post for the onSubmit parameter like this: onSubmit={handleSubmit( ...

The initialization of a static member in a JS class occurs prior to the loading of the cdn

After declaring this class member, I encountered the issue stating that MenuItem is not defined. It appears that the class initialization occurs before React or Material-UI finishes loading (I am currently loading them from their CDN using straight < ...

Tapping the image will redirect you to the corresponding component

My React Routes Challenge I've run into a roadblock while trying to implement react routes in my project. Greetings, everyone! This is my first time seeking help here, as I have previously posted about this issue on Codecademy. Below is the summary o ...

Transferring data between modules using Ajax or services in React.js

I have a React application where I need to pass data received in one component to another. After receiving the data successfully, I set the state and then try to pass this data as a property to the next component. However, when I try to access this passed ...

Update the state variable in a React.js Child Component

In my application, I have a parent component called DataTable that includes a button labeled View. class DataTable extends Component { constructor(props) { super(props); this.state = { modalOpen: false, }; t ...

Exploring the possibilities of custom layouts for specific routes within the pages directory in Next.js

I am interested in incorporating layout-based routing within my project's pages directory. I want to find a way to have a specific file, like the _app.tsx, that can only affect the files located inside a particular folder. This setup would operate si ...

Encountering a TypeScript error during Docker build causing npm run build to fail

Compilation Error. /app/node_modules/@ant-design/icons/lib/components/AntdIcon.d.ts TypeScript error in /app/node_modules/@ant-design/icons/lib/components/AntdIcon.d.ts(2,13): '=' expected. TS1005 1 | import * as React from 'react' ...

Issue with ThemeManager in Material UI & React: Constructor is not valid

Currently, I am integrating Material UI into a small React application, but I suspect that the tutorial I am following is outdated and relies on an older version of Material UI. The error _materialUi2.default.Styles.ThemeManager is not a constructor keeps ...

The Next.js React framework seems to be having trouble reading user input from a

I'm encountering an issue when attempting to save form email/password registration using Next.js as it is throwing an error. import {useState} from 'react' type Props = { label: string placeholder?: string onChange: () => void na ...

Extracting the value from a Text Editor in React Js: [Code snippet provided]

Currently, I am in the process of developing a basic app that generates a JSON form. So far, I have successfully incorporated sections for basic details and employment information. The basic details section consists of two input fields: First Name and Las ...

Is there a way for me to make this Select update when onChange occurs?

I am facing an issue with a react-select input that is supposed to display country options from a JSON file and submit the selected value. Currently, when a selection is made, the field does not populate with the selection visually, but it still sends the ...

Creating state in ReactJS without relying on the constructor function

I have come across some ReactJS tutorials where the component state is initialized directly without the use of a constructor, like in the example below. class App extends React.Component { state = { text: 'hello world' } render() { ...

A guide on integrating runtime environmental variables in a NextJS application

Currently, I am deploying a NextJS app across multiple K8 environments, each with its own set of variables that need to be passed. Although I can pass these variables and retrieve them using getServerSideProps(), the issue arises as this function only work ...

Should I consider using an UI framework when initiating a React project?

I'm embarking on a new website project and I'm contemplating whether using a React UI Framework is the way to go, or if I should create my own components and grid system from scratch. I've been drawn to projects like ElementalUI's take ...

What could be the reason for my React/HTML select element occasionally failing to display the default selected value?

I am currently working on creating a select element in Reactjs/HTML and I am facing an issue with setting a default value based on a component variable. Essentially, the default value of the select should be the id of a component. This is my current imple ...