(null);
const returnTo = searchParams.get("returnTo") || "/clients";
const shouldAutoLogin = searchParams.get("auto") === "1";
+ const authErrorMessage = useMemo(() => {
+ const message = auth.error?.message;
+ if (!message) {
+ return null;
+ }
+ if (message.includes("Crypto.subtle")) {
+ return insecurePkceMessage;
+ }
+ return message;
+ }, [auth.error?.message]);
+ const visibleLoginError = loginError || authErrorMessage;
useEffect(() => {
if (auth.isAuthenticated) {
@@ -33,6 +49,10 @@ function LoginPage() {
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
+ if (!canStartBrowserPkceLogin()) {
+ setLoginError(insecurePkceMessage);
+ return;
+ }
autoStartedRef.current = true;
void auth.signinRedirect({
@@ -44,6 +64,11 @@ function LoginPage() {
const handleSSOLogin = async () => {
try {
+ setLoginError(null);
+ if (!canStartBrowserPkceLogin()) {
+ setLoginError(insecurePkceMessage);
+ return;
+ }
await auth.signinRedirect({
state: {
returnTo: "/clients",
@@ -99,6 +124,16 @@ function LoginPage() {
)}
+ {visibleLoginError ? (
+
+
+
{visibleLoginError}
+
+ ) : null}
+
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
diff --git a/devfront/src/lib/authConfig.test.ts b/devfront/src/lib/authConfig.test.ts
index 974e5767..e5abdb1a 100644
--- a/devfront/src/lib/authConfig.test.ts
+++ b/devfront/src/lib/authConfig.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
DEVFRONT_AUTH_CALLBACK_PATH,
buildDevFrontAuthRedirectUris,
+ canStartBrowserPkceLogin,
resolveDevFrontPublicOrigin,
} from "./authConfig";
@@ -26,4 +27,69 @@ describe("devfront auth config", () => {
it("keeps the callback path aligned with the registered redirect path", () => {
expect(DEVFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
});
+
+ it("blocks browser PKCE login in an insecure context", () => {
+ expect(
+ canStartBrowserPkceLogin({
+ isSecureContext: false,
+ origin: "http://172.16.9.189:5174",
+ cryptoSubtleAvailable: false,
+ }),
+ ).toBe(false);
+ expect(
+ canStartBrowserPkceLogin({
+ isSecureContext: true,
+ origin: "http://172.16.9.189:5174",
+ cryptoSubtleAvailable: true,
+ }),
+ ).toBe(true);
+ });
+
+ it("allows host.docker.internal when WebCrypto is enabled by the browser", () => {
+ expect(
+ canStartBrowserPkceLogin({
+ isSecureContext: false,
+ origin: "http://host.docker.internal:5000",
+ cryptoSubtleAvailable: true,
+ }),
+ ).toBe(true);
+ expect(
+ canStartBrowserPkceLogin({
+ isSecureContext: false,
+ origin: "http://host.docker.internal:5000",
+ cryptoSubtleAvailable: false,
+ }),
+ ).toBe(false);
+ });
+
+ it("allows private network IPv4 origins when WebCrypto is enabled by the browser", () => {
+ for (const origin of [
+ "http://10.0.0.10:5000",
+ "http://172.16.9.189:5000",
+ "http://172.31.255.255:5000",
+ "http://192.168.0.20:5000",
+ ]) {
+ expect(
+ canStartBrowserPkceLogin({
+ isSecureContext: false,
+ origin,
+ cryptoSubtleAvailable: true,
+ }),
+ ).toBe(true);
+ }
+
+ for (const origin of [
+ "http://172.15.255.255:5000",
+ "http://172.32.0.1:5000",
+ "http://8.8.8.8:5000",
+ ]) {
+ expect(
+ canStartBrowserPkceLogin({
+ isSecureContext: false,
+ origin,
+ cryptoSubtleAvailable: true,
+ }),
+ ).toBe(false);
+ }
+ });
});
diff --git a/devfront/src/lib/authConfig.ts b/devfront/src/lib/authConfig.ts
index 1887431e..5fdc9b61 100644
--- a/devfront/src/lib/authConfig.ts
+++ b/devfront/src/lib/authConfig.ts
@@ -31,3 +31,54 @@ export function buildDevFrontAuthRedirectUris(
popupRedirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`,
};
}
+
+export type BrowserPkceLoginCheck = {
+ isSecureContext?: boolean;
+ origin?: string;
+ cryptoSubtleAvailable?: boolean;
+};
+
+const devTrustedPkceHosts = new Set([
+ "localhost",
+ "127.0.0.1",
+ "::1",
+ "host.docker.internal",
+]);
+
+function isPrivateIPv4(hostname: string) {
+ const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
+ if (
+ parts.length !== 4 ||
+ parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)
+ ) {
+ return false;
+ }
+
+ const [first, second] = parts;
+ return (
+ first === 10 ||
+ (first === 172 && second >= 16 && second <= 31) ||
+ (first === 192 && second === 168)
+ );
+}
+
+function isDevTrustedPkceOrigin(origin: string) {
+ try {
+ const hostname = new URL(origin).hostname;
+ return devTrustedPkceHosts.has(hostname) || isPrivateIPv4(hostname);
+ } catch {
+ return false;
+ }
+}
+
+export function canStartBrowserPkceLogin({
+ isSecureContext = window.isSecureContext,
+ origin = window.location.origin,
+ cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
+}: BrowserPkceLoginCheck = {}) {
+ if (isSecureContext) {
+ return true;
+ }
+
+ return isDevTrustedPkceOrigin(origin) && cryptoSubtleAvailable;
+}
diff --git a/devfront/tests/devfront-login.spec.ts b/devfront/tests/devfront-login.spec.ts
new file mode 100644
index 00000000..6cb081fa
--- /dev/null
+++ b/devfront/tests/devfront-login.spec.ts
@@ -0,0 +1,39 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("DevFront login", () => {
+ test("shows a clear error instead of silently failing when PKCE cannot run", async ({
+ page,
+ }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(window, "isSecureContext", {
+ configurable: true,
+ value: false,
+ });
+ });
+
+ let authorizeRequested = false;
+ await page.route("**/oidc/.well-known/openid-configuration", async (route) => {
+ await route.fulfill({
+ json: {
+ issuer: "http://localhost:5000/oidc",
+ authorization_endpoint: "http://localhost:5000/oidc/oauth2/auth",
+ token_endpoint: "http://localhost:5000/oidc/oauth2/token",
+ jwks_uri: "http://localhost:5000/oidc/.well-known/jwks.json",
+ },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ });
+ await page.route("**/oidc/oauth2/auth**", async (route) => {
+ authorizeRequested = true;
+ await route.fulfill({ status: 500, body: "unexpected authorize request" });
+ });
+
+ await page.goto("/login");
+ await page.getByRole("button", { name: "SSO 계정으로 로그인" }).click();
+
+ await expect(page.getByRole("alert")).toContainText(
+ "HTTPS 또는 localhost",
+ );
+ expect(authorizeRequested).toBe(false);
+ });
+});
diff --git a/docs/custom-field-jsonb-index-policy.md b/docs/custom-field-jsonb-index-policy.md
index bb45d84a..32610ed4 100644
--- a/docs/custom-field-jsonb-index-policy.md
+++ b/docs/custom-field-jsonb-index-policy.md
@@ -99,7 +99,7 @@ Tenant/RP 단위로 묶어서 전달한다.
{
"tenant_profiles": [
{
- "tenant_id": "tenant-uuid",
+ "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"tenant_slug": "hanmac-family",
"fields": {
"employeeNo": "E1001"
@@ -119,3 +119,123 @@ Tenant/RP 단위로 묶어서 전달한다.
- `claimEnabled=true` field만 RP claim 후보로 포함한다.
- 긴 JSON 값은 기본적으로 token claim보다 userinfo/profile API 응답에 싣는 방향을 우선한다.
+
+## 한맥가족 Tenant Claim Projection
+
+한맥가족(`hanmac-family`) subtree의 tenant claim은 기본 claim과 상세 claim으로 나눈다. 기본 claim은 대표소속 tenant UUID인 `tenant_id`와 전체 소속 목록인 `joined_tenants`이며, RP가 `tenant` claim을 요청하면 tenant별 map 안에 조직 소속 정보를 묶어서 전달한다. 이 정보는 RP가 tenant context를 표시하거나 조직별 기본값을 선택하기 위한 projection이며, 관계형 데이터의 SoT는 PostgreSQL Business DB와 사용자 metadata이다.
+
+기본 claim 예시는 다음과 같다.
+
+```json
+{
+ "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "joined_tenants": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
+ ]
+}
+```
+
+Issue #775 구현 결과 기준으로 RP가 `tenant` claim을 요청했을 때 받는 대표 예시는 다음과 같다.
+
+```json
+{
+ "email": "hanmac-user@example.com",
+ "name": "한맥 사용자",
+ "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "joined_tenants": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
+ ],
+ "lead_tenants": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
+ ],
+ "tenants": {
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210": {
+ "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "slug": "tech-planning",
+ "name": "기술기획팀",
+ "type": "USER_GROUP",
+ "lead": true,
+ "representative": true,
+ "isPrimary": true,
+ "grade": "책임",
+ "jobTitle": "기술기획",
+ "position": "팀장",
+ "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "ancestors": [
+ {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "slug": "hanmac",
+ "name": "한맥기술",
+ "type": "COMPANY",
+ "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
+ },
+ {
+ "id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "slug": "hanmac-family",
+ "name": "한맥가족",
+ "type": "COMPANY_GROUP",
+ "parentTenantId": null
+ }
+ ]
+ },
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661": {
+ "id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661",
+ "slug": "quality",
+ "name": "품질관리팀",
+ "type": "USER_GROUP",
+ "lead": false,
+ "representative": false,
+ "isPrimary": false,
+ "grade": "선임",
+ "jobTitle": "품질관리",
+ "position": "파트원",
+ "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "ancestors": [
+ {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "slug": "hanmac",
+ "name": "한맥기술",
+ "type": "COMPANY",
+ "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
+ },
+ {
+ "id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "slug": "hanmac-family",
+ "name": "한맥가족",
+ "type": "COMPANY_GROUP",
+ "parentTenantId": null
+ }
+ ]
+ }
+ },
+ "profile": {
+ "emails": [
+ "hanmac-user@example.com"
+ ],
+ "names": {
+ "name": "한맥 사용자"
+ }
+ }
+}
+```
+
+- 예시의 `id` 값은 UUID 형식의 샘플이며, `slug`와 다르다.
+- `tenant_id`와 `joined_tenants`는 기본 claim이다.
+- `tenant_id`는 사용자의 대표소속 tenant UUID이다. RP/client context tenant가 없더라도 공백으로 내려가지 않는다.
+- `joined_tenants`는 사용자가 claim 상에서 소속된 모든 tenant UUID 목록이다.
+- `lead_tenants`는 `tenant` claim 요청 시 포함되며, `lead=true`인 tenant UUID 목록이다.
+- `lead`는 tenant lead/조직장 역할을 나타낸다. 입력 metadata에서는 `lead`, `isLead`, `isOwner`, `isManager`를 허용한다.
+- `representative`와 `isPrimary`는 대표조직 여부를 나타낸다. 입력 metadata에서는 `representative`, `isPrimary`, `primary`를 허용한다.
+- `grade`, `jobTitle`, `position`은 각각 직급, 직무, 직책이다.
+- `parentTenantId`는 현재 tenant의 직속 parent tenant UUID이다. 최상위 root는 `null`이다.
+- `ancestors`는 직속 상위 tenant부터 `hanmac-family` root까지의 parent chain이다.
+- 기본 tenant와 각 ancestor 객체는 `parentTenantId`를 포함하므로, parent edge를 별도 추론 없이 그릴 수 있다.
+- 대표소속 결정은 명시적 `tenant_id`, `additionalAppointments`의 `representative/isPrimary/primary=true`, 가장 먼저 등록된 소속 순서로 적용한다.
+- 생성 시 소속 tenant가 하나도 없으면 PERSONAL tenant를 자동 생성하고, 해당 tenant를 `tenant_id`와 `joined_tenants`에 포함한다.
+- RP/client tenant context는 대표소속 `tenant_id`를 덮어쓰지 않는다.
+- tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 projection 항목을 만들 수 있다.
+- 멀티 소속이면 기본 claim의 `joined_tenants`에 모든 소속 tenant를 넣는다. `tenant` claim 요청 시에는 `tenants`에도 모든 소속 tenant 상세를 넣고, `lead_tenants`에는 lead tenant만 넣는다.
+- token 크기 보호를 위해 전체 조직도나 긴 custom JSON은 claim에 싣지 않고 profile/userinfo API 또는 backend API 응답으로 분리한다.
+- RP는 `joined_tenants`로 전체 소속을 읽고, `lead_tenants`로 lead tenant를 빠르게 식별한다. 상세 표시는 `tenants[tenant_id]` 또는 `tenants[joined_tenants[n]]`와 `ancestors`를 조합한다.
diff --git a/docs/integrations-org-context-json-api.md b/docs/integrations-org-context-json-api.md
new file mode 100644
index 00000000..e1680607
--- /dev/null
+++ b/docs/integrations-org-context-json-api.md
@@ -0,0 +1,138 @@
+# 조직 Context JSON API 계약
+
+## 목적
+
+외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성은 Baron SSO backend의 tenant/user projection을 SSOT로 사용하며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
+
+## 인증
+
+API Key 기반 headless 통신만 허용한다.
+
+```http
+X-Baron-Key-ID:
+X-Baron-Key-Secret:
+```
+
+필요 scope는 다음과 같다.
+
+```text
+org-context:read
+```
+
+API Key 발급/회수는 사람의 관리 행위이므로 super admin 권한으로만 수행한다. 반면 아래 조직 Context 조회 API는 사용자 세션 없이 API Key만으로 동작한다.
+
+## Endpoint
+
+```http
+GET /api/v1/integrations/org-context
+```
+
+### Query
+
+| 이름 | 기본값 | 설명 |
+| --- | --- | --- |
+| `tenantSlug` | `hanmac-family` | 조회할 subtree root tenant slug. 지정하지 않으면 `hanmac-family` 전체 subtree를 반환한다. |
+| `includeUsers` | `true` | `false`이면 `users`와 `directUserIds`를 비운다. |
+
+상위 조직 지정은 slug만 사용한다. UUID 기반 지정은 계약에 포함하지 않는다.
+
+## 예시 요청
+
+```bash
+curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&includeUsers=true' \
+ -H 'X-Baron-Key-ID: 01970f08-91da-7286-bd19-882fb98d1f2c' \
+ -H 'X-Baron-Key-Secret: '
+```
+
+## 예시 응답
+
+```json
+{
+ "schemaVersion": "baron.org-context.v1",
+ "issuedAt": "2026-05-13T12:00:00Z",
+ "scope": {
+ "tenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "tenantSlug": "hanmac"
+ },
+ "tree": {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "type": "COMPANY",
+ "name": "한맥기술",
+ "slug": "hanmac",
+ "parentId": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "status": "active",
+ "description": "",
+ "domains": [],
+ "memberCount": 0,
+ "visibility": "public",
+ "createdAt": "2026-05-13T00:00:00Z",
+ "updatedAt": "2026-05-13T00:00:00Z",
+ "directUserIds": [],
+ "children": [
+ {
+ "id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
+ "type": "USER_GROUP",
+ "name": "플랫폼실",
+ "slug": "platform",
+ "parentId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "status": "active",
+ "description": "",
+ "domains": [],
+ "memberCount": 0,
+ "visibility": "internal",
+ "orgUnitType": "실",
+ "createdAt": "2026-05-13T00:00:00Z",
+ "updatedAt": "2026-05-13T00:00:00Z",
+ "directUserIds": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
+ ],
+ "children": []
+ }
+ ]
+ },
+ "tenants": [
+ {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "type": "COMPANY",
+ "name": "한맥기술",
+ "slug": "hanmac",
+ "parentId": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "status": "active",
+ "description": "",
+ "domains": [],
+ "memberCount": 0,
+ "visibility": "public",
+ "createdAt": "2026-05-13T00:00:00Z",
+ "updatedAt": "2026-05-13T00:00:00Z"
+ }
+ ],
+ "users": [
+ {
+ "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "email": "user@example.com",
+ "name": "홍길동",
+ "role": "user",
+ "status": "active",
+ "tenantIds": [
+ "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a"
+ ],
+ "tenantSlugs": [
+ "platform"
+ ],
+ "grade": "책임",
+ "position": "실장",
+ "jobTitle": "Backend Engineer",
+ "createdAt": "2026-05-13T00:00:00Z",
+ "updatedAt": "2026-05-13T00:00:00Z"
+ }
+ ]
+}
+```
+
+## 정책
+
+- `tenantSlug`가 없으면 `hanmac-family` 전체 subtree를 반환한다.
+- `tenantSlug`는 slug만 허용한다. UUID를 query 계약으로 쓰지 않는다.
+- `visibility=private` tenant와 그 하위 tenant는 제외한다.
+- `visibility=internal` tenant는 M2M 연동용 JSON API에는 포함한다.
+- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다.
diff --git a/docs/rp-iam-integration-guide.md b/docs/rp-iam-integration-guide.md
index 20a4488a..4185717a 100644
--- a/docs/rp-iam-integration-guide.md
+++ b/docs/rp-iam-integration-guide.md
@@ -34,6 +34,229 @@ flowchart TD
G --> H[RP never parses or stores raw kratos_identity_id]
```
+## OIDC Tenant Claim Contract
+
+Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을 식별할 수 있도록 `tenant_id`, `joined_tenants`를 ID token claim에 포함할 수 있습니다. RP가 OIDC scope 또는 client metadata 정책을 통해 `tenant` claim을 요청하면 Baron은 여기에 더해 tenant별 상세 정보를 포함합니다. 이 claim은 RP가 UI 표시, 조직 맥락 선택, RP 내부 권한 매핑을 시작하기 위한 입력이며, 최종 권한 판정은 Baron gateway/Keto check 또는 Baron이 발급한 trusted header를 기준으로 해야 합니다.
+
+기본 claim 구조는 다음과 같습니다.
+
+```json
+{
+ "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "joined_tenants": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
+ ]
+}
+```
+
+`tenant` claim을 요청하면 상세 claim 구조는 다음과 같습니다.
+
+```json
+{
+ "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "joined_tenants": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
+ ],
+ "lead_tenants": ["01970f0a-5c28-74d8-a73a-f6e9e9a7b210"],
+ "tenants": {
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210": {
+ "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "slug": "tech-planning",
+ "name": "기술기획팀",
+ "type": "USER_GROUP",
+ "lead": true,
+ "representative": true,
+ "isPrimary": true,
+ "grade": "책임",
+ "jobTitle": "기술기획",
+ "position": "팀장",
+ "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "ancestors": [
+ {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "slug": "hanmac",
+ "name": "한맥기술",
+ "type": "COMPANY",
+ "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
+ },
+ {
+ "id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "slug": "hanmac-family",
+ "name": "한맥가족",
+ "type": "COMPANY_GROUP",
+ "parentTenantId": null
+ }
+ ]
+ },
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661": {
+ "id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661",
+ "slug": "quality",
+ "name": "품질관리팀",
+ "type": "USER_GROUP",
+ "lead": false,
+ "representative": false,
+ "isPrimary": false,
+ "grade": "선임",
+ "jobTitle": "품질관리",
+ "position": "파트원",
+ "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "ancestors": [
+ {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "slug": "hanmac",
+ "name": "한맥기술",
+ "type": "COMPANY",
+ "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
+ },
+ {
+ "id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "slug": "hanmac-family",
+ "name": "한맥가족",
+ "type": "COMPANY_GROUP",
+ "parentTenantId": null
+ }
+ ]
+ }
+ }
+}
+```
+
+필드 의미는 다음과 같습니다.
+
+- `tenant_id`: 기본 claim입니다. 사용자의 대표소속 tenant UUID입니다. 현재 RP/client context tenant가 없더라도 공백으로 내려가지 않습니다.
+- `joined_tenants`: 기본 claim입니다. 사용자가 claim 상에서 소속된 모든 tenant UUID 목록입니다. `additionalAppointments`의 모든 한맥가족 subtree tenant를 포함합니다.
+- `lead_tenants`: `tenant` claim 요청 시 포함됩니다. `lead=true`로 판정된 tenant id 목록입니다.
+- `tenants`: `tenant` claim 요청 시 포함됩니다. tenant UUID를 key로 하는 tenant별 claim map입니다. 멀티 소속이면 소속 tenant마다 하나씩 포함되며, `slug`는 별도 필드로 내려갑니다.
+- `tenants.*.lead`: 해당 tenant에서 lead 권한 또는 조직장 역할이 있으면 `true`입니다. Baron 입력에서는 `lead`, `isLead`, `isOwner`, `isManager`를 수용할 수 있습니다.
+- `tenants.*.representative`: 대표조직이면 `true`입니다. Baron 입력에서는 `representative`, `isPrimary`, `primary`를 수용할 수 있습니다.
+- `tenants.*.grade`: 직급입니다.
+- `tenants.*.jobTitle`: 직무입니다.
+- `tenants.*.position`: 직책입니다.
+- `tenants.*.parentTenantId`: 현재 tenant의 직속 parent tenant UUID입니다. 최상위 root면 `null`입니다.
+- `tenants.*.ancestors`: 직속 상위 tenant부터 `hanmac-family` root까지의 parent chain입니다.
+
+대표소속 결정 정책은 다음과 같습니다.
+
+- 명시적인 `tenant_id`가 있으면 이를 대표소속으로 사용합니다.
+- 명시적인 대표소속이 없으면 `additionalAppointments`에서 `representative=true`, `isPrimary=true`, `primary=true`인 소속을 사용합니다.
+- 대표 표시가 없으면 가장 먼저 등록된 소속 tenant를 대표소속으로 사용합니다.
+- 생성 시 소속 tenant가 하나도 없으면 Baron이 PERSONAL tenant를 자동 생성하고, 해당 PERSONAL tenant UUID를 `tenant_id`와 `joined_tenants`에 포함합니다.
+- RP/client의 tenant context는 대표소속을 덮어쓰지 않습니다. RP context tenant가 필요한 경우 별도 필드나 RP route context로 다뤄야 합니다.
+
+한맥가족(`hanmac-family`) subtree에 속한 tenant claim은 다음 규칙을 따릅니다.
+
+- `TenantService.GetTenant` 기준 parent chain이 `hanmac-family` root에 도달한 경우에만 한맥가족 확장 필드를 보강합니다.
+- `additionalAppointments`만 존재하고 tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 `tenants` 항목을 생성할 수 있습니다.
+- 사용자가 여러 tenant에 소속되면 기본 claim인 `joined_tenants`에는 모든 소속 tenant가 포함됩니다.
+- `tenant` claim 요청 시 `tenants`에도 모든 소속 tenant의 상세가 포함되고, `lead_tenants`에는 그중 `lead=true`인 tenant만 포함됩니다.
+- 직급/직무/직책과 대표조직/lead 여부는 사용자 소속 metadata(`additionalAppointments`)를 우선합니다.
+- `ancestors`는 직속 상위 tenant부터 root 방향으로 정렬되며, root가 `hanmac-family`일 때까지만 포함합니다.
+- 기본 tenant와 각 ancestor 객체는 `parentTenantId`를 포함합니다. 이 필드로 parent edge를 바로 그릴 수 있습니다.
+
+주의사항:
+
+- Tenant tree, 직급, 직무, 직책은 PostgreSQL Business SoT와 tenant/user metadata를 기준으로 합니다. Kratos traits는 인증 식별 정보 중심으로 유지해야 하며, 관계형 데이터의 영구 SoT로 취급하지 않습니다.
+- Token 크기가 커질 수 있으므로 RP가 긴 조직 전체 정보를 필요로 하면 ID token claim보다 userinfo/profile API 또는 Baron backend API 연동을 우선 검토합니다.
+- RP는 `lead_tenants` 또는 `tenants.*.lead`만으로 보안상 중요한 권한을 단독 판정하지 않습니다. 권한 변경/민감 리소스 접근은 Keto 기반 Baron authorization contract를 함께 사용해야 합니다.
+
+### Issue #775 구현 결과 예시
+
+아래 예시는 #775 구현 후 `tenant_id=01970f0a-5c28-74d8-a73a-f6e9e9a7b210`, 사용자 `additionalAppointments`에 대표 소속 `tech-planning`과 겸직 소속 `quality`가 함께 있고, 두 tenant의 parent chain이 `hanmac -> hanmac-family`로 이어지는 경우 ID token에 내려가는 데이터 형태입니다. 예시의 `id` 값은 UUID 형식의 샘플이며, `slug`와 다릅니다.
+
+```json
+{
+ "email": "hanmac-user@example.com",
+ "name": "한맥 사용자",
+ "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "joined_tenants": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
+ ],
+ "lead_tenants": [
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
+ ],
+ "tenants": {
+ "01970f0a-5c28-74d8-a73a-f6e9e9a7b210": {
+ "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
+ "slug": "tech-planning",
+ "name": "기술기획팀",
+ "type": "USER_GROUP",
+ "lead": true,
+ "representative": true,
+ "isPrimary": true,
+ "grade": "책임",
+ "jobTitle": "기술기획",
+ "position": "팀장",
+ "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "ancestors": [
+ {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "slug": "hanmac",
+ "name": "한맥기술",
+ "type": "COMPANY",
+ "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
+ },
+ {
+ "id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "slug": "hanmac-family",
+ "name": "한맥가족",
+ "type": "COMPANY_GROUP",
+ "parentTenantId": null
+ }
+ ]
+ },
+ "01970f0b-3448-7bb8-bdc7-16b6a1d2e661": {
+ "id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661",
+ "slug": "quality",
+ "name": "품질관리팀",
+ "type": "USER_GROUP",
+ "lead": false,
+ "representative": false,
+ "isPrimary": false,
+ "grade": "선임",
+ "jobTitle": "품질관리",
+ "position": "파트원",
+ "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "ancestors": [
+ {
+ "id": "01970f08-91da-7286-bd19-882fb98d1f2c",
+ "slug": "hanmac",
+ "name": "한맥기술",
+ "type": "COMPANY",
+ "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
+ },
+ {
+ "id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
+ "slug": "hanmac-family",
+ "name": "한맥가족",
+ "type": "COMPANY_GROUP",
+ "parentTenantId": null
+ }
+ ]
+ }
+ },
+ "profile": {
+ "emails": [
+ "hanmac-user@example.com"
+ ],
+ "names": {
+ "name": "한맥 사용자"
+ }
+ }
+}
+```
+
+RP 소비 기준:
+
+- lead tenant를 빠르게 찾을 때는 `lead_tenants`를 우선 사용합니다.
+- 전체 소속 tenant 목록은 `joined_tenants`로 읽고, 각 소속의 상세 조직 맥락은 `tenants[joined_tenants[n]]`에서 읽습니다.
+- 대표소속의 상세 조직 맥락은 `tenants[tenant_id]`에서 읽습니다.
+- 상위 조직 breadcrumb은 `tenants[tenant_id].ancestors`를 직속 상위부터 root 방향으로 표시합니다.
+- 조직 트리 edge는 기본 tenant와 각 ancestor의 `parentTenantId`를 사용해 그립니다.
+- 대표조직 여부는 `representative`를 우선 사용하고, 기존 primary 표현 호환이 필요하면 `isPrimary`를 함께 읽습니다.
+
## obj_id 조회 흐름
`obj_id`는 Keto check의 target object입니다. 명시적으로 전달된 `obj_id`가 있으면 정규화 후 사용하고, 없으면 route context에서 `client_id`, `tenant_id` 순서로 추론합니다. 둘 다 없으면 RP가 명확한 target object를 제공하지 않은 것이므로 요청을 거부해야 합니다.
diff --git a/orgfront/src/features/auth/AuthGuard.test.tsx b/orgfront/src/features/auth/AuthGuard.test.tsx
new file mode 100644
index 00000000..29d1d4ac
--- /dev/null
+++ b/orgfront/src/features/auth/AuthGuard.test.tsx
@@ -0,0 +1,77 @@
+import { act } from "react";
+import { type Root, createRoot } from "react-dom/client";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import AuthGuard from "./AuthGuard";
+
+const authState = {
+ isAuthenticated: false,
+ isLoading: false,
+ activeNavigator: undefined as string | undefined,
+ error: undefined as Error | undefined,
+ removeUser: vi.fn(),
+};
+
+vi.mock("react-oidc-context", () => ({
+ useAuth: () => authState,
+}));
+
+function LocationProbe() {
+ const location = useLocation();
+ return (
+
+ {location.pathname}
+ {location.search}
+
+ );
+}
+
+function renderGuard(initialEntry: string) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
+
+
+ }>
+ picker} />
+
+ } />
+
+ ,
+ );
+ });
+
+ return { container, root };
+}
+
+function cleanupRendered(container: HTMLDivElement, root: Root) {
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+}
+
+describe("OrgFront AuthGuard auto login redirects", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ authState.isAuthenticated = false;
+ authState.isLoading = false;
+ authState.activeNavigator = undefined;
+ authState.error = undefined;
+ window.localStorage.clear();
+ });
+
+ it("redirects protected picker entry to the auto login URL", () => {
+ const rendered = renderGuard(
+ "/embed/picker?mode=single&select=tenant&tenantId=hanmac-family-id",
+ );
+
+ expect(rendered.container.textContent).toBe(
+ "/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26tenantId%3Dhanmac-family-id",
+ );
+ cleanupRendered(rendered.container, rendered.root);
+ });
+});
diff --git a/orgfront/src/features/auth/AuthGuard.tsx b/orgfront/src/features/auth/AuthGuard.tsx
index cd2ceb5f..bf2cfd6a 100644
--- a/orgfront/src/features/auth/AuthGuard.tsx
+++ b/orgfront/src/features/auth/AuthGuard.tsx
@@ -30,7 +30,7 @@ export default function AuthGuard() {
const returnTo = `${location.pathname}${location.search}`;
return (
);
diff --git a/orgfront/vitest.config.ts b/orgfront/vitest.config.ts
index 55dd458b..f2474f57 100644
--- a/orgfront/vitest.config.ts
+++ b/orgfront/vitest.config.ts
@@ -1,8 +1,17 @@
+import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
+ server: {
+ fs: {
+ allow: [
+ fileURLToPath(new URL(".", import.meta.url)),
+ fileURLToPath(new URL("../common", import.meta.url)),
+ ],
+ },
+ },
test: {
globals: true,
environment: "jsdom",