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('Generating new RSA key pair for JWKS...'); 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 { const issuer = await Issuer.discover(process.env.ISSUER); console.log('Discovered issuer %s %O', issuer.issuer, issuer.metadata); 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('OIDC Discovery failed:', err); } } // 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; } // 로그인 API (PM-fork 방식: Headless Password Login Flow) app.post('/api/login', async (req, res) => { const { loginId, password } = req.body; const crypto = require('crypto'); console.log(`[PM-fork 방식 Headless OIDC] Attempting login for ID: ${loginId}`); try { const issuerInfo = await Issuer.discover(process.env.ISSUER); const authEndpoint = issuerInfo.authorization_endpoint; const tokenEndpoint = issuerInfo.token_endpoint; // 1. Authorization Flow 시작 -> login_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', process.env.REDIRECT_URI); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'openid profile'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('nonce', nonce); console.log('[Step 1] Requesting login_challenge from authorization endpoint...'); 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('리다이렉트 URL에서 login_challenge를 파싱할 수 없습니다.'); } if (!loginChallenge) { throw new Error(`login_challenge를 획득할 수 없습니다. Location: ${location}`); } // 쿠키 추출 (Ory 세션 유지를 위해 필요) let cookies = authRes.headers.get('set-cookie') || ''; // 2. Headless Password API 호출 (client_assertion 사용) const headlessEndpoint = process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/password/login'); console.log(`[Step 2] Sending client_assertion to Headless API: ${headlessEndpoint}`); const clientAssertion = await buildClientAssertion(process.env.CLIENT_ID, headlessEndpoint); const loginRes = await fetch(headlessEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: process.env.CLIENT_ID, client_assertion: clientAssertion, login_challenge: loginChallenge, loginId: loginId, password: password }) }); const loginPayload = await loginRes.json(); if (!loginRes.ok) { throw new Error(loginPayload.message || loginPayload.error_description || 'Headless login API 거부됨'); } const redirectTo = loginPayload.redirectTo; if (!redirectTo) throw new Error('응답에 redirectTo 필드가 없습니다.'); let newCookies = loginRes.headers.get('set-cookie'); if (newCookies) cookies = cookies ? `${cookies}; ${newCookies}` : newCookies; // 3. 리다이렉트를 따라가서 Authorization Code 획득 console.log(`[Step 3] Following redirectTo to obtain authorization code...`); const redirectRes = await fetch(redirectTo, { redirect: 'manual', headers: { 'Cookie': cookies } }); const callbackLocation = redirectRes.headers.get('location'); if (!callbackLocation || !callbackLocation.includes('code=')) { throw new Error('Authorization code를 획득하지 못했습니다. (동의 화면(Consent)이 필요할 수 있습니다)'); } const callbackUrl = new URL(callbackLocation); const code = callbackUrl.searchParams.get('code'); // 4. Token Endpoint 호출하여 토큰 교환 (private_key_jwt 방식) console.log(`[Step 4] Exchanging authorization code for tokens...`); const tokenAssertion = await buildClientAssertion(process.env.CLIENT_ID, tokenEndpoint); 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', process.env.REDIRECT_URI); tokenParams.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); tokenParams.set('client_assertion', tokenAssertion); const tokenRes = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenParams.toString() }); const tokenData = await tokenRes.json(); if (!tokenRes.ok) throw new Error(tokenData.error_description || '토큰 교환 실패'); // 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 || loginId, loginTime: new Date().toISOString(), method: 'password (pm-fork-secure)' }; console.log(`[Step 5] Success! User authenticated: ${userData.name}`); req.session.user = userData; res.json({ success: true, message: 'PM-fork 방식 인증 성공', user: userData, redirectTo: '/home.html' }); } catch (err) { console.error('[Headless OIDC Error]', err.message); res.status(500).json({ success: false, message: `로그인 실패: ${err.message}` }); } }); // 인증 링크 발송 API app.post('/api/send-link', async (req, res) => { const { phoneNumber } = req.body; console.log(`[Auth Link Request] Phone: ${phoneNumber}`); try { console.log(`[OIDC Step 1] Requesting Back-channel Auth for mobile identifier: ${phoneNumber}`); await delay(1000); if (phoneNumber) { console.log(`[OIDC Success] SSO server accepted request.`); // 데모를 위해 링크 발송 후 즉시 인증 완료된 것으로 간주하여 세션 생성 const userData = { id: phoneNumber, name: '사용자(휴대폰)', loginTime: new Date().toISOString(), method: 'phone' }; req.session.user = userData; res.json({ success: true, message: '인증 링크가 발송되었으며, 시뮬레이션 인증이 완료되었습니다.', redirectTo: '/home.html' }); } else { res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' }); } } catch (err) { console.error('Send link error:', err); res.status(500).json({ success: false, message: 'SSO 링크 발송 중 오류가 발생했습니다.' }); } }); // 내 정보 확인 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(); res.json({ success: true, message: '로그아웃 되었습니다.' }); }); // OIDC Callback (모바일 앱 리디렉션 등 처리용) app.get('/callback', (req, res) => { const params = oidcClient.callbackParams(req); console.log('[OIDC Callback] Params:', params); res.send('인증이 완료되었습니다. 앱으로 돌아가주세요.'); }); async function startServer() { await initJwks(); await initOidc(); // 0.0.0.0을 명시하여 외부(IP) 접속 허용 app.listen(PORT, '0.0.0.0', () => { console.log(`Headless Login Demo Server is running on http://172.16.9.208:${PORT}`); console.log(`Local Access: http://localhost:${PORT}`); console.log(`JWKS Endpoint: http://172.16.9.208:${PORT}/.well-known/jwks.json`); }); } startServer();