require('dotenv').config(); const express = require('express'); const session = require('express-session'); const path = require('path'); const fs = require('fs'); const { generateKeyPair, exportJWK } = require('jose'); const { Issuer, custom } = require('openid-client'); const app = express(); const PORT = process.env.PORT || 3000; app.use(express.json()); app.use(session({ secret: 'headless-demo-secret-key', resave: false, saveUninitialized: true, cookie: { secure: false } // 개발 환경이므로 false })); app.use(express.static(path.join(__dirname, 'public'))); // 인증 체크 미들웨어 const isAuthenticated = (req, res, next) => { if (req.session.user) { next(); } else { res.status(401).json({ success: false, message: '인증이 필요합니다.' }); } }; let jwks; let oidcClient; // JWKS 생성 및 로드 async function initJwks() { const keysPath = path.join(__dirname, 'keys.json'); if (fs.existsSync(keysPath)) { jwks = JSON.parse(fs.readFileSync(keysPath, 'utf8')); } else { console.log('--- [시스템] 새로운 보안 키 쌍(RSA)을 생성하고 저장합니다. ---'); const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }); const privateJwk = await exportJWK(privateKey); const publicJwk = await exportJWK(publicKey); const kid = 'demo-key-' + Math.random().toString(36).substr(2, 9); privateJwk.kid = kid; publicJwk.kid = kid; publicJwk.use = 'sig'; publicJwk.alg = 'RS256'; jwks = { publicJwks: { keys: [publicJwk] }, privateJwk: privateJwk }; fs.writeFileSync(keysPath, JSON.stringify(jwks, null, 2)); } } // OIDC 클라이언트 초기화 async function initOidc() { try { console.log(`--- [시스템] SSO 서버 정보를 조회 중입니다: ${process.env.ISSUER} ---`); const issuer = await Issuer.discover(process.env.ISSUER); console.log('--- [시스템] SSO 서버 연결 준비 완료. ---'); oidcClient = new issuer.Client({ client_id: process.env.CLIENT_ID, token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'RS256', }); } catch (err) { console.error('--- [오류] SSO 서버 연결에 실패했습니다: ---', err.message); } } // JWKS 엔드포인트 app.get('/.well-known/jwks.json', (req, res) => { res.json(jwks.publicJwks); }); const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // JWT Client Assertion 생성 헬퍼 함수 async function buildClientAssertion(clientId, audience) { const { SignJWT, importJWK } = require('jose'); const crypto = require('crypto'); const privateKey = await importJWK(jwks.privateJwk, 'RS256'); const jwt = await new SignJWT({ iss: clientId, sub: clientId, aud: audience, jti: crypto.randomUUID(), }) .setProtectedHeader({ alg: 'RS256', kid: jwks.privateJwk.kid }) .setIssuedAt() .setExpirationTime('5m') .sign(privateKey); return jwt; } function buildHeadlessAudienceCandidates(endpoint) { const audiences = [endpoint]; try { const pathOnly = new URL(endpoint).pathname; if (!audiences.includes(pathOnly)) audiences.push(pathOnly); } catch (e) {} if (!audiences.includes(process.env.ISSUER)) audiences.push(process.env.ISSUER); return audiences; } async function postHeadlessRequest(endpoint, body, label) { const audiences = buildHeadlessAudienceCandidates(endpoint); let payload; let response; for (const aud of audiences) { console.log(` --> 보안 검증 중 (${label}, 대상: ${aud})`); const clientAssertion = await buildClientAssertion(process.env.CLIENT_ID, aud); response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, client_assertion: clientAssertion, }), }); const rawBody = await response.text(); if (!rawBody.trim()) { payload = {}; } else { try { payload = JSON.parse(rawBody); } catch (parseErr) { payload = { rawBody }; } } if (payload === null) { payload = {}; } if (response.ok) break; const errorMsg = String(payload.error || payload.error_description || payload.rawBody || '').toLowerCase(); if (!errorMsg.includes('audience mismatch')) break; console.log(` --> 보안 검증 대상을 조정하여 다시 시도합니다...`); } return { response, payload }; } async function pollHeadlessLink(endpoint, pendingRef, initialIntervalSeconds = 2) { const timeoutMs = 3 * 60 * 1000; const startedAt = Date.now(); let intervalSeconds = Math.max(1, Number(initialIntervalSeconds) || 2); while (Date.now() - startedAt < timeoutMs) { const { response, payload } = await postHeadlessRequest( endpoint, { client_id: process.env.CLIENT_ID, pendingRef, }, 'headless link poll', ); if (response.ok && payload.redirectTo) { return payload; } const code = String(payload.code || payload.error || '').toLowerCase(); if (code === 'authorization_pending' || code === 'slow_down') { const nextInterval = Number(payload.interval); if (Number.isFinite(nextInterval) && nextInterval > 0) { intervalSeconds = nextInterval; } else if (code === 'slow_down') { intervalSeconds += 1; } await delay(intervalSeconds * 1000); continue; } if (code === 'expired_token') { throw new Error('전화번호 인증 링크가 만료되었습니다.'); } if (response.ok) { throw new Error(payload.message || payload.error_description || '전화번호 인증 상태를 확인할 수 없습니다.'); } throw new Error(payload.message || payload.error_description || payload.error || '전화번호 인증 상태 확인 실패'); } throw new Error('전화번호 인증 시간이 초과되었습니다.'); } async function runHeadlessSsoLogin({ req, res, identifier, password, mode }) { const crypto = require('crypto'); const isPhoneMode = mode === 'phone'; const identifierLabel = isPhoneMode ? '전화번호' : '사번'; console.log(`\n================================================================`); console.log(`[로그인 시작] ${identifierLabel} ${identifier} 님에 대한 안전한 로그인을 시도합니다.`); console.log(`================================================================`); try { const issuerInfo = await Issuer.discover(process.env.ISSUER); const authEndpoint = issuerInfo.authorization_endpoint; const tokenEndpoint = issuerInfo.token_endpoint; const redirectUri = process.env.REDIRECT_URI; console.log(`[1단계: 신호 요청] SSO 서버로부터 인증을 위한 고유 신호(Challenge)를 받아오고 있습니다.`); const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); const authUrl = new URL(authEndpoint); authUrl.searchParams.set('client_id', process.env.CLIENT_ID); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'openid profile'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('nonce', nonce); const authRes = await fetch(authUrl.toString(), { redirect: 'manual' }); const location = authRes.headers.get('location') || authRes.url; let loginChallenge; try { loginChallenge = new URL(location).searchParams.get('login_challenge'); } catch (e) { throw new Error('인증 신호 파싱 실패 (잘못된 경로)'); } if (!loginChallenge) { throw new Error(`인증 신호를 받지 못했습니다. (URL: ${location})`); } console.log(` --> 신호 획득 완료: ${loginChallenge.substring(0, 10)}...`); let cookies = authRes.headers.get('set-cookie') || ''; let redirectTo; if (isPhoneMode) { console.log(`[2단계: 본인 인증] 전화번호 SSO 링크 발송을 요청합니다.`); const linkInitEndpoint = process.env.PHONE_HEADLESS_LINK_INIT_ENDPOINT || process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/link/init'); const linkPollEndpoint = process.env.PHONE_HEADLESS_LINK_POLL_ENDPOINT || process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/link/poll'); const initBody = { client_id: process.env.CLIENT_ID, login_challenge: loginChallenge, loginId: identifier, }; const { response: initRes, payload: initPayload } = await postHeadlessRequest( linkInitEndpoint, initBody, 'headless link init', ); if (!initRes.ok) { console.error(' [실패] 서버에서 전화번호 링크 발송을 거부했습니다.'); console.error(' [상세 사유]:', JSON.stringify(initPayload, null, 2)); if (initRes.status === 503 && Object.keys(initPayload).length === 0) { throw new Error('전화번호 인증 요청이 SSO 서버에서 503으로 거부되었습니다. (응답 본문이 비어 있음)'); } throw new Error( initPayload.message || initPayload.error_description || initPayload.rawBody || `전화번호 인증 요청 거부됨 (${initRes.status})` ); } const pendingRef = initPayload.pendingRef; if (!pendingRef) { throw new Error('전화번호 인증 요청 응답에 pendingRef가 없습니다.'); } console.log(` --> 링크 발송 완료, 승인 대기 중입니다. (pendingRef: ${pendingRef})`); const pollResult = await pollHeadlessLink(linkPollEndpoint, pendingRef, initPayload.interval); redirectTo = pollResult.redirectTo; } else { console.log(`[2단계: 본인 인증] 사번과 비밀번호를 보안 도장(Digital Signature)과 함께 서버에 전달합니다.`); const defaultHeadlessEndpoint = process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/password/login'); const headlessEndpoint = defaultHeadlessEndpoint; const requestBody = { client_id: process.env.CLIENT_ID, login_challenge: loginChallenge, loginId: identifier, password: password, }; const { response: loginRes, payload: loginPayload } = await postHeadlessRequest( headlessEndpoint, requestBody, 'headless password login', ); if (!loginRes.ok) { console.error(' [실패] 서버에서 인증을 거부했습니다.'); console.error(' [상세 사유]:', JSON.stringify(loginPayload, null, 2)); throw new Error( loginPayload.message || loginPayload.error_description || loginPayload.rawBody || `${identifierLabel} 인증 거부됨 (${loginRes.status})` ); } redirectTo = loginPayload.redirectTo; if (!redirectTo) throw new Error('인증 후 리다이렉트 정보를 받지 못했습니다.'); const newCookies = loginRes.headers.get('set-cookie'); if (newCookies) cookies = cookies ? `${cookies}; ${newCookies}` : newCookies; console.log(` --> 본인 인증 성공! 다음 단계로 이동합니다.`); } // 3. 리다이렉트를 따라가서 Authorization Code 획득 (Consent 자동 승인 포함) console.log(`[3단계: 권한 획득] 인증 완료 후 필요한 권한(프로필 등)을 최종적으로 승인받는 과정입니다.`); async function resolveRedirects(currentUrl, currentCookies, depth = 0) { if (depth > 10) throw new Error('연결 과정이 너무 깁니다. (최대 횟수 초과)'); console.log(` --> 경로 추적 중: ${new URL(currentUrl).pathname}`); const res = await fetch(currentUrl, { redirect: 'manual', headers: { 'Cookie': currentCookies } }); const nextCookies = [currentCookies, ...(res.headers.getSetCookie?.() || [])].filter(Boolean).join('; '); const location = res.headers.get('location'); if (!location) { if (currentUrl.includes('/consent')) { console.log(' [자동 승인] 사용자의 정보 제공 동의 화면이 감지되어 시스템이 자동으로 승인 중입니다.'); const consentUrl = new URL(currentUrl); const consentChallenge = consentUrl.searchParams.get('consent_challenge'); const detailsUrl = new URL('/api/v1/auth/consent', currentUrl); detailsUrl.searchParams.set('consent_challenge', consentChallenge); const detailsRes = await fetch(detailsUrl.toString(), { headers: { 'Cookie': nextCookies } }); const consentInfo = await detailsRes.json(); const acceptUrl = new URL('/api/v1/auth/consent/accept', currentUrl); const acceptRes = await fetch(acceptUrl.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': nextCookies }, body: JSON.stringify({ consent_challenge: consentChallenge, grant_scope: consentInfo.requested_scope || ['openid', 'profile'], grant_access_token_audience: consentInfo.requested_access_token_audience || [] }) }); const acceptPayload = await acceptRes.json(); return resolveRedirects(acceptPayload.redirectTo, nextCookies, depth + 1); } throw new Error(`리다이렉트 지점을 찾을 수 없습니다. (URL: ${currentUrl})`); } const absoluteLocation = new URL(location, currentUrl).toString(); if (absoluteLocation.includes('code=')) { return { code: new URL(absoluteLocation).searchParams.get('code'), cookies: nextCookies }; } return resolveRedirects(absoluteLocation, nextCookies, depth + 1); } const { code, cookies: finalCookies } = await resolveRedirects(redirectTo, cookies); console.log(` --> 임시 승인 코드 획득 완료.`); // 4. Token Endpoint 호출하여 토큰 교환 (유연한 인증 방식 적용) console.log(`[4단계: 열쇠 발급] 획득한 코드를 실제 서비스 이용이 가능한 '보안 열쇠(Token)'로 교환합니다.`); async function exchangeToken(authMethod = 'private_key_jwt') { const tokenParams = new URLSearchParams(); tokenParams.set('grant_type', 'authorization_code'); tokenParams.set('code', code); tokenParams.set('client_id', process.env.CLIENT_ID); tokenParams.set('redirect_uri', redirectUri); if (authMethod === 'private_key_jwt') { console.log(' --> 가장 안전한 방식(보안 도장 포함)으로 열쇠를 요청합니다.'); const tokenAssertion = await buildClientAssertion(process.env.CLIENT_ID, tokenEndpoint); tokenParams.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); tokenParams.set('client_assertion', tokenAssertion); } else { console.log(' --> 표준 방식(일반 클라이언트)으로 열쇠를 다시 요청합니다.'); } const res = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenParams.toString() }); const data = await res.json(); if (!res.ok) { if (authMethod === 'private_key_jwt') { console.log(' [안내] 서버가 엄격한 보안 도장을 요구하지 않습니다. 일반 방식으로 전환합니다.'); return exchangeToken('none'); } throw new Error(data.error_description || data.error || '열쇠 교환 실패'); } return data; } const tokenData = await exchangeToken(); console.log(` --> 보안 열쇠 발급 완료.`); // 5. 성공: 토큰에서 사용자 정보 추출 const idTokenPayload = JSON.parse(Buffer.from(tokenData.id_token.split('.')[1], 'base64url').toString()); const userData = { id: idTokenPayload.sub, name: idTokenPayload.name || idTokenPayload.preferred_username || identifier, loginTime: new Date().toLocaleString('ko-KR'), method: isPhoneMode ? '전화번호 SSO 인증' : '사번 SSO 인증' }; console.log(`\n[5단계: 로그인 완료] 모든 과정이 성공했습니다!`); console.log(` - 환영합니다, ${userData.name} 님!`); console.log(` - 로그인 시각: ${userData.loginTime}`); console.log(`================================================================\n`); req.session.user = userData; res.json({ success: true, message: '성공적으로 로그인되었습니다.', user: userData, redirectTo: '/home.html' }); } catch (err) { console.error(`\n[!!! 로그인 실패 !!!]`); console.error(` - 사유: ${err.message}`); console.log(`================================================================\n`); res.status(500).json({ success: false, message: `로그인 실패: ${err.message}` }); } } // 로그인 API (PM-fork 방식: Headless Password Login Flow) app.post('/api/login', async (req, res) => { const { loginId, password } = req.body; return runHeadlessSsoLogin({ req, res, identifier: loginId, password, mode: 'employee' }); }); // 전화번호 SSO 인증 요청 API app.post('/api/send-link', async (req, res) => { const { phoneNumber } = req.body; if (!phoneNumber) { return res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' }); } console.log(`\n[전화번호 SSO 인증 요청] 번호: ${phoneNumber}`); return runHeadlessSsoLogin({ req, res, identifier: phoneNumber, mode: 'phone' }); }); // 내 정보 확인 API app.get('/api/me', isAuthenticated, (req, res) => { res.json({ success: true, user: req.session.user }); }); // 로그아웃 API app.post('/api/logout', (req, res) => { req.session.destroy(); console.log('[시스템] 사용자가 로그아웃했습니다.'); res.json({ success: true, message: '로그아웃 되었습니다.' }); }); // OIDC Callback app.get('/callback', (req, res) => { console.log('[시스템] 콜백 엔드포인트에 접속했습니다.'); res.send('인증이 완료되었습니다. 앱으로 돌아가주세요.'); }); async function startServer() { try { await initJwks(); await initOidc(); const server = app.listen(PORT, '0.0.0.0', () => { console.log(`\n================================================================`); console.log(`[서버 기동] Headless 로그인 데모 서버가 실행 중입니다.`); console.log(`- 접속 주소: http://172.16.9.208:${PORT}`); console.log(`- 로컬 주소: http://localhost:${PORT}`); console.log(`================================================================\n`); }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error(`\n[오류] ${PORT}번 포트가 이미 사용 중입니다. 다른 포트를 사용하거나 해당 프로세스를 종료해주세요.`); } else { console.error(`\n[오류] 서버 실행 중 에러 발생:`, err.message); } process.exit(1); }); } catch (err) { console.error('\n[오류] 서버 초기화 실패:', err.message); process.exit(1); } } startServer();