From 096f7fbbcbeee5860304500abe31b230f5da8e21 Mon Sep 17 00:00:00 2001 From: 0310700 Date: Tue, 30 Jun 2026 14:12:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Keycloak=20RP-initiated=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83(SLO)?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat/sso 작업을 라우트 개편된 현재 main 에 이식. - handleCallback 이 id_token 반환, 콜백에서 분리해 session.idToken 에만 보관(뷰/DB 비노출) - logoutUrl(): end_session_endpoint + id_token_hint 로 확인화면 없이 즉시 로그아웃 - /logout: 로컬 세션 destroy 후 Keycloak end-session 으로 리다이렉트해 SSO 세션까지 종료 Co-Authored-By: Claude Opus 4.8 --- src/auth.js | 13 +++++++++++++ src/server.js | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/auth.js b/src/auth.js index 51f2e0d..0a643df 100644 --- a/src/auth.js +++ b/src/auth.js @@ -56,9 +56,22 @@ export async function handleCallback(req) { email: claims.email, roles, isAdmin: roles.includes(config.adminRole), + // SSO 로그아웃(end-session) 시 id_token_hint 로 사용 + id_token: tokenSet.id_token, }; } +// SSO 로그아웃 — Keycloak end_session_endpoint 로 보내 로컬뿐 아니라 +// Keycloak SSO 세션까지 종료한다. id_token_hint 가 있으면 확인 화면 없이 즉시 로그아웃. +// 종료 후 post_logout_redirect_uri 로 복귀. +export function logoutUrl(idToken) { + if (!client) return `${config.baseUrl}/login`; + return client.endSessionUrl({ + id_token_hint: idToken, + post_logout_redirect_uri: `${config.baseUrl}/login`, + }); +} + // 로그인 필수 가드 export function requireAuth(req, res, next) { if (req.session.user) return next(); diff --git a/src/server.js b/src/server.js index c70c35b..93a0106 100644 --- a/src/server.js +++ b/src/server.js @@ -5,7 +5,7 @@ import { fileURLToPath } from "url"; import path from "path"; import { config } from "./config.js"; import { toolsForUser } from "./sites.js"; -import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady } from "./auth.js"; +import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js"; import { icon, identicon, langColor, langFromTags } from "./helpers.js"; import * as db from "./db.js"; @@ -58,8 +58,11 @@ app.get("/auth/login", (req, res) => loginRedirect(req, res)); app.get("/auth/callback", async (req, res) => { try { const u = await handleCallback(req); - await db.upsertUser(u); - req.session.user = u; // { sabun, name, email, roles, isAdmin } + // id_token 은 SSO 로그아웃용으로만 세션에 따로 보관(뷰/DB 에 노출 안 함) + const { id_token, ...user } = u; + req.session.idToken = id_token; + await db.upsertUser(user); + req.session.user = user; // { sabun, name, email, roles, isAdmin } res.redirect("/"); } catch (e) { console.error("oidc callback error", e); @@ -67,7 +70,13 @@ app.get("/auth/callback", async (req, res) => { } }); app.get("/logout", (req, res) => { - req.session.destroy(() => res.redirect("/login")); + const idToken = req.session.idToken; + // 로컬 세션을 먼저 destroy 한 뒤 Keycloak end-session 으로 보내 + // SSO 세션까지 종료한다. (로컬만 지우면 재접속 시 자동 재로그인됨) + req.session.destroy(() => { + if (oidcReady()) return res.redirect(logoutUrl(idToken)); + return res.redirect("/login"); + }); }); // --- 대시보드 ---