From 5091da737d253ee71d38cf2d21125b10b99b3d3e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 23 Oct 2026 15:14:35 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20jWT=20=EC=98=A4=EB=A5=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- docs/qa-feedback-api-integration-task.md | 3 +- docs/qa-login-to-save-transition.md | 2 +- src/index.js | 73 ++++++++++++++++++++---- 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1f1a327..224fc8a 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ python3 -m http.server 4173 ## 연동 지점 -- SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://sso.hmac.kr/oidc`에 등록된 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim을 버전이 지정된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 Q&A 세션에 저장된 작성자 정보를 반환합니다. feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다. +- SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://sso.hmac.kr/oidc`에 등록된 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim과 SSO access token을 `SESSION_SECRET`에서 파생한 키로 암호화해 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 작성자 정보만 반환하며 access token은 반환하지 않습니다. - 작성자 식별자: `ssoSubject`/`requesterId`, `userUuid`, `tenantId`/`requesterTenantId`, `tenantIds`, `scope`, `roles`, 이메일·이름·부서·전화번호를 payload에 넣습니다. `requester_id`와 `requester_tenant_id`가 없으면 제출을 차단합니다. -- API: 작성페이지는 같은 Worker의 `POST /api/feedbacks`를 호출합니다. Worker가 Q&A 세션 쿠키를 검증하고, SSO requester 정보와 `ABC_API_KEY` Secret을 추가한 뒤 `POST https://feedback.hmac.kr/api/projects/{projectId}/channels/{channelId}/feedbacks`로 전달합니다. API Key와 내부 ABC 주소는 브라우저에 노출하지 않습니다. +- API: 작성페이지는 같은 Worker의 `POST /api/feedbacks`를 호출합니다. Worker가 암호화된 Q&A 세션 쿠키를 검증하고, SSO requester 정보와 `Authorization: Bearer` access token, `ABC_API_KEY` Secret을 추가한 뒤 `POST https://feedback.hmac.kr/api/projects/{projectId}/channels/{channelId}/feedbacks`로 전달합니다. Bearer token, API Key와 내부 ABC 주소는 브라우저 JavaScript에 노출하지 않습니다. - 첨부파일: 작성페이지가 `multipart/form-data`의 반복 `images` 필드로 파일을 Worker에 전달하고, Worker가 검증된 requester 정보와 API Key를 추가해 ABC API로 전달합니다. ABC API가 `qa_cdn` 저장소에 파일을 저장하므로 R2 access key/secret은 정적 페이지에 넣지 않습니다. - 텍스트만 등록할 때는 기존 JSON 요청을 사용하고, 파일이 있을 때만 multipart 요청을 사용합니다. @@ -68,7 +68,7 @@ npx wrangler secret bulk qa-secrets.json --name baron-qa-gateway-test `AUTH_CLIENT_ID`, `AUTH_AUTHORIZE_URL`, `AUTH_TOKEN_URL`, 선택적인 `AUTH_USERINFO_URL`은 `wrangler.toml`에 실제 SSO 값으로 설정해야 합니다. 현재 Q&A RP는 `https://sso.hmac.kr/oidc`를 사용합니다. 이 RP는 PKCE Public Client이므로 `AUTH_CLIENT_SECRET`은 사용하지 않으며, `SESSION_SECRET`만 secret으로 등록합니다. -Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증한 사용자 claim을 서명된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환합니다. +Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 받은 access token과 정규화한 사용자 claim을 `SESSION_SECRET`에서 파생한 키로 암호화해 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환하며 access token은 반환하지 않습니다. 세션 쿠키 형식 변경 후에는 다시 로그인해야 합니다. ## Gitea Actions 등록값 diff --git a/docs/qa-feedback-api-integration-task.md b/docs/qa-feedback-api-integration-task.md index b33561d..a96df53 100644 --- a/docs/qa-feedback-api-integration-task.md +++ b/docs/qa-feedback-api-integration-task.md @@ -11,7 +11,7 @@ 작성페이지 → Worker POST → Q&A SSO 세션 확인 - → Worker가 requester 정보와 API Key 추가 + → Worker가 requester 정보, SSO Bearer token, API Key 추가 → ABC UserFeedback API → feedbacks 저장 → { id } 반환 @@ -48,6 +48,7 @@ - [x] `requester_phone_number` 매핑: SSO `profile.phones[0]` 또는 phone claim - [x] 브라우저 요청의 requester 값을 신뢰하지 않도록 처리 - [x] Worker Secret `ABC_API_KEY`로 `x-api-key` 추가 +- [x] SSO access token을 암호화된 HttpOnly 세션에 보관하고 글 저장 요청에 Bearer token 추가 - [x] `projectId`와 `channelId`를 Worker 설정에 등록 - [x] `POST /api/projects/{projectId}/channels/{channelId}/feedbacks` 호출 - [x] `x-source-namespace` 추가 diff --git a/docs/qa-login-to-save-transition.md b/docs/qa-login-to-save-transition.md index 7901982..da1f1c0 100644 --- a/docs/qa-login-to-save-transition.md +++ b/docs/qa-login-to-save-transition.md @@ -126,7 +126,7 @@ support_tickets.idempotency_key ``` - 첨부파일은 `qa_cdn`에 저장하고, DB에는 bucket/key 및 파일 메타데이터를 기록한다. -- 실제 운영에서는 feedback API가 서버 측에서 Q&A 세션 또는 SSO token을 검증해야 한다. +- Worker는 저장 요청에 SSO access token을 `Authorization: Bearer`로, 프로젝트 API 키를 `x-api-key`로 전달한다. 세션 쿠키와 access token은 브라우저 JavaScript에 노출하지 않는다. ## 4. 현재 테스트 모드와 운영 모드 diff --git a/src/index.js b/src/index.js index 7bd55c0..1513e6e 100644 --- a/src/index.js +++ b/src/index.js @@ -144,6 +144,28 @@ async function verifyValue(signed, secret) { return difference === 0 ? value : null; } +async function encryptValue(value, secret) { + const keyMaterial = await sha256(`baron-qa-session-encryption-v1:${secret}`); + const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-GCM' }, false, ['encrypt']); + const iv = randomBytes(12); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(value)); + return `v1.${base64url(iv)}.${base64url(new Uint8Array(ciphertext))}`; +} + +async function decryptValue(encrypted, secret) { + if (!encrypted || !secret) return null; + const [version, encodedIv, encodedCiphertext] = encrypted.split('.'); + if (version !== 'v1' || !encodedIv || !encodedCiphertext) return null; + try { + const keyMaterial = await sha256(`baron-qa-session-encryption-v1:${secret}`); + const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-GCM' }, false, ['decrypt']); + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64url(encodedIv) }, key, fromBase64url(encodedCiphertext)); + return decoder.decode(plaintext); + } catch (error) { + return null; + } +} + function randomBytes(size = 32) { const bytes = new Uint8Array(size); crypto.getRandomValues(bytes); @@ -297,6 +319,7 @@ async function finishLogin(request, env) { const tokenResponse = await fetch(env.AUTH_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: tokenParams }); const tokens = await tokenResponse.json().catch(() => ({})); if (!tokenResponse.ok) return oauthError(env, 'oauth_token_exchange_failed', tokens.error_description || tokens.error || '', 502); + if (typeof tokens.access_token !== 'string' || !tokens.access_token) return oauthError(env, 'oauth_access_token_missing', '', 502); let claims = parseJwt(tokens.id_token || tokens.access_token); if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return oauthError(env, 'invalid_oauth_nonce', '', 400); if (env.AUTH_USERINFO_URL && tokens.access_token) { @@ -306,19 +329,43 @@ async function finishLogin(request, env) { } const user = normalizeClaims(claims); if (!user.ssoSubject) return oauthError(env, 'sso_subject_missing', '', 502); - const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 })); - const session = await signValue(sessionPayload, env.SESSION_SECRET); - return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, Number(env.SESSION_TTL_SECONDS || 3600)), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] }); + const now = Date.now(); + const accessToken = typeof tokens.access_token === 'string' ? tokens.access_token : ''; + const accessTokenClaims = parseJwt(accessToken); + const tokenExpiresAt = Number(tokens.expires_in) > 0 + ? now + Number(tokens.expires_in) * 1000 + : (Number(accessTokenClaims.exp) > 0 ? Number(accessTokenClaims.exp) * 1000 : now + Number(env.SESSION_TTL_SECONDS || 3600) * 1000); + const sessionExpiresAt = Math.min(now + Number(env.SESSION_TTL_SECONDS || 3600) * 1000, tokenExpiresAt); + if (sessionExpiresAt <= now) return oauthError(env, 'oauth_access_token_expired', '', 502); + const sessionPayload = JSON.stringify({ + user, + accessToken, + accessTokenExpiresAt: Math.min(sessionExpiresAt, tokenExpiresAt), + expiresAt: sessionExpiresAt + }); + const session = await encryptValue(sessionPayload, env.SESSION_SECRET); + const sessionMaxAge = Math.max(1, Math.floor((sessionExpiresAt - now) / 1000)); + return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, sessionMaxAge), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] }); +} + +async function getSessionContext(request, env) { + const signedSession = getCookie(request, env.SESSION_COOKIE_NAME); + const value = await decryptValue(signedSession, env.SESSION_SECRET); + if (!value) return null; + try { + const payload = JSON.parse(value); + if (!(payload.expiresAt > Date.now()) || !payload.user) return null; + return { + user: payload.user, + accessToken: typeof payload.accessToken === 'string' ? payload.accessToken : '', + accessTokenExpiresAt: Number(payload.accessTokenExpiresAt) || 0 + }; + } catch (error) { return null; } } async function getSession(request, env) { - const signedSession = getCookie(request, env.SESSION_COOKIE_NAME); - const value = await verifyValue(signedSession, env.SESSION_SECRET); - if (!value) return null; - try { - const payload = JSON.parse(decoder.decode(fromBase64url(value))); - return payload.expiresAt > Date.now() ? payload.user : null; - } catch (error) { return null; } + const session = await getSessionContext(request, env); + return session?.user || null; } async function sessionResponse(request, env) { @@ -328,8 +375,11 @@ async function sessionResponse(request, env) { async function createFeedback(request, env) { if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); - const user = await getSession(request, env); + const session = await getSessionContext(request, env); + const user = session?.user; if (!user) return json({ error: 'unauthenticated' }, 401); + if (!session.accessToken) return json({ error: 'sso_access_token_missing' }, 401); + if (session.accessTokenExpiresAt && session.accessTokenExpiresAt <= Date.now()) return json({ error: 'sso_access_token_expired' }, 401); if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503); if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503); @@ -390,6 +440,7 @@ async function createFeedback(request, env) { const idempotencyConsumer = env.ABC_IDEMPOTENCY_CONSUMER || sourceNamespace; const upstreamHeaders = { accept: 'application/json', + authorization: `Bearer ${session.accessToken}`, 'x-api-key': env.ABC_API_KEY, 'x-source-namespace': sourceNamespace, 'x-source-record-id': feedbackId,