使用 keycloak 提供程序从下一个身份验证注销不起作用

问题描述 投票:0回答:4

我有一个带有 next-auth 的 nextjs 应用程序来管理身份验证。

这是我的配置

....
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
      })
    })
  ],
....

身份验证按预期工作,但是当我尝试使用下一个身份验证注销功能注销时,它不起作用。下一个身份验证会话被破坏,但 keycloak 保留了他的会话。

reactjs next.js single-sign-on keycloak next-auth
4个回答
10
投票

经过一番研究,我发现了一个 reddit 对话 https://www.reddit.com/r/nextjs/comments/redv1r/nextauth_signout_does_not_end_keycloak_session/ 描述了同样的问题。

这是我的解决方案。

我编写了一个自定义函数来注销

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

我定义了一个api路径来获取在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 });
};

更新

在最新版本的 keycloak 中(在本文更新时为 19.*.* -> https://github.com/keycloak/keycloak-documentation/blob/main/securing_apps/topics/oidc/java/logout .adoc)重定向 uri 变得有点复杂

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

请注意,如果包含 post_logout_redirect_uri,则需要包含 client_id 或 id_token_hint 参数。


8
投票

我遇到了同样的问题,但我没有创建另一条路线,而是扩展了signOut事件以对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)

而且,由于请求中提供了

id_token_hint
,因此用户无需单击两次注销。


5
投票

因此,我在此线程上采用了稍微不同的方法。

我不太喜欢应用程序中发生的所有重定向,也不喜欢仅仅为了处理“注销后握手”而向应用程序添加新端点

相反,我将

id_token
直接添加到生成的初始 JWT 令牌中,然后将一个名为
doFinalSignoutHandshake
的方法附加到
events.signOut
,该方法自动向 keycloak 服务端点执行
GET
请求并代表终止会话用户。

这项技术允许我维护应用程序中的所有当前流程,并且仍然使用

signOut
公开的标准
next-auth
方法,而无需在前端进行任何特殊自定义。

这是用打字稿编写的,因此我扩展了

JWT
定义以包含新值(在普通 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;
    }
}

以下是我的实现

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

-2
投票

signOut
仅清除会话cookie,而不破坏用户在提供商上的会话。

  1. 点击提供者的
    GET
    /logout
    端点来销毁用户会话
  2. 执行
    signOut()
    清除会话 cookie,仅当步骤 1 成功时

实施
假设:您将用户的

idToken
存储在
session
/
useSession
/
getSession
 返回的 
getServerSession

对象中
  1. 在服务器端创建一个幂等端点 (PUT),以对提供者进行 GET 调用
    创建文件:
    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. 通过函数包裹
    signOut
    ,使用此函数在整个应用程序中使用户退出
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;
  }
}

注意

theRealSignOut
只能在客户端使用,因为它在内部使用signOut

Keycloak 文档

注销

© www.soinside.com 2019 - 2024. All rights reserved.