first commit

This commit is contained in:
2026-05-06 14:35:49 +09:00
commit 8b3402160f
11 changed files with 824 additions and 0 deletions

9
.env.sample Normal file
View 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
View File

@@ -0,0 +1,2 @@
node_modules
.env

12
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>