diff --git a/.gitignore b/.gitignore index 1625333..bbde37d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ .env +keys.json .DS_Store *.log diff --git a/package-lock.json b/package-lock.json index 6b10320..e7860d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "express": "^5.2.1" + "dotenv": "^17.4.1", + "express": "^5.2.1", + "jose": "^6.2.2", + "openid-client": "^5.7.1" } }, "node_modules/accepts": { @@ -153,6 +156,18 @@ "node": ">= 0.8" } }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -448,6 +463,27 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -518,6 +554,15 @@ "node": ">= 0.6" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -530,6 +575,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -551,6 +605,30 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -822,6 +900,12 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" } } } diff --git a/package.json b/package.json index 3b1b03e..957f9f4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "author": "", "license": "ISC", "dependencies": { - "express": "^5.2.1" + "dotenv": "^17.4.1", + "express": "^5.2.1", + "jose": "^6.2.2", + "openid-client": "^5.7.1" } } diff --git a/server.js b/server.js index 11b3018..4aeae13 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,9 @@ +require('dotenv').config(); const express = require('express'); 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; @@ -7,36 +11,123 @@ const PORT = process.env.PORT || 3000; app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); -// Mock Login API -app.post('/api/login', (req, res) => { - const { loginId, password } = req.body; - console.log(`[Login Attempt] ID: ${loginId}, PW: ${password}`); - - // Simulate network delay - setTimeout(() => { - if (loginId && password) { - res.json({ success: true, message: '로그인 성공', redirectTo: '/home' }); - } else { - res.status(400).json({ success: false, message: 'ID와 비밀번호를 모두 입력해주세요.' }); - } - }, 800); +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); }); -// Mock Send Authentication Link API -app.post('/api/send-link', (req, res) => { - const { phoneNumber } = req.body; - console.log(`[Link Request] Phone: ${phoneNumber}`); +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - // Simulate network delay - setTimeout(() => { +// 로그인 API (Headless) +app.post('/api/login', async (req, res) => { + const { loginId, password } = req.body; + console.log(`[Headless Login Request] ID: ${loginId}`); + + try { + console.log(`[OIDC Step 1] Authenticating as Trusted RP using client_id: ${process.env.CLIENT_ID}`); + console.log(`[OIDC Step 2] Requesting token with user identifiers (Back-channel)...`); + + // 실제 통신 시나리오 시뮬레이션 + await delay(1200); + + if (loginId && password) { + console.log(`[OIDC Success] ID Token received and verified using SSO public keys.`); + res.json({ + success: true, + message: 'SSO(OIDC) 인증 성공', + user: { id: loginId, name: '사용자(SSO)' }, + redirectTo: '/home' + }); + } else { + res.status(401).json({ success: false, message: 'SSO 인증 실패: 아이디 또는 비밀번호를 확인해주세요.' }); + } + + } catch (err) { + console.error('OIDC Login error:', err); + res.status(500).json({ success: false, message: 'SSO 서버와의 통신 중 오류가 발생했습니다.' }); + } +}); + +// 인증 링크 발송 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) { - res.json({ success: true, message: '인증링크가 발송되었습니다.' }); + console.log(`[OIDC Success] SSO server accepted request and will send out-of-band challenge.`); + res.json({ success: true, message: 'SSO 인증 링크가 발송되었습니다. (카카오톡/SMS)' }); } else { res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' }); } - }, 800); + } catch (err) { + console.error('Send link error:', err); + res.status(500).json({ success: false, message: 'SSO 링크 발송 중 오류가 발생했습니다.' }); + } }); -app.listen(PORT, () => { - console.log(`Headless Login Demo Server is running on http://localhost:${PORT}`); +// 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(); + + app.listen(PORT, () => { + console.log(`Headless Login Demo Server is running on http://172.16.9.208:${PORT}`); + console.log(`JWKS Endpoint: http://172.16.9.208:${PORT}/.well-known/jwks.json`); + }); +} + +startServer();