From 94142d7031bc93cd4f574759142b1abaf0ec1288 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 3 Aug 2026 10:40:21 +0800 Subject: [PATCH] fix(auth): complete Keycloak logout flow --- src/app/RefineContext.tsx | 4 +- src/app/api/auth/[...nextauth]/options.ts | 3 + .../api/auth/keycloak-logout/route.test.ts | 88 +++++++++++++++++++ src/app/api/auth/keycloak-logout/route.ts | 50 +++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/app/api/auth/keycloak-logout/route.test.ts create mode 100644 src/app/api/auth/keycloak-logout/route.ts diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index e0a9fb5..3fb3bb7 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -7,7 +7,7 @@ import { } from "@refinedev/core"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { RefineSnackbarProvider } from "@refinedev/mui"; -import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; +import { SessionProvider, signIn, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; import React, { useEffect } from "react"; @@ -150,7 +150,7 @@ const App = (props: React.PropsWithChildren) => { if (data?.user?.id) { sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); } - signOut({ redirect: true, callbackUrl: "/login" }); + window.location.assign("/api/auth/keycloak-logout"); return { success: true }; }, onError: async (error) => { diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 5af6bae..aae95c3 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -94,6 +94,9 @@ const authOptions: NextAuthOptions = { if (account.refresh_token) { token.refreshToken = account.refresh_token; } + if (account.id_token) { + token.idToken = account.id_token; + } if (typeof account.expires_at === "number") { token.accessTokenExpires = account.expires_at * 1000; } diff --git a/src/app/api/auth/keycloak-logout/route.test.ts b/src/app/api/auth/keycloak-logout/route.test.ts new file mode 100644 index 0000000..b082d6b --- /dev/null +++ b/src/app/api/auth/keycloak-logout/route.test.ts @@ -0,0 +1,88 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; + +import { GET } from "./route"; + +jest.mock("next-auth/jwt", () => ({ + getToken: jest.fn(), +})); + +const getTokenMock = getToken as jest.MockedFunction; + +describe("GET /api/auth/keycloak-logout", () => { + const originalIssuer = process.env.KEYCLOAK_ISSUER; + const originalClientId = process.env.KEYCLOAK_CLIENT_ID; + const originalNextAuthUrl = process.env.NEXTAUTH_URL; + const originalPostLogoutRedirectUri = + process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI; + + beforeEach(() => { + process.env.KEYCLOAK_ISSUER = "https://keycloak.example.com/realms/tjwater"; + process.env.KEYCLOAK_CLIENT_ID = "tjwater"; + process.env.NEXTAUTH_URL = "https://frontend.example.com"; + delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI; + getTokenMock.mockReset(); + }); + + afterAll(() => { + process.env.KEYCLOAK_ISSUER = originalIssuer; + process.env.KEYCLOAK_CLIENT_ID = originalClientId; + process.env.NEXTAUTH_URL = originalNextAuthUrl; + if (originalPostLogoutRedirectUri) { + process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI = originalPostLogoutRedirectUri; + } else { + delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI; + } + }); + + it("clears the local session and redirects the browser to Keycloak logout", async () => { + getTokenMock.mockResolvedValue({ + idToken: "header.payload.signature", + }); + const request = new NextRequest( + "https://frontend.example.com/api/auth/keycloak-logout", + { + headers: { + cookie: + "__Secure-next-auth.session-token.0=first; __Secure-next-auth.session-token.1=second", + }, + }, + ); + + const response = await GET(request); + const logoutUrl = new URL(response.headers.get("location") ?? ""); + + expect(logoutUrl.origin).toBe("https://keycloak.example.com"); + expect(logoutUrl.pathname).toBe( + "/realms/tjwater/protocol/openid-connect/logout", + ); + expect(logoutUrl.searchParams.get("id_token_hint")).toBe( + "header.payload.signature", + ); + expect(logoutUrl.searchParams.get("client_id")).toBe("tjwater"); + expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull(); + expect(response.cookies.get("__Secure-next-auth.session-token.0")?.value).toBe(""); + expect(response.cookies.get("__Secure-next-auth.session-token.1")?.value).toBe(""); + }); + + it("uses an explicitly configured post-logout redirect URI", async () => { + process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI = + "https://frontend.example.com/login"; + getTokenMock.mockResolvedValue({ idToken: "header.payload.signature" }); + + const response = await GET( + new NextRequest( + "https://frontend.example.com/api/auth/keycloak-logout", + ), + ); + const logoutUrl = new URL(response.headers.get("location") ?? ""); + + expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBe( + "https://frontend.example.com/login", + ); + }); +}); diff --git a/src/app/api/auth/keycloak-logout/route.ts b/src/app/api/auth/keycloak-logout/route.ts new file mode 100644 index 0000000..f7d369a --- /dev/null +++ b/src/app/api/auth/keycloak-logout/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getToken } from "next-auth/jwt"; + +type KeycloakToken = { + idToken?: string; +}; + +const sessionCookiePrefixes = [ + "next-auth.session-token", + "__Secure-next-auth.session-token", +]; + +const isSessionCookie = (name: string) => + sessionCookiePrefixes.some((prefix) => name.startsWith(prefix)); + +export const GET = async (request: NextRequest) => { + const localLoginUrl = new URL( + "/login", + process.env.NEXTAUTH_URL ?? request.nextUrl.origin, + ); + const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, ""); + const clientId = process.env.KEYCLOAK_CLIENT_ID; + const postLogoutRedirectUri = + process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI; + const token = (await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + })) as KeycloakToken | null; + const logoutUrl = issuer + ? new URL(`${issuer}/protocol/openid-connect/logout`) + : localLoginUrl; + + if (issuer) { + if (clientId) logoutUrl.searchParams.set("client_id", clientId); + if (token?.idToken) logoutUrl.searchParams.set("id_token_hint", token.idToken); + if (postLogoutRedirectUri) { + logoutUrl.searchParams.set( + "post_logout_redirect_uri", + postLogoutRedirectUri, + ); + } + } + + const response = NextResponse.redirect(logoutUrl); + for (const { name } of request.cookies.getAll()) { + if (isSessionCookie(name)) response.cookies.delete(name); + } + + return response; +};