Files
headless-login-demo/server.js
2026-04-09 15:33:49 +09:00

297 lines
11 KiB
JavaScript

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();