Files
ai-dev-portal/src/auth.js
0310700 096f7fbbcb feat(auth): Keycloak RP-initiated 단일 로그아웃(SLO) 적용
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>
2026-06-30 14:12:42 +09:00

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("로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요."));
}