@@ -1719,6 +1772,7 @@ function ClientGeneralPage() {
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenant.id)}
className="text-muted-foreground transition hover:text-destructive"
+ disabled={isGeneralSettingsReadOnly}
>
@@ -1744,6 +1798,7 @@ function ClientGeneralPage() {
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenantId)}
className="text-muted-foreground transition hover:text-destructive"
+ disabled={isGeneralSettingsReadOnly}
>
@@ -1786,7 +1841,11 @@ function ClientGeneralPage() {
)}
+
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
@@ -1849,6 +1908,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
+ disabled={isGeneralSettingsReadOnly}
/>
@@ -1866,6 +1926,7 @@ function ClientGeneralPage() {
"Claim namespace",
)}
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}
>
{t(
@@ -1896,6 +1957,7 @@ function ClientGeneralPage() {
"Claim value type",
)}
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}
>
{t(
@@ -1944,6 +2006,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the claim value",
)}
+ disabled={isGeneralSettingsReadOnly}
/>
@@ -1952,6 +2015,7 @@ function ClientGeneralPage() {
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
+ disabled={isGeneralSettingsReadOnly}
>
@@ -2116,6 +2180,7 @@ function ClientGeneralPage() {
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
+ disabled={isGeneralSettingsReadOnly}
/>
)}
@@ -2589,6 +2654,7 @@ function ClientGeneralPage() {
mutation.mutate()}
disabled={
+ isGeneralSettingsReadOnly ||
mutation.isPending ||
isLoading ||
name.trim() === "" ||
diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx
index e93e9260..8e1e63c3 100644
--- a/devfront/src/features/clients/ClientRelationsPage.tsx
+++ b/devfront/src/features/clients/ClientRelationsPage.tsx
@@ -47,7 +47,6 @@ const relationOptions = [
"consent_revoker",
"relationship_viewer",
"audit_viewer",
- "status_operator",
] as const;
type RelationOption = (typeof relationOptions)[number];
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index 168a1d53..14bef279 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -389,6 +389,8 @@ note = "Keep endpoints read-only, and tie secret rotation/copy actions to audit
[msg.dev.clients.general]
load_error = "Error loading client: {{error}}"
loading = "Loading client..."
+read_only_forbidden = "You do not have permission to edit this RP's general settings."
+read_only_hint = "Only users with the `RP Admin` or `RP General Settings` relationship can edit this RP's general settings."
saved = "Saved"
save_error = "Failed to save: {{error}}"
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index b34eb75a..450725fa 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -389,6 +389,8 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
[msg.dev.clients.general]
load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}"
loading = "클라이언트 정보를 불러오는 중..."
+read_only_forbidden = "이 RP의 일반 설정을 수정할 권한이 없습니다."
+read_only_hint = "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다."
save_error = "저장 실패: {{error}}"
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
saved = "설정이 저장되었습니다."
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index 989bfdec..404f5348 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -414,6 +414,8 @@ note = ""
[msg.dev.clients.general]
load_error = ""
loading = ""
+read_only_forbidden = ""
+read_only_hint = ""
saved = ""
save_error = ""
save_forbidden = ""
From 6d5a861d17e30d77530cdbdf7f944893a8d5f653 Mon Sep 17 00:00:00 2001
From: kyy
Date: Thu, 30 Apr 2026 10:25:48 +0900
Subject: [PATCH 09/17] =?UTF-8?q?=EA=B4=80=EA=B3=84=20=ED=83=AD=20?=
=?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=A0=81=20=EB=85=B8=EC=B6=9C=20=ED=98=84?=
=?UTF-8?q?=EC=83=81=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
devfront/src/features/clients/ClientRelationsPage.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx
index 8e1e63c3..96653d1f 100644
--- a/devfront/src/features/clients/ClientRelationsPage.tsx
+++ b/devfront/src/features/clients/ClientRelationsPage.tsx
@@ -388,7 +388,11 @@ function ClientRelationsPage() {
- {isRelationshipViewForbidden ? (
+ {isLoading ? (
+
+ {t("msg.dev.clients.relationships.loading", "Loading relationships...")}
+
+ ) : isRelationshipViewForbidden ? (
{relationshipViewForbiddenMessage}
From 894565d87e7692d72480e69512c7710a18d3a26c Mon Sep 17 00:00:00 2001
From: kyy
Date: Thu, 30 Apr 2026 10:50:47 +0900
Subject: [PATCH 10/17] =?UTF-8?q?=EA=B0=90=EC=82=AC=EB=A1=9C=EA=B7=B8=20?=
=?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A0=84=EC=B2=B4=20=EC=83=88=EB=A1=9C?=
=?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
devfront/src/features/audit/AuditLogsPage.tsx | 304 ++++++++++--------
1 file changed, 165 insertions(+), 139 deletions(-)
diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx
index 1f20529a..abbab331 100644
--- a/devfront/src/features/audit/AuditLogsPage.tsx
+++ b/devfront/src/features/audit/AuditLogsPage.tsx
@@ -126,16 +126,26 @@ function AuditLogsPage() {
const [searchClientId, setSearchClientId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
+
+ // Use deferred values to avoid UI lag during rapid typing
+ const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
+ const deferredSearchAction = React.useDeferredValue(searchAction.trim());
+
const [expandedRows, setExpandedRows] = React.useState<
Record
>({});
const query = useInfiniteQuery({
- queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
+ queryKey: [
+ "dev-audit-logs",
+ deferredSearchClientId,
+ deferredSearchAction,
+ statusFilter,
+ ],
queryFn: ({ pageParam }) =>
fetchDevAuditLogs(50, pageParam, {
- client_id: searchClientId.trim() || undefined,
- action: searchAction.trim() || undefined,
+ client_id: deferredSearchClientId || undefined,
+ action: deferredSearchAction || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
}),
initialPageParam: undefined as string | undefined,
@@ -160,14 +170,6 @@ function AuditLogsPage() {
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
};
- if (query.isLoading) {
- return (
-
- {t("msg.dev.audit.loading", "Loading audit logs...")}
-
- );
- }
-
if (query.error) {
const axiosError = query.error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
@@ -227,7 +229,13 @@ function AuditLogsPage() {
-
+
-
-
-
-
- {t("ui.dev.audit.table.time", "Time")}
-
-
- {t("ui.dev.audit.table.actor", "Actor")}
-
-
- {t("ui.dev.audit.table.action", "Action")}
-
-
- {t("ui.dev.audit.table.target", "Target")}
-
-
- {t("ui.dev.audit.table.status", "Status")}
-
-
-
-
-
- {logs.length === 0 && (
+
+
+
-
- {t("msg.dev.audit.empty", "No audit logs found.")}
-
+
+ {t("ui.dev.audit.table.time", "Time")}
+
+
+ {t("ui.dev.audit.table.actor", "Actor")}
+
+
+ {t("ui.dev.audit.table.action", "Action")}
+
+
+ {t("ui.dev.audit.table.target", "Target")}
+
+
+ {t("ui.dev.audit.table.status", "Status")}
+
+
- )}
- {logs.map((row, index) => {
- const details = parseDetails(row.details);
- const actionLabel = details.action || row.event_type;
- const targetValue = details.target_id || "-";
- const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
- const expanded = Boolean(expandedRows[rowKey]);
- return (
-
-
-
- {formatDateTime(row.timestamp)}
-
-
-
-
{row.user_id || "-"}
- {row.user_id ? (
+
+
+ {query.isLoading && logs.length === 0 ? (
+
+
+ {t("msg.dev.audit.loading", "Loading audit logs...")}
+
+
+ ) : logs.length === 0 ? (
+
+
+ {t("msg.dev.audit.empty", "No audit logs found.")}
+
+
+ ) : (
+ logs.map((row, index) => {
+ const details = parseDetails(row.details);
+ const actionLabel = details.action || row.event_type;
+ const targetValue = details.target_id || "-";
+ const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
+ const expanded = Boolean(expandedRows[rowKey]);
+ return (
+
+
+
+ {formatDateTime(row.timestamp)}
+
+
+
+ {row.user_id || "-"}
+ {row.user_id ? (
+ handleCopy(row.user_id)}
+ >
+
+
+ ) : null}
+
+
+ {actionLabel}
+
+
+ {targetValue}
+ {targetValue !== "-" ? (
+ handleCopy(targetValue)}
+ >
+
+
+ ) : null}
+
+
+
+
+ {row.status}
+
+
+
handleCopy(row.user_id)}
+ size="sm"
+ onClick={() =>
+ setExpandedRows((prev) => ({
+ ...prev,
+ [rowKey]: !expanded,
+ }))
+ }
>
-
+ {expanded ? (
+
+ ) : (
+
+ )}
- ) : null}
-
-
- {actionLabel}
-
-
-
{targetValue}
- {targetValue !== "-" ? (
-
handleCopy(targetValue)}
+
+
+ {expanded ? (
+
+
-
-
- ) : null}
-
-
-
-
- {row.status}
-
-
-
-
- setExpandedRows((prev) => ({
- ...prev,
- [rowKey]: !expanded,
- }))
- }
- >
- {expanded ? (
-
- ) : (
-
- )}
-
-
-
- {expanded ? (
-
-
-
-
-
- Request ID: {formatValue(details.request_id)}
+
+
+
+ Request ID: {formatValue(details.request_id)}
+
+
Method: {formatValue(details.method)}
+
Path: {formatValue(details.path)}
+
+ Tenant: {formatValue(details.tenant_id)}
+
+
+
+
Before: {formatValue(details.before)}
+
After: {formatValue(details.after)}
+
Error: {formatValue(details.error)}
+
-
Method: {formatValue(details.method)}
-
Path: {formatValue(details.path)}
-
- Tenant: {formatValue(details.tenant_id)}
-
-
-
-
Before: {formatValue(details.before)}
-
After: {formatValue(details.after)}
-
Error: {formatValue(details.error)}
-
-
-
-
- ) : null}
-
- );
- })}
-
-
+
+
+ ) : null}
+
+ );
+ })
+ )}
+
+
+
{query.hasNextPage ? (
From 67b3420d009202452130dc7cb67902edc1251747 Mon Sep 17 00:00:00 2001
From: kyy
Date: Thu, 30 Apr 2026 12:00:40 +0900
Subject: [PATCH 11/17] =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EC=98=B5=EC=85=98?=
=?UTF-8?q?=EB=B3=84=20=EC=A0=95=EB=B3=B4=20=ED=88=B4=ED=8C=81=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../features/clients/ClientRelationsPage.tsx | 139 +++++++++++++-----
devfront/src/locales/en.toml | 10 ++
devfront/src/locales/ko.toml | 10 ++
devfront/src/locales/template.toml | 12 ++
4 files changed, 131 insertions(+), 40 deletions(-)
diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx
index 96653d1f..d5e317c2 100644
--- a/devfront/src/features/clients/ClientRelationsPage.tsx
+++ b/devfront/src/features/clients/ClientRelationsPage.tsx
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
+import { ArrowLeft, Info, Link2, Plus, Trash2, X } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useParams } from "react-router-dom";
@@ -62,6 +62,10 @@ function relationDescription(relation: RelationOption) {
);
}
+function relationPermitsInfo(relation: RelationOption) {
+ return t(`ui.dev.clients.relationships.option.${relation}.permits_info`, "");
+}
+
function formatUserLabel(user: DevAssignableUser) {
const primary = user.name.trim() || user.email.trim();
return `${primary} (${user.email.trim()})`;
@@ -81,6 +85,7 @@ function ClientRelationsPage() {
null,
);
const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const [infoRelation, setInfoRelation] = useState(null);
const systemRole = resolveProfileRole(
auth.user?.profile as Record | undefined,
@@ -307,6 +312,12 @@ function ClientRelationsPage() {
}
};
+ const handleInfoToggle = (event: React.MouseEvent, relation: RelationOption) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setInfoRelation(prev => (prev === relation ? null : relation));
+ };
+
if (!clientId) {
return (
@@ -498,46 +509,76 @@ function ClientRelationsPage() {
const disabled =
selectedUserExistingRelations.has(relation);
const isSelected = selectedRelations.includes(relation);
+ const isInfoVisible = infoRelation === relation;
+
return (
-
- handleRelationToggle(relation)}
- />
-
-
- {relationLabel(relation)}
+
+
+ handleRelationToggle(relation)}
+ />
+
+
+
+ {relationLabel(relation)}
+
+
handleInfoToggle(e, relation)}
+ >
+ {isInfoVisible ? (
+
+ ) : (
+
+ )}
+
+
+
+ {relationDescription(relation)}
+
+
+ {relation}
+
-
- {relationDescription(relation)}
+
+
+ {isInfoVisible && (
+
+
+
+ {t("ui.common.info", "상세 권한 안내")}
+
+ {relationPermitsInfo(relation)}
-
- {relation}
-
-
-
+ )}
+
);
})}
@@ -630,8 +671,26 @@ function ClientRelationsPage() {
-
- {relationLabel(item.relation as RelationOption)}
+
+
+ {relationLabel(item.relation as RelationOption)}
+ handleInfoToggle(e, item.relation as RelationOption)}
+ >
+
+
+
+ {infoRelation === item.relation && (
+
+ {relationPermitsInfo(item.relation as RelationOption)}
+
+ )}
{relationDescription(item.relation as RelationOption)}
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index 14bef279..a6491f7c 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -1579,6 +1579,7 @@ user_search_placeholder = "Search by name or email..."
[ui.dev.clients.relationships.option.admins]
label = "RP Admin"
description = "Full administrator relationship for RP operations."
+permits_info = "Has full administrative control over the RP, including config editing, secret management, JWKS, consents, and relationships."
[ui.dev.clients.relationships.option.creator]
label = "RP Creator"
@@ -1587,38 +1588,47 @@ description = "Marks the operator who created this RP."
[ui.dev.clients.relationships.option.config_editor]
label = "RP General Settings"
description = "Edit the name, redirect URIs, and general metadata."
+permits_info = "Can modify general RP settings such as Name, Redirect URIs, Post-Logout URIs, and metadata."
[ui.dev.clients.relationships.option.secret_viewer]
label = "Secret View"
description = "View the Client secret for this RP."
+permits_info = "Can view the Client Secret value in plain text."
[ui.dev.clients.relationships.option.secret_rotator]
label = "Secret Rotation"
description = "Rotate and reissue the client secret."
+permits_info = "Can regenerate the Client Secret or expire and rotate existing secrets."
[ui.dev.clients.relationships.option.jwks_viewer]
label = "JWKS View"
description = "View JWKS status, cache details, and key summaries."
+permits_info = "Can view JWKS status, cached key information, and key summaries."
[ui.dev.clients.relationships.option.jwks_operator]
label = "JWKS Operations"
description = "Run operational actions such as refresh and revoke."
+permits_info = "Can perform JWKS operations such as manual refresh and key revocation."
[ui.dev.clients.relationships.option.consent_viewer]
label = "Consent View"
description = "View consent grants for this RP."
+permits_info = "Can view the history of user consents granted to this RP."
[ui.dev.clients.relationships.option.consent_revoker]
label = "Consent Revoke"
description = "Revoke consent grants for this RP."
+permits_info = "Can revoke or cancel user consents granted to this RP."
[ui.dev.clients.relationships.option.relationship_viewer]
label = "Relationship View"
description = "View direct relations assigned to this RP."
+permits_info = "Can view the list of direct relationships and administrative roles assigned to this RP."
[ui.dev.clients.relationships.option.audit_viewer]
label = "Audit Log View"
description = "View DevFront audit logs for this RP."
+permits_info = "Can view DevFront audit logs for all configuration changes and operations on this RP."
[ui.dev.clients.relationships.option.status_operator]
label = "Status Change"
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index 450725fa..1cec9c82 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -1578,6 +1578,7 @@ user_search_placeholder = "이름 또는 이메일 검색..."
[ui.dev.clients.relationships.option.admins]
label = "RP 관리자"
description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다."
+permits_info = "RP 설정 수정, 시크릿 조회/재발급, JWKS 관리, 동의 조회/회수, 관계 조회/수정 등 모든 운영 권한을 가집니다."
[ui.dev.clients.relationships.option.creator]
label = "RP 생성자"
@@ -1586,38 +1587,47 @@ description = "이 RP를 생성한 운영 주체를 표시합니다."
[ui.dev.clients.relationships.option.config_editor]
label = "RP 일반 설정"
description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다."
+permits_info = "RP 이름, Redirect URIs, 로그아웃 URI, 메타데이터 등 일반 설정을 수정할 수 있습니다."
[ui.dev.clients.relationships.option.secret_viewer]
label = "시크릿 조회"
description = "이 RP의 Client secret을 조회합니다."
+permits_info = "RP의 Client Secret 값을 평문으로 확인할 수 있습니다."
[ui.dev.clients.relationships.option.secret_rotator]
label = "시크릿 재발급"
description = "Client secret 재발급과 회전을 수행합니다."
+permits_info = "새로운 Client Secret을 발급하거나 기존 시크릿을 만료시키고 회전시킬 수 있습니다."
[ui.dev.clients.relationships.option.jwks_viewer]
label = "JWKS 조회"
description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다."
+permits_info = "RP의 공개키 설정(JWKS) 상태와 캐시된 키 정보를 조회할 수 있습니다."
[ui.dev.clients.relationships.option.jwks_operator]
label = "JWKS 운영"
description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다."
+permits_info = "JWKS 캐시를 강제로 갱신하거나 등록된 키를 회수하는 등 키 관리를 수행할 수 있습니다."
[ui.dev.clients.relationships.option.consent_viewer]
label = "동의 조회"
description = "이 RP의 consent 내역을 조회합니다."
+permits_info = "사용자가 이 RP에 부여한 개인정보 제공 동의 내역을 조회할 수 있습니다."
[ui.dev.clients.relationships.option.consent_revoker]
label = "동의 회수"
description = "이 RP의 consent를 회수합니다."
+permits_info = "사용자의 동의 내역을 강제로 취소하거나 회수할 수 있습니다."
[ui.dev.clients.relationships.option.relationship_viewer]
label = "관계 조회"
description = "이 RP에 부여된 direct relation을 조회합니다."
+permits_info = "이 RP에 어떤 사용자가 어떤 관리 권한을 가지고 있는지 목록을 조회할 수 있습니다."
[ui.dev.clients.relationships.option.audit_viewer]
label = "감사 로그 조회"
description = "이 RP의 DevFront 감사 로그를 조회합니다."
+permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에 대한 감사 로그를 조회할 수 있습니다."
[ui.dev.clients.relationships.option.status_operator]
label = "상태 변경"
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index 404f5348..0644f030 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -1606,50 +1606,62 @@ user_search_placeholder = ""
[ui.dev.clients.relationships.option.admins]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.creator]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.config_editor]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.secret_viewer]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.secret_rotator]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.jwks_viewer]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.jwks_operator]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.consent_viewer]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.consent_revoker]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.relationship_viewer]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.audit_viewer]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.relationships.option.status_operator]
label = ""
description = ""
+permits_info = ""
[ui.dev.clients.help]
docs_body = ""
From 068d0adbd43de772522dd6233c50f896eb3246d0 Mon Sep 17 00:00:00 2001
From: kyy
Date: Mon, 4 May 2026 09:02:36 +0900
Subject: [PATCH 12/17] =?UTF-8?q?code-check=20=EC=98=A4=EB=A5=98=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/features/users/UserCreatePage.tsx | 10 +++++
.../src/features/users/UserDetailPage.tsx | 10 +++++
adminfront/tests/tenants.spec.ts | 6 ++-
.../handler/dev_handler_isolation_test.go | 6 +--
devfront/src/features/audit/AuditLogsPage.tsx | 15 +++++--
.../features/clients/ClientDetailsPage.tsx | 10 +++--
.../features/clients/ClientGeneralPage.tsx | 4 +-
.../features/clients/ClientRelationsPage.tsx | 31 ++++++++++----
devfront/src/locales/template.toml | 27 ++++++++++++
devfront/tests/helpers/devfront-fixtures.ts | 39 +++++++++++++++++-
docker/ory/oathkeeper/rules.active.json | 2 +-
locales/en.toml | 10 +++++
locales/ko.toml | 18 ++++++++
locales/template.toml | 18 ++++++++
scripts/run_adminfront_ci_tests.sh | 41 ++++++++++++++++++-
15 files changed, 220 insertions(+), 27 deletions(-)
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx
index 5900db79..dd28117a 100644
--- a/adminfront/src/features/users/UserCreatePage.tsx
+++ b/adminfront/src/features/users/UserCreatePage.tsx
@@ -56,6 +56,16 @@ import {
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
+type UserSchemaField = {
+ key: string;
+ label?: string;
+ type?: "text" | "number" | "boolean" | "date";
+ required?: boolean;
+ adminOnly?: boolean;
+ validation?: string;
+ isLoginId?: boolean;
+};
+
type UserFormValues = UserCreateRequest & { metadata: Record };
type UserType = "hanmac" | "external" | "personal";
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx
index 958685cd..07a94ca3 100644
--- a/adminfront/src/features/users/UserDetailPage.tsx
+++ b/adminfront/src/features/users/UserDetailPage.tsx
@@ -79,6 +79,16 @@ import {
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
+type UserSchemaField = {
+ key: string;
+ label?: string;
+ type?: "text" | "number" | "boolean" | "date";
+ required?: boolean;
+ adminOnly?: boolean;
+ validation?: string;
+ isLoginId?: boolean;
+};
+
type UserFormValues = Omit & {
metadata: Record>;
};
diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts
index 4d4c37a0..2bff1771 100644
--- a/adminfront/tests/tenants.spec.ts
+++ b/adminfront/tests/tenants.spec.ts
@@ -119,6 +119,7 @@ test.describe("Tenants Management", () => {
test("should export and import tenant CSV without organization/user combined import", async ({
page,
+ browserName,
}, testInfo) => {
let exportRequested = false;
let exportUrl = "";
@@ -213,9 +214,12 @@ test.describe("Tenants Management", () => {
/갱신 1|Updated 1/i,
);
expect(importRequested).toBe(true);
- if (testInfo.project.name !== "webkit") {
+ expect(importBody).toContain('filename="tenants.csv"');
+ if (browserName !== "webkit") {
+ if (testInfo.project.name !== "webkit") {
expect(importBody).toContain("tenant-alpha-id");
}
+ }
});
test("should resolve tenant CSV conflicts by choosing create and remapping parent ids", async ({
diff --git a/backend/internal/handler/dev_handler_isolation_test.go b/backend/internal/handler/dev_handler_isolation_test.go
index a73afaef..401cf035 100644
--- a/backend/internal/handler/dev_handler_isolation_test.go
+++ b/backend/internal/handler/dev_handler_isolation_test.go
@@ -192,7 +192,7 @@ func TestDevHandler_Isolation(t *testing.T) {
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
- t.Run("UpdateClient should enforce tenant isolation", func(t *testing.T) {
+ t.Run("UpdateClient should require direct edit permission within tenant isolation", func(t *testing.T) {
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
@@ -209,11 +209,11 @@ func TestDevHandler_Isolation(t *testing.T) {
"client_name": "Updated Name",
})
- // Case 1: Same tenant
+ // Case 1: Same tenant but no direct edit_config permission
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
- assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// Case 2: Different tenant
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))
diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx
index abbab331..61dced27 100644
--- a/devfront/src/features/audit/AuditLogsPage.tsx
+++ b/devfront/src/features/audit/AuditLogsPage.tsx
@@ -348,7 +348,9 @@ function AuditLogsPage() {
) : null}
-
{actionLabel}
+
+ {actionLabel}
+
{targetValue}
@@ -401,16 +403,21 @@ function AuditLogsPage() {
- Request ID: {formatValue(details.request_id)}
+ Request ID:{" "}
+ {formatValue(details.request_id)}
+
+
+ Method: {formatValue(details.method)}
-
Method: {formatValue(details.method)}
Path: {formatValue(details.path)}
Tenant: {formatValue(details.tenant_id)}
-
Before: {formatValue(details.before)}
+
+ Before: {formatValue(details.before)}
+
After: {formatValue(details.after)}
Error: {formatValue(details.error)}
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx
index 7a502957..4657e624 100644
--- a/devfront/src/features/clients/ClientDetailsPage.tsx
+++ b/devfront/src/features/clients/ClientDetailsPage.tsx
@@ -218,10 +218,12 @@ function ClientDetailsPage() {
const clientSecret = hasClientSecret
? client?.clientSecret || secretPlaceholder
: t("ui.common.na", "N/A");
- const displaySecret =
- !hasClientSecret
- ? t("msg.dev.clients.details.secret_not_applicable", "PKCE 앱에는 Client Secret이 없습니다.")
- : clientSecret === secretPlaceholder
+ const displaySecret = !hasClientSecret
+ ? t(
+ "msg.dev.clients.details.secret_not_applicable",
+ "PKCE 앱에는 Client Secret이 없습니다.",
+ )
+ : clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret;
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index 3f7db64b..79352aa2 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -463,9 +463,7 @@ function ClientGeneralPage() {
(item.relation === "admins" || item.relation === "config_editor"),
) === true;
const isGeneralSettingsReadOnly =
- !isCreate &&
- relationData != null &&
- !canEditExistingClientGeneralSettings;
+ !isCreate && relationData != null && !canEditExistingClientGeneralSettings;
const trimmedLogoUrl = logoUrl.trim();
const trimmedAutoLoginUrl = autoLoginUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx
index d5e317c2..3f6c1e2d 100644
--- a/devfront/src/features/clients/ClientRelationsPage.tsx
+++ b/devfront/src/features/clients/ClientRelationsPage.tsx
@@ -312,10 +312,13 @@ function ClientRelationsPage() {
}
};
- const handleInfoToggle = (event: React.MouseEvent, relation: RelationOption) => {
+ const handleInfoToggle = (
+ event: React.MouseEvent,
+ relation: RelationOption,
+ ) => {
event.preventDefault();
event.stopPropagation();
- setInfoRelation(prev => (prev === relation ? null : relation));
+ setInfoRelation((prev) => (prev === relation ? null : relation));
};
if (!clientId) {
@@ -401,7 +404,10 @@ function ClientRelationsPage() {
{isLoading ? (
- {t("msg.dev.clients.relationships.loading", "Loading relationships...")}
+ {t(
+ "msg.dev.clients.relationships.loading",
+ "Loading relationships...",
+ )}
) : isRelationshipViewForbidden ? (
@@ -510,7 +516,7 @@ function ClientRelationsPage() {
selectedUserExistingRelations.has(relation);
const isSelected = selectedRelations.includes(relation);
const isInfoVisible = infoRelation === relation;
-
+
return (
-
+
{isInfoVisible && (
@@ -673,7 +679,9 @@ function ClientRelationsPage() {
- {relationLabel(item.relation as RelationOption)}
+
+ {relationLabel(item.relation as RelationOption)}
+
handleInfoToggle(e, item.relation as RelationOption)}
+ onClick={(e) =>
+ handleInfoToggle(
+ e,
+ item.relation as RelationOption,
+ )
+ }
>
{infoRelation === item.relation && (
- {relationPermitsInfo(item.relation as RelationOption)}
+ {relationPermitsInfo(
+ item.relation as RelationOption,
+ )}
)}
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index 0644f030..797e5913 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -191,11 +191,17 @@ delete_confirm = ""
delete_success = ""
empty = ""
fetch_error = ""
+import_empty = ""
+import_error = ""
+import_result = ""
missing_id = ""
not_found = ""
remove_sub_confirm = ""
subtitle = ""
+[msg.admin.tenants.import_preview]
+description = ""
+
[msg.admin.tenants.admins]
add_success = ""
empty = ""
@@ -255,9 +261,13 @@ parsed_count = ""
update_success = ""
[msg.admin.users.create]
+appointment_required = ""
error = ""
+external_tenant_required = ""
password_required = ""
+personal_tenant_failed = ""
success = ""
+tenant_resolve_failed = ""
[msg.admin.users.create.account]
subtitle = ""
@@ -269,6 +279,7 @@ field_required = ""
name_required = ""
password_auto_help = ""
password_manual_help = ""
+picker_description = ""
role_help = ""
[msg.admin.users.create.password_generated]
@@ -291,7 +302,9 @@ password_hint = ""
[msg.admin.users.list]
delete_confirm = ""
empty = ""
+export_error = ""
fetch_error = ""
+status_error = ""
subtitle = ""
[msg.admin.users.list.columns]
@@ -991,6 +1004,9 @@ user = ""
[ui.admin.tenants]
add = ""
+csv_template = ""
+export = ""
+import = ""
title = ""
[ui.admin.tenants.admins]
@@ -1120,6 +1136,15 @@ search_placeholder = ""
title = ""
tree_search_placeholder = ""
+[ui.admin.tenants.import_preview]
+candidates = ""
+confirm = ""
+create_new = ""
+fixed_id = ""
+match = ""
+no_candidates = ""
+title = ""
+
[ui.admin.tenants.sub.table]
action = ""
name = ""
@@ -1128,6 +1153,7 @@ status = ""
[ui.admin.tenants.table]
actions = ""
+id = ""
members = ""
name = ""
slug = ""
@@ -1227,6 +1253,7 @@ empty = ""
fetch_error = ""
search_placeholder = ""
subtitle = ""
+toggle_status = ""
title = ""
[ui.admin.users.list.breadcrumb]
diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts
index 3f6ff964..3e1b0df5 100644
--- a/devfront/tests/helpers/devfront-fixtures.ts
+++ b/devfront/tests/helpers/devfront-fixtures.ts
@@ -221,6 +221,39 @@ function parseClientId(pathname: string): string {
}
export async function installDevApiMock(page: Page, state: DevApiMockState) {
+ const readMockRole = async () =>
+ (
+ (await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
+ "rp_admin"
+ ).trim();
+
+ const buildSelfConfigEditorRelation = (): ClientRelation => ({
+ relation: "config_editor",
+ subject: "User:playwright-user",
+ subjectType: "User",
+ subjectId: "playwright-user",
+ userName: "Playwright User",
+ userEmail: "playwright@example.com",
+ userLoginId: "playwright@example.com",
+ });
+
+ const shouldGrantDefaultEditRelation = (role: string) =>
+ role === "rp_admin" || role === "tenant_admin" || role === "super_admin";
+
+ const resolveClientRelations = async (clientId: string) => {
+ const explicitRelations = state.relations?.[clientId];
+ if (explicitRelations) {
+ return explicitRelations;
+ }
+
+ const role = await readMockRole();
+ if (!shouldGrantDefaultEditRelation(role)) {
+ return [];
+ }
+
+ return [buildSelfConfigEditorRelation()];
+ };
+
const appendAuditLog = (
eventType: string,
action: string,
@@ -431,6 +464,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
state.clients.push(created);
+ if (!state.relations) {
+ state.relations = {};
+ }
+ state.relations[created.id] = [buildSelfConfigEditorRelation()];
appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id);
return json(route, {
client: created,
@@ -451,7 +488,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
) {
const clientId = pathname.split("/")[5] ?? "";
return json(route, {
- items: state.relations?.[clientId] ?? [],
+ items: await resolveClientRelations(clientId),
});
}
diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json
index fd6bfb2d..4a0735da 100755
--- a/docker/ory/oathkeeper/rules.active.json
+++ b/docker/ory/oathkeeper/rules.active.json
@@ -156,4 +156,4 @@
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
-]
+]
\ No newline at end of file
diff --git a/locales/en.toml b/locales/en.toml
index f60f9a3f..cb2f0aa3 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -1204,6 +1204,15 @@ title = "Tenant Members ({{count}})"
total = "Total"
total_label = "Total"
+[ui.admin.tenants.import_preview]
+candidates = "Candidates"
+confirm = "Confirm Import"
+create_new = "Create New"
+fixed_id = "Fixed ID"
+match = "Matched Tenant"
+no_candidates = "No matching tenants found."
+title = "Import Preview"
+
[ui.admin.tenants.members.table]
email = "EMAIL"
name = "NAME"
@@ -1339,6 +1348,7 @@ name = "Name"
name_placeholder = "Name Placeholder"
password = "Password"
password_placeholder = "********"
+picker_description = "Search and select a tenant."
phone = "Phone number"
phone_placeholder = "010-1234-5678"
position = "Position"
diff --git a/locales/ko.toml b/locales/ko.toml
index 9795d21f..29f63aa3 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -672,11 +672,17 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다."
+import_empty = "임포트 파일에 테넌트 행이 없습니다."
+import_error = "테넌트 임포트에 실패했습니다: {{error}}"
+import_result = "{{count}}개의 테넌트 행을 처리했습니다."
missing_id = "테넌트 ID가 없습니다."
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?"
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
+[msg.admin.tenants.import_preview]
+description = "임포트 전에 각 행의 매칭 결과를 검토하고 처리 방식을 선택하세요."
+
[msg.admin.tenants.admins]
add_success = "관리자가 추가되었습니다."
empty = "등록된 관리자가 없습니다."
@@ -1645,6 +1651,8 @@ delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
[msg.admin.users]
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
+export_error = "사용자 내보내기에 실패했습니다: {{error}}"
+status_error = "사용자 상태 변경에 실패했습니다: {{error}}"
[ui.admin.apikeys.registry]
title = "API Key Registry"
@@ -1658,6 +1666,15 @@ title = "테넌트 구성원 ({{count}})"
total = "전체"
total_label = "전체"
+[ui.admin.tenants.import_preview]
+candidates = "후보"
+confirm = "임포트 확정"
+create_new = "새로 생성"
+fixed_id = "고정 ID"
+match = "매칭된 테넌트"
+no_candidates = "매칭 가능한 테넌트가 없습니다."
+title = "임포트 미리보기"
+
[ui.admin.tenants.members.table]
email = "EMAIL"
name = "NAME"
@@ -1793,6 +1810,7 @@ name = "이름"
name_placeholder = "홍길동"
password = "비밀번호"
password_placeholder = "********"
+picker_description = "배정할 테넌트를 검색해서 선택하세요."
phone = "전화번호"
phone_placeholder = "010-1234-5678"
position = "직급"
diff --git a/locales/template.toml b/locales/template.toml
index 7a3bbb00..fc9923c3 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -1517,9 +1517,17 @@ import_partial_success = ""
[msg.admin.tenants]
delete_bulk_confirm = ""
+import_empty = ""
+import_error = ""
+import_result = ""
+
+[msg.admin.tenants.import_preview]
+description = ""
[msg.admin.users]
self_delete_blocked = ""
+export_error = ""
+status_error = ""
[ui.admin.apikeys.registry]
title = ""
@@ -1604,6 +1612,15 @@ search_placeholder = ""
title = ""
tree_search_placeholder = ""
+[ui.admin.tenants.import_preview]
+candidates = ""
+confirm = ""
+create_new = ""
+fixed_id = ""
+match = ""
+no_candidates = ""
+title = ""
+
[ui.admin.tenants.sub.table]
action = ""
name = ""
@@ -1668,6 +1685,7 @@ name = ""
name_placeholder = ""
password = ""
password_placeholder = ""
+picker_description = ""
phone = ""
phone_placeholder = ""
position = ""
diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh
index 2237686d..3b6549e3 100755
--- a/scripts/run_adminfront_ci_tests.sh
+++ b/scripts/run_adminfront_ci_tests.sh
@@ -6,8 +6,40 @@ job_name="${1:-adminfront-tests}"
mkdir -p reports
rm -rf adminfront/node_modules
-playwright_install_cmd=(npx playwright install --with-deps)
-playwright_install_desc="npx playwright install --with-deps"
+is_port_available() {
+ local port="$1"
+ node -e '
+ const net = require("net");
+ const port = Number(process.argv[1]);
+ const server = net.createServer();
+ server.once("error", () => process.exit(1));
+ server.once("listening", () => server.close(() => process.exit(0)));
+ server.listen(port, "127.0.0.1");
+ ' "$port"
+}
+
+find_available_port() {
+ node -e '
+ const net = require("net");
+ const server = net.createServer();
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ process.stdout.write(String(address.port));
+ server.close();
+ });
+ '
+}
+
+playwright_install_cmd=(npx playwright install)
+playwright_install_desc="npx playwright install"
+
+if [ "$(id -u)" -eq 0 ]; then
+ playwright_install_cmd=(npx playwright install --with-deps)
+ playwright_install_desc="npx playwright install --with-deps"
+elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
+ playwright_install_cmd=(npx playwright install --with-deps)
+ playwright_install_desc="npx playwright install --with-deps"
+fi
set +e
(
@@ -67,6 +99,11 @@ fi
set +e
port="${PORT:-5180}"
+if ! is_port_available "$port"; then
+ fallback_port="$(find_available_port)"
+ echo "==> requested PORT=$port is already in use; switching to PORT=$fallback_port"
+ port="$fallback_port"
+fi
echo "==> adminfront using PORT=$port"
(
cd adminfront
From 0664640c6f1893408a101ad836a7ea6d8d04fe24 Mon Sep 17 00:00:00 2001
From: kyy
Date: Mon, 4 May 2026 11:00:02 +0900
Subject: [PATCH 13/17] =?UTF-8?q?Devfront=20back-channel=20logout=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20UI=20=EB=B0=8F=20i18n=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../features/clients/ClientGeneralPage.tsx | 171 ++++++++++++++++++
devfront/src/lib/devApi.ts | 4 +
devfront/src/locales/en.toml | 13 ++
devfront/src/locales/ko.toml | 13 ++
devfront/src/locales/template.toml | 13 ++
5 files changed, 214 insertions(+)
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index 79352aa2..a6dcd925 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -271,6 +271,29 @@ function isValidUrl(value: string): boolean {
}
}
+function isValidBackchannelLogoutUrl(value: string): boolean {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return true;
+ }
+
+ try {
+ const url = new URL(trimmed);
+ if (url.hash) {
+ return false;
+ }
+ if (url.protocol === "https:") {
+ return true;
+ }
+ if (url.protocol !== "http:") {
+ return false;
+ }
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1";
+ } catch {
+ return false;
+ }
+}
+
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
@@ -315,6 +338,11 @@ function ClientGeneralPage() {
const [status, setStatus] = useState("active");
const [initialStatus, setInitialStatus] = useState("active");
const [redirectUris, setRedirectUris] = useState("");
+ const [backchannelLogoutUri, setBackchannelLogoutUri] = useState("");
+ const [backchannelLogoutSessionRequired, setBackchannelLogoutSessionRequired] =
+ useState(false);
+ const [isBackchannelSessionRequiredInfoOpen, setIsBackchannelSessionRequiredInfoOpen] =
+ useState(false);
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
const [allowedTenantIds, setAllowedTenantIds] = useState([]);
const [tenantSearch, setTenantSearch] = useState("");
@@ -368,6 +396,14 @@ function ClientGeneralPage() {
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
+ setBackchannelLogoutUri(
+ client.backchannelLogoutUri ||
+ readMetadataString(metadata, "backchannel_logout_uri"),
+ );
+ setBackchannelLogoutSessionRequired(
+ client.backchannelLogoutSessionRequired === true ||
+ metadata.backchannel_logout_session_required === true,
+ );
setAutoLoginSupported(metadata.auto_login_supported === true);
if (typeof metadata.auto_login_url === "string")
setAutoLoginUrl(metadata.auto_login_url);
@@ -468,6 +504,11 @@ function ClientGeneralPage() {
const trimmedAutoLoginUrl = autoLoginUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
+ const trimmedBackchannelLogoutUri = backchannelLogoutUri.trim();
+ const hasBackchannelLogoutUri = trimmedBackchannelLogoutUri.length > 0;
+ const hasValidBackchannelLogoutUri =
+ !hasBackchannelLogoutUri ||
+ isValidBackchannelLogoutUrl(trimmedBackchannelLogoutUri);
const hasValidAutoLoginUrl =
!autoLoginSupported ||
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
@@ -893,6 +934,14 @@ function ClientGeneralPage() {
),
);
}
+ if (hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri) {
+ throw new Error(
+ t(
+ "msg.dev.clients.general.backchannel_logout.invalid",
+ "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
+ ),
+ );
+ }
if (isGeneralSettingsReadOnly) {
throw new Error(
t(
@@ -936,6 +985,11 @@ function ClientGeneralPage() {
trimmedJwksUri
? trimmedJwksUri
: undefined,
+ backchannelLogoutUri: trimmedBackchannelLogoutUri || undefined,
+ backchannelLogoutSessionRequired:
+ trimmedBackchannelLogoutUri !== ""
+ ? backchannelLogoutSessionRequired
+ : false,
metadata: {
description,
logo_url: trimmedLogoUrl,
@@ -957,6 +1011,11 @@ function ClientGeneralPage() {
allowed_tenants: tenantAccessRestricted
? normalizedAllowedTenantIds
: [],
+ backchannel_logout_uri: trimmedBackchannelLogoutUri || undefined,
+ backchannel_logout_session_required:
+ trimmedBackchannelLogoutUri !== ""
+ ? backchannelLogoutSessionRequired
+ : undefined,
},
};
@@ -1493,6 +1552,118 @@ function ClientGeneralPage() {
)}
+
+
+
+ {t(
+ "ui.dev.clients.general.backchannel_logout.uri",
+ "Back-Channel Logout URI",
+ )}
+
+
setBackchannelLogoutUri(e.target.value)}
+ placeholder={t(
+ "ui.dev.clients.general.backchannel_logout.uri_placeholder",
+ "https://rp.example.com/oidc/backchannel-logout",
+ )}
+ className="font-mono text-sm"
+ disabled={isGeneralSettingsReadOnly}
+ />
+
+ {t(
+ "msg.dev.clients.general.backchannel_logout.uri_help",
+ "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다.",
+ )}
+
+ {hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri ? (
+
+ {t(
+ "msg.dev.clients.general.backchannel_logout.invalid",
+ "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
+ )}
+
+ ) : null}
+
+
+
+
+
+
+ {t(
+ "ui.dev.clients.general.backchannel_logout.session_required",
+ "SID Claim Required",
+ )}
+
+
+ setIsBackchannelSessionRequiredInfoOpen(
+ (prev) => !prev,
+ )
+ }
+ aria-label={t(
+ "ui.dev.clients.general.backchannel_logout.session_required_info",
+ "SID Claim Required 설명 보기",
+ )}
+ >
+ {isBackchannelSessionRequiredInfoOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {t(
+ "msg.dev.clients.general.backchannel_logout.session_required_help",
+ "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다.",
+ )}
+
+ {isBackchannelSessionRequiredInfoOpen ? (
+
+
+
+ {t("ui.common.info", "상세 안내")}
+
+
+ {t(
+ "msg.dev.clients.general.backchannel_logout.session_required_on",
+ "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리",
+ )}
+
+
+ {t(
+ "msg.dev.clients.general.backchannel_logout.session_required_off",
+ "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능",
+ )}
+
+
+ ) : null}
+
+
+
+
+
diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts
index 0a6574f2..f1422f59 100644
--- a/devfront/src/lib/devApi.ts
+++ b/devfront/src/lib/devApi.ts
@@ -12,6 +12,8 @@ export type ClientSummary = {
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
+ backchannelLogoutUri?: string;
+ backchannelLogoutSessionRequired?: boolean;
redirectUris: string[];
scopes: string[];
metadata?: Record;
@@ -118,6 +120,8 @@ export type ClientUpsertRequest = {
responseTypes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
+ backchannelLogoutUri?: string;
+ backchannelLogoutSessionRequired?: boolean;
metadata?: Record;
};
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index a6491f7c..3733475c 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -432,6 +432,13 @@ subtitle = "Set the application name, description, and logo."
[msg.dev.clients.general.redirect]
help = "Enter the redirect URIs. You can modify them in the Federation tab after creation."
+[msg.dev.clients.general.backchannel_logout]
+uri_help = "RP endpoint that receives Baron's session termination event via server-to-server POST."
+invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1."
+session_required_help = "Use this when the RP should process logout_token only if the sid claim is included."
+session_required_on = "On: process logout only when the logout_token contains a sid."
+session_required_off = "Off: process logout using sub even if sid is missing."
+
[msg.dev.clients.general.scopes]
empty = "No scopes registered."
subtitle = "Define the permission scopes this application can request."
@@ -1484,6 +1491,12 @@ title = "Application Identity"
label = "Redirect URIs"
placeholder = "Placeholder"
+[ui.dev.clients.general.backchannel_logout]
+uri = "Back-Channel Logout URI"
+uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
+session_required = "SID Claim Required"
+session_required_info = "Show SID Claim Required help"
+
[ui.dev.clients.general.scopes]
add = "Scope Add"
description_placeholder = "Description Placeholder"
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index 1cec9c82..912012ca 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -432,6 +432,13 @@ subtitle = "앱 이름과 설명, 로고를 설정합니다."
[msg.dev.clients.general.redirect]
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다."
+[msg.dev.clients.general.backchannel_logout]
+uri_help = "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다."
+invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다."
+session_required_help = "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다."
+session_required_on = "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리"
+session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능"
+
[msg.dev.clients.general.scopes]
empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
@@ -1483,6 +1490,12 @@ title = "애플리케이션 정보"
label = "리디렉션 URI"
placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
+[ui.dev.clients.general.backchannel_logout]
+uri = "Back-Channel Logout URI"
+uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
+session_required = "SID Claim Required"
+session_required_info = "SID Claim Required 설명 보기"
+
[ui.dev.clients.general.scopes]
add = "스코프 추가"
description_placeholder = "권한에 대한 설명"
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index 797e5913..f53a4586 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -479,6 +479,13 @@ subtitle = ""
[msg.dev.clients.general.redirect]
help = ""
+[msg.dev.clients.general.backchannel_logout]
+uri_help = ""
+invalid = ""
+session_required_help = ""
+session_required_on = ""
+session_required_off = ""
+
[msg.dev.clients.general.scopes]
empty = ""
subtitle = ""
@@ -1539,6 +1546,12 @@ title = ""
label = ""
placeholder = ""
+[ui.dev.clients.general.backchannel_logout]
+uri = ""
+uri_placeholder = ""
+session_required = ""
+session_required_info = ""
+
[ui.dev.clients.general.scopes]
add = ""
description_placeholder = ""
From a72df2e839a0895be2fdb6474fdd76ed3b403679 Mon Sep 17 00:00:00 2001
From: kyy
Date: Mon, 4 May 2026 11:01:46 +0900
Subject: [PATCH 14/17] =?UTF-8?q?back-channel=20logout=20=EC=84=9C?=
=?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?=
=?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../handler/auth_handler_client_test.go | 66 +++++++++++
.../handler/auth_handler_sessions_test.go | 104 ++++++++++++++++++
.../backchannel_logout_service_test.go | 85 ++++++++++++++
3 files changed, 255 insertions(+)
create mode 100644 backend/internal/service/backchannel_logout_service_test.go
diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go
index 7263b358..6119340f 100644
--- a/backend/internal/handler/auth_handler_client_test.go
+++ b/backend/internal/handler/auth_handler_client_test.go
@@ -4,8 +4,11 @@ import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
+ "net/url"
+ "strings"
"testing"
"time"
@@ -53,6 +56,69 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
assert.Equal(t, 1, len(auditRepo.logs))
}
+func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
+ t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
+
+ var receivedBody string
+ transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path == "/sessions/whoami" {
+ return httpJSONAny(r, http.StatusOK, map[string]interface{}{
+ "identity": map[string]interface{}{"id": "user-123"},
+ }), nil
+ }
+ if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
+ return httpResponse(r, http.StatusNoContent, ""), nil
+ }
+ if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" {
+ return httpJSONAny(r, http.StatusOK, map[string]interface{}{
+ "client_id": "app-1",
+ "backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
+ }), nil
+ }
+ if r.URL.Host == "rp.example.com" && r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
+ raw, _ := io.ReadAll(r.Body)
+ receivedBody = string(raw)
+ return httpResponse(r, http.StatusNoContent, ""), nil
+ }
+ return httpResponse(r, http.StatusNotFound, "not found"), nil
+ })
+
+ client := &http.Client{Transport: transport}
+ origDefault := http.DefaultClient
+ http.DefaultClient = client
+ defer func() { http.DefaultClient = origDefault }()
+
+ backchannelLogout, err := service.NewBackchannelLogoutService()
+ assert.NoError(t, err)
+ backchannelLogout.HTTPClient = client
+
+ auditRepo := &mockAuditRepo{}
+ h := &AuthHandler{
+ Hydra: &service.HydraAdminService{
+ AdminURL: "http://hydra.test",
+ HTTPClient: client,
+ },
+ BackchannelLogout: backchannelLogout,
+ AuditRepo: auditRepo,
+ }
+ app := fiber.New()
+ app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil)
+ req.Header.Set("Cookie", "valid")
+
+ resp, _ := app.Test(req, -1)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.True(t, strings.Contains(receivedBody, "logout_token="))
+
+ values, err := url.ParseQuery(receivedBody)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, values.Get("logout_token"))
+
+ assert.Len(t, auditRepo.logs, 2)
+ assert.Equal(t, "backchannel_logout.sent", auditRepo.logs[1].EventType)
+}
+
func TestListRpHistory_Aggregation(t *testing.T) {
now := time.Now()
auditRepo := &mockAuditRepo{
diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go
index 8a12de2c..dfb2565e 100644
--- a/backend/internal/handler/auth_handler_sessions_test.go
+++ b/backend/internal/handler/auth_handler_sessions_test.go
@@ -8,6 +8,8 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/url"
+ "strings"
"testing"
"time"
@@ -500,6 +502,108 @@ func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t
mockKratos.AssertExpectations(t)
}
+func TestDeleteMySession_SendsBackchannelLogoutTokenWhenClientConfigured(t *testing.T) {
+ t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
+ t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
+
+ var receivedBody string
+ client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ switch r.URL.Host {
+ case "kratos.test":
+ if r.URL.Path == "/sessions/whoami" {
+ return httpJSONAny(r, http.StatusOK, map[string]any{
+ "id": "current-sid",
+ "authenticated_at": time.Now().UTC().Format(time.RFC3339),
+ "identity": map[string]any{
+ "id": "user-123",
+ "traits": map[string]any{
+ "email": "user@example.com",
+ "name": "User",
+ "role": "user",
+ },
+ },
+ }), nil
+ }
+ case "hydra.test":
+ if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
+ return httpResponse(r, http.StatusNoContent, ""), nil
+ }
+ if r.Method == http.MethodGet && r.URL.Path == "/clients/devfront" {
+ return httpJSONAny(r, http.StatusOK, map[string]any{
+ "client_id": "devfront",
+ "backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
+ }), nil
+ }
+ case "rp.example.com":
+ if r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
+ raw, _ := io.ReadAll(r.Body)
+ receivedBody = string(raw)
+ return httpResponse(r, http.StatusNoContent, ""), nil
+ }
+ }
+ return httpResponse(r, http.StatusNotFound, "not found"), nil
+ })}
+ setDefaultHTTPClientForTest(t, client.Transport)
+
+ mockKratos := new(MockKratosAdminService)
+ mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
+ {ID: "target-sid", Active: true},
+ }, nil).Once()
+ mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
+ ID: "target-sid",
+ Active: true,
+ }, nil).Once()
+ mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
+
+ backchannelLogout, err := service.NewBackchannelLogoutService()
+ assert.NoError(t, err)
+ backchannelLogout.HTTPClient = client
+
+ auditRepo := &mockAuditRepo{}
+ h := &AuthHandler{
+ KratosAdmin: mockKratos,
+ AuditRepo: auditRepo,
+ BackchannelLogout: backchannelLogout,
+ Hydra: &service.HydraAdminService{
+ AdminURL: "http://hydra.test",
+ HTTPClient: client,
+ },
+ }
+ auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
+ UserID: "user-123",
+ EventType: "POST /api/v1/auth/oidc/login/accept",
+ SessionID: "target-sid",
+ Details: `{"client_id":"devfront","client_name":"Devfront"}`,
+ })
+
+ app := fiber.New()
+ app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
+ req.Header.Set("Cookie", "ory_kratos_session=valid")
+ req.Header.Set("User-Agent", "session-test-agent")
+
+ resp, err := app.Test(req, -1)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.True(t, strings.Contains(receivedBody, "logout_token="))
+
+ values, err := url.ParseQuery(receivedBody)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, values.Get("logout_token"))
+
+ foundBackchannelAudit := false
+ for _, log := range auditRepo.logs {
+ if log.EventType == "backchannel_logout.sent" {
+ foundBackchannelAudit = true
+ break
+ }
+ }
+ assert.True(t, foundBackchannelAudit)
+
+ mockKratos.AssertExpectations(t)
+}
+
func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
var hydraRevokeCalls int
diff --git a/backend/internal/service/backchannel_logout_service_test.go b/backend/internal/service/backchannel_logout_service_test.go
new file mode 100644
index 00000000..09e70425
--- /dev/null
+++ b/backend/internal/service/backchannel_logout_service_test.go
@@ -0,0 +1,85 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/go-jose/go-jose/v4"
+ josejwt "github.com/go-jose/go-jose/v4/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
+ t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
+
+ svc, err := NewBackchannelLogoutService()
+ require.NoError(t, err)
+
+ token, err := svc.BuildLogoutToken("client-1", "user-1", "sid-1")
+ require.NoError(t, err)
+ require.NotEmpty(t, token)
+
+ jwksRaw, err := svc.MarshalPublicJWKS()
+ require.NoError(t, err)
+
+ var jwks struct {
+ Keys []jose.JSONWebKey `json:"keys"`
+ }
+ require.NoError(t, json.Unmarshal(jwksRaw, &jwks))
+ require.Len(t, jwks.Keys, 1)
+
+ parsed, err := josejwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
+ require.NoError(t, err)
+
+ var claims struct {
+ Issuer string `json:"iss"`
+ Subject string `json:"sub"`
+ Aud interface{} `json:"aud"`
+ Iat int64 `json:"iat"`
+ Jti string `json:"jti"`
+ Sid string `json:"sid"`
+ Events map[string]interface{} `json:"events"`
+ }
+ require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims))
+
+ assert.Equal(t, "https://sso.example.com/oidc", claims.Issuer)
+ assert.Equal(t, "user-1", claims.Subject)
+ switch aud := claims.Aud.(type) {
+ case string:
+ assert.Equal(t, "client-1", aud)
+ case []interface{}:
+ assert.Len(t, aud, 1)
+ assert.Equal(t, "client-1", aud[0])
+ default:
+ t.Fatalf("unexpected aud type: %T", claims.Aud)
+ }
+ assert.NotZero(t, claims.Iat)
+ assert.NotEmpty(t, claims.Jti)
+ assert.Equal(t, "sid-1", claims.Sid)
+ _, ok := claims.Events[backchannelLogoutEventURI]
+ assert.True(t, ok)
+}
+
+func TestBackchannelLogoutService_SendLogoutToken(t *testing.T) {
+ var body string
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
+ raw, _ := io.ReadAll(r.Body)
+ body = string(raw)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ svc, err := NewBackchannelLogoutService()
+ require.NoError(t, err)
+ svc.HTTPClient = clientForHandler(handler)
+
+ statusCode, err := svc.SendLogoutToken(context.Background(), "https://rp.example.com/backchannel-logout", "signed-token")
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, statusCode)
+ assert.Equal(t, "logout_token=signed-token", body)
+}
From f9f0ed0f140fe71f52e2cf971ce7d25bcd8004e0 Mon Sep 17 00:00:00 2001
From: kyy
Date: Mon, 4 May 2026 11:03:27 +0900
Subject: [PATCH 15/17] =?UTF-8?q?OIDC=20back-channel=20logout=20=EB=B0=B1?=
=?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=A0=84=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/cmd/server/main.go | 1 +
backend/internal/domain/hydra_models.go | 65 ++++--
backend/internal/handler/auth_handler.go | 188 +++++++++++++---
backend/internal/handler/dev_handler.go | 202 ++++++++++++------
.../service/backchannel_logout_service.go | 192 +++++++++++++++++
5 files changed, 539 insertions(+), 109 deletions(-)
create mode 100644 backend/internal/service/backchannel_logout_service.go
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 13cfbe2b..e8bb208c 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -572,6 +572,7 @@ func main() {
auth.Post("/qr/init", authHandler.InitQRLogin)
auth.Post("/qr/poll", authHandler.PollQRLogin)
auth.Post("/qr/approve", authHandler.ScanQRLogin)
+ auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS)
// Signup Routes
signup := auth.Group("/signup")
diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go
index f3bed2e3..0a09ef5a 100644
--- a/backend/internal/domain/hydra_models.go
+++ b/backend/internal/domain/hydra_models.go
@@ -6,30 +6,34 @@ import (
)
const (
- MetadataHeadlessLoginEnabled = "headless_login_enabled"
- MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
- MetadataHeadlessJWKSURI = "headless_jwks_uri"
- MetadataHeadlessJWKS = "headless_jwks"
- MetadataRequestObjectSigningAlg = "request_object_signing_alg"
- MetadataIDTokenClaims = "id_token_claims"
+ MetadataHeadlessLoginEnabled = "headless_login_enabled"
+ MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
+ MetadataHeadlessJWKSURI = "headless_jwks_uri"
+ MetadataHeadlessJWKS = "headless_jwks"
+ MetadataRequestObjectSigningAlg = "request_object_signing_alg"
+ MetadataIDTokenClaims = "id_token_claims"
+ MetadataBackChannelLogoutURI = "backchannel_logout_uri"
+ MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
MetadataAutoLoginSupported = "auto_login_supported"
MetadataAutoLoginURL = "auto_login_url"
)
type HydraClient struct {
- ClientID string `json:"client_id"`
- ClientName string `json:"client_name,omitempty"`
- ClientSecret string `json:"client_secret,omitempty"` // Added
- ClientURI string `json:"client_uri,omitempty"`
- RedirectURIs []string `json:"redirect_uris,omitempty"`
- GrantTypes []string `json:"grant_types,omitempty"`
- ResponseTypes []string `json:"response_types,omitempty"`
- Scope string `json:"scope,omitempty"`
- TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
- SkipConsent *bool `json:"skip_consent,omitempty"`
- JWKSUri string `json:"jwks_uri,omitempty"`
- JWKS interface{} `json:"jwks,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
+ ClientID string `json:"client_id"`
+ ClientName string `json:"client_name,omitempty"`
+ ClientSecret string `json:"client_secret,omitempty"` // Added
+ ClientURI string `json:"client_uri,omitempty"`
+ RedirectURIs []string `json:"redirect_uris,omitempty"`
+ GrantTypes []string `json:"grant_types,omitempty"`
+ ResponseTypes []string `json:"response_types,omitempty"`
+ Scope string `json:"scope,omitempty"`
+ TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
+ SkipConsent *bool `json:"skip_consent,omitempty"`
+ JWKSUri string `json:"jwks_uri,omitempty"`
+ JWKS interface{} `json:"jwks,omitempty"`
+ BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
+ BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (c *HydraClient) SupportsHeadlessLogin() bool {
@@ -87,6 +91,29 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool {
return false
}
+func (c *HydraClient) BackchannelLogoutURI() string {
+ if c.Metadata != nil {
+ if raw, ok := c.Metadata[MetadataBackChannelLogoutURI].(string); ok {
+ if value := strings.TrimSpace(raw); value != "" {
+ return value
+ }
+ }
+ }
+ return strings.TrimSpace(c.BackChannelLogoutURI)
+}
+
+func (c *HydraClient) BackchannelLogoutSessionRequiredValue() bool {
+ if c.Metadata != nil {
+ if raw, ok := c.Metadata[MetadataBackChannelLogoutSessionRequired].(bool); ok {
+ return raw
+ }
+ }
+ if c.BackChannelLogoutSessionRequired != nil {
+ return *c.BackChannelLogoutSessionRequired
+ }
+ return false
+}
+
type HydraConsentRequest struct {
Challenge string `json:"challenge"`
RequestedScope []string `json:"requested_scope"`
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index 3c83f230..e02f74f0 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -85,20 +85,21 @@ const (
)
type AuthHandler struct {
- SmsService domain.SmsService
- EmailService domain.EmailService
- RedisService domain.RedisRepository
- HeadlessJWKS *service.HeadlessJWKSCacheService
- KratosAdmin service.KratosAdminService
- IdpProvider domain.IdentityProvider
- AuditRepo domain.AuditRepository
- OathkeeperRepo domain.OathkeeperLogRepository
- Hydra *service.HydraAdminService
- TenantService service.TenantService
- KetoService service.KetoService
- KetoOutboxRepo repository.KetoOutboxRepository
- UserRepo repository.UserRepository
- ConsentRepo repository.ClientConsentRepository
+ SmsService domain.SmsService
+ EmailService domain.EmailService
+ RedisService domain.RedisRepository
+ HeadlessJWKS *service.HeadlessJWKSCacheService
+ KratosAdmin service.KratosAdminService
+ IdpProvider domain.IdentityProvider
+ AuditRepo domain.AuditRepository
+ OathkeeperRepo domain.OathkeeperLogRepository
+ Hydra *service.HydraAdminService
+ BackchannelLogout *service.BackchannelLogoutService
+ TenantService service.TenantService
+ KetoService service.KetoService
+ KetoOutboxRepo repository.KetoOutboxRepository
+ UserRepo repository.UserRepository
+ ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository
}
@@ -221,21 +222,26 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D
}
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler {
+ backchannelLogout, err := service.NewBackchannelLogoutService()
+ if err != nil {
+ slog.Warn("failed to initialize backchannel logout service", "error", err)
+ }
return &AuthHandler{
- SmsService: service.NewSmsService(),
- EmailService: service.NewEmailService(),
- RedisService: redisService,
- HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
- KratosAdmin: kratos,
- IdpProvider: idpProvider,
- AuditRepo: auditRepo,
- OathkeeperRepo: oathkeeperRepo,
- Hydra: service.NewHydraAdminService(),
- TenantService: tenantService,
- KetoService: ketoService,
- KetoOutboxRepo: ketoOutboxRepo,
- UserRepo: userRepo,
- ConsentRepo: consentRepo,
+ SmsService: service.NewSmsService(),
+ EmailService: service.NewEmailService(),
+ RedisService: redisService,
+ HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
+ KratosAdmin: kratos,
+ IdpProvider: idpProvider,
+ AuditRepo: auditRepo,
+ OathkeeperRepo: oathkeeperRepo,
+ Hydra: service.NewHydraAdminService(),
+ BackchannelLogout: backchannelLogout,
+ TenantService: tenantService,
+ KetoService: ketoService,
+ KetoOutboxRepo: ketoOutboxRepo,
+ UserRepo: userRepo,
+ ConsentRepo: consentRepo,
}
}
@@ -5348,6 +5354,8 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
})
}
+ h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
+
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "success",
"message": "Link revoked successfully",
@@ -7768,6 +7776,7 @@ func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error {
if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions")
}
+ h.triggerBackchannelLogoutForSession(c.Context(), c, profile.ID, targetSessionID)
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
return c.JSON(fiber.Map{"status": "ok"})
@@ -8187,6 +8196,129 @@ func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID strin
return nil
}
+func (h *AuthHandler) triggerBackchannelLogoutForSession(ctx context.Context, c *fiber.Ctx, userID string, sessionID string) {
+ if h == nil || h.Hydra == nil {
+ return
+ }
+
+ clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)]
+ for _, clientID := range clientIDs {
+ h.triggerBackchannelLogoutForClient(ctx, c, userID, clientID, sessionID)
+ }
+}
+
+func (h *AuthHandler) triggerBackchannelLogoutForClient(ctx context.Context, c *fiber.Ctx, userID string, clientID string, sessionID string) {
+ if h == nil || h.Hydra == nil || h.BackchannelLogout == nil {
+ return
+ }
+
+ clientID = strings.TrimSpace(clientID)
+ userID = strings.TrimSpace(userID)
+ sessionID = strings.TrimSpace(sessionID)
+ if clientID == "" || userID == "" {
+ return
+ }
+
+ client, err := h.Hydra.GetClient(ctx, clientID)
+ if err != nil {
+ h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, "", 0, "client_lookup_failed")
+ return
+ }
+ if client == nil {
+ h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "client_not_found")
+ return
+ }
+
+ endpoint := client.BackchannelLogoutURI()
+ if endpoint == "" {
+ h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "uri_not_configured")
+ return
+ }
+ if client.BackchannelLogoutSessionRequiredValue() && sessionID == "" {
+ h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, endpoint, 0, "sid_required")
+ return
+ }
+
+ logoutToken, err := h.BackchannelLogout.BuildLogoutToken(clientID, userID, sessionID)
+ if err != nil {
+ h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, 0, "token_build_failed")
+ return
+ }
+
+ statusCode, err := h.BackchannelLogout.SendLogoutToken(ctx, endpoint, logoutToken)
+ if err != nil {
+ h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, statusCode, "request_failed")
+ return
+ }
+
+ h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.sent", userID, clientID, sessionID, endpoint, statusCode, "")
+}
+
+func (h *AuthHandler) writeBackchannelLogoutAuditLog(c *fiber.Ctx, eventType string, userID string, clientID string, sessionID string, endpoint string, statusCode int, reason string) {
+ if h == nil || h.AuditRepo == nil {
+ return
+ }
+
+ endpointHost := ""
+ if endpoint != "" {
+ if parsed, err := url.Parse(endpoint); err == nil {
+ endpointHost = parsed.Host
+ }
+ }
+
+ details := map[string]any{
+ "client_id": strings.TrimSpace(clientID),
+ "session_id": strings.TrimSpace(sessionID),
+ "endpoint_host": strings.TrimSpace(endpointHost),
+ "status_code": statusCode,
+ "retry_count": 0,
+ "logout_issuer": h.BackchannelLogout.Issuer(),
+ }
+ if reason != "" {
+ details["reason"] = reason
+ }
+
+ raw, err := json.Marshal(details)
+ if err != nil {
+ return
+ }
+
+ status := "success"
+ if strings.HasSuffix(eventType, ".failed") {
+ status = "failure"
+ } else if strings.HasSuffix(eventType, ".skipped") {
+ status = "skipped"
+ }
+
+ ipAddress := ""
+ userAgent := ""
+ if c != nil {
+ ipAddress = extractClientIPFromHeaders(c)
+ userAgent = strings.TrimSpace(c.Get("User-Agent"))
+ }
+
+ _ = h.AuditRepo.Create(&domain.AuditLog{
+ EventID: fmt.Sprintf("backchannel-logout-%d", time.Now().UnixNano()),
+ Timestamp: time.Now().UTC(),
+ UserID: strings.TrimSpace(userID),
+ SessionID: strings.TrimSpace(sessionID),
+ EventType: eventType,
+ Status: status,
+ IPAddress: ipAddress,
+ UserAgent: userAgent,
+ Details: string(raw),
+ })
+}
+
+func (h *AuthHandler) GetBackchannelLogoutJWKS(c *fiber.Ctx) error {
+ if h == nil || h.BackchannelLogout == nil {
+ return errorJSON(c, fiber.StatusServiceUnavailable, "backchannel logout jwks unavailable")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
+ c.Set(fiber.HeaderCacheControl, "no-store")
+ return c.JSON(h.BackchannelLogout.PublicJWKS())
+}
+
func looksLikeInternalUserAgent(userAgent string) bool {
normalized := strings.ToLower(strings.TrimSpace(userAgent))
if normalized == "" {
diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go
index 9f9d5fdd..1fbdb4a8 100644
--- a/backend/internal/handler/dev_handler.go
+++ b/backend/internal/handler/dev_handler.go
@@ -94,19 +94,21 @@ type devStatsResponse struct {
}
type clientSummary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"`
- Status string `json:"status"`
- CreatedAt *time.Time `json:"createdAt,omitempty"`
- RedirectURIs []string `json:"redirectUris"`
- Scopes []string `json:"scopes"`
- ClientSecret string `json:"clientSecret,omitempty"`
- TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
- SkipConsent bool `json:"skipConsent"`
- JwksUri string `json:"jwksUri,omitempty"`
- Jwks interface{} `json:"jwks,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Status string `json:"status"`
+ CreatedAt *time.Time `json:"createdAt,omitempty"`
+ RedirectURIs []string `json:"redirectUris"`
+ Scopes []string `json:"scopes"`
+ ClientSecret string `json:"clientSecret,omitempty"`
+ TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
+ SkipConsent bool `json:"skipConsent"`
+ JwksUri string `json:"jwksUri,omitempty"`
+ Jwks interface{} `json:"jwks,omitempty"`
+ BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
+ BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type clientListResponse struct {
@@ -179,19 +181,21 @@ type consentListResponse struct {
}
type clientUpsertRequest struct {
- ID *string `json:"id"`
- Name *string `json:"name"`
- Type *string `json:"type"`
- Status *string `json:"status"`
- RedirectURIs *[]string `json:"redirectUris"`
- Scopes *[]string `json:"scopes"`
- GrantTypes *[]string `json:"grantTypes"`
- ResponseTypes *[]string `json:"responseTypes"`
- TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
- SkipConsent *bool `json:"skipConsent"`
- JwksUri *string `json:"jwksUri"`
- Jwks interface{} `json:"jwks"`
- Metadata *map[string]interface{} `json:"metadata"`
+ ID *string `json:"id"`
+ Name *string `json:"name"`
+ Type *string `json:"type"`
+ Status *string `json:"status"`
+ RedirectURIs *[]string `json:"redirectUris"`
+ Scopes *[]string `json:"scopes"`
+ GrantTypes *[]string `json:"grantTypes"`
+ ResponseTypes *[]string `json:"responseTypes"`
+ TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
+ SkipConsent *bool `json:"skipConsent"`
+ JwksUri *string `json:"jwksUri"`
+ Jwks interface{} `json:"jwks"`
+ BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
+ BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
+ Metadata *map[string]interface{} `json:"metadata"`
}
type normalizedIDTokenClaim struct {
@@ -1679,9 +1683,15 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if tenantID != "" {
metadata["tenant_id"] = tenantID
}
+ var err error
metadata["status"] = status
metadata["created_at"] = time.Now().Format(time.RFC3339)
- var err error
+ backchannelLogoutURI := strings.TrimSpace(valueOr(req.BackchannelLogoutURI, ""))
+ backchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, false)
+ metadata, err = normalizeBackchannelLogoutMetadata(metadata, backchannelLogoutURI, backchannelLogoutSessionRequired)
+ if err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, err.Error())
+ }
metadata, err = normalizeClientTenantAccessMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
@@ -1711,17 +1721,19 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
)
clientReq := domain.HydraClient{
- ClientID: clientID,
- ClientName: name,
- RedirectURIs: redirectURIs,
- GrantTypes: grantTypes,
- ResponseTypes: responseTypes,
- Scope: strings.Join(scopes, " "),
- TokenEndpointAuthMethod: tokenAuthMethod,
- SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
- JWKSUri: jwksURI,
- JWKS: jwks,
- Metadata: metadata,
+ ClientID: clientID,
+ ClientName: name,
+ RedirectURIs: redirectURIs,
+ GrantTypes: grantTypes,
+ ResponseTypes: responseTypes,
+ Scope: strings.Join(scopes, " "),
+ TokenEndpointAuthMethod: tokenAuthMethod,
+ SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
+ JWKSUri: jwksURI,
+ JWKS: jwks,
+ BackChannelLogoutURI: backchannelLogoutURI,
+ BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired),
+ Metadata: metadata,
}
h.setAuditDetailsExtra(c, map[string]any{
@@ -1866,6 +1878,16 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
metadata["status"] = status
}
+ resolvedBackchannelLogoutURI := valueOr(req.BackchannelLogoutURI, current.BackchannelLogoutURI())
+ resolvedBackchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, current.BackchannelLogoutSessionRequiredValue())
+ metadata, err = normalizeBackchannelLogoutMetadata(
+ metadata,
+ resolvedBackchannelLogoutURI,
+ resolvedBackchannelLogoutSessionRequired,
+ )
+ if err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, err.Error())
+ }
metadata, err = normalizeClientTenantAccessMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
@@ -1901,17 +1923,19 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true))
updated := domain.HydraClient{
- ClientID: current.ClientID,
- ClientName: valueOr(req.Name, current.ClientName),
- RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
- GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
- ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
- Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
- TokenEndpointAuthMethod: resolvedTokenAuthMethod,
- SkipConsent: boolPtr(resolvedSkipConsent),
- JWKSUri: resolvedJWKSURI,
- JWKS: resolvedJWKS,
- Metadata: metadata,
+ ClientID: current.ClientID,
+ ClientName: valueOr(req.Name, current.ClientName),
+ RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
+ GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
+ ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
+ Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
+ TokenEndpointAuthMethod: resolvedTokenAuthMethod,
+ SkipConsent: boolPtr(resolvedSkipConsent),
+ JWKSUri: resolvedJWKSURI,
+ JWKS: resolvedJWKS,
+ BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
+ BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired),
+ Metadata: metadata,
}
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error())
@@ -2651,19 +2675,21 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
}
return clientSummary{
- ID: client.ClientID,
- Name: name,
- Type: clientType,
- Status: status,
- CreatedAt: createdAt,
- RedirectURIs: client.RedirectURIs,
- Scopes: scopes,
- ClientSecret: clientSecret,
- TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
- SkipConsent: valueOrBool(client.SkipConsent, true),
- JwksUri: client.JWKSUri,
- Jwks: client.JWKS,
- Metadata: client.Metadata,
+ ID: client.ClientID,
+ Name: name,
+ Type: clientType,
+ Status: status,
+ CreatedAt: createdAt,
+ RedirectURIs: client.RedirectURIs,
+ Scopes: scopes,
+ ClientSecret: clientSecret,
+ TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
+ SkipConsent: valueOrBool(client.SkipConsent, true),
+ JwksUri: client.JWKSUri,
+ Jwks: client.JWKS,
+ BackchannelLogoutURI: client.BackchannelLogoutURI(),
+ BackchannelLogoutSessionRequired: client.BackchannelLogoutSessionRequiredValue(),
+ Metadata: client.Metadata,
}
}
@@ -2683,6 +2709,58 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
return value
}
+func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) {
+ if metadata == nil {
+ metadata = map[string]interface{}{}
+ }
+
+ trimmedURI := strings.TrimSpace(logoutURI)
+ if err := validateBackchannelLogoutURI(trimmedURI); err != nil {
+ return nil, err
+ }
+
+ if trimmedURI == "" {
+ delete(metadata, domain.MetadataBackChannelLogoutURI)
+ delete(metadata, domain.MetadataBackChannelLogoutSessionRequired)
+ return metadata, nil
+ }
+
+ metadata[domain.MetadataBackChannelLogoutURI] = trimmedURI
+ metadata[domain.MetadataBackChannelLogoutSessionRequired] = sessionRequired
+ return metadata, nil
+}
+
+func validateBackchannelLogoutURI(raw string) error {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return nil
+ }
+
+ parsed, err := url.Parse(trimmed)
+ if err != nil || parsed == nil {
+ return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL")
+ }
+ if parsed.Scheme == "" || parsed.Host == "" {
+ return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL")
+ }
+ if parsed.Fragment != "" {
+ return fmt.Errorf("backchannelLogoutUri must not include a fragment")
+ }
+
+ switch strings.ToLower(parsed.Scheme) {
+ case "https":
+ return nil
+ case "http":
+ host := strings.ToLower(parsed.Hostname())
+ if host == "localhost" || host == "127.0.0.1" {
+ return nil
+ }
+ return fmt.Errorf("backchannelLogoutUri must use https outside localhost development")
+ default:
+ return fmt.Errorf("backchannelLogoutUri must use http or https")
+ }
+}
+
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
if metadata == nil {
return metadata, nil
diff --git a/backend/internal/service/backchannel_logout_service.go b/backend/internal/service/backchannel_logout_service.go
new file mode 100644
index 00000000..69325bc8
--- /dev/null
+++ b/backend/internal/service/backchannel_logout_service.go
@@ -0,0 +1,192 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/go-jose/go-jose/v4"
+ josejwt "github.com/go-jose/go-jose/v4/jwt"
+)
+
+const backchannelLogoutEventURI = "http://schemas.openid.net/event/backchannel-logout"
+
+type BackchannelLogoutService struct {
+ issuer string
+ keyID string
+ signer jose.Signer
+ publicJWK jose.JSONWebKey
+ client *http.Client
+ HTTPClient *http.Client
+}
+
+func NewBackchannelLogoutService() (*BackchannelLogoutService, error) {
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate backchannel logout key: %w", err)
+ }
+
+ keyID := randomBackchannelKeyID()
+ if keyID == "" {
+ keyID = fmt.Sprintf("bcl-%d", time.Now().UnixNano())
+ }
+
+ signer, err := jose.NewSigner(jose.SigningKey{
+ Algorithm: jose.RS256,
+ Key: jose.JSONWebKey{
+ Key: privateKey,
+ KeyID: keyID,
+ Algorithm: string(jose.RS256),
+ Use: "sig",
+ },
+ }, (&jose.SignerOptions{}).WithType("JWT"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize backchannel logout signer: %w", err)
+ }
+
+ return &BackchannelLogoutService{
+ issuer: resolveBackchannelLogoutIssuer(),
+ keyID: keyID,
+ signer: signer,
+ publicJWK: jose.JSONWebKey{
+ Key: &privateKey.PublicKey,
+ KeyID: keyID,
+ Algorithm: string(jose.RS256),
+ Use: "sig",
+ },
+ client: &http.Client{
+ Timeout: 5 * time.Second,
+ Transport: &http.Transport{
+ DialContext: (&net.Dialer{
+ Timeout: 3 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ TLSHandshakeTimeout: 3 * time.Second,
+ },
+ },
+ }, nil
+}
+
+func randomBackchannelKeyID() string {
+ buf := make([]byte, 8)
+ if _, err := rand.Read(buf); err != nil {
+ return ""
+ }
+ return hex.EncodeToString(buf)
+}
+
+func resolveBackchannelLogoutIssuer() string {
+ if explicit := strings.TrimSpace(os.Getenv("BACKCHANNEL_LOGOUT_ISSUER")); explicit != "" {
+ return strings.TrimRight(explicit, "/")
+ }
+
+ if hydraPublic := strings.TrimSpace(os.Getenv("HYDRA_PUBLIC_URL")); hydraPublic != "" {
+ return strings.TrimRight(hydraPublic, "/")
+ }
+
+ if oathkeeperPublic := strings.TrimSpace(os.Getenv("OATHKEEPER_PUBLIC_URL")); oathkeeperPublic != "" {
+ return strings.TrimRight(oathkeeperPublic, "/") + "/oidc"
+ }
+
+ if userfrontURL := strings.TrimSpace(os.Getenv("USERFRONT_URL")); userfrontURL != "" {
+ return strings.TrimRight(userfrontURL, "/") + "/oidc"
+ }
+
+ return "http://localhost:5000/oidc"
+}
+
+func (s *BackchannelLogoutService) Issuer() string {
+ if s == nil {
+ return ""
+ }
+ return s.issuer
+}
+
+func (s *BackchannelLogoutService) PublicJWKS() map[string]any {
+ if s == nil {
+ return map[string]any{"keys": []any{}}
+ }
+ return map[string]any{
+ "keys": []jose.JSONWebKey{s.publicJWK.Public()},
+ }
+}
+
+func (s *BackchannelLogoutService) BuildLogoutToken(clientID, subject, sessionID string) (string, error) {
+ if s == nil || s.signer == nil {
+ return "", fmt.Errorf("backchannel logout service is unavailable")
+ }
+ clientID = strings.TrimSpace(clientID)
+ subject = strings.TrimSpace(subject)
+ sessionID = strings.TrimSpace(sessionID)
+ if clientID == "" {
+ return "", fmt.Errorf("client id is required")
+ }
+ if subject == "" && sessionID == "" {
+ return "", fmt.Errorf("subject or session id is required")
+ }
+
+ now := time.Now().UTC()
+ claims := josejwt.Claims{
+ Issuer: s.issuer,
+ Audience: josejwt.Audience{clientID},
+ IssuedAt: josejwt.NewNumericDate(now),
+ ID: fmt.Sprintf("%s-%d", s.keyID, now.UnixNano()),
+ }
+ if subject != "" {
+ claims.Subject = subject
+ }
+
+ extra := map[string]any{
+ "events": map[string]any{
+ backchannelLogoutEventURI: map[string]any{},
+ },
+ }
+ if sessionID != "" {
+ extra["sid"] = sessionID
+ }
+
+ return josejwt.Signed(s.signer).Claims(claims).Claims(extra).Serialize()
+}
+
+func (s *BackchannelLogoutService) SendLogoutToken(ctx context.Context, endpoint, logoutToken string) (int, error) {
+ if s == nil {
+ return 0, fmt.Errorf("backchannel logout service is unavailable")
+ }
+ form := url.Values{}
+ form.Set("logout_token", logoutToken)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+
+ client := s.client
+ if s.HTTPClient != nil {
+ client = s.HTTPClient
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return resp.StatusCode, fmt.Errorf("backchannel logout endpoint returned status %d", resp.StatusCode)
+ }
+ return resp.StatusCode, nil
+}
+
+func (s *BackchannelLogoutService) MarshalPublicJWKS() ([]byte, error) {
+ return json.Marshal(s.PublicJWKS())
+}
From 428ea888a75300b9de769026b40eb4fd216aba95 Mon Sep 17 00:00:00 2001
From: kyy
Date: Mon, 4 May 2026 11:45:28 +0900
Subject: [PATCH 16/17] =?UTF-8?q?dev=20=EB=B3=91=ED=95=A9=20TypeScript=20?=
=?UTF-8?q?=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=98=A4=EB=A5=98=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
adminfront/src/features/users/UserCreatePage.tsx | 10 ----------
adminfront/src/features/users/UserDetailPage.tsx | 10 ----------
2 files changed, 20 deletions(-)
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx
index dd28117a..5900db79 100644
--- a/adminfront/src/features/users/UserCreatePage.tsx
+++ b/adminfront/src/features/users/UserCreatePage.tsx
@@ -56,16 +56,6 @@ import {
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
-type UserSchemaField = {
- key: string;
- label?: string;
- type?: "text" | "number" | "boolean" | "date";
- required?: boolean;
- adminOnly?: boolean;
- validation?: string;
- isLoginId?: boolean;
-};
-
type UserFormValues = UserCreateRequest & { metadata: Record };
type UserType = "hanmac" | "external" | "personal";
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx
index 07a94ca3..958685cd 100644
--- a/adminfront/src/features/users/UserDetailPage.tsx
+++ b/adminfront/src/features/users/UserDetailPage.tsx
@@ -79,16 +79,6 @@ import {
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
-type UserSchemaField = {
- key: string;
- label?: string;
- type?: "text" | "number" | "boolean" | "date";
- required?: boolean;
- adminOnly?: boolean;
- validation?: string;
- isLoginId?: boolean;
-};
-
type UserFormValues = Omit & {
metadata: Record>;
};
From 128ac94575673b2a6c7e8bcc796d00aa2440f3b7 Mon Sep 17 00:00:00 2001
From: kyy
Date: Mon, 4 May 2026 13:17:40 +0900
Subject: [PATCH 17/17] =?UTF-8?q?code=20check=20=EC=98=A4=EB=A5=98=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../tenants/routes/TenantListPage.tsx | 5 +---
.../tests/tenant_seed_protection.spec.ts | 4 +--
adminfront/tests/tenants.spec.ts | 4 +--
backend/internal/domain/hydra_models.go | 4 +--
backend/internal/handler/auth_handler.go | 30 +++++++++----------
.../tenant_handler_seed_delete_test.go | 2 +-
.../features/clients/ClientGeneralPage.tsx | 20 ++++++-------
locales/en.toml | 2 ++
locales/ko.toml | 2 ++
locales/template.toml | 2 ++
10 files changed, 38 insertions(+), 37 deletions(-)
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx
index 4a724071..e86c2db8 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx
@@ -570,10 +570,7 @@ function TenantListPage() {
{tenant.name}
{isSeedTenant(tenant) && (
- {t(
- "ui.admin.tenants.seed_badge",
- "초기 설정",
- )}
+ {t("ui.admin.tenants.seed_badge", "초기 설정")}
)}
diff --git a/adminfront/tests/tenant_seed_protection.spec.ts b/adminfront/tests/tenant_seed_protection.spec.ts
index f47f2af2..a78deab6 100644
--- a/adminfront/tests/tenant_seed_protection.spec.ts
+++ b/adminfront/tests/tenant_seed_protection.spec.ts
@@ -103,9 +103,7 @@ test.describe("Seed tenant protection", () => {
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
- await expect(
- normalRow.getByRole("button", { name: /삭제/ }),
- ).toBeEnabled();
+ await expect(normalRow.getByRole("button", { name: /삭제/ })).toBeEnabled();
});
test("disables delete action on seed tenant profile", async ({ page }) => {
diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts
index 2bff1771..1847ce68 100644
--- a/adminfront/tests/tenants.spec.ts
+++ b/adminfront/tests/tenants.spec.ts
@@ -217,8 +217,8 @@ test.describe("Tenants Management", () => {
expect(importBody).toContain('filename="tenants.csv"');
if (browserName !== "webkit") {
if (testInfo.project.name !== "webkit") {
- expect(importBody).toContain("tenant-alpha-id");
- }
+ expect(importBody).toContain("tenant-alpha-id");
+ }
}
});
diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go
index 0a09ef5a..952fa17d 100644
--- a/backend/internal/domain/hydra_models.go
+++ b/backend/internal/domain/hydra_models.go
@@ -14,8 +14,8 @@ const (
MetadataIDTokenClaims = "id_token_claims"
MetadataBackChannelLogoutURI = "backchannel_logout_uri"
MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
- MetadataAutoLoginSupported = "auto_login_supported"
- MetadataAutoLoginURL = "auto_login_url"
+ MetadataAutoLoginSupported = "auto_login_supported"
+ MetadataAutoLoginURL = "auto_login_url"
)
type HydraClient struct {
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index e02f74f0..6f1d625b 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -85,21 +85,21 @@ const (
)
type AuthHandler struct {
- SmsService domain.SmsService
- EmailService domain.EmailService
- RedisService domain.RedisRepository
- HeadlessJWKS *service.HeadlessJWKSCacheService
- KratosAdmin service.KratosAdminService
- IdpProvider domain.IdentityProvider
- AuditRepo domain.AuditRepository
- OathkeeperRepo domain.OathkeeperLogRepository
- Hydra *service.HydraAdminService
- BackchannelLogout *service.BackchannelLogoutService
- TenantService service.TenantService
- KetoService service.KetoService
- KetoOutboxRepo repository.KetoOutboxRepository
- UserRepo repository.UserRepository
- ConsentRepo repository.ClientConsentRepository
+ SmsService domain.SmsService
+ EmailService domain.EmailService
+ RedisService domain.RedisRepository
+ HeadlessJWKS *service.HeadlessJWKSCacheService
+ KratosAdmin service.KratosAdminService
+ IdpProvider domain.IdentityProvider
+ AuditRepo domain.AuditRepository
+ OathkeeperRepo domain.OathkeeperLogRepository
+ Hydra *service.HydraAdminService
+ BackchannelLogout *service.BackchannelLogoutService
+ TenantService service.TenantService
+ KetoService service.KetoService
+ KetoOutboxRepo repository.KetoOutboxRepository
+ UserRepo repository.UserRepository
+ ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository
}
diff --git a/backend/internal/handler/tenant_handler_seed_delete_test.go b/backend/internal/handler/tenant_handler_seed_delete_test.go
index b0bb2347..b4c1eae4 100644
--- a/backend/internal/handler/tenant_handler_seed_delete_test.go
+++ b/backend/internal/handler/tenant_handler_seed_delete_test.go
@@ -138,7 +138,7 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
return c.Next()
})
app.Delete("/tenants/bulk", (&TenantHandler{DB: db}).DeleteTenantsBulk)
- body, _ := json.Marshal(map[string][]string{"ids": []string{seed.ID, normal.ID}})
+ body, _ := json.Marshal(map[string][]string{"ids": {seed.ID, normal.ID}})
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index a6dcd925..c0d76c06 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -339,10 +339,14 @@ function ClientGeneralPage() {
const [initialStatus, setInitialStatus] = useState("active");
const [redirectUris, setRedirectUris] = useState("");
const [backchannelLogoutUri, setBackchannelLogoutUri] = useState("");
- const [backchannelLogoutSessionRequired, setBackchannelLogoutSessionRequired] =
- useState(false);
- const [isBackchannelSessionRequiredInfoOpen, setIsBackchannelSessionRequiredInfoOpen] =
- useState(false);
+ const [
+ backchannelLogoutSessionRequired,
+ setBackchannelLogoutSessionRequired,
+ ] = useState(false);
+ const [
+ isBackchannelSessionRequiredInfoOpen,
+ setIsBackchannelSessionRequiredInfoOpen,
+ ] = useState(false);
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
const [allowedTenantIds, setAllowedTenantIds] = useState([]);
const [tenantSearch, setTenantSearch] = useState("");
@@ -1610,9 +1614,7 @@ function ClientGeneralPage() {
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={() =>
- setIsBackchannelSessionRequiredInfoOpen(
- (prev) => !prev,
- )
+ setIsBackchannelSessionRequiredInfoOpen((prev) => !prev)
}
aria-label={t(
"ui.dev.clients.general.backchannel_logout.session_required_info",
@@ -1657,9 +1659,7 @@ function ClientGeneralPage() {
id="backchannel-logout-session-required"
checked={backchannelLogoutSessionRequired}
onCheckedChange={setBackchannelLogoutSessionRequired}
- disabled={
- isGeneralSettingsReadOnly || !hasBackchannelLogoutUri
- }
+ disabled={isGeneralSettingsReadOnly || !hasBackchannelLogoutUri}
/>
diff --git a/locales/en.toml b/locales/en.toml
index cb2f0aa3..f7129211 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -1095,6 +1095,7 @@ delete_selected = "Delete Selected"
export_with_ids = "Include UUIDs"
export_without_ids = "Export without UUIDs"
import = "Import"
+seed_badge = "Seed"
title = "Tenant Registry"
view_org_chart = "View Full Org Chart"
@@ -1188,6 +1189,7 @@ import_partial_success = "Imported some organization data successfully."
[msg.admin.tenants]
delete_bulk_confirm = "Delete {{count}} selected tenants?"
+seed_delete_blocked = "Seed tenants cannot be deleted."
[msg.admin.users]
self_delete_blocked = "You cannot delete your own account."
diff --git a/locales/ko.toml b/locales/ko.toml
index 29f63aa3..9ca45c99 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -1570,6 +1570,7 @@ user = "TENANT MEMBER"
[ui.admin.tenants]
add = "테넌트 추가"
delete_selected = "선택 삭제"
+seed_badge = "초기 설정"
title = "테넌트 목록"
view_org_chart = "전체 조직도 보기"
@@ -1648,6 +1649,7 @@ import_partial_success = "일부 조직 정보를 가져왔습니다."
[msg.admin.tenants]
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
+seed_delete_blocked = "초기 설정 테넌트는 삭제할 수 없습니다."
[msg.admin.users]
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
diff --git a/locales/template.toml b/locales/template.toml
index fc9923c3..c3a94fdb 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -1439,6 +1439,7 @@ user = ""
[ui.admin.tenants]
add = ""
delete_selected = ""
+seed_badge = ""
title = ""
view_org_chart = ""
@@ -1520,6 +1521,7 @@ delete_bulk_confirm = ""
import_empty = ""
import_error = ""
import_result = ""
+seed_delete_blocked = ""
[msg.admin.tenants.import_preview]
description = ""