使用密钥斗篷提供程序从下一个身份验证注销不起作用



我有一个带有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 会保留他的会话。

经过一番研究,我发现了一个描述相同问题的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 路径来获取销毁密钥斗篷上的会话的路径/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 参数。

我遇到了同样的问题,但我没有创建另一个路由,而是扩展了 signOut 事件以对钥匙斗篷发出必要的请求:

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,因此用户无需单击注销两次。

因此,我在此线程的基础上采用了略有不同的方法。

我真的不喜欢我的应用程序中发生的所有重定向,也不喜欢为我的应用程序添加新端点只是为了处理"注销后握手">

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

这种技术允许我维护应用程序中的所有电流,并且仍然使用next-auth公开的标准signOut方法,而无需在前端进行任何特殊自定义。

这是用打字稿编写的,所以我扩展了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)
}
});

signOut只清除会话cookie,而不会破坏用户在提供程序上的会话。

2023年解决方案

  1. 命中提供程序GET/logout端点以销毁用户的会话
  2. 仅当步骤 1 成功时,才signOut()清除会话 Cookie

实现
假设:您将用户的idToken存储在useSession/getSession/getServerSession返回的session对象中

  1. 在服务器端创建一个幂等端点 (PUT),以便对提供程序进行此 GET 调用
    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. 通过函数换行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

钥匙斗篷文档注销

最新更新