fix(auth): ensure Keycloak logout clears secure session

This commit is contained in:
2026-08-07 14:47:20 +08:00
parent caf18f706d
commit 5d730ba1b8
4 changed files with 23 additions and 18 deletions
-1
View File
@@ -1,7 +1,6 @@
KEYCLOAK_CLIENT_ID="tjwater" KEYCLOAK_CLIENT_ID="tjwater"
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret" KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater" KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
KEYCLOAK_POST_LOGOUT_REDIRECT_URI="http://localhost:3000/login"
NEXTAUTH_SECRET="replace-with-nextauth-secret" NEXTAUTH_SECRET="replace-with-nextauth-secret"
NEXTAUTH_URL="https://frontend.example.com/" NEXTAUTH_URL="https://frontend.example.com/"
-1
View File
@@ -18,7 +18,6 @@ services:
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
KEYCLOAK_POST_LOGOUT_REDIRECT_URI: ${KEYCLOAK_POST_LOGOUT_REDIRECT_URI}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_URL: ${NEXTAUTH_URL}
NODE_ENV: production NODE_ENV: production
+16 -7
View File
@@ -48,7 +48,7 @@ describe("GET /api/auth/keycloak-logout", () => {
{ {
headers: { headers: {
cookie: cookie:
"__Secure-next-auth.session-token.0=first; __Secure-next-auth.session-token.1=second", "__Secure-next-auth.session-token.0=first; __Secure-next-auth.session-token.1=second; next-auth.session-token=local",
}, },
}, },
); );
@@ -65,11 +65,22 @@ describe("GET /api/auth/keycloak-logout", () => {
); );
expect(logoutUrl.searchParams.get("client_id")).toBe("tjwater"); expect(logoutUrl.searchParams.get("client_id")).toBe("tjwater");
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull(); expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
expect(response.cookies.get("__Secure-next-auth.session-token.0")?.value).toBe(""); expect(
expect(response.cookies.get("__Secure-next-auth.session-token.1")?.value).toBe(""); response.cookies.get("__Secure-next-auth.session-token.0"),
).toMatchObject({ value: "", path: "/", secure: true });
expect(
response.cookies.get("__Secure-next-auth.session-token.1"),
).toMatchObject({ value: "", path: "/", secure: true });
expect(response.cookies.get("next-auth.session-token")).toMatchObject({
value: "",
path: "/",
});
expect(
response.cookies.get("next-auth.session-token")?.secure,
).toBeFalsy();
}); });
it("uses an explicitly configured post-logout redirect URI", async () => { it("ignores a legacy automatic post-logout redirect setting", async () => {
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI = process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI =
"https://frontend.example.com/login"; "https://frontend.example.com/login";
getTokenMock.mockResolvedValue({ idToken: "header.payload.signature" }); getTokenMock.mockResolvedValue({ idToken: "header.payload.signature" });
@@ -81,8 +92,6 @@ describe("GET /api/auth/keycloak-logout", () => {
); );
const logoutUrl = new URL(response.headers.get("location") ?? ""); const logoutUrl = new URL(response.headers.get("location") ?? "");
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBe( expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
"https://frontend.example.com/login",
);
}); });
}); });
+7 -9
View File
@@ -20,8 +20,6 @@ export const GET = async (request: NextRequest) => {
); );
const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, ""); const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, "");
const clientId = process.env.KEYCLOAK_CLIENT_ID; const clientId = process.env.KEYCLOAK_CLIENT_ID;
const postLogoutRedirectUri =
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
const token = (await getToken({ const token = (await getToken({
req: request, req: request,
secret: process.env.NEXTAUTH_SECRET, secret: process.env.NEXTAUTH_SECRET,
@@ -33,17 +31,17 @@ export const GET = async (request: NextRequest) => {
if (issuer) { if (issuer) {
if (clientId) logoutUrl.searchParams.set("client_id", clientId); if (clientId) logoutUrl.searchParams.set("client_id", clientId);
if (token?.idToken) logoutUrl.searchParams.set("id_token_hint", token.idToken); 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); const response = NextResponse.redirect(logoutUrl);
for (const { name } of request.cookies.getAll()) { for (const { name } of request.cookies.getAll()) {
if (isSessionCookie(name)) response.cookies.delete(name); if (isSessionCookie(name)) {
response.cookies.delete({
name,
path: "/",
secure: name.startsWith("__Secure-"),
});
}
} }
return response; return response;