diff --git a/.env b/.env
index a0446e1..09902d8 100644
--- a/.env
+++ b/.env
@@ -3,4 +3,8 @@ DB_PORT=3306
DB_USER=itam
DB_PASS=itam1234
DB_NAME=itam
+CLIENT_ID=836cd2e1-995a-4027-bcb5-5dd9c94c2b84
+ISSUER=https://sso.hmac.kr/oidc
+REDIRECT_URI=http://172.16.9.44:8080/callback
+JWKS_URI=http://172.16.9.44:8080/.well-known/jwks.json
PORT=3000
\ No newline at end of file
diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml
index 38910fd..79bcf59 100644
--- a/docker-compose.prod.yaml
+++ b/docker-compose.prod.yaml
@@ -11,9 +11,13 @@ services:
environment:
NODE_ENV: production
PORT: 3000
+ KEYS_PATH: /app/data/keys.json
+ REDIRECT_URI: ${PROD_REDIRECT_URI:-http://172.16.10.175:9090/callback}
+ JWKS_URI: ${PROD_JWKS_URI:-http://172.16.10.175:9090/.well-known/jwks.json}
volumes:
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json:ro
+ - backend_keys:/app/data
expose:
- "3000"
restart: unless-stopped
@@ -70,4 +74,7 @@ services:
restart: always
command:
- --character-set-server=utf8mb4
- - --collation-server=utf8mb4_unicode_ci
\ No newline at end of file
+ - --collation-server=utf8mb4_unicode_ci
+
+volumes:
+ backend_keys:
\ No newline at end of file
diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml
index 4d354dd..ffd9fd9 100644
--- a/docker-compose.test.yaml
+++ b/docker-compose.test.yaml
@@ -13,11 +13,13 @@ services:
environment:
NODE_ENV: development
PORT: 3000
- DB_HOST: ${DB_HOST:-172.16.8.151}
+ DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
DB_PORT: ${DB_PORT:-3306}
DB_USER: ${DB_USER:-root}
DB_PASS: ${DB_PASS:-}
DB_NAME: ${DB_NAME:-itam}
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
ports:
- "3000:3000"
volumes:
diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf
index 58e44b2..e0e7d40 100644
--- a/docker/nginx/default.conf
+++ b/docker/nginx/default.conf
@@ -31,6 +31,16 @@ server {
application/json application/javascript;
gzip_min_length 1000;
+ # Expose the backend JWKS document for Baron SSO headless login verification.
+ location = /.well-known/jwks.json {
+ proxy_pass http://backend/.well-known/jwks.json;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_http_version 1.1;
+ }
+
# Forward all app requests to the frontend container
location / {
proxy_pass http://frontend;
diff --git a/index.html b/index.html
index dbeaba1..cc38b8a 100644
--- a/index.html
+++ b/index.html
@@ -14,6 +14,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index b7b5dae..3a7bb1c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "express-session": "^1.18.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
@@ -1250,6 +1251,50 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-session": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
+ "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "~0.7.2",
+ "cookie-signature": "~1.0.7",
+ "debug": "~2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "~5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/express-session/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express-session/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -1689,6 +1734,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1846,6 +1900,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1944,6 +2007,26 @@
"node": ">= 18"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2185,6 +2268,18 @@
"node": ">=14.17"
}
},
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
diff --git a/package.json b/package.json
index a71cabe..b046f3a 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "express-session": "^1.18.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
diff --git a/server.js b/server.js
index 5b8bb95..2142a3b 100644
--- a/server.js
+++ b/server.js
@@ -3,9 +3,25 @@ import mysql from 'mysql2/promise';
import cors from 'cors';
import dotenv from 'dotenv';
import fs from 'fs';
+import crypto from 'crypto';
+import session from 'express-session';
+import { getSigningKey, getPrivateKeyPem } from './src/utils/jwks.js';
dotenv.config();
+const {
+ CLIENT_ID,
+ ISSUER,
+ REDIRECT_URI,
+ JWKS_URI,
+ SESSION_SECRET,
+ ERROR_LOCALE_PATH
+} = process.env;
+
+const SESSION_SECRET_VALUE = SESSION_SECRET || 'itam-headless-session-secret';
+const DEFAULT_SCOPES = ['openid', 'profile', 'email'];
+const DEFAULT_ERROR_PATH = ERROR_LOCALE_PATH || '/ko/error';
+
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
@@ -24,6 +40,17 @@ const getDbConnectionSummary = () => ({
const app = express();
app.use(cors());
app.use(express.json({ limit: '50mb' }));
+app.use(session({
+ secret: SESSION_SECRET_VALUE,
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: false,
+ maxAge: 1000 * 60 * 60 * 8
+ }
+}));
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
// uploads 폴더가 없으면 생성
@@ -113,6 +140,401 @@ const ASSET_TABLES = [
'asset_core'
];
+const ensureSsoConfig = () => {
+ const missing = [];
+ if (!CLIENT_ID) missing.push('CLIENT_ID');
+ if (!ISSUER) missing.push('ISSUER');
+ if (!REDIRECT_URI) missing.push('REDIRECT_URI');
+ if (!JWKS_URI) missing.push('JWKS_URI');
+ if (missing.length > 0) {
+ throw new Error(`Missing SSO configuration: ${missing.join(', ')}`);
+ }
+};
+
+const base64Url = (input) => Buffer.from(input).toString('base64url');
+
+const sha256Base64Url = (input) => crypto.createHash('sha256').update(input).digest('base64url');
+
+const randomString = (size = 32) => crypto.randomBytes(size).toString('base64url');
+
+const parseJsonSafely = async (response) => {
+ const text = await response.text();
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return { raw: text };
+ }
+};
+
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const appendCookies = (currentCookies, response) => {
+ const rawSetCookie = response.headers.get('set-cookie');
+ if (!rawSetCookie) {
+ return currentCookies;
+ }
+
+ const cookieMap = new Map();
+ (currentCookies || '').split(';').map((entry) => entry.trim()).filter(Boolean).forEach((entry) => {
+ const [name, ...rest] = entry.split('=');
+ cookieMap.set(name, `${name}=${rest.join('=')}`);
+ });
+
+ rawSetCookie
+ .split(/,(?=[^;]+=[^;]+)/)
+ .map((entry) => entry.split(';')[0].trim())
+ .filter(Boolean)
+ .forEach((cookie) => {
+ const [name] = cookie.split('=');
+ cookieMap.set(name, cookie);
+ });
+
+ return Array.from(cookieMap.values()).join('; ');
+};
+
+const createClientAssertion = (audience) => {
+ const now = Math.floor(Date.now() / 1000);
+ const header = {
+ alg: 'RS256',
+ typ: 'JWT',
+ kid: getSigningKey().kid
+ };
+ const payload = {
+ iss: CLIENT_ID,
+ sub: CLIENT_ID,
+ aud: audience,
+ jti: randomString(16),
+ iat: now,
+ exp: now + 300
+ };
+
+ const encodedHeader = base64Url(JSON.stringify(header));
+ const encodedPayload = base64Url(JSON.stringify(payload));
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
+ const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), getPrivateKeyPem()).toString('base64url');
+ return `${signingInput}.${signature}`;
+};
+
+const fetchDiscoveryDocument = async () => {
+ ensureSsoConfig();
+ const discoveryUrl = `${ISSUER.replace(/\/$/, '')}/.well-known/openid-configuration`;
+ const response = await fetch(discoveryUrl);
+ if (!response.ok) {
+ throw new Error(`OIDC discovery failed: ${response.status}`);
+ }
+ return response.json();
+};
+
+const beginAuthorizationFlow = async () => {
+ const discovery = await fetchDiscoveryDocument();
+ const codeVerifier = randomString(48);
+ const codeChallenge = sha256Base64Url(codeVerifier);
+ const stateToken = randomString(16);
+ const nonce = randomString(16);
+
+ const authUrl = new URL(discovery.authorization_endpoint);
+ authUrl.searchParams.set('response_type', 'code');
+ authUrl.searchParams.set('client_id', CLIENT_ID);
+ authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
+ authUrl.searchParams.set('scope', DEFAULT_SCOPES.join(' '));
+ authUrl.searchParams.set('state', stateToken);
+ authUrl.searchParams.set('nonce', nonce);
+ authUrl.searchParams.set('code_challenge', codeChallenge);
+ authUrl.searchParams.set('code_challenge_method', 'S256');
+
+ const authRes = await fetch(authUrl.toString(), { redirect: 'manual' });
+ const cookies = appendCookies('', authRes);
+ const location = authRes.headers.get('location');
+ if (!location) {
+ throw new Error('Authorization redirect did not provide login_challenge');
+ }
+
+ const authRedirectUrl = new URL(location, authUrl.toString());
+ const loginChallenge = authRedirectUrl.searchParams.get('login_challenge');
+ if (!loginChallenge) {
+ throw new Error('login_challenge not found');
+ }
+
+ return {
+ discovery,
+ cookies,
+ loginChallenge,
+ authState: {
+ stateToken,
+ nonce,
+ codeVerifier
+ }
+ };
+};
+
+const getCodeFromRedirect = (redirectTo) => {
+ const url = new URL(redirectTo, ISSUER);
+ return url.searchParams.get('code');
+};
+
+const resolveRedirects = async (redirectTo, cookies, depth = 0) => {
+ if (depth > 10) {
+ throw new Error('Redirect resolution exceeded limit');
+ }
+
+ const currentUrl = new URL(redirectTo, ISSUER).toString();
+
+ if (currentUrl.includes('/consent')) {
+ const consentUrl = new URL(currentUrl);
+ const consentChallenge = consentUrl.searchParams.get('consent_challenge');
+ if (!consentChallenge) {
+ throw new Error('Missing consent_challenge');
+ }
+
+ const detailsUrl = new URL('/api/v1/auth/consent', ISSUER);
+ detailsUrl.searchParams.set('consent_challenge', consentChallenge);
+ const detailsRes = await fetch(detailsUrl.toString(), {
+ headers: cookies ? { Cookie: cookies } : {}
+ });
+
+ if (!detailsRes.ok) {
+ const detailsBody = await parseJsonSafely(detailsRes);
+ if (detailsRes.status === 403 && detailsBody?.code === 'tenant_not_allowed') {
+ const errorUrl = new URL(DEFAULT_ERROR_PATH, ISSUER);
+ errorUrl.searchParams.set('error', detailsBody.code);
+ errorUrl.searchParams.set('error_description', detailsBody.message || 'Tenant not allowed');
+ if (detailsBody.details) {
+ errorUrl.searchParams.set('details', JSON.stringify(detailsBody.details));
+ }
+ return { finalUrl: errorUrl.toString(), cookies, code: null, isErrorRedirect: true };
+ }
+ throw new Error(`Consent details failed: ${detailsRes.status}`);
+ }
+
+ const consentInfo = await detailsRes.json();
+ const acceptUrl = new URL('/api/v1/auth/consent/accept', ISSUER);
+ const acceptRes = await fetch(acceptUrl.toString(), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(cookies ? { Cookie: cookies } : {})
+ },
+ body: JSON.stringify({
+ consent_challenge: consentChallenge,
+ grant_scope: consentInfo.requested_scope || DEFAULT_SCOPES,
+ grant_access_token_audience: consentInfo.requested_access_token_audience || []
+ })
+ });
+
+ const nextCookies = appendCookies(cookies, acceptRes);
+ const acceptBody = await parseJsonSafely(acceptRes);
+ if (!acceptRes.ok || !acceptBody?.redirectTo) {
+ throw new Error(`Consent accept failed: ${acceptRes.status}`);
+ }
+ return resolveRedirects(acceptBody.redirectTo, nextCookies, depth + 1);
+ }
+
+ const response = await fetch(currentUrl, {
+ redirect: 'manual',
+ headers: cookies ? { Cookie: cookies } : {}
+ });
+
+ const nextCookies = appendCookies(cookies, response);
+ const location = response.headers.get('location');
+ if (location) {
+ const nextUrl = new URL(location, currentUrl).toString();
+ if (nextUrl.startsWith(REDIRECT_URI)) {
+ return {
+ finalUrl: nextUrl,
+ cookies: nextCookies,
+ code: getCodeFromRedirect(nextUrl),
+ isErrorRedirect: false
+ };
+ }
+ return resolveRedirects(nextUrl, nextCookies, depth + 1);
+ }
+
+ if (response.ok && currentUrl.startsWith(REDIRECT_URI)) {
+ return {
+ finalUrl: currentUrl,
+ cookies: nextCookies,
+ code: getCodeFromRedirect(currentUrl),
+ isErrorRedirect: false
+ };
+ }
+
+ throw new Error('Could not resolve authorization redirect');
+};
+
+const exchangeAuthorizationCode = async (code, discovery) => {
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: REDIRECT_URI,
+ client_id: CLIENT_ID,
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
+ client_assertion: createClientAssertion(discovery.token_endpoint)
+ });
+
+ const tokenRes = await fetch(discovery.token_endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: body.toString()
+ });
+
+ const tokenBody = await parseJsonSafely(tokenRes);
+ if (!tokenRes.ok) {
+ throw new Error(`Token exchange failed: ${tokenRes.status} ${JSON.stringify(tokenBody)}`);
+ }
+
+ return tokenBody;
+};
+
+const decodeJwtPayload = (jwt) => {
+ const [, payload] = jwt.split('.');
+ if (!payload) {
+ throw new Error('Invalid JWT payload');
+ }
+ return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
+};
+
+const runHeadlessSsoLogin = async ({ loginId, password }) => {
+ const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
+
+ const headlessEndpoint = new URL('/api/v1/auth/headless/password/login', ISSUER).toString();
+ const headlessRes = await fetch(headlessEndpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(cookies ? { Cookie: cookies } : {})
+ },
+ body: JSON.stringify({
+ client_id: CLIENT_ID,
+ login_challenge: loginChallenge,
+ loginId,
+ password,
+ client_assertion: createClientAssertion(headlessEndpoint)
+ })
+ });
+ const nextCookies = appendCookies(cookies, headlessRes);
+ const headlessBody = await parseJsonSafely(headlessRes);
+ if (!headlessRes.ok || !headlessBody?.redirectTo) {
+ throw new Error(`Headless login failed: ${headlessRes.status} ${JSON.stringify(headlessBody)}`);
+ }
+
+ const resolution = await resolveRedirects(headlessBody.redirectTo, nextCookies);
+ if (resolution.isErrorRedirect) {
+ return { errorRedirect: resolution.finalUrl };
+ }
+ if (!resolution.code) {
+ throw new Error('Authorization code not found after redirect resolution');
+ }
+
+ const tokenResponse = await exchangeAuthorizationCode(resolution.code, discovery);
+ const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
+
+ return {
+ tokens: tokenResponse,
+ profile: idTokenPayload,
+ authState
+ };
+};
+
+const initHeadlessPhoneLogin = async ({ loginId }) => {
+ const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
+ const headlessEndpoint = new URL('/api/v1/auth/headless/link/init', ISSUER).toString();
+
+ const initRes = await fetch(headlessEndpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(cookies ? { Cookie: cookies } : {})
+ },
+ body: JSON.stringify({
+ client_id: CLIENT_ID,
+ login_challenge: loginChallenge,
+ loginId,
+ client_assertion: createClientAssertion(headlessEndpoint)
+ })
+ });
+
+ const nextCookies = appendCookies(cookies, initRes);
+ const initBody = await parseJsonSafely(initRes);
+ if (!initRes.ok || !initBody?.pendingRef) {
+ throw new Error(`Phone link init failed: ${initRes.status} ${JSON.stringify(initBody)}`);
+ }
+
+ return {
+ discovery,
+ cookies: nextCookies,
+ pendingRef: initBody.pendingRef,
+ intervalMs: Math.max(2000, Number(initBody.interval || 3) * 1000),
+ expiresInMs: Math.max(60000, Number(initBody.expiresIn || 180) * 1000),
+ authState,
+ loginId
+ };
+};
+
+const pollHeadlessPhoneLogin = async (pendingContext) => {
+ const pollEndpoint = new URL('/api/v1/auth/headless/link/poll', ISSUER).toString();
+ const pollRes = await fetch(pollEndpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(pendingContext.cookies ? { Cookie: pendingContext.cookies } : {})
+ },
+ body: JSON.stringify({
+ client_id: CLIENT_ID,
+ pendingRef: pendingContext.pendingRef,
+ client_assertion: createClientAssertion(pollEndpoint)
+ })
+ });
+
+ const nextCookies = appendCookies(pendingContext.cookies, pollRes);
+ const pollBody = await parseJsonSafely(pollRes);
+
+ if (pollRes.ok && pollBody?.redirectTo) {
+ const resolution = await resolveRedirects(pollBody.redirectTo, nextCookies);
+ if (resolution.isErrorRedirect) {
+ return { status: 'error_redirect', redirectTo: resolution.finalUrl };
+ }
+
+ if (!resolution.code) {
+ throw new Error('Authorization code not found after phone redirect resolution');
+ }
+
+ const tokenResponse = await exchangeAuthorizationCode(resolution.code, pendingContext.discovery);
+ const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
+
+ return {
+ status: 'authenticated',
+ tokens: tokenResponse,
+ profile: idTokenPayload
+ };
+ }
+
+ const statusCode = pollBody?.code || pollBody?.error;
+ if (statusCode === 'authorization_pending') {
+ return {
+ status: 'pending',
+ cookies: nextCookies,
+ intervalMs: Math.max(2000, Number(pollBody?.interval || pendingContext.intervalMs / 1000 || 3) * 1000)
+ };
+ }
+
+ if (statusCode === 'slow_down') {
+ return {
+ status: 'pending',
+ cookies: nextCookies,
+ intervalMs: Math.max(pendingContext.intervalMs + 2000, Number(pollBody?.interval || 5) * 1000)
+ };
+ }
+
+ if (statusCode === 'expired_token') {
+ return { status: 'expired' };
+ }
+
+ throw new Error(`Phone poll failed: ${pollRes.status} ${JSON.stringify(pollBody)}`);
+};
+
// --- Helper Functions for Maps ---
function getCleanMapKey(path) {
let clean = path.replace('img/location_photo/', '').replace('.png', '');
@@ -139,6 +561,149 @@ function getLocationDetail(path, idx) {
// --- API Endpoints ---
+app.get('/api/auth/session', (req, res) => {
+ res.json({
+ authenticated: Boolean(req.session.user),
+ user: req.session.user || null
+ });
+});
+
+app.post('/api/auth/logout', (req, res) => {
+ req.session.destroy(() => {
+ res.json({ success: true });
+ });
+});
+
+app.post('/api/auth/headless/login', async (req, res) => {
+ const { loginId, password } = req.body;
+
+ if (!loginId || !password) {
+ return res.status(400).json({ error: 'loginId and password are required' });
+ }
+
+ try {
+ const loginResult = await runHeadlessSsoLogin({ loginId, password });
+ if (loginResult.errorRedirect) {
+ return res.status(403).json({ redirectTo: loginResult.errorRedirect, code: 'tenant_not_allowed' });
+ }
+
+ req.session.user = {
+ loginId,
+ profile: loginResult.profile,
+ tokens: {
+ accessToken: loginResult.tokens.access_token,
+ idToken: loginResult.tokens.id_token,
+ expiresIn: loginResult.tokens.expires_in,
+ scope: loginResult.tokens.scope,
+ tokenType: loginResult.tokens.token_type
+ }
+ };
+
+ res.json({ success: true, user: req.session.user });
+ } catch (error) {
+ console.error('Headless SSO login failed:', error);
+ res.status(500).json({ error: error.message || 'Headless SSO login failed' });
+ }
+});
+
+app.post('/api/auth/headless/phone/init', async (req, res) => {
+ const { loginId } = req.body;
+
+ if (!loginId) {
+ return res.status(400).json({ error: 'loginId is required' });
+ }
+
+ try {
+ const pendingLogin = await initHeadlessPhoneLogin({ loginId });
+ req.session.pendingPhoneLogin = pendingLogin;
+ res.json({
+ success: true,
+ pendingRef: pendingLogin.pendingRef,
+ intervalMs: pendingLogin.intervalMs,
+ expiresInMs: pendingLogin.expiresInMs,
+ message: '인증 링크를 발송했습니다. 모바일에서 승인 후 잠시만 기다려주세요.'
+ });
+ } catch (error) {
+ console.error('Headless phone login init failed:', error);
+ res.status(500).json({ error: error.message || 'Headless phone login init failed' });
+ }
+});
+
+app.post('/api/auth/headless/phone/poll', async (req, res) => {
+ const pendingLogin = req.session.pendingPhoneLogin;
+ const { pendingRef } = req.body || {};
+
+ if (!pendingLogin || !pendingLogin.pendingRef) {
+ return res.status(400).json({ error: 'No pending phone login session found' });
+ }
+
+ if (pendingRef && pendingRef !== pendingLogin.pendingRef) {
+ return res.status(400).json({ error: 'Pending reference does not match current session' });
+ }
+
+ if (pendingLogin.startedAt && Date.now() - pendingLogin.startedAt > pendingLogin.expiresInMs) {
+ delete req.session.pendingPhoneLogin;
+ return res.status(410).json({ code: 'expired_token', error: 'Phone login request expired' });
+ }
+
+ try {
+ const result = await pollHeadlessPhoneLogin(pendingLogin);
+
+ if (result.status === 'pending') {
+ req.session.pendingPhoneLogin = {
+ ...pendingLogin,
+ cookies: result.cookies,
+ intervalMs: result.intervalMs,
+ startedAt: pendingLogin.startedAt || Date.now()
+ };
+ return res.json({
+ success: true,
+ status: 'pending',
+ pendingRef: pendingLogin.pendingRef,
+ intervalMs: result.intervalMs
+ });
+ }
+
+ if (result.status === 'expired') {
+ delete req.session.pendingPhoneLogin;
+ return res.status(410).json({ code: 'expired_token', error: 'Phone login request expired' });
+ }
+
+ if (result.status === 'error_redirect') {
+ delete req.session.pendingPhoneLogin;
+ return res.status(403).json({ redirectTo: result.redirectTo, code: 'tenant_not_allowed' });
+ }
+
+ delete req.session.pendingPhoneLogin;
+ req.session.user = {
+ loginId: pendingLogin.loginId,
+ profile: result.profile,
+ tokens: {
+ accessToken: result.tokens.access_token,
+ idToken: result.tokens.id_token,
+ expiresIn: result.tokens.expires_in,
+ scope: result.tokens.scope,
+ tokenType: result.tokens.token_type
+ }
+ };
+
+ return res.json({ success: true, status: 'authenticated', user: req.session.user });
+ } catch (error) {
+ console.error('Headless phone login poll failed:', error);
+ res.status(500).json({ error: error.message || 'Headless phone login poll failed' });
+ }
+});
+
+app.get('/callback', (req, res) => {
+ if (req.session.user) {
+ return res.redirect('/');
+ }
+
+ const error = req.query.error || 'login_failed';
+ const description = req.query.error_description || 'Authentication failed';
+ return res.redirect(`/?auth_error=${encodeURIComponent(error)}&auth_error_description=${encodeURIComponent(description)}`);
+});
+
// 1. Generic Batch Save (Dynamic Table Detection)
app.post('/api/:table/batch', async (req, res) => {
const { table } = req.params;
@@ -1207,3 +1772,17 @@ app.get('/ready', async (req, res) => {
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`);
});
+
+// Ensure keys are generated on startup
+getSigningKey();
+
+// JWKS Endpoint
+app.get('/.well-known/jwks.json', (req, res) => {
+ try {
+ const jwk = getSigningKey();
+ res.json({ keys: [jwk] });
+ } catch (error) {
+ console.error('Error serving JWKS endpoint:', error);
+ res.status(500).json({ error: 'Could not retrieve JWKS' });
+ }
+});
diff --git a/src/main.ts b/src/main.ts
index 1211b1b..0235bc2 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -20,6 +20,13 @@ import { initGuide } from './components/Guide';
import { pcFlowModal } from './components/Modal/PCFlowModal';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
+interface AuthSessionResponse {
+ authenticated: boolean;
+ user: unknown;
+}
+
+let phoneLoginPollTimer: number | undefined;
+
// 화면 갱신 통합 핸들러
function refreshView(tab?: string) {
@@ -208,4 +215,210 @@ function initializeAppDirectly() {
renderNavigation((tab) => refreshView(tab));
}
-document.addEventListener('DOMContentLoaded', initializeAppDirectly);
+function showLoginScreen(errorMessage?: string) {
+ const loginContainer = document.getElementById('login-container');
+ const appLayout = document.getElementById('app-layout');
+ const loginError = document.getElementById('login-error');
+ const phoneLoginError = document.getElementById('phone-login-error');
+ const phoneLoginStatus = document.getElementById('phone-login-status');
+ const loginForm = document.getElementById('login-form') as HTMLFormElement | null;
+ const phoneLoginForm = document.getElementById('phone-login-form') as HTMLFormElement | null;
+ const loginModeTabs = document.querySelectorAll('.login-mode-tab');
+
+ if (appLayout) appLayout.style.display = 'none';
+ if (loginContainer) loginContainer.style.display = 'flex';
+
+ const setMessage = (element: HTMLElement | null, message?: string) => {
+ if (!element) return;
+ if (message) {
+ element.textContent = message;
+ element.removeAttribute('hidden');
+ } else {
+ element.textContent = '';
+ element.setAttribute('hidden', 'true');
+ }
+ };
+
+ setMessage(loginError, errorMessage);
+ setMessage(phoneLoginError, undefined);
+ setMessage(phoneLoginStatus, undefined);
+
+ const switchLoginMode = (mode: 'password' | 'phone') => {
+ if (loginForm) loginForm.hidden = mode !== 'password';
+ if (phoneLoginForm) phoneLoginForm.hidden = mode !== 'phone';
+ loginModeTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.mode === mode));
+ setMessage(loginError, mode === 'password' ? errorMessage : undefined);
+ setMessage(phoneLoginError, mode === 'phone' ? errorMessage : undefined);
+ };
+
+ loginModeTabs.forEach((tab) => {
+ if (!tab.dataset.bound) {
+ tab.dataset.bound = 'true';
+ tab.addEventListener('click', () => switchLoginMode((tab.dataset.mode as 'password' | 'phone') || 'password'));
+ }
+ });
+
+ const clearPhonePollTimer = () => {
+ if (phoneLoginPollTimer) {
+ window.clearTimeout(phoneLoginPollTimer);
+ phoneLoginPollTimer = undefined;
+ }
+ };
+
+ const pollPhoneLogin = async (pendingRef: string, intervalMs: number) => {
+ clearPhonePollTimer();
+ phoneLoginPollTimer = window.setTimeout(async () => {
+ try {
+ const response = await fetch('/api/auth/headless/phone/poll', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pendingRef })
+ });
+ const payload = await response.json();
+
+ if (!response.ok) {
+ const message = payload.redirectTo
+ ? '접근 권한이 없는 테넌트입니다. 관리자에게 문의하세요.'
+ : (payload.error || '전화번호 로그인 확인에 실패했습니다.');
+ clearPhonePollTimer();
+ setMessage(phoneLoginStatus, undefined);
+ setMessage(phoneLoginError, message);
+ return;
+ }
+
+ if (payload.status === 'authenticated') {
+ clearPhonePollTimer();
+ initializeAppDirectly();
+ return;
+ }
+
+ setMessage(phoneLoginStatus, '모바일에서 인증 링크를 승인하는 중입니다. 승인 후 자동으로 로그인됩니다.');
+ pollPhoneLogin(payload.pendingRef || pendingRef, payload.intervalMs || intervalMs);
+ } catch (error) {
+ console.error('Phone SSO poll failed:', error);
+ clearPhonePollTimer();
+ setMessage(phoneLoginStatus, undefined);
+ setMessage(phoneLoginError, '전화번호 로그인 확인 중 오류가 발생했습니다.');
+ }
+ }, intervalMs);
+ };
+
+ if (loginForm && !loginForm.dataset.bound) {
+ loginForm.dataset.bound = 'true';
+ loginForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const submitButton = document.getElementById('login-submit') as HTMLButtonElement | null;
+ const loginId = (document.getElementById('login-id') as HTMLInputElement | null)?.value.trim() || '';
+ const password = (document.getElementById('login-password') as HTMLInputElement | null)?.value || '';
+
+ if (!loginId || !password) {
+ showLoginScreen('사번과 비밀번호를 입력하세요.');
+ return;
+ }
+
+ if (submitButton) {
+ submitButton.disabled = true;
+ submitButton.textContent = '로그인 중...';
+ }
+
+ try {
+ const response = await fetch('/api/auth/headless/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ loginId, password })
+ });
+ const payload = await response.json();
+
+ if (!response.ok) {
+ const message = payload.redirectTo
+ ? '접근 권한이 없는 테넌트입니다. 관리자에게 문의하세요.'
+ : (payload.error || '로그인에 실패했습니다.');
+ showLoginScreen(message);
+ return;
+ }
+
+ initializeAppDirectly();
+ } catch (error) {
+ console.error('SSO login failed:', error);
+ showLoginScreen('로그인 요청 처리 중 오류가 발생했습니다.');
+ } finally {
+ if (submitButton) {
+ submitButton.disabled = false;
+ submitButton.textContent = '로그인';
+ }
+ }
+ });
+ }
+
+ if (phoneLoginForm && !phoneLoginForm.dataset.bound) {
+ phoneLoginForm.dataset.bound = 'true';
+ phoneLoginForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const submitButton = document.getElementById('phone-login-submit') as HTMLButtonElement | null;
+ const loginId = (document.getElementById('phone-login-id') as HTMLInputElement | null)?.value.trim() || '';
+
+ if (!loginId) {
+ setMessage(phoneLoginError, '전화번호를 입력하세요.');
+ return;
+ }
+
+ clearPhonePollTimer();
+ setMessage(phoneLoginError, undefined);
+ setMessage(phoneLoginStatus, '인증 링크를 요청하는 중입니다...');
+
+ if (submitButton) {
+ submitButton.disabled = true;
+ submitButton.textContent = '링크 전송 중...';
+ }
+
+ try {
+ const response = await fetch('/api/auth/headless/phone/init', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ loginId })
+ });
+ const payload = await response.json();
+
+ if (!response.ok) {
+ setMessage(phoneLoginStatus, undefined);
+ setMessage(phoneLoginError, payload.error || '전화번호 로그인 시작에 실패했습니다.');
+ return;
+ }
+
+ setMessage(phoneLoginStatus, payload.message || '인증 링크를 발송했습니다. 모바일에서 승인해 주세요.');
+ pollPhoneLogin(payload.pendingRef, payload.intervalMs || 3000);
+ } catch (error) {
+ console.error('Phone SSO init failed:', error);
+ setMessage(phoneLoginStatus, undefined);
+ setMessage(phoneLoginError, '전화번호 로그인 요청 중 오류가 발생했습니다.');
+ } finally {
+ if (submitButton) {
+ submitButton.disabled = false;
+ submitButton.textContent = '인증 링크 보내기';
+ }
+ }
+ });
+ }
+
+ switchLoginMode('password');
+}
+
+async function bootstrapApp() {
+ const params = new URLSearchParams(window.location.search);
+ const authError = params.get('auth_error_description') || params.get('auth_error');
+
+ try {
+ const response = await fetch('/api/auth/session');
+ const sessionInfo = await response.json() as AuthSessionResponse;
+ if (response.ok && sessionInfo.authenticated) {
+ initializeAppDirectly();
+ return;
+ }
+ } catch (error) {
+ console.error('Failed to load auth session:', error);
+ }
+
+ showLoginScreen(authError || undefined);
+}
+
+document.addEventListener('DOMContentLoaded', bootstrapApp);
diff --git a/src/styles/login.css b/src/styles/login.css
index b7255cf..0bcb7c4 100644
--- a/src/styles/login.css
+++ b/src/styles/login.css
@@ -53,6 +53,91 @@
gap: 1.5rem;
}
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.login-mode-tabs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+ margin-bottom: 1.25rem;
+}
+
+.login-mode-tab {
+ height: 48px;
+ border-radius: 999px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-light);
+ color: var(--text-muted);
+ font-size: 1rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.login-mode-tab.active {
+ background: var(--text-main);
+ color: var(--white);
+ border-color: var(--text-main);
+}
+
+.login-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ color: var(--text-main);
+ font-size: 1rem;
+ font-weight: 700;
+}
+
+.login-field input {
+ height: 52px;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ padding: 0 1rem;
+ font-size: 1rem;
+ color: var(--text-main);
+ background: var(--white);
+ outline: none;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.login-field input:focus {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 4px rgba(23, 23, 23, 0.08);
+}
+
+.login-error {
+ min-height: 1.5rem;
+ color: var(--danger);
+ font-size: 0.95rem;
+}
+
+.login-status {
+ min-height: 1.5rem;
+ color: var(--success);
+ font-size: 0.95rem;
+}
+
+.login-hint {
+ color: var(--text-muted);
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+.login-submit {
+ width: 100%;
+ margin-top: 0.5rem;
+}
+
+.login-submit[disabled] {
+ opacity: 0.7;
+ cursor: wait;
+}
+
.role-card {
display: flex;
flex-direction: column;
diff --git a/src/utils/jwks.js b/src/utils/jwks.js
new file mode 100644
index 0000000..947d791
--- /dev/null
+++ b/src/utils/jwks.js
@@ -0,0 +1,96 @@
+import { generateKeyPairSync, createPublicKey } from 'crypto';
+import { readFileSync, writeFileSync, existsSync } from 'fs';
+import { join } from 'path';
+
+const keysPath = process.env.KEYS_PATH || join(process.cwd(), 'keys.json');
+
+let currentKey;
+let currentKeySet;
+
+const toPublicJwk = (jwk) => ({
+ kty: jwk.kty,
+ kid: jwk.kid,
+ use: jwk.use,
+ alg: jwk.alg,
+ n: jwk.n,
+ e: jwk.e
+});
+
+const isValidPublicJwk = (jwk) => {
+ return Boolean(
+ jwk &&
+ jwk.kty === 'RSA' &&
+ typeof jwk.n === 'string' &&
+ typeof jwk.e === 'string' &&
+ !jwk.n.startsWith('LS0tLS1CRUdJTi')
+ );
+};
+
+const generateNewKeyPair = () => {
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: {
+ type: 'spki',
+ format: 'pem'
+ },
+ privateKeyEncoding: {
+ type: 'pkcs8',
+ format: 'pem'
+ }
+ });
+
+ const exportedJwk = createPublicKey(publicKey).export({ format: 'jwk' });
+ const jwk = {
+ kty: 'RSA',
+ kid: 'jwt-key-id',
+ use: 'sig',
+ alg: 'RS256',
+ n: exportedJwk.n,
+ e: exportedJwk.e
+ };
+
+ const keySet = {
+ privateKeyPem: privateKey,
+ publicKeyPem: publicKey,
+ keys: [jwk]
+ };
+
+ writeFileSync(keysPath, JSON.stringify(keySet, null, 2));
+ currentKeySet = keySet;
+ return keySet;
+};
+
+const getKeySet = () => {
+ if (currentKeySet) {
+ return currentKeySet;
+ }
+
+ if (existsSync(keysPath)) {
+ const keySet = JSON.parse(readFileSync(keysPath, 'utf8'));
+ if (keySet && keySet.keys && keySet.keys.length > 0) {
+ const candidateKey = toPublicJwk(keySet.keys[0]);
+ if (isValidPublicJwk(candidateKey) && keySet.privateKeyPem && keySet.publicKeyPem) {
+ currentKeySet = {
+ ...keySet,
+ keys: [candidateKey]
+ };
+ return currentKeySet;
+ }
+ }
+ }
+
+ return generateNewKeyPair();
+};
+
+const getSigningKey = () => {
+ if (currentKey) {
+ return currentKey;
+ }
+
+ currentKey = toPublicJwk(getKeySet().keys[0]);
+ return currentKey;
+};
+
+const getPrivateKeyPem = () => getKeySet().privateKeyPem;
+
+export { getSigningKey, getKeySet, getPrivateKeyPem };