BARON-SSO 로그인 기능 연동

This commit is contained in:
2026-06-30 15:05:24 +09:00
parent 933afb02b1
commit 792917aba6
11 changed files with 1138 additions and 3 deletions

579
server.js
View File

@@ -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' });
}
});