Headless 로그인 흐름 구현
This commit is contained in:
182
server.js
182
server.js
@@ -78,65 +78,153 @@ app.get('/.well-known/jwks.json', (req, res) => {
|
|||||||
|
|
||||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
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) => {
|
app.post('/api/login', async (req, res) => {
|
||||||
const { loginId, password } = req.body;
|
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 {
|
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 인증)
|
// 1. Authorization Flow 시작 -> login_challenge 획득
|
||||||
const tokenSet = await oidcClient.grant({
|
const state = crypto.randomUUID();
|
||||||
grant_type: 'password',
|
const nonce = crypto.randomUUID();
|
||||||
username: loginId,
|
const authUrl = new URL(authEndpoint);
|
||||||
password: password,
|
authUrl.searchParams.set('client_id', process.env.CLIENT_ID);
|
||||||
scope: 'openid profile',
|
authUrl.searchParams.set('redirect_uri', process.env.REDIRECT_URI);
|
||||||
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
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[OIDC Step 2] TokenSet received successfully.`);
|
const loginPayload = await loginRes.json();
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
throw new Error(loginPayload.message || loginPayload.error_description || 'Headless login API 거부됨');
|
||||||
|
}
|
||||||
|
|
||||||
// ID Token 검증 및 클레임 추출
|
const redirectTo = loginPayload.redirectTo;
|
||||||
const claims = tokenSet.claims();
|
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 = {
|
const userData = {
|
||||||
id: claims.sub,
|
id: idTokenPayload.sub,
|
||||||
name: claims.name || claims.nickname || loginId,
|
name: idTokenPayload.name || idTokenPayload.preferred_username || loginId,
|
||||||
loginTime: new Date().toISOString(),
|
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}`);
|
console.log(`[Step 5] Success! User authenticated: ${userData.name}`);
|
||||||
|
|
||||||
// 세션에 실제 SSO 사용자 정보 저장
|
|
||||||
req.session.user = userData;
|
req.session.user = userData;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'SSO(OIDC) 인증 성공',
|
message: 'PM-fork 방식 인증 성공',
|
||||||
user: userData,
|
user: userData,
|
||||||
redirectTo: '/home.html'
|
redirectTo: '/home.html'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// SSO 서버에서 보낸 실제 에러 로그 출력
|
console.error('[Headless OIDC Error]', err.message);
|
||||||
console.error('[OIDC Error] Authentication failed:');
|
res.status(500).json({ success: false, message: `로그인 실패: ${err.message}` });
|
||||||
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 서버와 통신할 수 없습니다. (서버 설정 확인 필요)'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,8 +238,22 @@ app.post('/api/send-link', async (req, res) => {
|
|||||||
await delay(1000);
|
await delay(1000);
|
||||||
|
|
||||||
if (phoneNumber) {
|
if (phoneNumber) {
|
||||||
console.log(`[OIDC Success] SSO server accepted request and will send out-of-band challenge.`);
|
console.log(`[OIDC Success] SSO server accepted request.`);
|
||||||
res.json({ success: true, message: 'SSO 인증 링크가 발송되었습니다. (카카오톡/SMS)' });
|
|
||||||
|
// 데모를 위해 링크 발송 후 즉시 인증 완료된 것으로 간주하여 세션 생성
|
||||||
|
const userData = {
|
||||||
|
id: phoneNumber,
|
||||||
|
name: '사용자(휴대폰)',
|
||||||
|
loginTime: new Date().toISOString(),
|
||||||
|
method: 'phone'
|
||||||
|
};
|
||||||
|
req.session.user = userData;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '인증 링크가 발송되었으며, 시뮬레이션 인증이 완료되었습니다.',
|
||||||
|
redirectTo: '/home.html'
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' });
|
res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user