commit 8b3402160fee8d3d8af36ead390ac2e363016983 Author: kyy Date: Wed May 6 14:35:49 2026 +0900 first commit diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..21544e4 --- /dev/null +++ b/.env.sample @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..459c222 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 4444 + +CMD ["node", "app.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dfcc85 --- /dev/null +++ b/README.md @@ -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 교환 성공 +[세션 매핑] 등록 완료 +[백채널 로그아웃] 요청 수신 +[백채널 로그아웃] 토큰 검증 성공 +[백채널 로그아웃] 세션 파기 완료 +[백채널 로그아웃] 처리 완료 +[프로필] 비로그인 상태로 접근하여 루트로 이동 +``` diff --git a/app.js b/app.js new file mode 100644 index 0000000..654602c --- /dev/null +++ b/app.js @@ -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); +}); diff --git a/backchannel-logout.js b/backchannel-logout.js new file mode 100644 index 0000000..54c18ba --- /dev/null +++ b/backchannel-logout.js @@ -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, +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c677e55 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..532b96c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/views/error.ejs b/views/error.ejs new file mode 100644 index 0000000..81b94fe --- /dev/null +++ b/views/error.ejs @@ -0,0 +1,26 @@ + + + + Server-Side Demo Error + + + +
+

인증 오류

+
+

Message: <%= message %>

+

Detail: <%= detail %>

+
+ +

자세한 내용은 서버 로그를 확인하세요.

+ + 홈으로 이동 +
+ + diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..e7e8f23 --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,25 @@ + + + + Baron SSO Server-Side Demo + + + +
+

Baron SSO Server-Side Demo

+ <% if (user) { %> +

로그인됨: <%= user.userinfo.name || user.userinfo.sub %>

+ 프로필 보기 + 로그아웃 + <% } else { %> +

현재 로그인되지 않았습니다.

+ Baron SSO로 로그인 + <% } %> +
+ + diff --git a/views/profile.ejs b/views/profile.ejs new file mode 100644 index 0000000..034284f --- /dev/null +++ b/views/profile.ejs @@ -0,0 +1,28 @@ + + + + Server-Side User Profile + + + +
+

Server-Side User Profile

+

Sub: <%= user.userinfo.sub %>

+

Name: <%= user.userinfo.name || 'N/A' %>

+

Email: <%= user.userinfo.email || 'N/A' %>

+ +

Raw User Info

+
<%= JSON.stringify(user.userinfo, null, 2) %>
+ +

Tokens

+
<%= JSON.stringify(user.tokenset, null, 2) %>
+ + 홈으로 이동 +
+ +