我有一个带有 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路径来获取在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 参数。
我遇到了同样的问题,但我没有创建另一条路线,而是扩展了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
,因此用户无需单击两次注销。
因此,我在此线程上采用了稍微不同的方法。
我不太喜欢应用程序中发生的所有重定向,也不喜欢仅仅为了处理“注销后握手”而向应用程序添加新端点
相反,我将
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)
}
});
signOut
仅清除会话cookie,而不破坏用户在提供商上的会话。
GET
/logout
端点来销毁用户会话signOut()
清除会话 cookie,仅当步骤 1 成功时实施:
假设:您将用户的
idToken
存储在 session
/useSession
/getSession
返回的
getServerSession
对象中
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);
}
}
}
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 文档