From f60b15a17b0b327cd85bf512e209768789b23a3d Mon Sep 17 00:00:00 2001
From: Lectom
Date: Thu, 11 Jun 2026 11:27:11 +0900
Subject: [PATCH] =?UTF-8?q?custom=20claim=20=ED=83=80=EC=9E=85=EB=B3=B4?=
=?UTF-8?q?=EC=A0=95=20UI.=20=EB=8C=80=ED=91=9C=ED=85=8C=EB=84=8C=ED=8A=B8?=
=?UTF-8?q?=20=EB=85=B8=EC=B6=9C=20=EB=B3=B4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/code_check.yml | 51 +-
adminfront/src/components/ui/use-toast.ts | 5 +
.../tenants/routes/TenantWorksmobilePage.tsx | 18 +-
.../tenants/utils/protectedTenants.test.ts | 25 +-
.../tenants/utils/protectedTenants.ts | 15 +-
.../users/GlobalCustomClaimsPage.test.tsx | 96 +++
.../features/users/GlobalCustomClaimsPage.tsx | 19 +-
.../src/features/users/UserDetailPage.tsx | 2 +-
.../users/UserListPage.render.test.tsx | 71 +-
.../src/features/users/UserListPage.tsx | 148 +++--
adminfront/src/locales/en.toml | 6 +-
adminfront/src/locales/ko.toml | 6 +-
.../tests/tenant_seed_protection.spec.ts | 29 +-
adminfront/tests/users.spec.ts | 88 +++
backend/internal/bootstrap/tenant_seed.go | 69 ++
.../internal/bootstrap/tenant_seed_test.go | 39 +-
backend/internal/handler/auth_handler.go | 152 +++++
.../auth_handler_dynamic_claims_test.go | 145 +++++
backend/internal/handler/dev_handler.go | 67 +-
.../handler/dev_handler_rp_metadata_test.go | 39 +-
backend/internal/handler/dev_handler_test.go | 17 +-
.../internal/handler/rp_claims_e2e_test.go | 328 ++++++++++
.../features/clients/ClientConsentsPage.tsx | 94 +--
.../clients/ClientDetailTabs.test.tsx | 4 +-
.../src/features/clients/ClientDetailTabs.tsx | 1 -
.../clients/ClientGeneralPage.claims.test.tsx | 241 +++++++
.../features/clients/ClientGeneralPage.tsx | 585 +++++++++++------
devfront/src/locales/en.toml | 18 +-
devfront/src/locales/ko.toml | 22 +-
devfront/src/locales/template.toml | 2 +
.../devfront-client-claims-cache.spec.ts | 242 +++++++
devfront/tests/devfront-client-tabs.spec.ts | 6 +-
devfront/tests/devfront-consents.spec.ts | 43 +-
docs/tenant-policy.md | 17 +-
test/code_check_biome_dedup_test.sh | 47 ++
test/rp_claims_live_e2e.mjs | 604 ++++++++++++++++++
.../tests/login-performance-budget.spec.ts | 8 +-
37 files changed, 2952 insertions(+), 417 deletions(-)
create mode 100644 adminfront/src/features/users/GlobalCustomClaimsPage.test.tsx
create mode 100644 backend/internal/handler/rp_claims_e2e_test.go
create mode 100644 test/code_check_biome_dedup_test.sh
create mode 100644 test/rp_claims_live_e2e.mjs
diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml
index 5bacafda..d67cf863 100644
--- a/.gitea/workflows/code_check.yml
+++ b/.gitea/workflows/code_check.yml
@@ -132,6 +132,7 @@ jobs:
global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)'
front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)'
i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)'
+ react_i18n='^(adminfront/src/locales/|devfront/src/locales/|orgfront/src/locales/)'
backend=false
userfront=false
@@ -154,7 +155,7 @@ jobs:
if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi
lint=false
- if [ "$backend" = true ] || [ "$userfront" = true ] || [ "$adminfront" = true ] || [ "$devfront" = true ] || [ "$orgfront" = true ] || matches "$i18n_shared"; then
+ if [ "$backend" = true ] || [ "$userfront" = true ] || matches "$global|$i18n_shared|$react_i18n"; then
lint=true
fi
@@ -213,42 +214,6 @@ jobs:
channel: "stable"
cache: true
- - name: Install adminfront dependencies
- run: |
- cd adminfront
- npx pnpm install -C ../common --no-frozen-lockfile
- npx pnpm install --no-frozen-lockfile
-
- - name: Biome check adminfront (lint + format)
- run: |
- cd adminfront
- npx biome check . --formatter-enabled=false --assist-enabled=false
- npx biome check . --linter-enabled=false --assist-enabled=false
-
- - name: Install devfront dependencies
- run: |
- cd devfront
- npx pnpm install -C ../common --no-frozen-lockfile
- npx pnpm install --no-frozen-lockfile
-
- - name: Biome check devfront (lint + format)
- run: |
- cd devfront
- npx biome check . --formatter-enabled=false --assist-enabled=false
- npx biome check . --linter-enabled=false --assist-enabled=false
-
- - name: Install orgfront dependencies
- run: |
- cd orgfront
- npx pnpm install -C ../common --no-frozen-lockfile
- npx pnpm install --no-frozen-lockfile
-
- - name: Biome check orgfront (lint + format)
- run: |
- cd orgfront
- npx biome check . --formatter-enabled=false --assist-enabled=false
- npx biome check . --linter-enabled=false --assist-enabled=false
-
- name: Lint Go backend
run: |
docker run --rm \
@@ -879,7 +844,7 @@ jobs:
adminfront-vitest-coverage:
needs:
- changes
- - lint
+ - biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest
steps:
@@ -1010,7 +975,7 @@ jobs:
devfront-vitest-coverage:
needs:
- changes
- - lint
+ - biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest
steps:
@@ -1141,7 +1106,7 @@ jobs:
orgfront-vitest-coverage:
needs:
- changes
- - lint
+ - biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest
steps:
@@ -1272,7 +1237,7 @@ jobs:
adminfront-tests:
needs:
- changes
- - lint
+ - biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -1367,7 +1332,7 @@ jobs:
devfront-tests:
needs:
- changes
- - lint
+ - biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
runs-on: ubuntu-latest
steps:
@@ -1550,7 +1515,7 @@ jobs:
orgfront-tests:
needs:
- changes
- - lint
+ - biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
runs-on: ubuntu-latest
steps:
diff --git a/adminfront/src/components/ui/use-toast.ts b/adminfront/src/components/ui/use-toast.ts
index 402ed87c..aaea4c9a 100644
--- a/adminfront/src/components/ui/use-toast.ts
+++ b/adminfront/src/components/ui/use-toast.ts
@@ -18,6 +18,11 @@ const notify = () => {
};
const toastBase = (message: string, type: ToastType = "success") => {
+ if (
+ toasts.some((toast) => toast.message === message && toast.type === type)
+ ) {
+ return;
+ }
const id = Math.random().toString(36).substring(2, 9);
toasts = [...toasts, { id, message, type }];
notify();
diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
index 31a5652f..499e0390 100644
--- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
@@ -656,7 +656,7 @@ export function TenantWorksmobilePage() {
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
updateActionLabel="선택 구성원 업데이트 적용"
onCreateSelected={(ids, initialPassword) =>
- createSelectedMutation.mutate({
+ createSelectedMutation.mutateAsync({
resourceKind: "users",
ids,
initialPassword,
@@ -1031,7 +1031,7 @@ function ComparisonTable({
actionLabel: string;
updateActionLabel?: string;
actionDisabled: boolean;
- onCreateSelected: (ids: string[], initialPassword?: string) => void;
+ onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
onUpdateSelected?: (ids: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string;
@@ -1222,13 +1222,17 @@ function ComparisonTable({
onUpdateSelected(selectedUpdateUserIds);
};
- const confirmInitialPassword = () => {
+ const confirmInitialPassword = async () => {
const password = initialPassword.trim();
if (!password) {
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
return;
}
- onCreateSelected(pendingInitialPasswordIds, password);
+ try {
+ await onCreateSelected(pendingInitialPasswordIds, password);
+ } catch {
+ return;
+ }
setInitialPasswordOpen(false);
setInitialPassword("");
setPendingInitialPasswordIds([]);
@@ -1383,7 +1387,11 @@ function ComparisonTable({
>
취소
-
- {rpClaimSchemas.length === 0 && (
-
-
- {t("ui.common.add", "추가")}
-
- )}
- {row.schemaBacked ? (
-
- {row.key}
-
- ) : (
-
- updateMetadataDraftRow(row.id, {
- key: event.target.value,
- })
- }
- className="font-mono text-xs"
- placeholder={t(
- "ui.dev.clients.consents.rp_claims.key_placeholder",
- "claim_key",
- )}
- />
- )}
+
+ {row.key}
+
{row.valueType === "boolean" ? (
- {row.schemaBacked ? (
-
- {row.valueType}
-
- ) : (
- removeMetadataDraftRow(row.id)}
- >
-
-
- )}
+
+ {row.valueType}
+
))
)}
diff --git a/devfront/src/features/clients/ClientDetailTabs.test.tsx b/devfront/src/features/clients/ClientDetailTabs.test.tsx
index 5b8effee..f8927187 100644
--- a/devfront/src/features/clients/ClientDetailTabs.test.tsx
+++ b/devfront/src/features/clients/ClientDetailTabs.test.tsx
@@ -7,7 +7,7 @@ vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string) =>
({
"ui.dev.clients.details.tab.connection": "연동 설정",
- "ui.dev.clients.details.tab.user_claims": "사용자 Claim",
+ "ui.dev.clients.details.tab.consents": "Consents & Claims",
"ui.dev.clients.details.tab.settings": "설정",
"ui.dev.clients.details.tab.relationships": "관계",
})[key] ??
@@ -23,7 +23,7 @@ describe("ClientDetailTabs", () => {
,
);
- expect(html).toContain("사용자 Claim");
+ expect(html).toContain("Consents & Claims");
expect(html).toContain('href="/clients/client-a/consents"');
});
});
diff --git a/devfront/src/features/clients/ClientDetailTabs.tsx b/devfront/src/features/clients/ClientDetailTabs.tsx
index ebb174d3..c9a6b189 100644
--- a/devfront/src/features/clients/ClientDetailTabs.tsx
+++ b/devfront/src/features/clients/ClientDetailTabs.tsx
@@ -18,7 +18,6 @@ const tabOrder: Array<{
{
key: "consents",
href: (clientId) => `/clients/${clientId}/consents`,
- labelKey: "ui.dev.clients.details.tab.user_claims",
},
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
{
diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
index a44d7cb9..87372149 100644
--- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
@@ -126,6 +126,26 @@ async function setInputValue(input: HTMLInputElement, value: string) {
await flush();
}
+async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
+ const descriptor = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ "value",
+ );
+ descriptor?.set?.call(textarea, value);
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ await flush();
+}
+
+async function setSelectValue(select: HTMLSelectElement, value: string) {
+ const descriptor = Object.getOwnPropertyDescriptor(
+ HTMLSelectElement.prototype,
+ "value",
+ );
+ descriptor?.set?.call(select, value);
+ select.dispatchEvent(new Event("change", { bubbles: true }));
+ await flush();
+}
+
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -229,4 +249,225 @@ describe("ClientGeneralPage RP claims", () => {
},
]);
});
+
+ it("forces user read permission on when user write permission is enabled for RP claims", async () => {
+ const { container } = await renderPage();
+
+ const switches = Array.from(
+ container.querySelectorAll('[role="switch"]'),
+ );
+ const readSwitch = switches.find((button) =>
+ /Read|읽기/.test(button.getAttribute("aria-label") ?? ""),
+ );
+ const writeSwitch = switches.find((button) =>
+ /Write|쓰기/.test(button.getAttribute("aria-label") ?? ""),
+ );
+
+ expect(readSwitch).toBeDefined();
+ expect(writeSwitch).toBeDefined();
+ expect(readSwitch?.getAttribute("aria-checked")).toBe("false");
+ expect(writeSwitch?.getAttribute("aria-checked")).toBe("false");
+
+ await act(async () => {
+ writeSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+
+ expect(readSwitch?.getAttribute("aria-checked")).toBe("true");
+ expect(writeSwitch?.getAttribute("aria-checked")).toBe("true");
+
+ const saveButton = Array.from(container.querySelectorAll("button")).find(
+ (button) =>
+ button.textContent?.includes("저장") ||
+ button.textContent?.includes("Save"),
+ );
+ expect(saveButton).toBeDefined();
+
+ await act(async () => {
+ saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+
+ expect(updateClientMock).toHaveBeenCalledWith(
+ "client-claims",
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ id_token_claims: [
+ expect.objectContaining({
+ readPermission: "user_and_admin",
+ writePermission: "user_and_admin",
+ }),
+ ],
+ }),
+ }),
+ );
+ });
+
+ it("keeps nullable and default value as separate RP claim settings", async () => {
+ const { container } = await renderPage();
+
+ expect(container.textContent).toContain("Nullable");
+ expect(container.textContent).toContain("Default Value");
+ expect(container.textContent).not.toContain("Nullable/default");
+ expect(container.textContent).toContain(
+ "RP 전용 확장 claim을 구분해서 관리합니다",
+ );
+ });
+
+ it("blocks saving a number RP claim default value that is not numeric", async () => {
+ const { container } = await renderPage();
+
+ const valueTypeSelect = container.querySelector(
+ 'select[aria-label="Claim 값 타입"]',
+ );
+ expect(valueTypeSelect).not.toBeNull();
+ await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
+
+ const saveButton = Array.from(container.querySelectorAll("button")).find(
+ (button) =>
+ button.textContent?.includes("저장") ||
+ button.textContent?.includes("Save"),
+ );
+ expect(saveButton).toBeDefined();
+
+ await act(async () => {
+ saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+
+ expect(updateClientMock).not.toHaveBeenCalled();
+ });
+
+ it("blocks saving a number RP claim default value that is not an integer", async () => {
+ const { container } = await renderPage();
+
+ const valueTypeSelect = container.querySelector(
+ 'select[aria-label="Claim 값 타입"]',
+ );
+ expect(valueTypeSelect).not.toBeNull();
+ await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
+
+ const defaultValueInput = container.querySelector(
+ 'input[placeholder="Enter the default value"]',
+ );
+ expect(defaultValueInput).not.toBeNull();
+ await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
+
+ expect(container.textContent).toContain(
+ "Claim 기본값이 타입과 맞지 않습니다",
+ );
+
+ const saveButton = Array.from(container.querySelectorAll("button")).find(
+ (button) =>
+ button.textContent?.includes("저장") ||
+ button.textContent?.includes("Save"),
+ );
+ expect(saveButton).toBeDefined();
+
+ await act(async () => {
+ saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+
+ expect(updateClientMock).not.toHaveBeenCalled();
+ });
+
+ it("saves a float RP claim default value", async () => {
+ const { container } = await renderPage();
+
+ const valueTypeSelect = container.querySelector(
+ 'select[aria-label="Claim 값 타입"]',
+ );
+ expect(valueTypeSelect).not.toBeNull();
+ expect(
+ valueTypeSelect?.querySelector('option[value="float"]'),
+ ).not.toBeNull();
+ await setSelectValue(valueTypeSelect as HTMLSelectElement, "float");
+
+ const defaultValueInput = container.querySelector(
+ 'input[placeholder="Enter the default value"]',
+ );
+ expect(defaultValueInput).not.toBeNull();
+ expect(defaultValueInput?.getAttribute("inputmode")).toBe("decimal");
+ await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
+
+ const saveButton = Array.from(container.querySelectorAll("button")).find(
+ (button) =>
+ button.textContent?.includes("저장") ||
+ button.textContent?.includes("Save"),
+ );
+ expect(saveButton).toBeDefined();
+
+ await act(async () => {
+ saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+
+ expect(updateClientMock).toHaveBeenCalledWith(
+ "client-claims",
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ id_token_claims: [
+ expect.objectContaining({
+ value: "3.14",
+ valueType: "float",
+ }),
+ ],
+ }),
+ }),
+ );
+ });
+
+ it("renders constrained default value controls for boolean and date RP claims", async () => {
+ const { container } = await renderPage();
+
+ const valueTypeSelect = container.querySelector(
+ 'select[aria-label="Claim 값 타입"]',
+ );
+ expect(valueTypeSelect).not.toBeNull();
+
+ await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean");
+ const booleanDefaultSelect = Array.from(
+ container.querySelectorAll("select"),
+ ).find((select) =>
+ Array.from(select.options).some((option) => option.value === "false"),
+ );
+ expect(booleanDefaultSelect).toBeDefined();
+
+ await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
+ expect(container.querySelector('input[type="date"]')).not.toBeNull();
+ });
+
+ it("blocks saving an object RP claim default value that is not a JSON object", async () => {
+ const { container } = await renderPage();
+
+ const valueTypeSelect = container.querySelector(
+ 'select[aria-label="Claim 값 타입"]',
+ );
+ expect(valueTypeSelect).not.toBeNull();
+ await setSelectValue(valueTypeSelect as HTMLSelectElement, "object");
+
+ const defaultValueInput = container.querySelector(
+ 'textarea[placeholder="{\\"key\\": \\"value\\"}"]',
+ );
+ expect(defaultValueInput).not.toBeNull();
+ await setTextareaValue(
+ defaultValueInput as HTMLTextAreaElement,
+ "not-json",
+ );
+
+ const saveButton = Array.from(container.querySelectorAll("button")).find(
+ (button) =>
+ button.textContent?.includes("저장") ||
+ button.textContent?.includes("Save"),
+ );
+ expect(saveButton).toBeDefined();
+
+ await act(async () => {
+ saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+
+ expect(updateClientMock).not.toHaveBeenCalled();
+ });
});
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index 994951e0..bd76d937 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -71,6 +71,7 @@ type ClaimNamespace = "rp_claims";
type ClaimValueType =
| "text"
| "number"
+ | "float"
| "boolean"
| "array"
| "object"
@@ -149,6 +150,7 @@ function isClaimValueType(value: string): value is ClaimValueType {
return (
value === "text" ||
value === "number" ||
+ value === "float" ||
value === "boolean" ||
value === "array" ||
value === "object" ||
@@ -176,6 +178,18 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
};
}
+function normalizeIdTokenClaimPermissions(
+ claim: IdTokenClaimItem,
+): IdTokenClaimItem {
+ if (claim.writePermission !== "user_and_admin") {
+ return claim;
+ }
+ return {
+ ...claim,
+ readPermission: "user_and_admin",
+ };
+}
+
function readIdTokenClaimsMetadata(
metadata: Record,
): IdTokenClaimItem[] {
@@ -213,7 +227,7 @@ function readIdTokenClaimsMetadata(
? record.valueType
: "text";
- return {
+ return normalizeIdTokenClaimPermissions({
id: `claim-${index + 1}`,
namespace: namespaceValue,
key: keyValue,
@@ -226,7 +240,7 @@ function readIdTokenClaimsMetadata(
writePermission: isCustomClaimPermission(record.writePermission)
? record.writePermission
: "admin_only",
- };
+ });
})
.filter((item): item is IdTokenClaimItem => item !== null);
}
@@ -240,7 +254,7 @@ function normalizeClaimPreviewValue(
if (nullable && trimmed === "") {
return null;
}
- if (valueType === "number") {
+ if (valueType === "number" || valueType === "float") {
if (trimmed === "") return "";
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : trimmed;
@@ -279,6 +293,137 @@ function normalizeClaimPreviewValue(
return trimmed;
}
+function isJsonObjectValue(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function isIntegerClaimDefaultValue(value: string) {
+ return /^-?\d+$/.test(value);
+}
+
+function isFloatClaimDefaultValue(value: string) {
+ return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value);
+}
+
+function isValidDateInputValue(value: string) {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
+ const date = new Date(`${value}T00:00:00Z`);
+ if (Number.isNaN(date.getTime())) return false;
+ return date.toISOString().slice(0, 10) === value;
+}
+
+function isValidDateTimeInputValue(value: string) {
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(value)) {
+ return false;
+ }
+ const date = new Date(value);
+ return !Number.isNaN(date.getTime());
+}
+
+function claimDefaultValueValidationError(claim: IdTokenClaimItem) {
+ const value = claim.value.trim();
+ if (value === "") {
+ return null;
+ }
+
+ switch (claim.valueType) {
+ case "number":
+ return isIntegerClaimDefaultValue(value)
+ ? null
+ : t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ case "float":
+ return isFloatClaimDefaultValue(value)
+ ? null
+ : t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ case "boolean":
+ return value === "true" || value === "false"
+ ? null
+ : t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ case "array": {
+ try {
+ return Array.isArray(JSON.parse(value))
+ ? null
+ : t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ } catch {
+ return t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ }
+ }
+ case "object": {
+ try {
+ return isJsonObjectValue(JSON.parse(value))
+ ? null
+ : t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ } catch {
+ return t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ }
+ }
+ case "date":
+ return isValidDateInputValue(value)
+ ? null
+ : t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ case "datetime":
+ return isValidDateTimeInputValue(value)
+ ? null
+ : t(
+ "msg.dev.clients.general.id_token_claims.invalid_default_value",
+ "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
+ { key: claim.key || "-", valueType: claim.valueType },
+ );
+ default:
+ return null;
+ }
+}
+
+function claimDefaultInputType(valueType: ClaimValueType) {
+ if (valueType === "date") return "date";
+ if (valueType === "datetime") return "datetime-local";
+ return "text";
+}
+
+function claimDefaultInputMode(valueType: ClaimValueType) {
+ if (valueType === "number") return "numeric";
+ if (valueType === "float") return "decimal";
+ return undefined;
+}
+
+function claimDefaultInputPattern(valueType: ClaimValueType) {
+ if (valueType === "number") return "-?[0-9]*";
+ if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
+ return undefined;
+}
+
function buildIdTokenClaimsPreview(
items: IdTokenClaimItem[],
): Record {
@@ -777,10 +922,10 @@ function ClientGeneralPage() {
if (claim.id !== id) {
return claim;
}
- return {
+ return normalizeIdTokenClaimPermissions({
...claim,
[field]: permission,
- };
+ });
}),
);
};
@@ -840,11 +985,13 @@ function ClientGeneralPage() {
"허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
);
- const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
- ...claim,
- key: claim.key.trim(),
- value: claim.value.trim(),
- }));
+ const normalizedIdTokenClaims = idTokenClaims.map((claim) =>
+ normalizeIdTokenClaimPermissions({
+ ...claim,
+ key: claim.key.trim(),
+ value: claim.value.trim(),
+ }),
+ );
if (headlessLoginEnabled) {
if (!trimmedJwksUri) {
@@ -930,6 +1077,11 @@ function ClientGeneralPage() {
continue;
}
seenClaimKeys.add(keySignature);
+
+ const defaultValueError = claimDefaultValueValidationError(claim);
+ if (defaultValueError) {
+ claimValidationErrors.push(defaultValueError);
+ }
}
validationErrors.push(...claimValidationErrors);
@@ -2103,7 +2255,7 @@ function ClientGeneralPage() {
{t(
"msg.dev.clients.general.id_token_claims.subtitle",
- "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
+ "RP 전용 확장 claim을 구분해서 관리합니다.",
)}
@@ -2151,13 +2303,13 @@ function ClientGeneralPage() {
{t(
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
- "Read",
+ "User read",
)}
|
{t(
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
- "Write",
+ "User write",
)}
|
@@ -2175,190 +2327,255 @@ function ClientGeneralPage() {
|
- {idTokenClaims.map((claim) => (
-
- |
-
- updateIdTokenClaim(
- claim.id,
- "key",
- e.target.value,
- )
- }
- className="h-9 font-mono text-xs"
- placeholder={t(
- "ui.dev.clients.general.id_token_claims.key_placeholder",
- "e.g. locale",
- )}
- disabled={isGeneralSettingsReadOnly}
- />
- |
-
-
- {t(
- "ui.dev.clients.general.id_token_claims.namespace_rp_claims",
- "rp_claims",
- )}
-
- |
-
-
- |
-
-
-
+ {idTokenClaims.map((claim) => {
+ const defaultValueError =
+ claimDefaultValueValidationError(claim);
+
+ return (
+
+ |
+
updateIdTokenClaim(
claim.id,
- "nullable",
- checked,
+ "key",
+ e.target.value,
)
}
- aria-label={t(
- "ui.dev.clients.general.id_token_claims.nullable_label",
- "Nullable",
+ className="h-9 font-mono text-xs"
+ placeholder={t(
+ "ui.dev.clients.general.id_token_claims.key_placeholder",
+ "e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
-
- |
-
-
-
- setIdTokenClaimPermissionAllowed(
+ |
+
+
+ {t(
+ "ui.dev.clients.general.id_token_claims.namespace_rp_claims",
+ "rp_claims",
+ )}
+
+ |
+
+ |
-
-
-
- setIdTokenClaimPermissionAllowed(
- claim.id,
- "writePermission",
- checked,
- )
- }
- aria-label={t(
- "ui.dev.clients.general.id_token_claims.write_user_allowed_label",
- "Write 사용자 허용",
- )}
- disabled={isGeneralSettingsReadOnly}
- />
-
- |
-
-
- updateIdTokenClaim(
- claim.id,
- "value",
- e.target.value,
- )
- }
- className="h-9 font-mono text-xs"
- placeholder={t(
- "ui.dev.clients.general.id_token_claims.value_placeholder",
- "Enter the default value",
+ >
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ updateIdTokenClaim(
+ claim.id,
+ "nullable",
+ checked,
+ )
+ }
+ aria-label={t(
+ "ui.dev.clients.general.id_token_claims.nullable_label",
+ "Nullable",
+ )}
+ disabled={isGeneralSettingsReadOnly}
+ />
+
+ |
+
+
+
+ setIdTokenClaimPermissionAllowed(
+ claim.id,
+ "readPermission",
+ checked,
+ )
+ }
+ aria-label={t(
+ "ui.dev.clients.general.id_token_claims.read_user_allowed_label",
+ "사용자 읽기 허용",
+ )}
+ disabled={isGeneralSettingsReadOnly}
+ />
+
+ |
+
+
+
+ setIdTokenClaimPermissionAllowed(
+ claim.id,
+ "writePermission",
+ checked,
+ )
+ }
+ aria-label={t(
+ "ui.dev.clients.general.id_token_claims.write_user_allowed_label",
+ "사용자 쓰기 허용",
+ )}
+ disabled={isGeneralSettingsReadOnly}
+ />
+
+ |
+
+ {claim.valueType === "array" ||
+ claim.valueType === "object" ? (
+ |
-
- removeIdTokenClaim(claim.id)}
- className="h-9 w-9 text-muted-foreground hover:text-destructive"
- disabled={isGeneralSettingsReadOnly}
- >
-
-
- |
-
- ))}
+ {defaultValueError && (
+
+ {defaultValueError}
+
+ )}
+ |
+
+ removeIdTokenClaim(claim.id)}
+ className="h-9 w-9 text-muted-foreground hover:text-destructive"
+ disabled={isGeneralSettingsReadOnly}
+ >
+
+
+ |
+
+ );
+ })}
{idTokenClaims.length === 0 && (
|
{t(
"msg.dev.clients.general.id_token_claims.hint",
- "RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
+ "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
)}
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index e5d0eaf1..4f2b0c60 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -454,13 +454,14 @@ subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim"
[msg.dev.clients.general.id_token_claims]
-subtitle = "Separate shared claims from RP-specific extension claims."
+subtitle = "Manage RP-specific extension claims separately."
empty = "No ID Token claims have been added yet."
-hint = "Manage RP-specific extension claims only. Arrays accept JSON or comma-separated values, and objects accept JSON."
+hint = "Manage RP-specific extension claims separately. Edit per-user claim values in Consents & Claims."
preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
key_required = "Enter a claim key."
reserved_key = "`rp_claims` is a reserved namespace key."
duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}"
+invalid_default_value = "The claim default value does not match its type: {{key}} ({{valueType}})"
[msg.dev.clients.general.security]
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
@@ -1537,10 +1538,10 @@ title = "Security Note"
[ui.dev.clients.details.tab]
connection = "Federation"
-consents = "Consent & Users"
+consents = "Consents & Claims"
settings = "Settings"
relationships = "Relationships"
-user_claims = "User Claims"
+user_claims = "Consents & Claims"
[ui.dev.clients.general]
create = "Create Application"
@@ -1618,19 +1619,20 @@ namespace_label = "Claim namespace"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
nullable_label = "Nullable"
-read_user_allowed_label = "Read user allowed"
-write_user_allowed_label = "Write user allowed"
+read_user_allowed_label = "Allow user read"
+write_user_allowed_label = "Allow user write"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
table.nullable = "Nullable"
-table.read_user_allowed = "Read"
-table.write_user_allowed = "Write"
+table.read_user_allowed = "User read"
+table.write_user_allowed = "User write"
table.default_value = "Default Value"
table.delete = "Delete"
value_type_label = "Claim value type"
value_type_text = "Text"
value_type_number = "Number"
+value_type_float = "Float"
value_type_boolean = "Boolean"
value_type_array = "Array"
value_type_object = "Object"
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index 96593843..bc5638ca 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -454,13 +454,14 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.id_token_claims]
-subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
+subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다."
-hint = "RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
+hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
+invalid_default_value = "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})"
[msg.dev.clients.general.security]
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
@@ -1536,10 +1537,10 @@ title = "보안 메모"
[ui.dev.clients.details.tab]
connection = "연동 설정"
-consents = "동의 및 사용자"
+consents = "동의 및 Claims"
settings = "설정"
relationships = "관계"
-user_claims = "사용자 Claim"
+user_claims = "Consents & Claims"
[ui.dev.clients.general]
create = "앱 생성"
@@ -1616,20 +1617,21 @@ preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
-nullable_label = "Null 허용"
-read_user_allowed_label = "Read 사용자 허용"
-write_user_allowed_label = "Write 사용자 허용"
+nullable_label = "Nullable"
+read_user_allowed_label = "사용자 읽기 허용"
+write_user_allowed_label = "사용자 쓰기 허용"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
-table.nullable = "Null 허용"
-table.read_user_allowed = "Read"
-table.write_user_allowed = "Write"
+table.nullable = "Nullable"
+table.read_user_allowed = "사용자 읽기"
+table.write_user_allowed = "사용자 쓰기"
table.default_value = "기본값"
table.delete = "삭제"
value_type_label = "Claim 값 타입"
value_type_text = "텍스트"
value_type_number = "숫자"
+value_type_float = "실수"
value_type_boolean = "불리언"
value_type_array = "배열"
value_type_object = "객체"
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index d0969cb0..bb8af261 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -445,6 +445,7 @@ preview_hint = ""
key_required = ""
reserved_key = ""
duplicate_key = ""
+invalid_default_value = ""
[msg.dev.clients.relationships]
subtitle = ""
@@ -1679,6 +1680,7 @@ table.delete = ""
value_type_label = ""
value_type_text = ""
value_type_number = ""
+value_type_float = ""
value_type_boolean = ""
value_type_array = ""
value_type_object = ""
diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts
index e3a45fe0..40f44332 100644
--- a/devfront/tests/devfront-client-claims-cache.spec.ts
+++ b/devfront/tests/devfront-client-claims-cache.spec.ts
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import {
+ type ClientRelation,
type Consent,
installDevApiMock,
makeClient,
@@ -7,6 +8,39 @@ import {
} from "./helpers/devfront-fixtures";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
+const editRelations = [
+ {
+ relation: "config_editor",
+ subject: "User:playwright-user",
+ subjectType: "User",
+ subjectId: "playwright-user",
+ },
+ {
+ relation: "admins",
+ subject: "User:playwright-user",
+ subjectType: "User",
+ subjectId: "playwright-user",
+ },
+ {
+ relation: "config_editor",
+ subject: "User:admin-user",
+ subjectType: "User",
+ subjectId: "admin-user",
+ },
+ {
+ relation: "config_editor",
+ subject: "User:undefined",
+ subjectType: "User",
+ subjectId: "undefined",
+ },
+ {
+ relation: "config_editor",
+ subject: "User:",
+ subjectType: "User",
+ subjectId: "",
+ },
+] satisfies ClientRelation[];
+
test.describe("DevFront RP claim cache", () => {
test.beforeEach(async ({ page }) => {
await installDevFrontStaticRoutes(page);
@@ -33,6 +67,9 @@ test.describe("DevFront RP claim cache", () => {
}),
],
consents: [] as Consent[],
+ relations: {
+ "client-claims": editRelations,
+ },
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
@@ -44,6 +81,7 @@ test.describe("DevFront RP claim cache", () => {
.getByPlaceholder(/e\.g\. locale|예: locale/i)
.first();
await expect(claimKeyInput).toHaveValue("old_claim");
+ await expect(claimKeyInput).toBeEnabled();
await claimKeyInput.fill("new_claim");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
@@ -60,4 +98,208 @@ test.describe("DevFront RP claim cache", () => {
.toBe("new_claim");
await expect(claimKeyInput).toHaveValue("new_claim");
});
+
+ test("forces read permission on when write permission is enabled", async ({
+ page,
+ }) => {
+ const state = {
+ clients: [
+ makeClient("client-claims", {
+ name: "Claims app",
+ metadata: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "locale",
+ value: "ko",
+ valueType: "text",
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ }),
+ ],
+ consents: [] as Consent[],
+ relations: {
+ "client-claims": editRelations,
+ },
+ auditLogsByCursor: undefined,
+ mockRole: "super_admin",
+ };
+ await installDevApiMock(page, state);
+
+ await page.goto("http://devfront.test/clients/client-claims/settings");
+
+ const readSwitch = page
+ .getByRole("switch", { name: /사용자 읽기|Allow user read/i })
+ .first();
+ const writeSwitch = page
+ .getByRole("switch", { name: /사용자 쓰기|Allow user write/i })
+ .first();
+
+ await expect(readSwitch).toHaveAttribute("aria-checked", "false");
+ await expect(writeSwitch).toHaveAttribute("aria-checked", "false");
+ await expect(readSwitch).toBeEnabled();
+ await expect(writeSwitch).toBeEnabled();
+
+ await writeSwitch.click();
+
+ await expect(readSwitch).toHaveAttribute("aria-checked", "true");
+ await expect(writeSwitch).toHaveAttribute("aria-checked", "true");
+
+ await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
+
+ await expect
+ .poll(
+ () =>
+ (
+ state.clients[0]?.metadata?.id_token_claims as
+ | Array<{
+ readPermission?: string;
+ writePermission?: string;
+ }>
+ | undefined
+ )?.[0],
+ )
+ .toMatchObject({
+ readPermission: "user_and_admin",
+ writePermission: "user_and_admin",
+ });
+ });
+
+ test("blocks saving an RP claim default value that does not match the selected value type", async ({
+ page,
+ }) => {
+ const state = {
+ clients: [
+ makeClient("client-claims", {
+ name: "Claims app",
+ metadata: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "profile",
+ value: "{}",
+ valueType: "text",
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ }),
+ ],
+ consents: [] as Consent[],
+ relations: {
+ "client-claims": editRelations,
+ },
+ auditLogsByCursor: undefined,
+ mockRole: "super_admin",
+ };
+ await installDevApiMock(page, state);
+
+ await page.goto("http://devfront.test/clients/client-claims/settings");
+
+ await page
+ .getByLabel(/Claim 값 타입|Claim value type/i)
+ .first()
+ .selectOption("object");
+ await page
+ .locator('textarea[placeholder="{\\"key\\": \\"value\\"}"]')
+ .fill("not-json");
+
+ await expect(
+ page.getByRole("button", { name: /^저장$|^Save$/i }),
+ ).toBeDisabled();
+
+ expect(
+ (
+ state.clients[0]?.metadata?.id_token_claims as
+ | Array<{ valueType?: string; value?: string }>
+ | undefined
+ )?.[0],
+ ).toMatchObject({
+ value: "{}",
+ valueType: "text",
+ });
+ });
+
+ test("saves a float RP claim default value and blocks decimal values for integer number claims", async ({
+ page,
+ }) => {
+ const state = {
+ clients: [
+ makeClient("client-claims", {
+ name: "Claims app",
+ metadata: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "ratio",
+ value: "0",
+ valueType: "text",
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ }),
+ ],
+ consents: [] as Consent[],
+ relations: {
+ "client-claims": editRelations,
+ },
+ auditLogsByCursor: undefined,
+ mockRole: "super_admin",
+ };
+ await installDevApiMock(page, state);
+
+ await page.goto("http://devfront.test/clients/client-claims/settings");
+
+ await page
+ .getByLabel(/Claim 값 타입|Claim value type/i)
+ .first()
+ .selectOption("float");
+ await page
+ .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
+ .first()
+ .fill("3.14");
+ await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
+
+ await expect
+ .poll(
+ () =>
+ (
+ state.clients[0]?.metadata?.id_token_claims as
+ | Array<{ valueType?: string; value?: string }>
+ | undefined
+ )?.[0],
+ )
+ .toMatchObject({
+ value: "3.14",
+ valueType: "float",
+ });
+
+ const valueTypeSelect = page
+ .getByLabel(/Claim 값 타입|Claim value type/i)
+ .first();
+ await expect(valueTypeSelect).toHaveValue("float");
+ await expect(
+ page.getByRole("button", { name: /^저장$|^Save$/i }),
+ ).toBeEnabled();
+
+ await valueTypeSelect.selectOption("number");
+ await expect(valueTypeSelect).toHaveValue("number");
+ await page
+ .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
+ .first()
+ .fill("3.14");
+
+ await expect(
+ page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /^저장$|^Save$/i }),
+ ).toBeDisabled();
+ });
});
diff --git a/devfront/tests/devfront-client-tabs.spec.ts b/devfront/tests/devfront-client-tabs.spec.ts
index c54f1df0..368f26d5 100644
--- a/devfront/tests/devfront-client-tabs.spec.ts
+++ b/devfront/tests/devfront-client-tabs.spec.ts
@@ -6,6 +6,7 @@ import {
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
+import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
return async ({ page }: { page: Page }) => {
@@ -24,9 +25,10 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
},
auditLogsByCursor: undefined,
};
+ await installDevFrontStaticRoutes(page);
await installDevApiMock(page, state);
- await page.goto(pagePath);
+ await page.goto(`http://devfront.test${pagePath}`);
const header = page
.locator("header")
@@ -38,7 +40,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
await expect(tabs).toHaveText([
"연동 설정",
- "사용자 Claim",
+ "동의 및 Claims",
"설정",
"관계",
]);
diff --git a/devfront/tests/devfront-consents.spec.ts b/devfront/tests/devfront-consents.spec.ts
index d7e39755..0b560881 100644
--- a/devfront/tests/devfront-consents.spec.ts
+++ b/devfront/tests/devfront-consents.spec.ts
@@ -6,6 +6,7 @@ import {
seedAuth,
} from "./helpers/devfront-fixtures";
import { captureEvidence } from "./helpers/evidence";
+import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
test.describe("DevFront consents", () => {
test.afterEach(async ({ page }, testInfo) => {
@@ -15,6 +16,7 @@ test.describe("DevFront consents", () => {
});
test.beforeEach(async ({ page }) => {
+ await installDevFrontStaticRoutes(page);
page.on("dialog", async (dialog) => {
await dialog.accept();
});
@@ -81,7 +83,7 @@ test.describe("DevFront consents", () => {
};
await installDevApiMock(page, state);
- await page.goto("/clients/client-consent/consents");
+ await page.goto("http://devfront.test/clients/client-consent/consents");
await expect(page.getByText("Alice")).toBeVisible();
await expect(page.getByText("Tenant A")).toBeVisible();
await expect(page.getByText(/approvalLevel:\s*A/)).toBeVisible();
@@ -127,4 +129,43 @@ test.describe("DevFront consents", () => {
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
});
+
+ test("does not allow adding undefined RP claims from consents and claims", async ({
+ page,
+ }) => {
+ const state = {
+ clients: [
+ makeClient("client-consent", {
+ name: "Consent app",
+ metadata: {},
+ }),
+ ],
+ consents: [
+ {
+ subject: "user-1",
+ userName: "Alice",
+ clientId: "client-consent",
+ clientName: "Consent app",
+ grantedScopes: ["openid", "profile"],
+ authenticatedAt: "2026-03-03T08:00:00.000Z",
+ createdAt: "2026-03-02T08:00:00.000Z",
+ status: "active",
+ tenantId: "tenant-a",
+ tenantName: "Tenant A",
+ rpMetadata: {},
+ },
+ ] as Consent[],
+ auditLogsByCursor: undefined,
+ };
+ await installDevApiMock(page, state);
+
+ await page.goto("http://devfront.test/clients/client-consent/consents");
+ await page.getByRole("button", { name: /Claims|Claim/i }).click();
+
+ await expect(page.getByText("RP Custom Claims")).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /^추가$|^Add$/ }),
+ ).toHaveCount(0);
+ await expect(page.getByPlaceholder(/claim_key/i)).toHaveCount(0);
+ });
});
diff --git a/docs/tenant-policy.md b/docs/tenant-policy.md
index 7b39c9a8..fe4e83e9 100644
--- a/docs/tenant-policy.md
+++ b/docs/tenant-policy.md
@@ -19,7 +19,20 @@ Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터
- **Ory Keto (Relationship SSOT)**: 테넌트 소속, 소유, 접근 같은 권한 관계를 저장하고 판정합니다.
- **Backend DB read model**: Ory에 저장되지 않거나 조회가 불가능한 테넌트 표시/검색 metadata, 설정, 외부 연동 상태만 저장합니다.
-## 3. 데이터베이스 스키마 분리 전략
+## 3. Seed tenant 식별 정책
+
+`adminfront/seed-tenant.csv`에 정의된 초기 테넌트는 CSV의 `id`/`tenant_id` UUID를 source of truth로 삼습니다. `slug`는 운영자가 읽고 외부 연동에서 다루기 쉬운 식별자지만, 오타 수정이나 명칭 정책 변경으로 바뀔 수 있으므로 초기 테넌트 보호 여부와 seed 동기화의 최종 식별 기준으로 사용하지 않습니다.
+
+- 초기 테넌트 여부는 seed CSV의 UUID와 `tenants.id` 일치 여부로 판단합니다.
+- `slug`, `name`, `memo`, 도메인 같은 표시/설정 값은 UUID로 식별된 seed tenant의 동기화 대상 metadata입니다.
+- 기존 DB row가 seed UUID와 일치하지만 `slug`가 CSV와 다르면, backend bootstrap seed 경로는 CSV의 `slug`로 보정해야 합니다.
+- 목표 `slug`를 다른 활성 tenant가 이미 사용 중이면 자동 보정하지 않고 충돌로 처리합니다.
+- AdminFront의 “초기 설정” 표시와 삭제 보호도 `slug`가 아니라 seed UUID 기준으로 동작해야 합니다.
+- 일반 import/export 정책에서는 운영 편의를 위해 `slug`를 우선 사용할 수 있지만, seed tenant의 identity 보존 및 복구 정책은 UUID 기준을 우선합니다.
+
+예를 들어 한라산업개발 seed row의 UUID가 `5a03efd2-e62f-4243-800d-58334bf48b2f`이면, 기존 DB의 `slug`가 `hanlla`여도 같은 UUID row는 동일 seed tenant로 보고 CSV 값인 `halla`로 보정합니다.
+
+## 4. 데이터베이스 스키마 분리 전략
테넌트 테이블의 비대화를 막기 위해, Identity(신분증) 역할과 무거운 Business 데이터를 분리 조인(Join)합니다.
@@ -27,7 +40,7 @@ Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터
- **`company_settings` 테이블**: `COMPANY` 및 `COMPANY_GROUP` 타입 전용 무거운 비즈니스 설정 (결제 정보, 커스텀 도메인 등).
- **`user_groups` 테이블**: `USER_GROUP` 타입 전용 사내 조직도 메타데이터 (`parent_id`, 조직장 정보 등).
-## 4. 논리적 다중 테넌트 OIDC 관리 (Logical Pooling)
+## 5. 논리적 다중 테넌트 OIDC 관리 (Logical Pooling)
인프라 비용의 팽창을 막기 위해 테넌트별로 Hydra(OAuth2) 데이터베이스를 물리적으로 복제하는 방식은 금지합니다.
대신 공유되는 소수의 Hydra 클러스터 앞단에 도메인 및 헤더를 재작성하는 지능형 프록시를 배치하고, 백엔드의 동의(Consent) 로직을 통해 요청된 클라이언트의 테넌트 맥락에 맞는 **동적 클레임(Dynamic Claim)**을 ID Token에 주입합니다.
diff --git a/test/code_check_biome_dedup_test.sh b/test/code_check_biome_dedup_test.sh
new file mode 100644
index 00000000..61a8bd79
--- /dev/null
+++ b/test/code_check_biome_dedup_test.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WORKFLOW_FILE="$ROOT_DIR/.gitea/workflows/code_check.yml"
+
+fail() {
+ echo "ERROR: $*" >&2
+ exit 1
+}
+
+job_block() {
+ local job="$1"
+ awk -v job=" ${job}:" '
+ $0 == job { in_job = 1; print; next }
+ in_job && /^ [a-zA-Z0-9_-]+:/ { exit }
+ in_job { print }
+ ' "$WORKFLOW_FILE"
+}
+
+lint_block="$(job_block lint)"
+biome_block="$(job_block biome-check)"
+
+if printf '%s\n' "$lint_block" | grep -Eq 'Biome check (adminfront|devfront|orgfront)|npx biome check'; then
+ fail "lint job must not duplicate frontend Biome checks; keep them in biome-check"
+fi
+
+for app in adminfront devfront orgfront; do
+ printf '%s\n' "$biome_block" | grep -Fq "Install ${app} dependencies" ||
+ fail "biome-check job must install ${app} dependencies"
+ printf '%s\n' "$biome_block" | grep -Fq "Biome check ${app}" ||
+ fail "biome-check job must check ${app}"
+done
+
+for job in \
+ adminfront-vitest-coverage \
+ devfront-vitest-coverage \
+ orgfront-vitest-coverage \
+ adminfront-tests \
+ devfront-tests \
+ orgfront-tests; do
+ block="$(job_block "$job")"
+ printf '%s\n' "$block" | grep -Fq " - biome-check" ||
+ fail "${job} must depend on biome-check instead of duplicating/depending on lint for frontend quality gate"
+done
+
+echo "OK: Code Check runs frontend Biome only in the biome-check job"
diff --git a/test/rp_claims_live_e2e.mjs b/test/rp_claims_live_e2e.mjs
new file mode 100644
index 00000000..803396aa
--- /dev/null
+++ b/test/rp_claims_live_e2e.mjs
@@ -0,0 +1,604 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { createHash, randomBytes } from "node:crypto";
+import playwright from "../devfront/node_modules/playwright/index.js";
+
+const { chromium } = playwright;
+
+const gatewayUrl = process.env.RP_CLAIMS_E2E_GATEWAY_URL ?? "http://localhost:5000";
+const devfrontUrl = process.env.RP_CLAIMS_E2E_DEVFRONT_URL ?? "http://localhost:5174";
+const reportDir = process.env.RP_CLAIMS_E2E_REPORT_DIR ?? "reports/rp-claims-live-e2e";
+const tenantSlug = process.env.RP_CLAIMS_E2E_TENANT_SLUG ?? "public-org";
+const runId = process.env.RP_CLAIMS_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
+const email = `rp-claims-${runId}@example.test`;
+const loginId = `rp${runId.replaceAll("-", "").slice(0, 18)}`;
+const loginIdentifier = email;
+const password = `RpClaims${runId.slice(0, 6)}!1aA`;
+const redirectUri = "http://127.0.0.1:18080/callback";
+const subjectByLoginId = new Map();
+
+const scenarios = [
+ {
+ name: "rp-a",
+ clientId: `rp-claims-a-${runId}`,
+ defaults: {
+ approvalLevel: "A",
+ activeMember: true,
+ score: 1,
+ featureList: ["default"],
+ preferences: { theme: "light", density: "comfortable" },
+ contractDate: "2026-06-09",
+ approvedAt: "2026-06-09T09:30",
+ adminManagedNote: "admin-default",
+ },
+ updates: [
+ {
+ label: "admin-update",
+ metadata: {
+ approvalLevel: "B",
+ activeMember: false,
+ score: 42,
+ featureList: ["sso", "claims"],
+ preferences: { theme: "dark", density: "compact" },
+ contractDate: "2026-06-10",
+ approvedAt: "2026-06-09T10:30",
+ adminManagedNote: "admin-updated",
+ approvalLevel_permissions: {
+ readPermission: "admin_only",
+ writePermission: "user_and_admin",
+ },
+ },
+ expected: {
+ approvalLevel: "B",
+ activeMember: false,
+ score: 42,
+ featureList: ["sso", "claims"],
+ preferences: { theme: "dark", density: "compact" },
+ contractDate: "2026-06-10",
+ approvedAt: "2026-06-09T10:30",
+ adminManagedNote: "admin-updated",
+ },
+ },
+ {
+ label: "second-update",
+ metadata: {
+ approvalLevel: "C",
+ activeMember: true,
+ score: 7.5,
+ featureList: ["delta", "omega", "42"],
+ preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
+ contractDate: "2026-06-11",
+ approvedAt: "2026-06-11T14:45:30Z",
+ adminManagedNote: "admin-updated-again",
+ approvalLevel_permissions: {
+ readPermission: "user_and_admin",
+ writePermission: "user_and_admin",
+ },
+ },
+ expected: {
+ approvalLevel: "C",
+ activeMember: true,
+ score: 7.5,
+ featureList: ["delta", "omega", "42"],
+ preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
+ contractDate: "2026-06-11",
+ approvedAt: "2026-06-11T14:45:30Z",
+ adminManagedNote: "admin-updated-again",
+ },
+ },
+ ],
+ },
+ {
+ name: "rp-b",
+ clientId: `rp-claims-b-${runId}`,
+ defaults: {
+ approvalLevel: "B-default",
+ activeMember: false,
+ score: 100,
+ featureList: ["rp-b-default"],
+ preferences: { theme: "blue", density: "wide" },
+ contractDate: "2026-07-01",
+ approvedAt: "2026-07-01T08:00",
+ },
+ updates: [
+ {
+ label: "rp-b-isolated-update",
+ metadata: {
+ approvalLevel: "B-only",
+ activeMember: true,
+ score: 314,
+ featureList: ["only", "rp-b"],
+ preferences: { theme: "green", density: "narrow" },
+ contractDate: "2026-07-02",
+ approvedAt: "2026-07-02T09:15",
+ },
+ expected: {
+ approvalLevel: "B-only",
+ activeMember: true,
+ score: 314,
+ featureList: ["only", "rp-b"],
+ preferences: { theme: "green", density: "narrow" },
+ contractDate: "2026-07-02",
+ approvedAt: "2026-07-02T09:15",
+ },
+ },
+ ],
+ },
+];
+
+const report = {
+ runId,
+ gatewayUrl,
+ devfrontUrl,
+ loginId,
+ loginIdentifier,
+ email,
+ createdAt: new Date().toISOString(),
+ steps: [],
+ scenarios: [],
+ passed: false,
+};
+
+function assertDeepEqual(actual, expected, label) {
+ const actualJson = JSON.stringify(canonicalize(actual));
+ const expectedJson = JSON.stringify(canonicalize(expected));
+ if (actualJson !== expectedJson) {
+ throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`);
+ }
+}
+
+function canonicalize(value) {
+ if (Array.isArray(value)) {
+ return value.map(canonicalize);
+ }
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, item]) => [key, canonicalize(item)]),
+ );
+ }
+ return value;
+}
+
+function assertAbsent(record, key, label) {
+ if (Object.hasOwn(record ?? {}, key)) {
+ throw new Error(`${label}: ${key} must not be present`);
+ }
+}
+
+function base64urlDecode(input) {
+ const padded = input.padEnd(input.length + ((4 - (input.length % 4)) % 4), "=");
+ return Buffer.from(padded.replaceAll("-", "+").replaceAll("_", "/"), "base64").toString("utf8");
+}
+
+function decodeJwtPayload(token) {
+ const parts = String(token).split(".");
+ if (parts.length < 2) {
+ throw new Error("id_token is not a JWT");
+ }
+ return JSON.parse(base64urlDecode(parts[1]));
+}
+
+function base64url(buffer) {
+ return Buffer.from(buffer)
+ .toString("base64")
+ .replaceAll("+", "-")
+ .replaceAll("/", "_")
+ .replaceAll("=", "");
+}
+
+function pkceChallenge(verifier) {
+ return base64url(createHash("sha256").update(verifier).digest());
+}
+
+function claimDefinitions(defaults) {
+ const claims = [
+ { key: "approvalLevel", valueType: "text", value: defaults.approvalLevel },
+ { key: "activeMember", valueType: "boolean", value: String(defaults.activeMember) },
+ { key: "score", valueType: "number", value: String(defaults.score) },
+ { key: "featureList", valueType: "array", value: JSON.stringify(defaults.featureList) },
+ { key: "preferences", valueType: "object", value: JSON.stringify(defaults.preferences) },
+ { key: "contractDate", valueType: "date", value: defaults.contractDate },
+ { key: "approvedAt", valueType: "datetime", value: defaults.approvedAt },
+ ].filter((claim) => claim.value !== undefined);
+ if (defaults.adminManagedNote !== undefined) {
+ claims.push({
+ key: "adminManagedNote",
+ valueType: "text",
+ value: defaults.adminManagedNote,
+ writePermission: "admin_only",
+ });
+ }
+ return claims.map((claim) => ({
+ namespace: "rp_claims",
+ readPermission: "user_and_admin",
+ writePermission: claim.writePermission ?? "user_and_admin",
+ ...claim,
+ }));
+}
+
+async function requestJson(url, options = {}) {
+ const { cookieJar, ...fetchOptions } = options;
+ const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
+ const response = await fetch(url, {
+ ...fetchOptions,
+ headers: {
+ "Content-Type": "application/json",
+ "X-Test-Role": "super_admin",
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
+ ...(options.headers ?? {}),
+ },
+ });
+ rememberCookies(cookieJar, response);
+ const text = await response.text();
+ let body = null;
+ if (text) {
+ try {
+ body = JSON.parse(text);
+ } catch {
+ body = text;
+ }
+ }
+ if (!response.ok) {
+ throw new Error(`${options.method ?? "GET"} ${url} failed: ${response.status} ${text}`);
+ }
+ return { response, body };
+}
+
+async function createUser() {
+ const body = {
+ email,
+ loginId,
+ password,
+ name: `RP Claims ${runId}`,
+ role: "user",
+ tenantSlug,
+ };
+ const result = await requestJson(`${gatewayUrl}/api/v1/admin/users`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ const id = result.body?.id ?? result.body?.user?.id ?? result.body?.identityId;
+ if (!id) {
+ throw new Error(`created user response has no id: ${JSON.stringify(result.body)}`);
+ }
+ subjectByLoginId.set(loginIdentifier, id);
+ report.steps.push({ step: "create-user", status: "ok", subject: id });
+ return id;
+}
+
+async function createClient(scenario) {
+ const body = {
+ id: scenario.clientId,
+ name: `RP Claims E2E ${scenario.name} ${runId}`,
+ type: "pkce",
+ status: "active",
+ redirectUris: [redirectUri],
+ scopes: ["openid", "profile", "email"],
+ grantTypes: ["authorization_code", "refresh_token"],
+ responseTypes: ["code"],
+ tokenEndpointAuthMethod: "none",
+ skipConsent: false,
+ metadata: {
+ id_token_claims: claimDefinitions(scenario.defaults),
+ tenant_id: "tenant-rp-claims-live-e2e",
+ },
+ };
+ await requestJson(`${devfrontUrl}/api/v1/dev/clients`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ report.steps.push({ step: "create-client", status: "ok", clientId: scenario.clientId });
+}
+
+function cookieHeaderFromJar(cookieJar) {
+ return Object.entries(cookieJar ?? {})
+ .map(([name, value]) => `${name}=${value}`)
+ .join("; ");
+}
+
+function rememberCookies(cookieJar, response) {
+ if (!cookieJar || !response?.headers) {
+ return;
+ }
+ const setCookies =
+ typeof response.headers.getSetCookie === "function"
+ ? response.headers.getSetCookie()
+ : response.headers.get("set-cookie")
+ ? [response.headers.get("set-cookie")]
+ : [];
+ for (const header of setCookies) {
+ const firstPart = String(header ?? "").split(";")[0];
+ const separator = firstPart.indexOf("=");
+ if (separator <= 0) {
+ continue;
+ }
+ cookieJar[firstPart.slice(0, separator)] = firstPart.slice(separator + 1);
+ }
+}
+
+async function manualFetch(url, options = {}) {
+ try {
+ const { cookieJar, ...fetchOptions } = options;
+ const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
+ const response = await fetch(url, {
+ redirect: "manual",
+ ...fetchOptions,
+ headers: {
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
+ ...(options.headers ?? {}),
+ },
+ });
+ rememberCookies(cookieJar, response);
+ return response;
+ } catch (error) {
+ throw new Error(`manual fetch failed for ${url}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+}
+
+function locationFrom(response, label) {
+ const location = response.headers.get("location");
+ if (!location) {
+ throw new Error(`${label}: redirect location missing, status=${response.status}`);
+ }
+ return new URL(location, gatewayUrl).toString();
+}
+
+function searchParam(url, name, label) {
+ const value = new URL(url).searchParams.get(name);
+ if (!value) {
+ throw new Error(`${label}: ${name} missing from ${url}`);
+ }
+ return value;
+}
+
+async function authorizeAndDecodeClaims(clientId, label) {
+ const state = `${label}-${Math.random().toString(16).slice(2, 8)}`;
+ const nonce = `${label}-${Math.random().toString(16).slice(2, 8)}`;
+ const verifier = base64url(randomBytes(32));
+ const cookieJar = {};
+ const authorize = new URL(`${gatewayUrl}/oidc/oauth2/auth`);
+ authorize.searchParams.set("client_id", clientId);
+ authorize.searchParams.set("redirect_uri", redirectUri);
+ authorize.searchParams.set("response_type", "code");
+ authorize.searchParams.set("scope", "openid profile email");
+ authorize.searchParams.set("state", state);
+ authorize.searchParams.set("nonce", nonce);
+ authorize.searchParams.set("code_challenge", pkceChallenge(verifier));
+ authorize.searchParams.set("code_challenge_method", "S256");
+ const trace = { label, clientId, authorize: authorize.toString(), redirects: [] };
+ report.steps.push({ step: "authorize-token", status: "started", trace });
+
+ const first = await manualFetch(authorize, { cookieJar });
+ let next = locationFrom(first, `${label}: authorize`);
+ trace.redirects.push({ from: "authorize", status: first.status, location: next });
+ const loginChallenge = searchParam(next, "login_challenge", `${label}: login redirect`);
+
+ const login = await requestJson(`${gatewayUrl}/api/v1/auth/password/login`, {
+ method: "POST",
+ cookieJar,
+ body: JSON.stringify({ loginId: loginIdentifier, password, login_challenge: loginChallenge }),
+ });
+ next = login.body.redirectTo;
+ if (!next) {
+ throw new Error(`${label}: password login response missing redirectTo`);
+ }
+ trace.redirects.push({ from: "password-login", location: next });
+
+ let afterLogin = await manualFetch(next, { cookieJar });
+ next = locationFrom(afterLogin, `${label}: after login`);
+ trace.redirects.push({ from: "after-login", status: afterLogin.status, location: next });
+ if (new URL(next).searchParams.has("consent_challenge")) {
+ const consentChallenge = searchParam(next, "consent_challenge", `${label}: consent redirect`);
+ const consent = await requestJson(`${gatewayUrl}/api/v1/auth/consent/accept`, {
+ method: "POST",
+ cookieJar,
+ body: JSON.stringify({
+ consent_challenge: consentChallenge,
+ grant_scope: ["openid", "profile", "email"],
+ }),
+ });
+ next = consent.body.redirectTo;
+ if (!next) {
+ throw new Error(`${label}: consent response missing redirectTo`);
+ }
+ trace.redirects.push({ from: "consent-accept", location: next });
+ afterLogin = await manualFetch(next, { cookieJar });
+ next = locationFrom(afterLogin, `${label}: after consent`);
+ trace.redirects.push({ from: "after-consent", status: afterLogin.status, location: next });
+ }
+
+ if (new URL(next).searchParams.has("error")) {
+ const callback = new URL(next);
+ throw new Error(`${label}: callback error ${callback.searchParams.get("error")} ${callback.searchParams.get("error_description") ?? ""}`.trim());
+ }
+
+ if (!new URL(next).searchParams.has("code")) {
+ if (new URL(next).hostname === "127.0.0.1") {
+ throw new Error(`${label}: callback has no code: ${next}`);
+ }
+ const final = await manualFetch(next, { cookieJar });
+ next = locationFrom(final, `${label}: final code redirect`);
+ trace.redirects.push({ from: "final", status: final.status, location: next });
+ }
+
+ const code = searchParam(next, "code", `${label}: callback`);
+ const returnedState = searchParam(next, "state", `${label}: callback`);
+ if (returnedState !== state) {
+ throw new Error(`${label}: state mismatch`);
+ }
+
+ const tokenResponse = await fetch(`${gatewayUrl}/oidc/oauth2/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ code,
+ code_verifier: verifier,
+ }),
+ });
+ const tokenText = await tokenResponse.text();
+ let tokenBody;
+ try {
+ tokenBody = JSON.parse(tokenText);
+ } catch {
+ tokenBody = tokenText;
+ }
+ if (!tokenResponse.ok) {
+ throw new Error(`${label}: token failed ${tokenResponse.status} ${tokenText}`);
+ }
+ const payload = decodeJwtPayload(tokenBody.id_token);
+ report.steps.push({ step: "authorize-token", status: "ok", trace });
+ return {
+ idTokenPayload: payload,
+ rpClaims: payload.rp_claims,
+ tokenResponse: {
+ token_type: tokenBody.token_type,
+ expires_in: tokenBody.expires_in,
+ scope: tokenBody.scope,
+ has_access_token: Boolean(tokenBody.access_token),
+ has_id_token: Boolean(tokenBody.id_token),
+ },
+ };
+}
+
+async function updateClaimsFromDevfrontBrowser(page, scenario, metadata, label) {
+ const result = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata);
+ if (!result.ok) {
+ throw new Error(`${label}: devfront metadata update failed ${result.status} ${JSON.stringify(result.body)}`);
+ }
+ return result;
+}
+
+async function requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata) {
+ const subject = subjectByLoginId.get(loginIdentifier);
+ return page.evaluate(
+ async ({ clientId, subject, metadata }) => {
+ const response = await fetch(`/api/v1/dev/clients/${clientId}/users/${subject}/metadata`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Test-Role": "super_admin",
+ "X-Tenant-ID": "tenant-rp-claims-live-e2e",
+ },
+ body: JSON.stringify({ metadata }),
+ });
+ const text = await response.text();
+ let body;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {
+ body = text;
+ }
+ return { ok: response.ok, status: response.status, body };
+ },
+ { clientId: scenario.clientId, subject, metadata },
+ );
+}
+
+let browser;
+try {
+ await mkdir(reportDir, { recursive: true });
+ const subject = await createUser();
+ report.subject = subject;
+
+ for (const scenario of scenarios) {
+ await createClient(scenario);
+ }
+
+ browser = await chromium.launch({ headless: true });
+ const page = await browser.newPage({
+ extraHTTPHeaders: {
+ "X-Test-Role": "super_admin",
+ "X-Tenant-ID": "tenant-rp-claims-live-e2e",
+ },
+ });
+ await page.goto(devfrontUrl, { waitUntil: "domcontentloaded" });
+
+ for (const scenario of scenarios) {
+ const scenarioReport = {
+ name: scenario.name,
+ clientId: scenario.clientId,
+ defaultCheck: null,
+ negativeChecks: [],
+ updates: [],
+ };
+ const defaults = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-default`);
+ assertDeepEqual(defaults.rpClaims, scenario.defaults, `${scenario.name}: default rp_claims`);
+ scenarioReport.defaultCheck = {
+ expected: scenario.defaults,
+ actual: defaults.rpClaims,
+ token: defaults.tokenResponse,
+ };
+
+ const undefinedClaimResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
+ internalMemo: "must-be-rejected",
+ });
+ if (undefinedClaimResult.status !== 400) {
+ throw new Error(`${scenario.name}: undefined claim update should be rejected with 400, got ${undefinedClaimResult.status}`);
+ }
+ scenarioReport.negativeChecks.push({
+ label: "undefined-claim-rejected",
+ status: undefinedClaimResult.status,
+ body: undefinedClaimResult.body,
+ });
+
+ if (Object.hasOwn(scenario.defaults, "score")) {
+ const invalidTypeResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
+ score: "not-a-number",
+ });
+ if (invalidTypeResult.status !== 400) {
+ throw new Error(`${scenario.name}: invalid number update should be rejected with 400, got ${invalidTypeResult.status}`);
+ }
+ scenarioReport.negativeChecks.push({
+ label: "invalid-number-rejected",
+ status: invalidTypeResult.status,
+ body: invalidTypeResult.body,
+ });
+ }
+
+ for (const update of scenario.updates) {
+ const updateResult = await updateClaimsFromDevfrontBrowser(page, scenario, update.metadata, `${scenario.name}-${update.label}`);
+ const decoded = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-${update.label}`);
+ assertDeepEqual(decoded.rpClaims, update.expected, `${scenario.name}.${update.label}: updated rp_claims`);
+ assertAbsent(decoded.rpClaims, "internalMemo", `${scenario.name}.${update.label}`);
+ assertAbsent(decoded.rpClaims, "approvalLevel_permissions", `${scenario.name}.${update.label}`);
+ assertAbsent(decoded.rpClaims, "adminManagedNote_permissions", `${scenario.name}.${update.label}`);
+ scenarioReport.updates.push({
+ label: update.label,
+ devfrontUpdateStatus: updateResult.status,
+ expected: update.expected,
+ actual: decoded.rpClaims,
+ token: decoded.tokenResponse,
+ });
+ }
+ report.scenarios.push(scenarioReport);
+ }
+
+ const rpAAfter = report.scenarios.find((item) => item.name === "rp-a")?.updates.at(-1)?.actual;
+ const rpBAfter = report.scenarios.find((item) => item.name === "rp-b")?.updates.at(-1)?.actual;
+ if (rpAAfter?.approvalLevel === rpBAfter?.approvalLevel) {
+ throw new Error("rp isolation failed: rp-a and rp-b approvalLevel unexpectedly match");
+ }
+ assertAbsent(rpBAfter, "internalMemo", "rp-b isolation");
+ assertAbsent(rpBAfter, "adminManagedNote", "rp-b isolation");
+
+ report.passed = true;
+} catch (error) {
+ report.passed = false;
+ report.error = {
+ message: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ };
+ process.exitCode = 1;
+} finally {
+ if (browser) {
+ await browser.close();
+ }
+ report.finishedAt = new Date().toISOString();
+ const reportPath = `${reportDir}/rp-claims-live-e2e-${runId}.json`;
+ await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
+ console.log(JSON.stringify({ passed: report.passed, reportPath, runId }, null, 2));
+}
diff --git a/userfront-e2e/tests/login-performance-budget.spec.ts b/userfront-e2e/tests/login-performance-budget.spec.ts
index 386aa403..4cd9181b 100644
--- a/userfront-e2e/tests/login-performance-budget.spec.ts
+++ b/userfront-e2e/tests/login-performance-budget.spec.ts
@@ -279,10 +279,16 @@ test.describe("UserFront login performance budget", () => {
const rootIndex = requestedUrls.findIndex(
(url) => new URL(url).pathname === "/",
);
+ const signinIndex = requestedUrls.findIndex(
+ (url) => new URL(url).pathname === "/ko/signin",
+ );
const bootstrapIndex = requestedUrls.findIndex((url) =>
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
);
expect(rootIndex).toBeGreaterThanOrEqual(0);
- expect(bootstrapIndex).toBe(-1);
+ expect(signinIndex).toBeGreaterThan(rootIndex);
+ if (bootstrapIndex >= 0) {
+ expect(bootstrapIndex).toBeGreaterThan(signinIndex);
+ }
});
});
|