From ccf48c8224994bd9a5008020bce1196f663dd955 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 30 Oct 2026 12:49:15 +0900 Subject: [PATCH] fix: use supported redirect mode for Workers organization requests --- README.md | 2 + docs/sso-tenant-organization-review.md | 12 +++++- src/org-context.js | 3 +- tests/org-context.test.js | 2 +- tests/runtime/organization-test.mjs | 50 +++++++++++++++++++++++++ tests/runtime/organization-upstream.mjs | 26 +++++++++++++ tests/runtime/organization.capnp | 19 ++++++++++ tests/worker-runtime.test.js | 16 ++++++++ 8 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/runtime/organization-test.mjs create mode 100644 tests/runtime/organization-upstream.mjs create mode 100644 tests/runtime/organization.capnp create mode 100644 tests/worker-runtime.test.js diff --git a/README.md b/README.md index 3a0e509..96e1b10 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ Worker는 서명된 세션의 사용자 UUID로 소속을 찾습니다. 조회 조직 조회가 실패하면 기존 세션·글쓰기 흐름과 확인된 SSO 정보를 유지합니다. 누락되거나 모호한 사용자는 `orgContextStatus`로 구분하고 다른 작성자의 정보를 대신 쓰지 않습니다. 로그인 후 `/auth/session`의 `user.orgContextStatus=unavailable`이면 `user.orgContextError`에서 허용된 오류 코드만 확인할 수 있습니다. `org_context_missing_credentials`는 Worker의 조직 API 키 누락, `org_context_http_401`은 API 인증 거부, `org_context_http_403`은 API 접근 거부, `org_context_timeout`은 요청 시간 초과를 뜻합니다. 알 수 없는 오류는 `org_context_unavailable`로 표시하며 키·원본 오류 메시지·조직 디렉터리는 응답에 넣지 않습니다. 조회가 정상화되면 이전 `orgContextError`는 제거됩니다. HTTP 200이나 요청 로그의 `outcome=ok`만으로 조직 조회 성공을 판단하지 않습니다. +조직 API는 Workers가 지원하는 `redirect: manual`로 호출하고, 3xx 응답은 실패 처리해 다른 주소로 인증 헤더를 전달하지 않습니다. Workers에서는 `redirect: error`가 요청 전 TypeError를 발생시킵니다. `npm test`에는 설치된 workerd로 실제 `/auth/session` 경로를 실행하는 검사가 포함됩니다. 외부 API는 로컬 모의 서비스로 대체하며 조직 매핑과 리다이렉트 거부를 함께 확인합니다. + 로컬 검증: ```sh diff --git a/docs/sso-tenant-organization-review.md b/docs/sso-tenant-organization-review.md index ea37e41..a8bcb8e 100644 --- a/docs/sso-tenant-organization-review.md +++ b/docs/sso-tenant-organization-review.md @@ -42,6 +42,14 @@ CI 검증은 요청당 15초, 실제 Worker 조회는 요청당 5초 제한이 로그 조회만으로 원인을 구분하지 못한 문제를 보완하기 위해 `user.orgContextError`에 허용된 오류 코드만 추가했다. 임의의 upstream 메시지는 숨기고, 성공 시 이전 오류 값은 제거한다. 배포 후 `/auth/session`의 이 필드로 원인을 확인할 수 있으며 실제 사용자 조직 조회 실패 자체를 해결한 변경은 아니다. +### 후속 확인: Workers 런타임에서 실패하는 코드 수정 + +진단 보완 배포 후 사용자가 제공한 오류는 `org_context_request_failed`였다. 조직 API 호출에 지정한 `redirect: "error"`가 Workers에서 지원되지 않아 요청 전 TypeError를 발생시키는 결함을 확인했다. Node.js는 이 옵션을 허용하므로 CI의 API 검증만 성공할 수 있었다. [Cloudflare Request 문서](https://developers.cloudflare.com/workers/runtime-apis/request/)에도 이 제한이 명시되어 있다. + +동일한 배포 compatibility date를 사용한 실제 workerd 테스트에서 수정 전 `/auth/session`이 약 3ms 만에 `org_context_request_failed`와 `unavailable`을 반환하는 것을 재현했다. `redirect: "manual"`로 변경한 뒤에는 모의 조직 응답을 사용한 동일한 실제 런타임 경로에서 회사 `총괄기획실&기술개발센터`, 부서 `IS3`, 상태 `resolved`를 확인했다. 302 응답은 따라가지 않고 `org_context_http_302`로 거부하는 검사도 통과했다. + +수정 후 전체 테스트는 77개 통과했다. 키·테넌트 설정 변경 없이 요청 옵션을 수정했으며, 실제 사용자에게 발급된 키로 원본 조직 데이터를 조회하거나 운영 배포를 수행한 것은 아니다. 사용자가 push하고 스테이징 배포가 성공한 뒤 실제 `/auth/session`에서 `resolved`와 회사·부서를 확인해야 한다. 과거 잘못 저장된 글의 정정은 여전히 별도 작업이다. + ## 조회 경로 | 경로 | 확인한 필드·범위 | Q&A 적용 시 주의점 | @@ -108,13 +116,13 @@ Accept: application/json ## 필요한 후속 작업 -1. 사용자가 확인한 최상위 분류 조직 기준을 유지한다. 실제 세션에서 `orgContextStatus=unavailable`을 확인했으므로 먼저 Worker에서 기록하는 정확한 `org_context_...` 오류를 확인하고 조회 실패를 해결한다. 이후 해당 사용자의 canonical UUID 및 회사·IS3 UUID를 원본 조직 응답에서 대조한다. +1. 사용자가 확인한 최상위 분류 조직 기준을 유지한다. 확인된 Workers 미지원 요청 옵션의 수정을 배포하고 실제 `/auth/session`의 상태·회사·부서를 확인한다. 이후에도 잘못된 값이 있으면 해당 사용자의 canonical UUID 및 회사·IS3 UUID를 원본 조직 응답에서 대조한다. 2. 조회 범위와 회사 판별 기준을 구분한다. API의 `type`, `orgUnitType`, `parentId`와 실제 계층을 확인하고, 의미를 구분할 수 없는 동일 타입 계층은 별도의 명시적 회사 기준을 사용한다. 조회 기준이라는 이유만으로 그 노드를 회사 후보에서 제외하지 않는다. 3. 모든 허용 subtree의 membership을 사용자 UUID로 모으고, 현재 인증 테넌트 문맥과 `isPrimary`로 소속을 선택한다. `/user/me`의 `joinedTenants`를 도입할 경우 기존 OIDC 식별자를 유지하면서 보충 정보로 결합한다. 4. 신규 저장에서 회사 `총괄기획실&기술개발센터`, 부서 `IS3`가 각각 올바른 필드로 나가는지 확인한다. 회사 기준 조회·분류 루트 조회·부서 기준 조회·겸직을 구분해 검증한다. 5. 기존 글 중 잘못 저장된 항목의 정정 범위를 별도로 확인한다. 현재 조회자의 조직을 다른 작성자의 글에 덮어쓰거나 과거 전보 이력을 현재 소속으로 일괄 대체하지 않는다. -초기 확인에서는 조사만 진행했고, 추가 로그 확인 후 서비스 코드에 안전한 오류 코드 진단을 보완했다. 조직 소속과 기존 글 데이터는 변경하지 않았다. 실제 사용자 응답은 사용자가 제공한 `/auth/session` 결과만 확인했고, 사용자 token이나 Integration Credential로 직접 원본 조직 API를 조회하지 않았다. +초기 확인에서는 조사만 진행했고, 후속 확인에서 안전한 오류 코드 진단과 Workers 요청 옵션 수정을 진행했다. 조직 소속과 기존 글 데이터는 변경하지 않았다. 실제 사용자 응답은 사용자가 제공한 `/auth/session` 결과만 확인했고, 사용자 token이나 Integration Credential로 직접 원본 조직 API를 조회하지 않았다. ## 확인 자료 diff --git a/src/org-context.js b/src/org-context.js index 6eca86d..8f40e91 100644 --- a/src/org-context.js +++ b/src/org-context.js @@ -160,7 +160,8 @@ export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now, const response = await fetchImpl(url.toString(), { headers: { accept: 'application/json', 'X-Baron-Key-ID': config.keyId, 'X-Baron-Key-Secret': config.keySecret }, signal: controller.signal, - redirect: 'error' + // Workers supports manual/follow only. Reject 3xx below without forwarding credentials. + redirect: 'manual' }); if (!response.ok) throw new Error('org_context_http_' + response.status); const payload = await response.json(); diff --git a/tests/org-context.test.js b/tests/org-context.test.js index 199e5dd..4dc425a 100644 --- a/tests/org-context.test.js +++ b/tests/org-context.test.js @@ -132,7 +132,7 @@ test('fetches each root with identifiers and caches only complete responses', as assert.equal(parsed.searchParams.get('exposure'), 'identifiers'); assert.equal(init.headers['X-Baron-Key-ID'], config.ORG_CONTEXT_KEY_ID); assert.equal(init.headers['X-Baron-Key-Secret'], config.ORG_CONTEXT_KEY_SECRET); - assert.equal(init.redirect, 'error'); + assert.equal(init.redirect, 'manual'); return response(payload(parsed.searchParams.get('tenantId'))); }; // Use disjoint roots with no repeated tenant parent to emulate two independent subtrees. diff --git a/tests/runtime/organization-test.mjs b/tests/runtime/organization-test.mjs new file mode 100644 index 0000000..d2c05de --- /dev/null +++ b/tests/runtime/organization-test.mjs @@ -0,0 +1,50 @@ +import worker from './index.js'; + +const ROOT = '038326b6-954a-48a7-a85f-efd83f62b82a'; +const USER = '01900000-0000-7000-8000-000000000001'; +const DEPT = 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916'; +const encoder = new TextEncoder(); +const encode = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +function equal(actual, expected, field) { + if (actual !== expected) throw new Error(`${field}: expected ${expected}, received ${actual}`); +} + +export default { + async test() { + const user = { + userUuid: USER, ssoSubject: USER, requesterId: USER, + tenantId: DEPT, requesterTenantId: DEPT, + company: 'IS3', companySource: 'sso', department: '' + }; + const env = { + SESSION_COOKIE_NAME: 'fixture_session', SESSION_SECRET: 'fixture-session-secret', + ORG_CONTEXT_ENABLED: 'true', ORG_CONTEXT_API_URL: 'https://organization.example.test/org-context', + ORG_CONTEXT_KEY_ID: 'fixture-key-id', ORG_CONTEXT_KEY_SECRET: 'fixture-key-secret', + ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT]) + }; + const payload = encode(encoder.encode(JSON.stringify({ user, expiresAt: Date.now() + 60000 }))); + const key = await crypto.subtle.importKey('raw', encoder.encode(env.SESSION_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const signature = encode(new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)))); + const request = () => new Request('https://qa.example.test/auth/session', { + headers: { cookie: `${env.SESSION_COOKIE_NAME}=${payload}.${signature}` } + }); + + const response = await worker.fetch(request(), env); + const session = await response.json(); + equal(response.status, 200, 'session HTTP status'); + equal(session.authenticated, true, 'authenticated'); + equal(session.user.orgContextStatus, 'resolved', 'organization lookup'); + equal(session.user.company, '총괄기획실&기술개발센터', 'company'); + equal(session.user.department, 'IS3', 'department'); + equal(session.user.requesterId, USER, 'canonical identity'); + equal(session.user.orgContextError, undefined, 'error after success'); + + const rejected = await worker.fetch(request(), { + ...env, ORG_CONTEXT_API_URL: 'https://organization.example.test/redirect' + }); + const rejectedSession = await rejected.json(); + equal(rejectedSession.authenticated, true, 'session after upstream redirect'); + equal(rejectedSession.user.orgContextError, 'org_context_http_302', 'redirect rejection'); + } +}; diff --git a/tests/runtime/organization-upstream.mjs b/tests/runtime/organization-upstream.mjs new file mode 100644 index 0000000..ef66c1d --- /dev/null +++ b/tests/runtime/organization-upstream.mjs @@ -0,0 +1,26 @@ +const ROOT = '038326b6-954a-48a7-a85f-efd83f62b82a'; +const COMPANY = '9951229e-eea5-4376-96f4-78989bc3a506'; +const DEPT = 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916'; +const USER = '01900000-0000-7000-8000-000000000001'; + +export default { + fetch(request) { + const url = new URL(request.url); + if (url.origin !== 'https://organization.example.test') throw new Error('Redirect must never be followed'); + if (request.headers.get('X-Baron-Key-ID') !== 'fixture-key-id' || request.headers.get('X-Baron-Key-Secret') !== 'fixture-key-secret') { + throw new Error('Expected organization integration credentials'); + } + if (url.pathname === '/redirect') return Response.redirect('https://must-not-follow.example.test/', 302); + if (url.searchParams.get('tenantId') !== ROOT || url.searchParams.get('exposure') !== 'identifiers') { + throw new Error('Unexpected organization query'); + } + return Response.json({ + schemaVersion: 'baron.org-context.v2', scope: { tenantId: ROOT }, + tree: { id: ROOT, name: '한맥가족', type: 'COMPANY_GROUP', children: [ + { id: COMPANY, name: '총괄기획실&기술개발센터', type: 'COMPANY', children: [ + { id: DEPT, name: 'IS3', type: 'COMPANY', members: [{ id: USER, isPrimary: true }] } + ] } + ] } + }); + } +}; diff --git a/tests/runtime/organization.capnp b/tests/runtime/organization.capnp new file mode 100644 index 0000000..0f45572 --- /dev/null +++ b/tests/runtime/organization.capnp @@ -0,0 +1,19 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "organization", worker = ( + compatibilityDate = "2026-09-18", + globalOutbound = "organization-upstream", + modules = [ + (name = "test.mjs", esModule = embed "organization-test.mjs"), + (name = "index.js", esModule = embed "../../src/index.js"), + (name = "org-context.js", esModule = embed "../../src/org-context.js") + ] + )), + (name = "organization-upstream", worker = ( + compatibilityDate = "2026-09-18", + modules = [(name = "upstream.mjs", esModule = embed "organization-upstream.mjs")] + )) + ] +); diff --git a/tests/worker-runtime.test.js b/tests/worker-runtime.test.js new file mode 100644 index 0000000..4904672 --- /dev/null +++ b/tests/worker-runtime.test.js @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const require = createRequire(import.meta.url); +const workerd = require('workerd').default; + +test('native Workers runtime resolves session organization and rejects upstream redirects', () => { + const result = spawnSync(workerd, ['test', fileURLToPath(new URL('./runtime/organization.capnp', import.meta.url)), 'organization'], { + encoding: 'utf8', timeout: 20000 + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, result.stdout + result.stderr); +});