1
0
forked from baron/baron-sso

custom claim 타입보정 UI. 대표테넌트 노출 보정

This commit is contained in:
2026-06-11 11:27:11 +09:00
parent 0bb3ccb850
commit f60b15a17b
37 changed files with 2952 additions and 417 deletions

View File

@@ -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:

View File

@@ -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();

View File

@@ -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({
>
</Button>
<Button type="button" onClick={confirmInitialPassword}>
<Button
type="button"
onClick={confirmInitialPassword}
disabled={actionDisabled}
>
</Button>
</DialogFooter>

View File

@@ -1,12 +1,25 @@
import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
import { getSeedTenantIds, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => {
expect(getSeedTenantSlugs()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]),
it("marks tenants from seed-tenant.csv as protected by UUID", () => {
expect(getSeedTenantIds()).toEqual(
expect.arrayContaining([
"038326b6-954a-48a7-a85f-efd83f62b82a",
"5a03efd2-e62f-4243-800d-58334bf48b2f",
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
]),
);
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
expect(
isSeedTenant({
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
}),
).toBe(true);
expect(
isSeedTenant({
id: "5A03EFD2-E62F-4243-800D-58334BF48B2F",
}),
).toBe(true);
expect(isSeedTenant({ id: "normal-tenant" })).toBe(false);
});
});

View File

@@ -4,16 +4,15 @@ import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport";
const seedTenantSlugs = new Set(
parseTenantCSV(seedTenantCSVRaw)
.map((row) => row.slug.trim().toLowerCase())
.filter(Boolean),
const seedTenants = parseTenantCSV(seedTenantCSVRaw);
const seedTenantIds = new Set(
seedTenants.map((row) => row.tenantId.trim().toLowerCase()).filter(Boolean),
);
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
export function isSeedTenant(tenant: Pick<TenantSummary, "id">): boolean {
return seedTenantIds.has(tenant.id.trim().toLowerCase());
}
export function getSeedTenantSlugs(): string[] {
return Array.from(seedTenantSlugs);
export function getSeedTenantIds(): string[] {
return Array.from(seedTenantIds);
}

View File

@@ -0,0 +1,96 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import GlobalCustomClaimsPage from "./GlobalCustomClaimsPage";
const fetchGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
const updateGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchGlobalCustomClaimDefinitions: fetchGlobalCustomClaimDefinitionsMock,
updateGlobalCustomClaimDefinitions: updateGlobalCustomClaimDefinitionsMock,
}));
vi.mock("../../components/ui/use-toast", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function renderGlobalCustomClaimsPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<GlobalCustomClaimsPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("GlobalCustomClaimsPage", () => {
beforeEach(() => {
fetchGlobalCustomClaimDefinitionsMock.mockReset();
fetchGlobalCustomClaimDefinitionsMock.mockResolvedValue({
items: [
{
key: "locale",
label: "Locale",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
],
});
updateGlobalCustomClaimDefinitionsMock.mockReset();
updateGlobalCustomClaimDefinitionsMock.mockResolvedValue({ items: [] });
});
it("forces user read permission on when user write permission is enabled", async () => {
renderGlobalCustomClaimsPage();
const readSelect = await screen.findByTestId(
"global-claim-definition-read-permission-locale",
);
const writeSelect = await screen.findByTestId(
"global-claim-definition-write-permission-locale",
);
expect(readSelect).toHaveValue("admin_only");
expect(writeSelect).toHaveValue("admin_only");
fireEvent.change(writeSelect, { target: { value: "user_and_admin" } });
await waitFor(() => {
expect(readSelect).toHaveValue("user_and_admin");
expect(writeSelect).toHaveValue("user_and_admin");
});
fireEvent.click(screen.getByRole("button", { name: /저장|Save/ }));
await waitFor(() => {
expect(updateGlobalCustomClaimDefinitionsMock).toHaveBeenCalled();
});
expect(updateGlobalCustomClaimDefinitionsMock.mock.calls[0][0]).toEqual({
items: [
expect.objectContaining({
key: "locale",
readPermission: "user_and_admin",
writePermission: "user_and_admin",
}),
],
});
});
});

View File

@@ -52,6 +52,7 @@ function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
return drafts
.map((draft) => normalizeClaimDraftPermissions(draft))
.map((draft) => ({
key: draft.key.trim(),
label: draft.label.trim(),
@@ -63,6 +64,16 @@ function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
.filter((draft) => draft.key.length > 0);
}
function normalizeClaimDraftPermissions(draft: ClaimDraft): ClaimDraft {
if (draft.writePermission !== "user_and_admin") {
return draft;
}
return {
...draft,
readPermission: "user_and_admin",
};
}
function permissionLabel(permission: GlobalCustomClaimPermission) {
return permission === "user_and_admin"
? t(
@@ -116,7 +127,9 @@ export default function GlobalCustomClaimsPage() {
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
setDrafts((current) =>
current.map((draft) =>
draft.id === id ? { ...draft, ...patch } : draft,
draft.id === id
? normalizeClaimDraftPermissions({ ...draft, ...patch })
: draft,
),
);
};
@@ -140,7 +153,7 @@ export default function GlobalCustomClaimsPage() {
)}
description={t(
"msg.admin.users.global_custom_claims.description",
"모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다.",
"모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다.",
)}
actions={
<>
@@ -185,7 +198,7 @@ export default function GlobalCustomClaimsPage() {
<CardDescription>
{t(
"msg.admin.users.global_custom_claims.registry",
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다.",
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다.",
)}
</CardDescription>
</CardHeader>

View File

@@ -2051,7 +2051,7 @@ function UserDetailPage() {
<CardDescription>
{t(
"msg.admin.users.detail.custom_claims.description",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
)}
</CardDescription>
</div>

View File

@@ -22,6 +22,7 @@ const users = Array.from({ length: 200 }, (_, index) => ({
}));
const fetchUsersMock = vi.hoisted(() => vi.fn());
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
const searchRenderBudgetMs =
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
@@ -34,10 +35,7 @@ vi.mock("../../lib/adminApi", () => ({
name: "Admin",
email: "admin@example.com",
})),
fetchAllTenants: vi.fn(async () => ({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
})),
fetchAllTenants: fetchAllTenantsMock,
fetchTenant: vi.fn(async () => ({
id: "tenant-1",
name: "한맥",
@@ -108,6 +106,11 @@ describe("UserListPage search rendering", () => {
beforeEach(() => {
selectRenderCounter.count = 0;
fetchUsersMock.mockReset();
fetchAllTenantsMock.mockReset();
fetchAllTenantsMock.mockResolvedValue({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
});
fetchUsersMock.mockImplementation(
async (_limit: number, _offset: number, search?: string) => {
const normalizedSearch = search?.trim().toLowerCase();
@@ -157,7 +160,7 @@ describe("UserListPage search rendering", () => {
expect(content).toHaveClass("flex", "h-full", "items-center");
});
it("renders additional tenant appointments in the tenant column", async () => {
it("does not render private additional tenant appointments in the tenant column", async () => {
fetchUsersMock.mockResolvedValueOnce({
items: [
{
@@ -183,7 +186,63 @@ describe("UserListPage search rendering", () => {
expect(
await screen.findByText("Additional Tenant User"),
).toBeInTheDocument();
expect(screen.getByText("비공개 팀")).toBeInTheDocument();
expect(screen.getAllByText("한맥").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
});
it("excludes private tenants when choosing the representative tenant for the user list", async () => {
fetchAllTenantsMock.mockResolvedValueOnce({
items: [
{
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
total: 2,
});
fetchUsersMock.mockResolvedValueOnce({
items: [
{
...users[0],
name: "Private Primary User",
tenantSlug: "private-team",
tenant: {
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
joinedTenants: [
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
metadata: {
primaryTenantId: "tenant-private",
primaryTenantSlug: "private-team",
primaryTenantName: "비공개 팀",
},
},
],
total: 1,
});
renderUserListPage();
expect(await screen.findByText("Private Primary User")).toBeInTheDocument();
expect(screen.getByText("공개 팀")).toBeInTheDocument();
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
});
it("centers the initial loading message across the user table", async () => {

View File

@@ -151,50 +151,111 @@ function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user";
}
function collectAdditionalTenantLabels(user: UserSummary) {
const primaryKeys = new Set(
[user.tenant?.id, user.tenant?.slug, user.tenantSlug]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase()),
type RepresentativeTenantCandidate = {
id?: string;
slug?: string;
name?: string;
config?: Record<string, unknown>;
};
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function tenantVisibility(tenant?: RepresentativeTenantCandidate) {
const visibility = tenant?.config?.visibility;
return typeof visibility === "string" ? visibility.trim() : "";
}
function findTenantCandidate(
candidate: RepresentativeTenantCandidate,
tenants: TenantSummary[],
) {
const id = candidate.id?.toLowerCase() ?? "";
const slug = candidate.slug?.toLowerCase() ?? "";
if (!id && !slug) return undefined;
return tenants.find(
(tenant) =>
(id && tenant.id.toLowerCase() === id) ||
(slug && tenant.slug.toLowerCase() === slug),
);
const labels: string[] = [];
const seen = new Set<string>();
const addLabel = (
tenantId?: unknown,
tenantSlug?: unknown,
tenantName?: unknown,
) => {
const id = typeof tenantId === "string" ? tenantId.trim() : "";
const slug = typeof tenantSlug === "string" ? tenantSlug.trim() : "";
const name = typeof tenantName === "string" ? tenantName.trim() : "";
const key = (id || slug || name).toLowerCase();
if (!key || primaryKeys.has(key) || seen.has(key)) {
return;
}
seen.add(key);
labels.push(name || slug || id);
};
}
function isPrivateTenantCandidate(
candidate: RepresentativeTenantCandidate,
tenants: TenantSummary[],
) {
const tenant = findTenantCandidate(candidate, tenants) ?? candidate;
return tenantVisibility(tenant) === "private";
}
function candidateLabel(candidate: RepresentativeTenantCandidate) {
return candidate.name || candidate.slug || candidate.id || "";
}
function metadataTenantCandidate(
metadata: Record<string, unknown> | undefined,
): RepresentativeTenantCandidate | null {
const id = stringValue(metadata?.primaryTenantId);
const slug = stringValue(metadata?.primaryTenantSlug);
const name = stringValue(metadata?.primaryTenantName);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function appointmentTenantCandidate(
appointment: unknown,
): RepresentativeTenantCandidate | null {
if (!appointment || typeof appointment !== "object") return null;
const value = appointment as Record<string, unknown>;
const id = stringValue(value.tenantId);
const slug = stringValue(value.tenantSlug ?? value.slug);
const name = stringValue(value.tenantName ?? value.name);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function resolveRepresentativeTenantLabel(
user: UserSummary,
tenants: TenantSummary[],
) {
const candidates: RepresentativeTenantCandidate[] = [];
const knownTenants = [
...(user.tenant ? [user.tenant] : []),
...(user.joinedTenants ?? []),
...tenants,
];
const primaryFromMetadata = metadataTenantCandidate(user.metadata);
if (primaryFromMetadata) candidates.push(primaryFromMetadata);
if (user.tenant) candidates.push(user.tenant);
for (const tenant of user.joinedTenants ?? []) {
addLabel(tenant.id, tenant.slug, tenant.name);
candidates.push(tenant);
}
const appointments = user.metadata?.additionalAppointments;
if (Array.isArray(appointments)) {
for (const appointment of appointments) {
if (!appointment || typeof appointment !== "object") {
if (
appointment &&
typeof appointment === "object" &&
(appointment as Record<string, unknown>).isPrimary !== true
) {
continue;
}
const value = appointment as Record<string, unknown>;
addLabel(
value.tenantId,
value.tenantSlug ?? value.slug,
value.tenantName ?? value.name,
);
const candidate = appointmentTenantCandidate(appointment);
if (candidate) candidates.push(candidate);
}
}
if (user.tenantSlug) candidates.push({ slug: user.tenantSlug });
return labels;
const representative = candidates.find(
(candidate) =>
candidateLabel(candidate) &&
!isPrivateTenantCandidate(candidate, knownTenants),
);
return candidateLabel(representative ?? {});
}
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
@@ -467,10 +528,10 @@ function UserListPage() {
name_email: (user) =>
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
tenant_dept: (user) =>
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${collectAdditionalTenantLabels(user).join(" ")} ${user.department ?? ""}`,
`${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`,
},
),
[userSchema],
[tenants, userSchema],
);
const items = React.useMemo(() => {
if (!sortConfig) {
@@ -1019,8 +1080,9 @@ function UserListPage() {
virtualRows.map((virtualRow) => {
const user = items[virtualRow.index];
if (!user) return null;
const additionalTenantLabels =
collectAdditionalTenantLabels(user);
const representativeTenantLabel =
resolveRepresentativeTenantLabel(user, tenants) ||
t("ui.common.unassigned", "미배정");
return (
<TableRow
@@ -1151,27 +1213,13 @@ function UserListPage() {
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
{representativeTenantLabel}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
{additionalTenantLabels.length > 0 && (
<div className="flex flex-wrap gap-1">
{additionalTenantLabels.map((label) => (
<span
key={label}
className="max-w-40 truncate rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground"
>
{label}
</span>
))}
</div>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}

View File

@@ -348,9 +348,13 @@ update_error = "Failed to User Edit."
update_success = "Update Success"
[msg.admin.users.detail.custom_claims]
description = "Manage this user's values for globally defined custom claims. Add claim definitions and change types only from the global settings screen."
description = "Manage this user's values for globally defined custom claims. Read/Write indicates whether the user may view or update their own claim value. Add claim definitions and change types only from the global settings screen."
empty = "No global custom claims have been defined."
[msg.admin.users.global_custom_claims]
description = "Manage user claim definitions shared across all RPs and the default user read/write permissions. Enabling write also enables read."
registry = "Only defined claim keys are available in per-user global claim values. Read/Write is a user self-service permission, not an administrator permission."
[msg.admin.users.detail.form]
field_required = "Required."
invalid_format = "Invalid format."

View File

@@ -353,9 +353,13 @@ update_success = "사용자 정보가 수정되었습니다."
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
[msg.admin.users.detail.custom_claims]
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
empty = "전역으로 정의된 custom claim이 없습니다."
[msg.admin.users.global_custom_claims]
description = "모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다."
registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다."
[msg.admin.users.detail.form]
field_required = "필수입니다."
invalid_format = "형식이 올바르지 않습니다."

View File

@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
const tenants = [
{
id: "seed-hanmac",
id: "038326b6-954a-48a7-a85f-efd83f62b82a",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
@@ -13,6 +13,19 @@ const tenants = [
createdAt: "",
updatedAt: "",
},
{
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
name: "한라산업개발",
slug: "hallasanup",
type: "COMPANY",
description: "네이버웍스 한라 HALLA_DOMAIN_ID",
status: "active",
domains: ["hallasanup.com"],
memberCount: 0,
parentId: "038326b6-954a-48a7-a85f-efd83f62b82a",
createdAt: "",
updatedAt: "",
},
{
id: "normal-tenant",
name: "일반 테넌트",
@@ -96,11 +109,21 @@ test.describe("Seed tenant protection", () => {
}) => {
await page.goto("/tenants");
const seedRow = page.getByRole("row", { name: /한맥가족/ });
const seedRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "한맥가족", exact: true }),
});
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
await expect(seedRow.getByText("초기 설정")).toBeVisible();
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
const hallaRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "한라산업개발", exact: true }),
});
await expect(hallaRow.getByRole("checkbox")).toHaveCount(0);
await expect(hallaRow.getByText("초기 설정")).toBeVisible();
const normalRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "일반 테넌트", exact: true }),
});
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
});

View File

@@ -293,6 +293,94 @@ test.describe("User Management", () => {
});
});
test("should hide private representative tenants in the user list row", async ({
page,
}) => {
await page.route(/\/admin\/tenants(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
type: "USER_GROUP",
status: "active",
config: { visibility: "private" },
},
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
type: "USER_GROUP",
status: "active",
config: { visibility: "public" },
},
],
total: 2,
limit: 100,
offset: 0,
},
});
});
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: "u-private",
name: "Private Primary User",
email: "private-primary@example.com",
phone: "010-0000-0000",
loginId: "private-primary",
role: "user",
status: "active",
tenantSlug: "private-team",
tenant: {
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
joinedTenants: [
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
metadata: {
primaryTenantId: "tenant-private",
primaryTenantSlug: "private-team",
primaryTenantName: "비공개 팀",
},
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
limit: 50,
offset: 0,
},
});
});
await page.goto("/users");
const row = page.getByRole("row").filter({
hasText: "Private Primary User",
});
await expect(row).toContainText("공개 팀");
await expect(row).not.toContainText("비공개 팀");
});
test("should successfully edit a user's Login ID", async ({ page }) => {
await page.goto("/users/u-1");

View File

@@ -14,7 +14,9 @@ import (
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
@@ -51,6 +53,10 @@ func SeedTenants(db *gorm.DB) error {
return errors.New("seed tenant csv has no tenant rows")
}
if err := syncExistingSeedTenantConfigs(db, configs); err != nil {
return err
}
existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db)
if err != nil {
return err
@@ -71,6 +77,69 @@ func SeedTenants(db *gorm.DB) error {
return seedTenantConfigs(db, missingConfigs)
}
func syncExistingSeedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
for _, config := range configs {
id := strings.TrimSpace(config.TenantID)
if id == "" {
continue
}
targetSlug := strings.TrimSpace(strings.ToLower(config.Slug))
if targetSlug == "" {
continue
}
if err := db.Transaction(func(tx *gorm.DB) error {
var tenant domain.Tenant
if err := tx.First(&tenant, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load existing seed tenant %q: %w", id, err)
}
if strings.TrimSpace(strings.ToLower(tenant.Slug)) == targetSlug {
return nil
}
var conflict domain.Tenant
err := tx.Select("id").
Where("LOWER(TRIM(slug)) = ? AND id <> ?", targetSlug, tenant.ID).
First(&conflict).Error
if err == nil {
return fmt.Errorf("seed tenant slug %q for id %q conflicts with tenant id %q", targetSlug, id, conflict.ID)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("check seed tenant slug conflict %q: %w", targetSlug, err)
}
suffix := "-deleted-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
if err := tx.Unscoped().
Model(&domain.Tenant{}).
Where("slug = ? AND id <> ? AND deleted_at IS NOT NULL", targetSlug, tenant.ID).
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
return fmt.Errorf("rename deleted tenant slug %q before seed repair: %w", targetSlug, err)
}
slog.Info(
"[Bootstrap] Repairing existing seed tenant slug",
"id", tenant.ID,
"oldSlug", tenant.Slug,
"newSlug", targetSlug,
)
if err := tx.Model(&domain.Tenant{}).
Where("id = ?", tenant.ID).
Update("slug", targetSlug).Error; err != nil {
return fmt.Errorf("repair seed tenant slug %q for id %q: %w", targetSlug, id, err)
}
return nil
}); err != nil {
return err
}
}
return nil
}
func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) {
var tenants []domain.Tenant
if err := db.Select("id", "slug").Find(&tenants).Error; err != nil {

View File

@@ -273,7 +273,7 @@ func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) {
}
}
func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) {
func TestSeedTenantsCreatesMissingSeedRowsAndRepairsExistingSeedSlug(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
@@ -326,18 +326,30 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
existingSeedTenantWithTypoSlug := domain.Tenant{
ID: "5a03efd2-e62f-4243-800d-58334bf48b2f",
Name: "한라산업개발",
Slug: "hanlla",
Type: domain.TenantTypeCompany,
Description: "seed tenant with a typo slug must be repaired by UUID",
Status: domain.TenantStatusActive,
ParentID: &existingRoot.ID,
}
if err := db.Create(&existingRoot).Error; err != nil {
t.Fatalf("failed to create existing root tenant: %v", err)
}
if err := db.Create(&nonSeedTenant).Error; err != nil {
t.Fatalf("failed to create non-seed tenant: %v", err)
}
if err := db.Create(&existingSeedTenantWithTypoSlug).Error; err != nil {
t.Fatalf("failed to create existing seed tenant with typo slug: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" +
"00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" +
"5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,existing-root,halla,seed typo slug must be repaired,hallasanup.com\n" +
"10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
@@ -359,6 +371,22 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name)
}
var manual domain.Tenant
if err := db.First(&manual, "id = ?", nonSeedTenant.ID).Error; err != nil {
t.Fatalf("failed to load non-seed tenant after seed: %v", err)
}
if manual.Slug != nonSeedTenant.Slug {
t.Fatalf("non-seed tenant slug = %q, want untouched %q", manual.Slug, nonSeedTenant.Slug)
}
var repairedSeed domain.Tenant
if err := db.First(&repairedSeed, "id = ?", existingSeedTenantWithTypoSlug.ID).Error; err != nil {
t.Fatalf("failed to load existing seed tenant after seed: %v", err)
}
if repairedSeed.Slug != "halla" {
t.Fatalf("existing seed tenant slug = %q, want halla", repairedSeed.Slug)
}
var child domain.Tenant
if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil {
t.Fatalf("missing seed child was not created: %v", err)
@@ -378,11 +406,4 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
t.Fatalf("existing-root row count = %d, want 1", rootCount)
}
var conflictingIDCount int64
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil {
t.Fatalf("failed to count conflicting-id rows: %v", err)
}
if conflictingIDCount != 0 {
t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount)
}
}

View File

@@ -17,6 +17,7 @@ import (
"io"
"log/slog"
"maps"
"math"
"math/rand"
"net"
"net/http"
@@ -1646,6 +1647,154 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
return baseClaims
}
func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
if claims == nil {
claims = map[string]any{}
}
if h == nil || h.RPUserMetadataRepo == nil {
return claims
}
clientID := strings.TrimSpace(client.ClientID)
subject = strings.TrimSpace(subject)
if clientID == "" || subject == "" {
return claims
}
rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata)
if len(rpClaimDefinitions) == 0 {
return claims
}
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
if err != nil || row == nil || len(row.Metadata) == 0 {
return claims
}
rpClaims, _ := claims["rp_claims"].(map[string]any)
if rpClaims == nil {
rpClaims = map[string]any{}
}
for _, claim := range rpClaimDefinitions {
raw, ok := row.Metadata[claim.Key]
if !ok || raw == nil {
continue
}
value, ok := coerceRPUserMetadataClaimValue(raw, claim.ValueType)
if !ok {
slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType)
continue
}
rpClaims[claim.Key] = value
}
if len(rpClaims) > 0 {
claims["rp_claims"] = rpClaims
}
return claims
}
func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim {
if metadata == nil {
return nil
}
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
if !ok || rawClaims == nil {
return nil
}
normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
if err != nil {
slog.Warn("failed to normalize rp claim definitions", "error", err)
return nil
}
definitions := make([]normalizedIDTokenClaim, 0, len(normalizedClaims))
seen := make(map[string]struct{}, len(normalizedClaims))
for _, claim := range normalizedClaims {
if claim.Namespace != "rp_claims" {
continue
}
if _, exists := seen[claim.Key]; exists {
continue
}
seen[claim.Key] = struct{}{}
definitions = append(definitions, claim)
}
return definitions
}
func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
switch value := raw.(type) {
case string:
if strings.TrimSpace(value) == "" {
return nil, false
}
parsed, err := parseConfiguredClaimValue(value, valueType)
return parsed, err == nil
case []any:
if valueType == "array" {
return value, true
}
case []string:
if valueType == "array" {
items := make([]any, 0, len(value))
for _, item := range value {
items = append(items, item)
}
return items, true
}
case map[string]any:
if valueType == "object" {
return value, true
}
case bool:
if valueType == "boolean" {
return value, true
}
case float64:
if valueType == "float" {
return value, true
}
if valueType == "number" && value == math.Trunc(value) {
return value, true
}
case float32:
floatValue := float64(value)
if valueType == "float" {
return floatValue, true
}
if valueType == "number" && floatValue == math.Trunc(floatValue) {
return floatValue, true
}
case int:
if valueType == "number" {
return float64(value), true
}
if valueType == "float" {
return float64(value), true
}
case int64:
if valueType == "number" {
return float64(value), true
}
if valueType == "float" {
return float64(value), true
}
case json.Number:
if valueType == "number" {
parsed, err := value.Int64()
return float64(parsed), err == nil
}
if valueType == "float" {
parsed, err := value.Float64()
return parsed, err == nil
}
}
parsed, err := parseConfiguredClaimValue(fmt.Sprint(raw), valueType)
return parsed, err == nil
}
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
if claims == nil {
claims = map[string]any{}
@@ -6046,6 +6195,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
currentSessionID,
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err == nil {
@@ -6084,6 +6234,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
currentSessionID,
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
@@ -6275,6 +6426,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
currentSessionID,
)
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)

View File

@@ -827,3 +827,148 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
}
}
func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) {
var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-rp-user-claims",
"requested_scope": []string{"openid", "profile"},
"subject": "user-rp-claims",
"client": map[string]any{
"client_id": "client-rp-claims",
"metadata": map[string]any{
"id_token_claims": []map[string]any{
{
"namespace": "rp_claims",
"key": "approvalLevel",
"value": "A",
"valueType": "text",
},
{
"namespace": "rp_claims",
"key": "activeMember",
"value": "true",
"valueType": "boolean",
},
{
"namespace": "rp_claims",
"key": "score",
"value": "1",
"valueType": "number",
},
{
"namespace": "rp_claims",
"key": "featureList",
"value": `["default"]`,
"valueType": "array",
},
{
"namespace": "rp_claims",
"key": "preferences",
"value": `{"theme":"light","density":"comfortable"}`,
"valueType": "object",
},
{
"namespace": "rp_claims",
"key": "contractDate",
"value": "2026-06-09",
"valueType": "date",
},
{
"namespace": "rp_claims",
"key": "approvedAt",
"value": "2026-06-09T09:30",
"valueType": "datetime",
},
},
},
},
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
body, _ := io.ReadAll(r.Body)
var acceptReq map[string]any
json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]any)
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService),
}
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-rp-claims").Return(&service.KratosIdentity{
ID: "user-rp-claims",
Traits: map[string]any{
"email": "rp-user@example.com",
"name": "RP User",
},
}, nil)
repo := new(devMockRPUserMetadataRepo)
repo.On("Get", mock.Anything, "client-rp-claims", "user-rp-claims").Return(&domain.RPUserMetadata{
ClientID: "client-rp-claims",
UserID: "user-rp-claims",
Metadata: domain.JSONMap{
"approvalLevel": "B",
"activeMember": false,
"score": float64(42),
"featureList": []any{"sso", "claims"},
"preferences": map[string]any{
"theme": "dark",
"density": "compact",
},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"internalMemo": "must-not-leak",
"approvalLevel_permissions": map[string]any{
"readPermission": "admin_only",
"writePermission": "user_and_admin",
},
},
}, nil).Once()
h.RPUserMetadataRepo = repo
app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-rp-user-claims",
"grant_scope": []string{"openid", "profile"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims)
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
if assert.True(t, ok) {
assert.Equal(t, "B", rpClaims["approvalLevel"])
assert.Equal(t, false, rpClaims["activeMember"])
assert.Equal(t, float64(42), rpClaims["score"])
assert.Equal(t, []any{"sso", "claims"}, rpClaims["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaims["preferences"])
assert.Equal(t, "2026-06-10", rpClaims["contractDate"])
assert.Equal(t, "2026-06-09T10:30", rpClaims["approvedAt"])
assert.NotContains(t, rpClaims, "internalMemo")
assert.NotContains(t, rpClaims, "approvalLevel_permissions")
}
assert.NotContains(t, capturedClaims, "rp_profiles")
repo.AssertExpectations(t)
}

View File

@@ -3588,7 +3588,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
valueType = "text"
}
switch valueType {
case "text", "number", "boolean", "array", "object", "date", "datetime":
case "text", "number", "float", "boolean", "array", "object", "date", "datetime":
default:
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
}
@@ -3641,9 +3641,24 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
if trimmed == "" {
return nil, errors.New("number value is required")
}
if !isIntegerClaimLiteral(trimmed) {
return nil, errors.New("number value must be an integer")
}
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, errors.New("number value must be a finite number")
return nil, errors.New("number value must be an integer")
}
return parsed, nil
case "float":
if trimmed == "" {
return nil, errors.New("float value is required")
}
if !isFloatClaimLiteral(trimmed) {
return nil, errors.New("float value must be a finite decimal number")
}
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, errors.New("float value must be a finite decimal number")
}
return parsed, nil
case "boolean":
@@ -3708,6 +3723,54 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
}
}
func isIntegerClaimLiteral(value string) bool {
if value == "" {
return false
}
start := 0
if value[0] == '-' {
if len(value) == 1 {
return false
}
start = 1
}
for _, char := range value[start:] {
if char < '0' || char > '9' {
return false
}
}
return true
}
func isFloatClaimLiteral(value string) bool {
if value == "" {
return false
}
start := 0
if value[0] == '-' {
if len(value) == 1 {
return false
}
start = 1
}
hasDigit := false
hasDot := false
for _, char := range value[start:] {
switch {
case char >= '0' && char <= '9':
hasDigit = true
case char == '.':
if hasDot {
return false
}
hasDot = true
default:
return false
}
}
return hasDigit
}
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
if req.Jwks != nil {
return true

View File

@@ -60,6 +60,30 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"valueType": "number",
"value": "1",
},
{
"namespace": "rp_claims",
"key": "featureList",
"valueType": "array",
"value": `["default"]`,
},
{
"namespace": "rp_claims",
"key": "preferences",
"valueType": "object",
"value": `{"theme":"light","density":"comfortable"}`,
},
{
"namespace": "rp_claims",
"key": "contractDate",
"valueType": "date",
"value": "2026-06-09",
},
{
"namespace": "rp_claims",
"key": "approvedAt",
"valueType": "datetime",
"value": "2026-06-09T09:30",
},
},
},
}), nil
@@ -74,8 +98,14 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
row.Metadata["approvalLevel"] == "A" &&
row.Metadata["activeMember"] == false &&
row.Metadata["score"] == float64(42) &&
assert.ObjectsAreEqual([]any{"sso", "claims"}, row.Metadata["featureList"]) &&
assert.ObjectsAreEqual(map[string]any{"theme": "dark", "density": "compact"}, row.Metadata["preferences"]) &&
row.Metadata["contractDate"] == "2026-06-10" &&
row.Metadata["approvedAt"] == "2026-06-09T10:30" &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin"
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" &&
row.Metadata["featureList_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
row.Metadata["featureList_permissions"].(map[string]any)["writePermission"] == "admin_only"
})).Return(nil).Once()
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
ClientID: "client-1",
@@ -103,6 +133,13 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"approvalLevel": "A",
"activeMember": false,
"score": 42,
"featureList": []string{"sso", "claims"},
"preferences": map[string]any{
"theme": "dark",
"density": "compact",
},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin",
},

View File

@@ -2520,6 +2520,13 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
"value": "2",
"valueType": "number",
},
{
"id": "claim-3",
"namespace": "rp_claims",
"key": "ratio",
"value": "3.14",
"valueType": "float",
},
},
},
})
@@ -2530,7 +2537,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
assert.Equal(t, http.StatusCreated, resp.StatusCode)
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any)
if assert.True(t, ok) && assert.Len(t, claims, 2) {
if assert.True(t, ok) && assert.Len(t, claims, 3) {
first, ok := claims[0].(map[string]any)
if assert.True(t, ok) {
assert.Equal(t, "rp_claims", first["namespace"])
@@ -2548,6 +2555,14 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
assert.Equal(t, "2", second["value"])
assert.Equal(t, "number", second["valueType"])
}
third, ok := claims[2].(map[string]any)
if assert.True(t, ok) {
assert.Equal(t, "rp_claims", third["namespace"])
assert.Equal(t, "ratio", third["key"])
assert.Equal(t, "3.14", third["value"])
assert.Equal(t, "float", third["valueType"])
}
}
}

View File

@@ -0,0 +1,328 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type rpClaimsE2ERepo struct {
mu sync.Mutex
rows map[string]*domain.RPUserMetadata
getKeys []string
}
func newRPClaimsE2ERepo() *rpClaimsE2ERepo {
return &rpClaimsE2ERepo{rows: map[string]*domain.RPUserMetadata{}}
}
func (r *rpClaimsE2ERepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := rpClaimsE2ERepoKey(clientID, userID)
r.getKeys = append(r.getKeys, key)
row, ok := r.rows[key]
if !ok {
return nil, fmt.Errorf("rp user metadata not found")
}
return cloneRPUserMetadata(row), nil
}
func (r *rpClaimsE2ERepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
r.mu.Lock()
defer r.mu.Unlock()
r.rows[rpClaimsE2ERepoKey(metadata.ClientID, metadata.UserID)] = cloneRPUserMetadata(metadata)
return nil
}
func (r *rpClaimsE2ERepo) seenGet(clientID, userID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
key := rpClaimsE2ERepoKey(clientID, userID)
for _, seen := range r.getKeys {
if seen == key {
return true
}
}
return false
}
func rpClaimsE2ERepoKey(clientID, userID string) string {
return strings.TrimSpace(clientID) + "\x00" + strings.TrimSpace(userID)
}
func cloneRPUserMetadata(row *domain.RPUserMetadata) *domain.RPUserMetadata {
if row == nil {
return nil
}
cloned := &domain.RPUserMetadata{
ClientID: row.ClientID,
UserID: row.UserID,
Metadata: domain.JSONMap{},
}
for key, value := range row.Metadata {
cloned.Metadata[key] = value
}
return cloned
}
func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
const userID = "user-rp-e2e"
const clientA = "client-rp-a"
const clientB = "client-rp-b"
clients := map[string]map[string]any{
clientA: rpClaimsE2EClient(clientA, []map[string]any{
rpClaimsE2EClaim("approvalLevel", "text", "A", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("activeMember", "boolean", "true", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("contractDate", "date", "2026-06-09", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"),
}),
clientB: rpClaimsE2EClient(clientB, []map[string]any{
rpClaimsE2EClaim("approvalLevel", "text", "B-default", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("activeMember", "boolean", "false", "user_and_admin", "user_and_admin"),
}),
}
challenges := map[string]string{
"challenge-client-a-default": clientA,
"challenge-client-a-admin-update": clientA,
"challenge-client-a-self-update": clientA,
"challenge-client-b-default": clientB,
"challenge-client-b-update": clientB,
}
capturedClaims := map[string]map[string]any{}
hydraClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if strings.HasPrefix(r.URL.Path, "/clients/") {
clientID := strings.TrimPrefix(r.URL.Path, "/clients/")
client, ok := clients[clientID]
if !ok {
return httpJSONAny(r, http.StatusNotFound, nil), nil
}
return httpJSONAny(r, http.StatusOK, client), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent" {
challenge := r.URL.Query().Get("consent_challenge")
clientID, ok := challenges[challenge]
if !ok {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": challenge,
"requested_scope": []string{"openid", "profile"},
"subject": userID,
"client": clients[clientID],
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" {
challenge := r.URL.Query().Get("consent_challenge")
body, _ := io.ReadAll(r.Body)
var acceptReq map[string]any
_ = json.Unmarshal(body, &acceptReq)
session, _ := acceptReq["session"].(map[string]any)
idToken, _ := session["id_token"].(map[string]any)
capturedClaims[challenge] = idToken
return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})}
repo := newRPClaimsE2ERepo()
kratos := new(MockKratosAdminService)
kratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
State: "active",
Traits: map[string]any{
"email": "rp-e2e@example.com",
"name": "RP E2E User",
},
}, nil)
authHandler := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: hydraClient,
},
KratosAdmin: kratos,
RPUserMetadataRepo: repo,
}
devHandler := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: hydraClient,
},
RPUserMetadataRepo: repo,
}
app := fiber.New()
app.Put("/api/v1/dev/clients/:id/users/me/metadata", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: userID, Role: domain.RoleUser})
return devHandler.SelfUpdateRPUserMetadata(c)
})
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
return devHandler.UpsertRPUserMetadata(c)
})
app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest)
initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default")
assert.Equal(t, "A", initialA["approvalLevel"])
assert.Equal(t, true, initialA["activeMember"])
assert.Equal(t, float64(1), initialA["score"])
assert.Equal(t, []any{"default"}, initialA["featureList"])
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"])
assert.Equal(t, "2026-06-09", initialA["contractDate"])
assert.Equal(t, "2026-06-09T09:30", initialA["approvedAt"])
upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{
"approvalLevel": "B",
"activeMember": false,
"score": 42,
"featureList": []string{"sso", "claims"},
"preferences": map[string]any{"theme": "dark", "density": "compact"},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"adminManagedNote": "admin-updated",
"approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin",
},
})
updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update")
assert.Equal(t, "B", updatedA["approvalLevel"])
assert.Equal(t, false, updatedA["activeMember"])
assert.Equal(t, float64(42), updatedA["score"])
assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"])
assert.Equal(t, "2026-06-10", updatedA["contractDate"])
assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"])
assert.Equal(t, "admin-updated", updatedA["adminManagedNote"])
assert.NotContains(t, updatedA, "approvalLevel_permissions")
assert.NotContains(t, updatedA, "adminManagedNote_permissions")
rejectedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
"metadata": map[string]any{
"adminManagedNote": "user-should-not-overwrite",
},
})
assert.Equal(t, http.StatusForbidden, rejectedSelfUpdate.StatusCode)
allowedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
"metadata": map[string]any{
"approvalLevel": "C",
},
})
assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode)
selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update")
assert.Equal(t, "C", selfUpdatedA["approvalLevel"])
assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"])
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
assert.Equal(t, "B-default", defaultB["approvalLevel"])
assert.Equal(t, false, defaultB["activeMember"])
assert.NotContains(t, defaultB, "score")
assert.NotContains(t, defaultB, "featureList")
assert.NotContains(t, defaultB, "adminManagedNote")
upsertRPClaimsE2EMetadata(t, app, clientB, userID, map[string]any{
"approvalLevel": "B-rp-only",
"activeMember": true,
})
updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update")
assert.Equal(t, "B-rp-only", updatedB["approvalLevel"])
assert.Equal(t, true, updatedB["activeMember"])
assert.NotEqual(t, selfUpdatedA["approvalLevel"], updatedB["approvalLevel"])
assert.NotContains(t, updatedB, "score")
assert.NotContains(t, updatedB, "featureList")
require.True(t, repo.seenGet(clientA, userID))
require.True(t, repo.seenGet(clientB, userID))
require.Contains(t, capturedClaims, "challenge-client-a-admin-update")
require.Contains(t, capturedClaims, "challenge-client-b-update")
kratos.AssertExpectations(t)
}
func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any {
return map[string]any{
"client_id": clientID,
"client_name": clientID,
"metadata": map[string]any{
"tenant_id": "tenant-rp-e2e",
"id_token_claims": claims,
},
}
}
func rpClaimsE2EClaim(key, valueType, value, readPermission, writePermission string) map[string]any {
return map[string]any{
"namespace": "rp_claims",
"key": key,
"valueType": valueType,
"value": value,
"readPermission": readPermission,
"writePermission": writePermission,
}
}
func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[string]map[string]any, challenge string) map[string]any {
t.Helper()
body, _ := json.Marshal(map[string]any{
"consent_challenge": challenge,
"grant_scope": []string{"openid", "profile"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
idToken, ok := capturedClaims[challenge]
require.True(t, ok)
rpClaims, ok := idToken["rp_claims"].(map[string]any)
require.True(t, ok)
return rpClaims
}
func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) {
t.Helper()
resp := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientID+"/users/"+userID+"/metadata", map[string]any{
"metadata": metadata,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
}
func putRPClaimsE2EMetadata(t *testing.T, app *fiber.App, method, path string, body map[string]any) *http.Response {
t.Helper()
payload, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
return resp
}

View File

@@ -52,6 +52,7 @@ import { ClientDetailTabs } from "./ClientDetailTabs";
type RPClaimValueType =
| "text"
| "number"
| "float"
| "boolean"
| "array"
| "object"
@@ -167,7 +168,9 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
function draftRowValueToMetadataValue(row: MetadataDraftRow) {
const value = row.value.trim();
switch (row.valueType) {
case "number": {
case "number":
return /^-?\d+$/.test(value) ? Number.parseInt(value, 10) : value;
case "float": {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
@@ -200,6 +203,7 @@ function isRPClaimValueType(value: string): value is RPClaimValueType {
return (
value === "text" ||
value === "number" ||
value === "float" ||
value === "boolean" ||
value === "array" ||
value === "object" ||
@@ -268,10 +272,21 @@ function readRPClaimSchemas(
function rpClaimInputType(valueType: RPClaimValueType) {
if (valueType === "date") return "date";
if (valueType === "datetime") return "datetime-local";
if (valueType === "number") return "number";
return "text";
}
function rpClaimInputMode(valueType: RPClaimValueType) {
if (valueType === "number") return "numeric";
if (valueType === "float") return "decimal";
return undefined;
}
function rpClaimInputPattern(valueType: RPClaimValueType) {
if (valueType === "number") return "-?[0-9]*";
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
return undefined;
}
function ClientConsentsPage() {
const params = useParams();
const clientId = params.id ?? "";
@@ -452,25 +467,6 @@ function ClientConsentsPage() {
);
};
const addMetadataDraftRow = () => {
setMetadataDraftRows((current) => [
...current,
{
id: `rp-metadata-${Date.now()}`,
key: "",
value: "",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
schemaBacked: false,
},
]);
};
const removeMetadataDraftRow = (id: string) => {
setMetadataDraftRows((current) => current.filter((row) => row.id !== id));
};
if (error) {
const axiosError = error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
@@ -958,16 +954,6 @@ function ClientConsentsPage() {
</p>
</div>
<div className="flex gap-2">
{rpClaimSchemas.length === 0 && (
<Button
variant="outline"
className="gap-2"
onClick={addMetadataDraftRow}
>
<Edit3 className="h-4 w-4" />
{t("ui.common.add", "추가")}
</Button>
)}
<Button
variant="ghost"
className="gap-2"
@@ -1008,25 +994,9 @@ function ClientConsentsPage() {
key={row.id}
className="grid gap-3 md:grid-cols-[minmax(180px,0.8fr)_minmax(220px,1fr)_150px_150px_auto]"
>
{row.schemaBacked ? (
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
{row.key}
</div>
) : (
<Input
value={row.key}
onChange={(event) =>
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.valueType === "boolean" ? (
<select
value={row.value === "false" ? "false" : "true"}
@@ -1061,6 +1031,8 @@ function ClientConsentsPage() {
) : (
<Input
type={rpClaimInputType(row.valueType)}
inputMode={rpClaimInputMode(row.valueType)}
pattern={rpClaimInputPattern(row.valueType)}
value={row.value}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
@@ -1129,22 +1101,12 @@ function ClientConsentsPage() {
)}
</option>
</select>
{row.schemaBacked ? (
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
>
{row.valueType}
</Badge>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => removeMetadataDraftRow(row.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))
)}

View File

@@ -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", () => {
</MemoryRouter>,
);
expect(html).toContain("사용자 Claim");
expect(html).toContain("Consents &amp; Claims");
expect(html).toContain('href="/clients/client-a/consents"');
});
});

View File

@@ -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` },
{

View File

@@ -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<HTMLButtonElement>('[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<HTMLSelectElement>(
'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<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
const defaultValueInput = container.querySelector<HTMLInputElement>(
'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<HTMLSelectElement>(
'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<HTMLInputElement>(
'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<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean");
const booleanDefaultSelect = Array.from(
container.querySelectorAll<HTMLSelectElement>("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<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "object");
const defaultValueInput = container.querySelector<HTMLTextAreaElement>(
'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();
});
});

View File

@@ -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<string, unknown>,
): 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<string, unknown> {
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<string, unknown> {
@@ -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) => ({
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() {
<CardDescription>
{t(
"msg.dev.clients.general.id_token_claims.subtitle",
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
"RP 전용 확장 claim을 구분해서 관리합니다.",
)}
</CardDescription>
</div>
@@ -2151,13 +2303,13 @@ function ClientGeneralPage() {
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
"Read",
"User read",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
"Write",
"User write",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
@@ -2175,7 +2327,11 @@ function ClientGeneralPage() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{idTokenClaims.map((claim) => (
{idTokenClaims.map((claim) => {
const defaultValueError =
claimDefaultValueValidationError(claim);
return (
<tr key={claim.id} className="hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<Input
@@ -2218,7 +2374,7 @@ function ClientGeneralPage() {
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.value_type_label",
"Claim value type",
"Claim 값 타입",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
@@ -2235,6 +2391,12 @@ function ClientGeneralPage() {
"Number",
)}
</option>
<option value="float">
{t(
"ui.dev.clients.general.id_token_claims.value_type_float",
"Float",
)}
</option>
<option value="boolean">
{t(
"ui.dev.clients.general.id_token_claims.value_type_boolean",
@@ -2301,7 +2463,7 @@ function ClientGeneralPage() {
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
"Read 사용자 허용",
"사용자 읽기 허용",
)}
disabled={isGeneralSettingsReadOnly}
/>
@@ -2322,14 +2484,59 @@ function ClientGeneralPage() {
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
"Write 사용자 허용",
"사용자 쓰기 허용",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
{claim.valueType === "array" ||
claim.valueType === "object" ? (
<Textarea
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="min-h-9 font-mono text-xs"
placeholder={
claim.valueType === "array"
? `["value"]`
: `{"key": "value"}`
}
disabled={isGeneralSettingsReadOnly}
/>
) : claim.valueType === "boolean" ? (
<select
value={
claim.value === "false" ? "false" : "true"
}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<Input
type={claimDefaultInputType(claim.valueType)}
inputMode={claimDefaultInputMode(
claim.valueType,
)}
pattern={claimDefaultInputPattern(
claim.valueType,
)}
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
@@ -2344,7 +2551,16 @@ function ClientGeneralPage() {
"Enter the default value",
)}
disabled={isGeneralSettingsReadOnly}
aria-invalid={
defaultValueError ? true : undefined
}
/>
)}
{defaultValueError && (
<p className="mt-1 text-xs text-destructive">
{defaultValueError}
</p>
)}
</td>
<td className="px-4 py-3 text-right align-top">
<Button
@@ -2358,7 +2574,8 @@ function ClientGeneralPage() {
</Button>
</td>
</tr>
))}
);
})}
{idTokenClaims.length === 0 && (
<tr>
<td
@@ -2378,7 +2595,7 @@ function ClientGeneralPage() {
<p className="text-xs leading-6 text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.hint",
"RP 전용 확장 claim 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
"RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
)}
</p>
</div>

View File

@@ -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"

View File

@@ -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 = "객체"

View File

@@ -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 = ""

View File

@@ -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();
});
});

View File

@@ -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",
"설정",
"관계",
]);

View File

@@ -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);
});
});

View File

@@ -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에 주입합니다.

View File

@@ -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"

604
test/rp_claims_live_e2e.mjs Normal file
View File

@@ -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));
}

View File

@@ -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);
}
});
});