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 <noreply@anthropic.com>
85 lines
3.1 KiB
JavaScript
85 lines
3.1 KiB
JavaScript
// 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("로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요."));
|
|
}
|