diff --git a/server.js b/server.js index 13c7c04..29da6e1 100644 --- a/server.js +++ b/server.js @@ -78,65 +78,153 @@ app.get('/.well-known/jwks.json', (req, res) => { const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); -// 로그인 API (Headless - Real OIDC 통신) +// 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; - console.log(`[Real OIDC Request] Attempting login for ID: ${loginId}`); + const crypto = require('crypto'); + console.log(`[PM-fork 방식 Headless OIDC] Attempting login for ID: ${loginId}`); try { - console.log(`[OIDC Step 1] Sending grant_type: 'password' request to SSO Token Endpoint...`); + const issuerInfo = await Issuer.discover(process.env.ISSUER); + const authEndpoint = issuerInfo.authorization_endpoint; + const tokenEndpoint = issuerInfo.token_endpoint; - // 실제 SSO 서버에 토큰 요청 (Headless 인증) - const tokenSet = await oidcClient.grant({ - grant_type: 'password', - username: loginId, - password: password, - scope: 'openid profile', - redirect_uri: process.env.REDIRECT_URI // 추가 - }); + // 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(`[OIDC Step 2] TokenSet received successfully.`); + 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; - // ID Token 검증 및 클레임 추출 - const claims = tokenSet.claims(); + 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: claims.sub, - name: claims.name || claims.nickname || loginId, + id: idTokenPayload.sub, + name: idTokenPayload.name || idTokenPayload.preferred_username || loginId, loginTime: new Date().toISOString(), - id_token: tokenSet.id_token // 디버깅용 + method: 'password (pm-fork-secure)' }; - console.log(`[OIDC Step 3] ID Token verified. User: ${userData.name}`); - - // 세션에 실제 SSO 사용자 정보 저장 + console.log(`[Step 5] Success! User authenticated: ${userData.name}`); req.session.user = userData; res.json({ success: true, - message: 'SSO(OIDC) 인증 성공', + message: 'PM-fork 방식 인증 성공', user: userData, redirectTo: '/home.html' }); } catch (err) { - // SSO 서버에서 보낸 실제 에러 로그 출력 - console.error('[OIDC Error] Authentication failed:'); - if (err.response) { - console.error(' - Status:', err.response.statusCode); - console.error(' - Error:', err.response.body.error); - console.error(' - Description:', err.response.body.error_description); - - res.status(err.response.statusCode || 401).json({ - success: false, - message: `SSO 인증 실패: ${err.response.body.error_description || err.response.body.error}` - }); - } else { - console.error(' - Detail:', err.message); - res.status(500).json({ - success: false, - message: 'SSO 서버와 통신할 수 없습니다. (서버 설정 확인 필요)' - }); - } + console.error('[Headless OIDC Error]', err.message); + res.status(500).json({ success: false, message: `로그인 실패: ${err.message}` }); } }); @@ -150,8 +238,22 @@ app.post('/api/send-link', async (req, res) => { await delay(1000); if (phoneNumber) { - console.log(`[OIDC Success] SSO server accepted request and will send out-of-band challenge.`); - res.json({ success: true, message: 'SSO 인증 링크가 발송되었습니다. (카카오톡/SMS)' }); + 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: '전화번호를 입력해주세요.' }); }