first commit
This commit is contained in:
9
.env.sample
Normal file
9
.env.sample
Normal file
@@ -0,0 +1,9 @@
|
||||
PORT=4444
|
||||
SESSION_SECRET=demo-session-secret
|
||||
OIDC_ISSUER_URL=https://sso-test.hmac.kr/oidc
|
||||
OIDC_CLIENT_ID=replace-with-server-side-client-id
|
||||
OIDC_CLIENT_SECRET=replace-with-client-secret
|
||||
OIDC_REDIRECT_URI=http://localhost:4444/callback
|
||||
OIDC_CLIENT_AUTH_METHOD=client_secret_basic
|
||||
BARON_API_BASE_URL=https://sso-test.hmac.kr
|
||||
BARON_SESSION_VALIDATION_ENABLED=false
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4444
|
||||
|
||||
CMD ["node", "app.js"]
|
||||
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Baron SSO Server-Side App Demo (Express.js)
|
||||
|
||||
이 프로젝트는 `baron-sso`의 `server-side-app` RP를 테스트하기 위한 단순한 Express.js 데모입니다.
|
||||
|
||||
## 목적
|
||||
|
||||
이 데모는 다음을 확인하기 위한 용도입니다.
|
||||
|
||||
1. confidential client 기반 OIDC Authorization Code 로그인
|
||||
2. RP 로컬 세션 생성 및 유지
|
||||
3. `Back-Channel Logout URI` 호출 수신
|
||||
4. `logout_token` 검증 후 로컬 세션 즉시 파기
|
||||
|
||||
## 사전 준비
|
||||
|
||||
1. `baron-sso` 프로젝트가 실행 중이어야 합니다.
|
||||
2. `baron_net` 네트워크가 생성되어 있어야 합니다.
|
||||
3. devfront에서 `server-side-app` 타입 RP를 생성해야 합니다.
|
||||
|
||||
## 권장 RP 설정
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
Type: server-side-app
|
||||
Client ID: <생성된 client id>
|
||||
Client Secret: <생성된 secret>
|
||||
Redirect URI: http://localhost:4444/callback
|
||||
Back-Channel Logout URI: http://172.16.x.x:4444/backchannel-logout
|
||||
SID Claim Required: off
|
||||
```
|
||||
|
||||
주의:
|
||||
- `Back-Channel Logout URI`는 브라우저 기준이 아니라 Baron backend가 실제로 접근 가능한 주소여야 합니다.
|
||||
- Docker 환경에서 `localhost`는 backend 컨테이너 자신을 가리킬 수 있으므로, 필요하면 사설 IP 또는 Docker 서비스명을 사용해야 합니다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `PORT`: 기본값 `4444`
|
||||
- `SESSION_SECRET`: Express session secret
|
||||
- `OIDC_ISSUER_URL`: Baron OIDC issuer URL
|
||||
- `OIDC_CLIENT_ID`: server-side-app client id
|
||||
- `OIDC_CLIENT_SECRET`: server-side-app client secret
|
||||
- `OIDC_REDIRECT_URI`: callback URL
|
||||
- `OIDC_CLIENT_AUTH_METHOD`: 기본값 `client_secret_basic`, 필요 시 `client_secret_post`
|
||||
- `BARON_API_BASE_URL`: Baron backend/public gateway URL
|
||||
- `BARON_BACKCHANNEL_JWKS_URL`: Baron Back-Channel Logout JWKS URL
|
||||
- `BARON_SESSION_VALIDATION_ENABLED`: `false`로 두면 Baron 세션 재검증을 끄고 백채널 로그아웃만 단독 검증 가능
|
||||
|
||||
## 라우트
|
||||
|
||||
```text
|
||||
GET /
|
||||
GET /login
|
||||
GET /callback
|
||||
GET /profile
|
||||
GET /logout
|
||||
POST /backchannel-logout
|
||||
```
|
||||
|
||||
## 동작 방식
|
||||
|
||||
1. `/login`에서 state/nonce를 만들고 Baron authorize endpoint로 이동
|
||||
2. `/callback`에서 authorization code를 token으로 교환
|
||||
3. ID Token의 `sid/sub`를 현재 RP 세션 ID와 매핑
|
||||
4. Baron이 `/backchannel-logout`으로 `logout_token` 전송
|
||||
5. 데모 앱이 서명 및 claim을 검증
|
||||
6. `sid` 또는 `sub`로 대상 세션을 찾아 세션 스토어에서 직접 파기
|
||||
|
||||
## 테스트 포인트
|
||||
|
||||
정상 동작 시 아래 로그 흐름이 보여야 합니다.
|
||||
|
||||
```text
|
||||
[로그인 시작]
|
||||
[콜백] Authorization Code -> Token 교환 성공
|
||||
[세션 매핑] 등록 완료
|
||||
[백채널 로그아웃] 요청 수신
|
||||
[백채널 로그아웃] 토큰 검증 성공
|
||||
[백채널 로그아웃] 세션 파기 완료
|
||||
[백채널 로그아웃] 처리 완료
|
||||
[프로필] 비로그인 상태로 접근하여 루트로 이동
|
||||
```
|
||||
314
app.js
Normal file
314
app.js
Normal file
@@ -0,0 +1,314 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const {
|
||||
discovery,
|
||||
randomNonce,
|
||||
randomState,
|
||||
buildAuthorizationUrl,
|
||||
authorizationCodeGrant,
|
||||
fetchUserInfo,
|
||||
ClientSecretBasic,
|
||||
ClientSecretPost,
|
||||
} = require('openid-client');
|
||||
const path = require('path');
|
||||
const { createBackchannelLogoutManager } = require('./backchannel-logout');
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 4444;
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
const sessionStore = new session.MemoryStore();
|
||||
const sessionMiddleware = session({
|
||||
store: sessionStore,
|
||||
name: 'baron.server.demo.sid',
|
||||
secret: process.env.SESSION_SECRET || 'demo-session-secret',
|
||||
resave: true,
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
function deriveBaronApiBaseUrl() {
|
||||
const explicit = (process.env.BARON_API_BASE_URL || '').trim();
|
||||
if (explicit) {
|
||||
return explicit.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
const issuerUrl = (process.env.OIDC_ISSUER_URL || 'http://localhost:5000/oidc').trim();
|
||||
return issuerUrl.replace(/\/oidc\/?$/, '');
|
||||
}
|
||||
|
||||
function deriveBackchannelJwksUrl() {
|
||||
const explicit = (process.env.BARON_BACKCHANNEL_JWKS_URL || '').trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
return `${deriveBaronApiBaseUrl()}/api/v1/auth/backchannel/jwks.json`;
|
||||
}
|
||||
|
||||
function createClientAuthentication(method, clientSecret) {
|
||||
switch (method) {
|
||||
case 'client_secret_basic':
|
||||
return ClientSecretBasic(clientSecret);
|
||||
case 'client_secret_post':
|
||||
return ClientSecretPost(clientSecret);
|
||||
default:
|
||||
throw new Error(`Unsupported OIDC client auth method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBaronSession(accessToken) {
|
||||
if (!accessToken) {
|
||||
return { ok: false, reason: 'missing_access_token' };
|
||||
}
|
||||
|
||||
const baseUrl = deriveBaronApiBaseUrl();
|
||||
const response = await fetch(`${baseUrl}/api/v1/user/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
return {
|
||||
ok: false,
|
||||
reason: `baron_validation_failed:${response.status}`,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
const profile = await response.json().catch(() => null);
|
||||
return { ok: true, profile };
|
||||
}
|
||||
|
||||
function destroyDemoSession(req, res, removeSessionBinding) {
|
||||
const sessionId = req.sessionID;
|
||||
console.log('[로컬 로그아웃] 현재 세션 정리 시작', { sessionId });
|
||||
removeSessionBinding(sessionId);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
req.session.destroy(() => {
|
||||
if (res) {
|
||||
res.clearCookie('baron.server.demo.sid');
|
||||
}
|
||||
console.log('[로컬 로그아웃] 현재 세션 정리 완료', { sessionId });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function setupOIDC() {
|
||||
const issuerUrl = process.env.OIDC_ISSUER_URL || 'http://localhost:5000/oidc';
|
||||
const clientId = process.env.OIDC_CLIENT_ID || 'demo-client';
|
||||
const clientSecret = process.env.OIDC_CLIENT_SECRET || 'demo-secret';
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://localhost:4444/callback';
|
||||
const clientAuthMethod = process.env.OIDC_CLIENT_AUTH_METHOD || 'client_secret_basic';
|
||||
const backchannelJwksUrl = deriveBackchannelJwksUrl();
|
||||
const sessionValidationEnabled =
|
||||
String(process.env.BARON_SESSION_VALIDATION_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const clientAuthentication = createClientAuthentication(clientAuthMethod, clientSecret);
|
||||
const backchannelLogoutManager = createBackchannelLogoutManager({
|
||||
issuerUrl,
|
||||
clientId,
|
||||
jwksUrl: backchannelJwksUrl,
|
||||
sessionStore,
|
||||
logger: console,
|
||||
});
|
||||
|
||||
console.log(`OIDC Issuer 조회: ${issuerUrl}`);
|
||||
console.log(`백채널 로그아웃 JWKS 주소: ${backchannelJwksUrl}`);
|
||||
const issuer = await discovery(
|
||||
new URL(issuerUrl),
|
||||
clientId,
|
||||
{
|
||||
client_secret: clientSecret,
|
||||
token_endpoint_auth_method: clientAuthMethod,
|
||||
},
|
||||
clientAuthentication,
|
||||
);
|
||||
const authorizationEndpoint =
|
||||
(typeof issuer.serverMetadata === 'function'
|
||||
? issuer.serverMetadata()?.authorization_endpoint
|
||||
: undefined) ||
|
||||
issuer.authorization_server_metadata?.authorization_endpoint ||
|
||||
issuer.authorization_endpoint ||
|
||||
'(확인 불가)';
|
||||
console.log('[시스템] OIDC 설정 완료', {
|
||||
clientId,
|
||||
redirectUri,
|
||||
clientAuthMethod,
|
||||
authorizationEndpoint,
|
||||
sessionValidationEnabled,
|
||||
});
|
||||
|
||||
if (sessionValidationEnabled) {
|
||||
app.use(async (req, res, next) => {
|
||||
const skipPaths = new Set(['/login', '/callback', '/logout', '/backchannel-logout']);
|
||||
if (skipPaths.has(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const accessToken = req.session?.user?.tokenset?.access_token;
|
||||
if (!accessToken) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateBaronSession(accessToken);
|
||||
if (validation.ok) {
|
||||
if (validation.profile) {
|
||||
req.session.user.userinfo = validation.profile;
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
console.warn('[세션 검증] Baron 세션이 유효하지 않아 로컬 세션을 정리합니다.', {
|
||||
path: req.path,
|
||||
reason: validation.reason,
|
||||
});
|
||||
await destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding);
|
||||
return res.redirect('/');
|
||||
} catch (error) {
|
||||
console.error('[세션 검증] Baron 세션 확인 실패로 로컬 세션을 정리합니다.', error);
|
||||
await destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding);
|
||||
return res.redirect('/');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[시스템] Baron 세션 재검증 미들웨어를 비활성화했습니다. 백채널 로그아웃 테스트 전용 모드입니다.');
|
||||
}
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.render('index', { user: req.session.user });
|
||||
});
|
||||
|
||||
app.get('/login', (req, res) => {
|
||||
console.log(`\n[로그인 시작] 세션 ID: ${req.sessionID}`);
|
||||
|
||||
if (!req.session.state) {
|
||||
req.session.state = randomState();
|
||||
req.session.nonce = randomNonce();
|
||||
console.log('[로그인] 신규 state/nonce 생성 완료');
|
||||
} else {
|
||||
console.log('[로그인] 기존 state 재사용');
|
||||
}
|
||||
|
||||
req.session.save((err) => {
|
||||
if (err) {
|
||||
return res.status(500).send('Session save failed');
|
||||
}
|
||||
|
||||
const url = buildAuthorizationUrl(issuer, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid profile email',
|
||||
nonce: req.session.nonce,
|
||||
state: req.session.state,
|
||||
response_type: 'code',
|
||||
});
|
||||
|
||||
console.log('[로그인] Baron 인증 화면으로 이동', { url: url.href });
|
||||
res.redirect(url.href);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/callback', async (req, res) => {
|
||||
console.log(`\n[콜백 시작] 세션 ID: ${req.sessionID}`);
|
||||
console.log('[콜백] URL state / 세션 state', {
|
||||
urlState: req.query.state,
|
||||
sessionState: req.session.state,
|
||||
});
|
||||
|
||||
if (!req.session.state) {
|
||||
if (req.session.user) {
|
||||
return res.redirect('/profile');
|
||||
}
|
||||
|
||||
return res.status(400).render('error', {
|
||||
message: 'Session Data Missing',
|
||||
detail: '세션 정보가 유실되었습니다. 브라우저가 쿠키를 차단했는지 확인하세요.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const currentUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const tokenset = await authorizationCodeGrant(
|
||||
issuer,
|
||||
currentUrl,
|
||||
{
|
||||
expectedNonce: req.session.nonce,
|
||||
expectedState: req.session.state,
|
||||
},
|
||||
);
|
||||
|
||||
console.log('[콜백] Authorization Code -> Token 교환 성공');
|
||||
|
||||
const tokenClaims = tokenset.claims();
|
||||
const userinfo = await fetchUserInfo(
|
||||
issuer,
|
||||
tokenset.access_token,
|
||||
tokenClaims.sub,
|
||||
).catch(() => tokenClaims);
|
||||
|
||||
req.session.user = { tokenset, userinfo };
|
||||
backchannelLogoutManager.registerSessionBinding(req.sessionID, tokenClaims);
|
||||
|
||||
delete req.session.state;
|
||||
delete req.session.nonce;
|
||||
|
||||
console.log('[콜백] 사용자 세션 생성 완료', {
|
||||
sessionId: req.sessionID,
|
||||
sid: tokenClaims.sid || '(없음)',
|
||||
sub: tokenClaims.sub || '(없음)',
|
||||
});
|
||||
|
||||
req.session.save(() => res.redirect('/profile'));
|
||||
} catch (err) {
|
||||
console.error('[콜백] 인증 처리 실패', err);
|
||||
res.status(500).render('error', {
|
||||
message: 'Authentication Failed',
|
||||
detail: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/backchannel-logout', backchannelLogoutManager.handleBackchannelLogout);
|
||||
|
||||
app.get('/profile', (req, res) => {
|
||||
if (!req.session.user) {
|
||||
console.log('[프로필] 비로그인 상태로 접근하여 루트로 이동');
|
||||
return res.redirect('/');
|
||||
}
|
||||
console.log('[프로필] 로그인 세션으로 접근', { sessionId: req.sessionID });
|
||||
res.render('profile', { user: req.session.user });
|
||||
});
|
||||
|
||||
app.get('/logout', (req, res) => {
|
||||
destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding).then(() => {
|
||||
res.redirect('/');
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`[시스템] 데모 앱이 실행 중입니다. http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
setupOIDC().catch((err) => {
|
||||
console.error('[시스템] OIDC 초기화 실패', err);
|
||||
process.exit(1);
|
||||
});
|
||||
274
backchannel-logout.js
Normal file
274
backchannel-logout.js
Normal file
@@ -0,0 +1,274 @@
|
||||
const { createRemoteJWKSet, jwtVerify } = require('jose');
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT_URI =
|
||||
'http://schemas.openid.net/event/backchannel-logout';
|
||||
const LOGOUT_TOKEN_REPLAY_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function createBackchannelLogoutManager(options) {
|
||||
const {
|
||||
issuerUrl,
|
||||
clientId,
|
||||
jwksUrl,
|
||||
sessionStore,
|
||||
logger = console,
|
||||
} = options;
|
||||
|
||||
if (!issuerUrl) {
|
||||
throw new Error('issuerUrl is required');
|
||||
}
|
||||
if (!clientId) {
|
||||
throw new Error('clientId is required');
|
||||
}
|
||||
if (!jwksUrl) {
|
||||
throw new Error('jwksUrl is required');
|
||||
}
|
||||
if (!sessionStore || typeof sessionStore.destroy !== 'function') {
|
||||
throw new Error('sessionStore.destroy is required');
|
||||
}
|
||||
|
||||
const sidToSessionIds = new Map();
|
||||
const subToSessionIds = new Map();
|
||||
const sessionIdToBinding = new Map();
|
||||
const processedLogoutTokens = new Map();
|
||||
const jwks = createRemoteJWKSet(new URL(jwksUrl));
|
||||
|
||||
function addSessionBinding(map, key, sessionId) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing = map.get(key);
|
||||
if (!existing) {
|
||||
existing = new Set();
|
||||
map.set(key, existing);
|
||||
}
|
||||
existing.add(sessionId);
|
||||
}
|
||||
|
||||
function removeSessionBindingFromMap(map, key, sessionId) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = map.get(key);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
existing.delete(sessionId);
|
||||
if (existing.size === 0) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function removeSessionBinding(sessionId) {
|
||||
const existing = sessionIdToBinding.get(sessionId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeSessionBindingFromMap(sidToSessionIds, existing.sid, sessionId);
|
||||
removeSessionBindingFromMap(subToSessionIds, existing.sub, sessionId);
|
||||
sessionIdToBinding.delete(sessionId);
|
||||
|
||||
logger.log('[세션 매핑] 제거 완료', {
|
||||
sessionId,
|
||||
sid: existing.sid || '(없음)',
|
||||
sub: existing.sub || '(없음)',
|
||||
});
|
||||
}
|
||||
|
||||
function registerSessionBinding(sessionId, claims) {
|
||||
const sid = typeof claims?.sid === 'string' ? claims.sid.trim() : '';
|
||||
const sub = typeof claims?.sub === 'string' ? claims.sub.trim() : '';
|
||||
|
||||
removeSessionBinding(sessionId);
|
||||
sessionIdToBinding.set(sessionId, { sid, sub });
|
||||
addSessionBinding(sidToSessionIds, sid, sessionId);
|
||||
addSessionBinding(subToSessionIds, sub, sessionId);
|
||||
|
||||
logger.log('[세션 매핑] 등록 완료', {
|
||||
sessionId,
|
||||
sid: sid || '(없음)',
|
||||
sub: sub || '(없음)',
|
||||
sidSessionCount: sid ? sidToSessionIds.get(sid)?.size || 0 : 0,
|
||||
subSessionCount: sub ? subToSessionIds.get(sub)?.size || 0 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionIdsForLogoutClaims(claims) {
|
||||
const targets = new Set();
|
||||
const sid = typeof claims?.sid === 'string' ? claims.sid.trim() : '';
|
||||
const sub = typeof claims?.sub === 'string' ? claims.sub.trim() : '';
|
||||
|
||||
if (sid && sidToSessionIds.has(sid)) {
|
||||
for (const sessionId of sidToSessionIds.get(sid)) {
|
||||
targets.add(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.size === 0 && sub && subToSessionIds.has(sub)) {
|
||||
for (const sessionId of subToSessionIds.get(sub)) {
|
||||
targets.add(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
function destroySessionById(sessionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
sessionStore.destroy(sessionId, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupProcessedLogoutTokens(now = Date.now()) {
|
||||
for (const [jti, expiresAt] of processedLogoutTokens.entries()) {
|
||||
if (expiresAt <= now) {
|
||||
processedLogoutTokens.delete(jti);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProcessedLogoutToken(jti) {
|
||||
cleanupProcessedLogoutTokens();
|
||||
if (processedLogoutTokens.has(jti)) {
|
||||
return false;
|
||||
}
|
||||
processedLogoutTokens.set(jti, Date.now() + LOGOUT_TOKEN_REPLAY_TTL_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function verifyLogoutToken(logoutToken) {
|
||||
const { payload, protectedHeader } = await jwtVerify(logoutToken, jwks, {
|
||||
issuer: issuerUrl,
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
if (payload.nonce !== undefined) {
|
||||
throw new Error('logout_token must not include nonce');
|
||||
}
|
||||
|
||||
if (!payload.events || typeof payload.events !== 'object') {
|
||||
throw new Error('logout_token is missing events claim');
|
||||
}
|
||||
|
||||
if (!(BACKCHANNEL_LOGOUT_EVENT_URI in payload.events)) {
|
||||
throw new Error('logout_token is missing back-channel logout event');
|
||||
}
|
||||
|
||||
const sid = typeof payload.sid === 'string' ? payload.sid.trim() : '';
|
||||
const sub = typeof payload.sub === 'string' ? payload.sub.trim() : '';
|
||||
if (!sid && !sub) {
|
||||
throw new Error('logout_token requires sid or sub');
|
||||
}
|
||||
|
||||
const jti = typeof payload.jti === 'string' ? payload.jti.trim() : '';
|
||||
if (!jti) {
|
||||
throw new Error('logout_token is missing jti');
|
||||
}
|
||||
if (!rememberProcessedLogoutToken(jti)) {
|
||||
throw new Error('logout_token replay detected');
|
||||
}
|
||||
|
||||
logger.log('[백채널 로그아웃] 토큰 검증 성공', {
|
||||
alg: protectedHeader.alg,
|
||||
kid: protectedHeader.kid || '(없음)',
|
||||
iss: payload.iss,
|
||||
aud: payload.aud,
|
||||
sid: sid || '(없음)',
|
||||
sub: sub || '(없음)',
|
||||
jti,
|
||||
});
|
||||
|
||||
return {
|
||||
sid,
|
||||
sub,
|
||||
jti,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function destroySessionsForLogout(claims) {
|
||||
const sessionIds = getSessionIdsForLogoutClaims(claims);
|
||||
let destroyedCount = 0;
|
||||
|
||||
logger.log('[백채널 로그아웃] 세션 탐색 결과', {
|
||||
sid: claims.sid || '(없음)',
|
||||
sub: claims.sub || '(없음)',
|
||||
matchedSessionIds: sessionIds,
|
||||
});
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
removeSessionBinding(sessionId);
|
||||
try {
|
||||
await destroySessionById(sessionId);
|
||||
destroyedCount += 1;
|
||||
logger.log('[백채널 로그아웃] 세션 파기 완료', { sessionId });
|
||||
} catch (error) {
|
||||
logger.error('[백채널 로그아웃] 세션 파기 실패', {
|
||||
sessionId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sessionIds, destroyedCount };
|
||||
}
|
||||
|
||||
async function handleBackchannelLogout(req, res) {
|
||||
const logoutToken = typeof req.body.logout_token === 'string'
|
||||
? req.body.logout_token.trim()
|
||||
: '';
|
||||
|
||||
logger.log('\n[백채널 로그아웃] 요청 수신', {
|
||||
hasLogoutToken: logoutToken !== '',
|
||||
contentType: req.headers['content-type'] || '(없음)',
|
||||
userAgent: req.headers['user-agent'] || '(없음)',
|
||||
});
|
||||
|
||||
if (!logoutToken) {
|
||||
logger.warn('[백채널 로그아웃] logout_token 누락');
|
||||
return res.status(400).json({ error: 'logout_token is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const claims = await verifyLogoutToken(logoutToken);
|
||||
const result = await destroySessionsForLogout(claims);
|
||||
|
||||
logger.log('[백채널 로그아웃] 처리 완료', {
|
||||
sid: claims.sid || '(없음)',
|
||||
sub: claims.sub || '(없음)',
|
||||
destroyedCount: result.destroyedCount,
|
||||
sessionIds: result.sessionIds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
destroyedSessionCount: result.destroyedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[백채널 로그아웃] 검증 또는 세션 정리 실패', error);
|
||||
return res.status(400).json({
|
||||
error: 'invalid logout token',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registerSessionBinding,
|
||||
removeSessionBinding,
|
||||
handleBackchannelLogout,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackchannelLogoutManager,
|
||||
};
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
login-demo:
|
||||
build: .
|
||||
container_name: baron-sso-server-side-demo
|
||||
ports:
|
||||
- "4444:4444"
|
||||
environment:
|
||||
- PORT=4444
|
||||
- SESSION_SECRET=demo-session-secret
|
||||
- OIDC_ISSUER_URL=https://sso-test.hmac.kr/oidc
|
||||
- OIDC_CLIENT_ID=replace-with-server-side-client-id
|
||||
- OIDC_CLIENT_SECRET=replace-with-client-secret
|
||||
- OIDC_REDIRECT_URI=http://localhost:4444/callback
|
||||
- OIDC_CLIENT_AUTH_METHOD=client_secret_basic
|
||||
- BARON_API_BASE_URL=https://sso-test.hmac.kr
|
||||
- BARON_SESSION_VALIDATION_ENABLED=false
|
||||
extra_hosts:
|
||||
- "localhost:host-gateway"
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
networks:
|
||||
baron_net:
|
||||
external: true
|
||||
name: baron_net
|
||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "baron-sso-server-side-demo",
|
||||
"version": "1.0.0",
|
||||
"description": "Baron SSO server-side app demo",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^5.0.2",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"jose": "^6.1.0",
|
||||
"openid-client": "^6.8.3"
|
||||
}
|
||||
}
|
||||
26
views/error.ejs
Normal file
26
views/error.ejs
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Server-Side Demo Error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 2rem; background-color: #f0f2f5; }
|
||||
.container { max-width: 800px; margin: 0 auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.error-box { background: #fff1f0; border: 1px solid #ffa39e; padding: 1rem; border-radius: 4px; color: #cf1322; }
|
||||
pre { background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; margin-top: 1rem; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>인증 오류</h1>
|
||||
<div class="error-box">
|
||||
<p><strong>Message:</strong> <%= message %></p>
|
||||
<p><strong>Detail:</strong> <%= detail %></p>
|
||||
</div>
|
||||
|
||||
<p>자세한 내용은 서버 로그를 확인하세요.</p>
|
||||
|
||||
<a href="/" class="btn">홈으로 이동</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
views/index.ejs
Normal file
25
views/index.ejs
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Baron SSO Server-Side Demo</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f0f2f5; }
|
||||
.container { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); text-align: center; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-top: 1rem; }
|
||||
.btn:hover { background-color: #0056b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Baron SSO Server-Side Demo</h1>
|
||||
<% if (user) { %>
|
||||
<p>로그인됨: <strong><%= user.userinfo.name || user.userinfo.sub %></strong></p>
|
||||
<a href="/profile" class="btn">프로필 보기</a>
|
||||
<a href="/logout" class="btn" style="background-color: #6c757d;">로그아웃</a>
|
||||
<% } else { %>
|
||||
<p>현재 로그인되지 않았습니다.</p>
|
||||
<a href="/login" class="btn">Baron SSO로 로그인</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
28
views/profile.ejs
Normal file
28
views/profile.ejs
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Server-Side User Profile</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 2rem; background-color: #f0f2f5; }
|
||||
.container { max-width: 800px; margin: 0 auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
pre { background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Server-Side User Profile</h1>
|
||||
<p><strong>Sub:</strong> <%= user.userinfo.sub %></p>
|
||||
<p><strong>Name:</strong> <%= user.userinfo.name || 'N/A' %></p>
|
||||
<p><strong>Email:</strong> <%= user.userinfo.email || 'N/A' %></p>
|
||||
|
||||
<h3>Raw User Info</h3>
|
||||
<pre><%= JSON.stringify(user.userinfo, null, 2) %></pre>
|
||||
|
||||
<h3>Tokens</h3>
|
||||
<pre><%= JSON.stringify(user.tokenset, null, 2) %></pre>
|
||||
|
||||
<a href="/" class="btn">홈으로 이동</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user