// Keycloak OIDC (Authorization Code). openid-client v5. import { Issuer, generators } from "openid-client"; import { config } from "./config.js"; let client; // OIDC 클라이언트가 준비됐는지(discovery 성공) 여부 export function oidcReady() { return !!client; } export async function initOidc() { const issuer = await Issuer.discover(config.oidc.issuer); client = new issuer.Client({ client_id: config.oidc.clientId, client_secret: config.oidc.clientSecret, redirect_uris: [`${config.baseUrl}/auth/callback`], response_types: ["code"], }); return client; } // 로그인 시작 — state/nonce 를 세션에 저장하고 Keycloak 으로 리다이렉트 export function loginRedirect(req, res) { const state = generators.state(); const nonce = generators.nonce(); req.session.oidc = { state, nonce }; const url = client.authorizationUrl({ scope: "openid profile email", state, nonce, }); res.redirect(url); } // 콜백 처리 — 토큰 교환 후 사용자 클레임 반환 export async function handleCallback(req) { const params = client.callbackParams(req); const { state, nonce } = req.session.oidc || {}; const tokenSet = await client.callback(`${config.baseUrl}/auth/callback`, params, { state, nonce, }); const claims = tokenSet.claims(); // Keycloak realm 역할: realm_access.roles. (클라이언트 역할은 resource_access[client].roles) const realmRoles = (claims.realm_access && claims.realm_access.roles) || []; const clientRoles = (claims.resource_access && claims.resource_access[config.oidc.clientId] && claims.resource_access[config.oidc.clientId].roles) || []; const roles = [...new Set([...realmRoles, ...clientRoles])]; return { sabun: claims.preferred_username, // 사번 name: claims.name || claims.given_name || claims.preferred_username, 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(); // API 요청은 401 if (req.path.startsWith("/api/")) return res.status(401).json({ error: "unauthorized" }); // 페이지 요청은 중간 화면 없이 곧장 Keycloak 로그인 폼으로 보낸다. if (oidcReady()) return loginRedirect(req, res); // OIDC 미준비(discovery 실패)면 안내 페이지로 폴백 return res.redirect("/login?error=" + encodeURIComponent("로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.")); }