Logout from next-auth with keycloak provider not works
Asked Answered
L

6

12

I have a nextjs application with next-auth to manage the authentication.

Here my configuration

....
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 works as expected, but when i try to logout using the next-auth signOut function it doesn't works. Next-auth session is destroyed but keycloak mantain his session.

Lascivious answered 14/4, 2022 at 13:37 Comment(0)
L
12

After some research i found a reddit conversation https://www.reddit.com/r/nextjs/comments/redv1r/nextauth_signout_does_not_end_keycloak_session/ that describe the same problem.

Here my solution.

I write a custom function to logout

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

And i define an api path to obtain the path to destroy the session on keycloak /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 the latest versions of keycloak (at time of this post update is 19.*.* -> https://github.com/keycloak/keycloak-documentation/blob/main/securing_apps/topics/oidc/java/logout.adoc) the redirect uri becomes a bit more complex

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 });
};

Note that you need to include either the client_id or id_token_hint parameter in case that post_logout_redirect_uri is included.

Lascivious answered 14/4, 2022 at 13:37 Comment(2)
Thank you for your solution. I found that after clicking on the logout button in Keycloak, the page doesn't redirect to my app login page. Am I missing any configuration from Keycloak?Dittmer
How to sign out same user from all devices if he logged in to multiple devices since next auth set cookies?Grigson
E
16

I've got the same problem, but instead of creating another route, I extended signOut event to make necessery 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)

And, because id_token_hint is provided in request, users don't need to click logOut twice.

Eject answered 21/2, 2023 at 23:44 Comment(1)
what if the logout API call that you are making in events fails (for some unknown reason)? your user will be logged out from UI but not from keycloak (can be a security vulnerability)Knute
L
12

After some research i found a reddit conversation https://www.reddit.com/r/nextjs/comments/redv1r/nextauth_signout_does_not_end_keycloak_session/ that describe the same problem.

Here my solution.

I write a custom function to logout

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

And i define an api path to obtain the path to destroy the session on keycloak /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 the latest versions of keycloak (at time of this post update is 19.*.* -> https://github.com/keycloak/keycloak-documentation/blob/main/securing_apps/topics/oidc/java/logout.adoc) the redirect uri becomes a bit more complex

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 });
};

Note that you need to include either the client_id or id_token_hint parameter in case that post_logout_redirect_uri is included.

Lascivious answered 14/4, 2022 at 13:37 Comment(2)
Thank you for your solution. I found that after clicking on the logout button in Keycloak, the page doesn't redirect to my app login page. Am I missing any configuration from Keycloak?Dittmer
How to sign out same user from all devices if he logged in to multiple devices since next auth set cookies?Grigson
T
6

So, I had a slightly different approach building upon this thread here.

I didn't really like all the redirects happening in my application, nor did I like adding a new endpoint to my application just for dealing with the "post-logout handshake"

Instead, I added the id_token directly into the initial JWT token generated, then attached a method called doFinalSignoutHandshake to the events.signOut which automatically performs a GET request to the keycloak service endpoint and terminates the session on behalf of the user.

This technique allows me to maintain all of the current flows in the application and still use the standard signOut method exposed by next-auth without any special customizations on the front-end.

This is written in typescript, so I extended the JWT definition to include the new values (shouldn't be necessary in vanilla JS

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

Following 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 performs the final handshake for the keycloak
// provider, the way it's written could also potentially
// perform the action 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 contain a confirmation 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;
                //20230822 - updated to include the "provider" property
                token.provider = account.provider;
            }

            return token;
        }
    },
    events: {
        signOut: ({ session, token }) => doFinalSignoutHandshake(token)
    }
});
Taste answered 19/1, 2023 at 20:48 Comment(4)
What does token.exp mean? JWT in next-auth does not have this property, at least the latest version.Biotechnology
This didn't work for me until I added the following to the jwt callback: token.provider = account.providerDeranged
token.exp is a standard JWT property signifying the token's expiration date.Taste
You can probably omit the provider check if you're only using keycloak to authenticate and not accepting other authentication methodsTaste
V
0

I had the same issue and I was getting new issues every time I was fixing one and the solution was becoming overly complex.

Finaly, Here is my simple solution working in less then 10 minutes.

npm install keycloak-js

In app/api/auth/logout/route.ts

export async function POST() {
  // Keycloak client instanciation has been ommitted for simplicity
  const client = keycloakClient()

  if (client.authenticated) {
    const logoutResult = await client.logout()
  }
  return NextResponse.json({})
}

On the frontend side

const handleSignOut = () => {
  const protocol = window.location.protocol
  const host = window.location.host
  fetch(`${protocol}://${host}/api/auth/logout`, {
    method: 'POST'
  }).then(() => {
    signOut()
  }).catch((e) => {
    console.log(JSON.stringify(e))
  })
}

It's not perfect but it works

Vinny answered 24/6, 2024 at 14:30 Comment(0)
L
0

Jumping on this to say that I got this working after days of trying. Shout out to Tyler for this comment and saving my app:

This didn't work for me until I added the following to the jwt callback: token.provider = account.provider – Tyler

My code is pretty simple other than that, I just have my signout event setup like this in auth options and I call that when the logout button is clicked:

  events: {
    async signOut({ token }: { token:any }) {
      if (token.provider === "keycloak") {
        const issuerUrl = (authOptions.providers.find(p => p.id === "keycloak")).options!.issuer!
        const logOutUrl = new URL(`${issuerUrl}/protocol/openid-connect/logout`)
        logOutUrl.searchParams.set("id_token_hint", token.id_token!)
        fetch(logOutUrl);
      }
    }
  }
Linea answered 16/8, 2024 at 0:4 Comment(0)
K
-2

signOut only clears session cookies without destroying user's session on the provider.

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

Implementation:
Assumption: you are storing user's idToken in the session object returned by session callback

  1. create an idempotent endpoint (PUT) on server side to make this GET call 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 {
        // destroy 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 {  
      // if user is not signed in, give 200
      res.status(200).json(null);
    }
  }
}
  1. wrap signOut by a function, use this function to sign a user out throughout your app
import { signOut } from "next-auth/react";

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

Note: theRealSignOut can be used on client side only as it is using signOut internally.

Keycloak docs logout

Knute answered 17/2, 2023 at 13:21 Comment(1)
alternatively one can also use getToken( { req } ) instead of getServerSession to get token?.idTokenKnute

© 2022 - 2025 — McMap. All rights reserved.