fix: use supported redirect mode for Workers organization requests
Deploy EG-BIM QA Gateway / deploy (push) Successful in 1m24s

This commit is contained in:
root committed 2026-10-30 12:49:15 +09:00
1 parent c87e0aa0c1
commit ccf48c8224
8 files changed
+126 -4

No files matched your search

+2
View File
@@ -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
+10 -2
View File
@@ -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를 조회하지 않았다.
## 확인 자료
+2 -1
View File
@@ -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();
+1 -1
View File
@@ -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.
+50
View File
@@ -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');
}
};
+26
View File
@@ -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 }] }
] }
] }
});
}
};
+19
View File
@@ -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")]
))
]
);
+16
View File
@@ -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);
});