1
0
forked from baron/baron-sso

Merge pull request 'feature/back-channel-logout' (#715) from feature/back-channel-logout into dev

Reviewed-on: baron/baron-sso#715
This commit is contained in:
2026-05-07 11:28:40 +09:00
37 changed files with 1348 additions and 228 deletions

View File

@@ -190,7 +190,7 @@ PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATI
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-orgfront-tests code-check-userfront-e2e-tests
CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1
@@ -208,7 +208,8 @@ code-check-test-jobs:
code-check-userfront-tests \
code-check-userfront-e2e-tests \
code-check-adminfront-tests \
code-check-devfront-tests
code-check-devfront-tests \
code-check-orgfront-tests
code-check-i18n:
@echo "==> i18n resource check"
@@ -263,6 +264,11 @@ code-check-front-lint:
cd devfront && npm ci --ignore-scripts
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@echo "==> orgfront biome lint/format check"
rm -rf orgfront/playwright-report orgfront/test-results
cd orgfront && npm ci --ignore-scripts
cd orgfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd orgfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
code-check-backend-tests:
@echo "==> backend tests"
@@ -310,6 +316,22 @@ code-check-devfront-tests:
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
exit $$status
code-check-orgfront-tests:
@echo "==> orgfront tests"
@mkdir -p reports/orgfront
@rm -rf reports/orgfront/playwright-report reports/orgfront/test-results
@status=0; \
(cd orgfront && npm ci --ignore-scripts) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd orgfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd orgfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d orgfront/playwright-report ] && cp -R orgfront/playwright-report reports/orgfront/ || true; \
[ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \
exit $$status
code-check-userfront-e2e-tests:
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
@mkdir -p reports/userfront-e2e

View File

@@ -641,7 +641,12 @@ export function buildWorksmobilePasswordManageUrl({
}) {
const normalizedTenantId = tenantId?.trim();
const normalizedUserIdNo = userIdNo?.trim();
if (!normalizedTenantId || !domainId || domainId <= 0 || !normalizedUserIdNo) {
if (
!normalizedTenantId ||
!domainId ||
domainId <= 0 ||
!normalizedUserIdNo
) {
return "";
}
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
@@ -790,10 +795,7 @@ function ComparisonTable({
.filter(canSelectWorksmobileRow)
.map(getWorksmobileRowSelectionKey)
.filter(Boolean);
const selectedActionIds = getWorksmobileSelectedActionIds(
rows,
selectedKeys,
);
const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys);
const allSelectableSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key));

View File

@@ -462,7 +462,8 @@ test.describe("User Management", () => {
"John Doe john@test.com 010-1111-2222",
);
await page.getByTestId("user-status-toggle-u-1").click();
await page.getByTestId("user-status-select-u-1").click();
await page.getByRole("option", { name: /비활성|inactive/i }).click();
await expect
.poll(() => updatePayload)
.toMatchObject({ status: "inactive" });
@@ -816,22 +817,27 @@ test.describe("User Management", () => {
(form as HTMLFormElement).requestSubmit();
});
await expect.poll(() => updatePayload).toMatchObject({
tenantSlug: "hanmac-team",
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantIsOwner: true,
metadata: {
await expect
.poll(() => updatePayload)
.toMatchObject({
tenantSlug: "hanmac-team",
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantSlug: "hanmac-team",
primaryTenantIsOwner: true,
additionalAppointments: [
{ tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", isPrimary: false },
{ tenantId: "hanmac-team-id", isPrimary: true },
],
},
});
metadata: {
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantSlug: "hanmac-team",
primaryTenantIsOwner: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
isPrimary: false,
},
{ tenantId: "hanmac-team-id", isPrimary: true },
],
},
});
});
test("should show conflict error when creating with an existing Login ID", async ({

View File

@@ -211,11 +211,9 @@ test.describe("Worksmobile tenant management", () => {
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
})
.allTextContents();
await expect.poll(() => filterButtons).toEqual([
"바론에만 있음",
"웍스에만 있음",
"양쪽 다 있음",
]);
await expect
.poll(() => filterButtons)
.toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]);
await page.getByRole("button", { name: "웍스에만 있음" }).click();
await expect(page.getByText("박웍스")).not.toBeVisible();
@@ -481,16 +479,17 @@ test.describe("Worksmobile tenant management", () => {
Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth),
).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1);
const userTableScroll = await page.locator("table").first().evaluate(
(table) => {
const userTableScroll = await page
.locator("table")
.first()
.evaluate((table) => {
const container = table.parentElement?.parentElement as HTMLElement;
return {
clientWidth: container.clientWidth,
overflowX: window.getComputedStyle(container).overflowX,
scrollWidth: container.scrollWidth,
};
},
);
});
expect(userTableScroll.overflowX).toBe("auto");
expect(userTableScroll.scrollWidth).toBeGreaterThan(
userTableScroll.clientWidth,

View File

@@ -18,7 +18,6 @@ func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) {
Name: "New Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}
@@ -67,7 +66,6 @@ func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) {
Name: "Existing Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}

View File

@@ -13,6 +13,7 @@ import (
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
@@ -2749,16 +2750,35 @@ func validateBackchannelLogoutURI(raw string) error {
case "https":
return nil
case "http":
host := strings.ToLower(parsed.Hostname())
if host == "localhost" || host == "127.0.0.1" {
if isAllowedLocalBackchannelLogoutHost(parsed.Hostname()) {
return nil
}
return fmt.Errorf("backchannelLogoutUri must use https outside localhost development")
return fmt.Errorf("backchannelLogoutUri must use https outside local development")
default:
return fmt.Errorf("backchannelLogoutUri must use http or https")
}
}
func isAllowedLocalBackchannelLogoutHost(rawHost string) bool {
host := strings.ToLower(strings.TrimSpace(rawHost))
if host == "" {
return false
}
switch host {
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
}
// Docker service names and other single-label local hostnames are
// permitted only for local HTTP development workflows.
return !strings.Contains(host, ".")
}
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
if metadata == nil {
return metadata, nil

View File

@@ -20,9 +20,11 @@ import (
"time"
)
const defaultWorksmobileAPIBaseURL = "https://www.worksapis.com"
const defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token"
const defaultWorksmobileOAuthScope = "directory"
const (
defaultWorksmobileAPIBaseURL = "https://www.worksapis.com"
defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token"
defaultWorksmobileOAuthScope = "directory"
)
type WorksmobileDirectoryClient interface {
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error

View File

@@ -363,32 +363,41 @@ func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User)
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
user := f.byID[id]
return &user, nil
}
func (f *fakeWorksmobileUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
return nil, 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
return 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
return f.byTenant, nil
}
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
return nil, nil
}
@@ -396,12 +405,15 @@ func (f *fakeWorksmobileUserRepo) Delete(ctx context.Context, id string) error {
func (f *fakeWorksmobileUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (f *fakeWorksmobileUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}

View File

@@ -14,34 +14,34 @@ export function ForbiddenMessage({ resourceToken }: Props) {
let explanation = t(
"msg.dev.forbidden.default",
"해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.",
"You do not have permission to access this resource. Contact your administrator.",
);
if (role === "rp_admin") {
explanation = t(
"msg.dev.forbidden.rp_admin",
"RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.",
"RP administrators can only access resources for their assigned applications.",
);
} else if (role === "tenant_admin") {
explanation = t(
"msg.dev.forbidden.tenant_admin",
"테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.",
"Your tenant administrator permission is missing, misconfigured, or expired.",
);
} else if (role === "user" || role === "tenant_member") {
if (resourceToken === "consents") {
explanation = t(
"msg.dev.forbidden.user.consents",
"해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
"Viewing consent records for this application requires an RP administrator, consent read, or consent revoke relationship. Request access from an administrator if needed.",
);
} else if (resourceToken === "audit") {
explanation = t(
"msg.dev.forbidden.user.audit",
"해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
"Viewing audit logs for this application requires an RP administrator or audit read relationship. Request access from an administrator if needed.",
);
} else {
explanation = t(
"msg.dev.forbidden.user.clients",
"일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
"Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target RP. Request access from an administrator if needed.",
);
}
}
@@ -51,9 +51,9 @@ export function ForbiddenMessage({ resourceToken }: Props) {
? t("ui.dev.audit.title", "Audit Logs")
: resourceToken === "consents"
? t("ui.dev.clients.consents.title", "User Consent Grants")
: t("ui.dev.clients.registry.subtitle", "연동 앱");
: t("ui.dev.clients.registry.subtitle", "Connected Applications");
const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", {
const title = t("msg.dev.forbidden.title", "Access denied: {{resource}}", {
resource: resourceLabel,
});

View File

@@ -32,7 +32,7 @@ const navItems = [
},
{
labelKey: "ui.dev.nav.developer_request",
labelFallback: "개발자 권한 신청",
labelFallback: "Developer Access Request",
to: "/developer-requests",
icon: ClipboardCheck,
},
@@ -71,7 +71,11 @@ function AppLayout() {
});
const handleLogout = () => {
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
if (
window.confirm(
t("msg.dev.logout_confirm", "Are you sure you want to log out?"),
)
) {
auth.removeUser();
navigate("/login");
}
@@ -136,7 +140,7 @@ function AppLayout() {
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 자동 연장에 실패했습니다.", error);
console.error("Silent session renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
@@ -184,7 +188,7 @@ function AppLayout() {
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
console.error("Unlimited session keepalive renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
@@ -241,7 +245,7 @@ function AppLayout() {
void auth
.signinSilent()
.catch((error) => {
console.error("세션 자동 연장에 실패했습니다.", error);
console.error("Silent session renewal failed.", error);
})
.finally(() => {
isRenewInFlightRef.current = false;
@@ -289,15 +293,15 @@ function AppLayout() {
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "세션 활성");
let sessionText = t("ui.dev.session.active", "Session active");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "알 수 없음");
sessionText = t("ui.dev.session.unknown", "Unknown");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "세션 만료");
sessionText = t("ui.dev.session.expired", "Session expired");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
@@ -307,7 +311,7 @@ function AppLayout() {
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"만료 임박: {{minutes}} {{seconds}}초 남음",
"Expiring soon: {{minutes}}m {{seconds}}s left",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
@@ -316,7 +320,7 @@ function AppLayout() {
} else {
sessionText = t(
"ui.dev.session.remaining",
"만료 예정: {{minutes}} {{seconds}}초 남음",
"Expires in {{minutes}}m {{seconds}}s",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
@@ -343,7 +347,7 @@ function AppLayout() {
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.dev.brand", "Baron 로그인")}
{t("ui.dev.brand", "Baron Sign In")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.dev.console_title", "Developer Console")}
@@ -423,7 +427,7 @@ function AppLayout() {
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.theme_toggle", "테마 전환")}
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light"
@@ -447,7 +451,10 @@ function AppLayout() {
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu"
aria-expanded={isProfileMenuOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
aria-label={t(
"ui.dev.profile.menu_aria",
"Open account menu",
)}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
@@ -496,14 +503,14 @@ function AppLayout() {
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
{t("ui.dev.session.auto_extend", "Session expiry")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
"Session expiry disabled",
)}
</p>
</div>
@@ -539,7 +546,7 @@ function AppLayout() {
}}
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.dev.profile.title", "내 정보")}</span>
<span>{t("ui.dev.profile.title", "My Profile")}</span>
</button>
<button
type="button"

View File

@@ -288,7 +288,26 @@ function isValidBackchannelLogoutUrl(value: string): boolean {
if (url.protocol !== "http:") {
return false;
}
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
const host = url.hostname.toLowerCase();
if (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "host.docker.internal"
) {
return true;
}
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
return (
host.startsWith("10.") ||
host.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
host.startsWith("169.254.")
);
}
// Docker service names and other single-label local hosts are allowed
// only for HTTP local development use.
return !host.includes(".");
} catch {
return false;
}
@@ -949,7 +968,7 @@ function ClientGeneralPage() {
throw new Error(
t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.",
),
);
}
@@ -1590,7 +1609,7 @@ function ClientGeneralPage() {
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.",
)}
</p>
) : null}

View File

@@ -433,9 +433,14 @@ 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.auto_login]
subtitle = "If the RP can build an OIDC request from its own login start URL, userfront can jump directly into that flow."
help = "This URL must generate state, nonce, and PKCE values on the RP side and then redirect to Baron OIDC."
invalid_url = "The auto login URL format is invalid. Enter an http or https URL."
[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."
invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1, host.docker.internal, Docker service names, and private IPs."
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."
@@ -496,7 +501,7 @@ docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
[msg.dev.clients.registry]
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
[msg.dev.clients.scopes]
email = "Email"
@@ -1256,7 +1261,9 @@ copy = "Copy"
create = "Create"
delete = "Delete"
details = "Details"
disabled = "Disabled"
edit = "Edit"
enabled = "Enabled"
export = "Export"
fail = "Fail"
go_home = "Go Home"
@@ -1492,6 +1499,13 @@ title = "Application Identity"
label = "Redirect URIs"
placeholder = "Placeholder"
[ui.dev.clients.general.auto_login]
title = "Auto Login"
supported = "Auto Login Enabled"
unsupported = "Auto Login Disabled"
url = "Auto Login Start URL"
url_placeholder = "https://app.example.com/login?auto=1"
[ui.dev.clients.general.backchannel_logout]
uri = "Back-Channel Logout URI"
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
@@ -1668,7 +1682,7 @@ subtitle = "Tenant admin on-call"
title = "Owner"
[ui.dev.clients.registry]
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
subtitle = "Applications"
title = "RP registry"

View File

@@ -433,9 +433,14 @@ subtitle = "앱 이름과 설명, 로고를 설정합니다."
[msg.dev.clients.general.redirect]
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다."
[msg.dev.clients.general.auto_login]
subtitle = "RP가 자체 로그인 시작 URL에서 OIDC 요청을 만들 수 있으면 userfront에서 바로 로그인 진입을 제공합니다."
help = "이 URL은 RP가 state, nonce, PKCE 값을 직접 생성한 뒤 Baron OIDC로 리다이렉트해야 합니다."
invalid_url = "자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요."
[msg.dev.clients.general.backchannel_logout]
uri_help = "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다."
invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다."
invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다."
session_required_help = "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다."
session_required_on = "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리"
session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능"
@@ -1256,7 +1261,9 @@ copy = "복사"
create = "생성"
delete = "삭제"
details = "상세정보"
disabled = "사용 안 함"
edit = "편집"
enabled = "사용"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
@@ -1491,6 +1498,13 @@ title = "애플리케이션 정보"
label = "리디렉션 URI"
placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
[ui.dev.clients.general.auto_login]
title = "자동 로그인"
supported = "자동 로그인 지원"
unsupported = "자동 로그인 미지원"
url = "자동 로그인 시작 URL"
url_placeholder = "https://app.example.com/login?auto=1"
[ui.dev.clients.general.backchannel_logout]
uri = "Back-Channel Logout URI"
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"

View File

@@ -480,6 +480,11 @@ subtitle = ""
[msg.dev.clients.general.redirect]
help = ""
[msg.dev.clients.general.auto_login]
subtitle = ""
help = ""
invalid_url = ""
[msg.dev.clients.general.backchannel_logout]
uri_help = ""
invalid = ""
@@ -1309,7 +1314,9 @@ copy = ""
create = ""
delete = ""
details = ""
disabled = ""
edit = ""
enabled = ""
export = ""
fail = ""
go_home = ""
@@ -1547,6 +1554,13 @@ title = ""
label = ""
placeholder = ""
[ui.dev.clients.general.auto_login]
title = ""
supported = ""
unsupported = ""
url = ""
url_placeholder = ""
[ui.dev.clients.general.backchannel_logout]
uri = ""
uri_placeholder = ""

View File

@@ -0,0 +1,321 @@
# PKCE RP Back-Channel Logout 구현 가이드
이 문서는 Baron SSO와 연동하는 PKCE RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
## 목적
PKCE RP도 OIDC `Authorization Code + PKCE` 흐름을 사용하면서 Baron SSO의 원격 세션 종료 이벤트를 받을 수 있어야 합니다. 다만 `Back-Channel Logout`은 브라우저가 아니라 OP(Baron)가 RP 서버로 직접 `logout_token`을 보내는 방식이므로, **순수 frontend-only PKCE 앱만으로는 구현할 수 없습니다.**
즉, PKCE RP가 `Back-Channel Logout`을 사용하려면 다음 둘을 모두 가져야 합니다.
1. PKCE 로그인 플로우를 시작하고 callback을 처리하는 RP
2. `logout_token`을 수신하는 서버 endpoint
## 적용 대상
이 가이드는 다음 경우를 대상으로 합니다.
- 브라우저에서 `Authorization Code + PKCE`를 사용하는 RP
- RP가 자체 세션 또는 BFF 세션을 보유하는 경우
- RP가 `Back-Channel Logout URI`를 등록하고 Baron의 세션 종료 이벤트를 직접 수신하려는 경우
다음 경우는 이 가이드의 직접 대상이 아닙니다.
- 순수 frontend-only SPA
- 서버 없이 `localStorage`/`sessionStorage`만 사용하는 PKCE 앱
이 경우에는 `Back-Channel Logout` 대신 front-channel logout, 세션 재검증, 짧은 token TTL 같은 별도 전략을 사용해야 합니다.
## devfront 등록 기준
PKCE RP는 devfront에서 아래 항목을 등록합니다.
1. `Type`: `pkce`
2. `Redirect URI`: RP callback URL
3. `Back-Channel Logout URI`: RP 서버 endpoint
4. 필요 시 `SID Claim Required`
예시:
```text
Type: pkce
Redirect URI: https://rp.example.com/callback
Back-Channel Logout URI: https://rp.example.com/backchannel-logout
SID Claim Required: off
```
로컬 Docker 개발 예시:
```text
Redirect URI: http://localhost:3333/callback
Back-Channel Logout URI: http://baron-sso-login-demo:3333/backchannel-logout
```
주의:
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, Docker 서비스명이나 사설 IP를 사용해야 할 수 있습니다.
## 구현 요구사항
PKCE RP는 최소한 아래를 구현해야 합니다.
### 1. 로그인 후 세션 매핑 저장
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
- `sid -> rpSessionId`
- `sub -> rpSessionId`
권장 순서는 다음과 같습니다.
1. `sid`를 우선 저장
2. `sub`도 함께 저장
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
예시:
```text
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
```
### 2. `POST /backchannel-logout` endpoint
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
예:
```text
POST /backchannel-logout
Content-Type: application/x-www-form-urlencoded
Body: logout_token=<jwt>
```
RP는 이 endpoint에서:
1. `logout_token` 존재 여부 확인
2. JWT 서명 및 claim 검증
3. `sid` 또는 `sub`로 로컬 세션 탐색
4. 세션 스토어에서 직접 세션 파기
5. 성공 시 `2xx` 응답
을 수행해야 합니다.
### 3. `logout_token` 검증
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
```text
GET /api/v1/auth/backchannel/jwks.json
```
검증 필수 항목:
1. JWT 서명 검증
2. `iss`가 Baron OIDC issuer와 일치
3. `aud`에 현재 RP `client_id` 포함
4. `iat` 존재
5. `jti` 존재
6. `events``http://schemas.openid.net/event/backchannel-logout` 포함
7. `nonce`가 없어야 함
8. `sid` 또는 `sub`가 있어야 함
추가 권장 항목:
- `jti` replay 방지 캐시
- 시계 오차 허용 범위 설정
- 검증 실패 시 `400`
## 세션 종료 기준
### 권장 순서
1. `sid`로 매칭 시도
2. 매칭 실패 시 `sub`로 fallback
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
### `SID Claim Required = true`
- `logout_token``sid`가 있어야만 처리
- `sub` fallback 금지
- 세션 모델이 `sid` 중심으로 안정적으로 유지되는 RP에 적합
### `SID Claim Required = false`
- `sid`가 있으면 우선 사용
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
- 실제 운영에서는 이 모드가 더 현실적일 수 있음
## 세션 파기 방식
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
예:
```text
store.destroy(rpSessionId)
```
필수 조건:
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
- 이미 삭제된 세션은 idempotent success 처리
## 권장 로그 항목
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
1. 요청 수신
2. 토큰 검증 성공/실패
3. `sid`, `sub`, `jti`
4. 매칭된 `rpSessionId` 목록
5. 세션 파기 성공/실패 수
예시:
```text
[백채널 로그아웃] 요청 수신
[백채널 로그아웃] 토큰 검증 성공
[백채널 로그아웃] 세션 탐색 결과
[백채널 로그아웃] 세션 파기 완료
[백채널 로그아웃] 처리 완료
```
주의:
- raw `logout_token` 전체를 로그에 남기지 않습니다.
- access token, refresh token, cookie raw value도 남기지 않습니다.
## 테스트 체크리스트
### 기본 성공 시나리오
1. PKCE RP 로그인
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
3. UserFront에서 `세션 종료`
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
5. RP가 `logout_token` 검증 성공
6. RP 세션 파기 성공
7. 보호 페이지 접근 시 비로그인 상태 확인
### 확인 포인트
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
2. Baron backend가 해당 URI에 실제로 도달 가능한가
3. RP 로그에 `요청 수신``토큰 검증 성공`이 찍히는가
4. 세션 스토어에서 실제 세션이 삭제됐는가
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
## 구현 예시 구조
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
```text
GET /login
GET /callback
GET /profile
GET /logout
POST /backchannel-logout
```
내부 저장 예시:
```text
sidToSessionIds: Map<string, Set<string>>
subToSessionIds: Map<string, Set<string>>
sessionIdToBinding: Map<string, { sid: string, sub: string }>
```
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
- 백채널 로그아웃 모듈: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/backchannel-logout.js`
- 데모 앱 엔트리포인트: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/app.js`
이 데모는:
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
구조로 동작합니다.
## 자주 생기는 문제
### 1. `localhost`로는 안 되는데 입력은 저장됨
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
예:
```text
http://localhost:3333/backchannel-logout
```
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
### 2. `sid`가 로그인 시 값과 다름
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
따라서:
1. `sid` 우선
2. `sub` fallback
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
### 3. 순수 frontend-only PKCE인데 endpoint를 만들 수 없음
그 경우는 `Back-Channel Logout` 자체를 구현할 수 없습니다. 최소한 logout 수신용 서버 컴포넌트를 추가해야 합니다.
## 로직 흐름
```mermaid
sequenceDiagram
autonumber
participant Browser as 브라우저
participant RP as PKCE RP
participant Baron as Baron SSO
participant Store as 세션 스토어
Browser->>RP: GET /login 호출
RP->>Browser: Baron authorize endpoint로 리다이렉트
Browser->>Baron: Authorization Code + PKCE 로그인
Baron->>Browser: /callback?code=... 으로 리다이렉트
Browser->>RP: GET /callback 호출
RP->>Baron: code_verifier 포함 token 요청
Baron-->>RP: ID Token / Access Token 반환
RP->>Store: RP 세션 생성
RP->>RP: registerSessionBinding(sessionId, sid, sub)
RP-->>Browser: 로그인 완료 응답
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
Baron->>RP: POST /backchannel-logout (logout_token)
RP->>Baron: Back-Channel JWKS로 logout_token 검증
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
RP->>RP: sid 또는 sub로 sessionId 탐색
RP->>Store: destroy(sessionId)
RP->>RP: removeSessionBinding(sessionId)
RP-->>Baron: 200 OK
Browser->>RP: GET /profile 호출
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
```
## 권장 결론
PKCE RP에서 `Back-Channel Logout`을 쓰려면, 다음 원칙을 따르십시오.
1. PKCE 로그인 플로우는 그대로 유지
2. logout 수신용 서버 endpoint 별도 구현
3. `sid``sub`를 모두 저장
4. 세션 스토어에서 직접 세션 파기
5. 로컬 개발 시 Baron backend가 도달 가능한 URI를 사용
이 다섯 가지가 갖춰져야 Baron의 원격 세션 종료가 RP 로컬 세션 종료까지 이어집니다.

View File

@@ -0,0 +1,322 @@
# Server-Side App RP Back-Channel Logout 구현 가이드
이 문서는 Baron SSO와 연동하는 `server-side-app` RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
## 목적
`server-side-app` RP는 confidential client로 동작하면서, Baron SSO의 원격 세션 종료 이벤트를 받아 RP 로컬 세션을 즉시 정리할 수 있어야 합니다.
즉, `server-side-app` RP는 다음 둘을 모두 구현해야 합니다.
1. OIDC Authorization Code 로그인과 callback 처리
2. `logout_token`을 수신하는 `Back-Channel Logout URI`
## 적용 대상
이 가이드는 다음 경우를 대상으로 합니다.
- `server-side-app` 타입 RP
- confidential client
- `client_secret_basic` 또는 `client_secret_post`를 사용하는 RP
- 자체 서버 세션 또는 BFF 세션을 보유하는 RP
다음 경우는 이 가이드의 직접 대상이 아닙니다.
- 순수 frontend-only SPA
- public client 기반 PKCE 앱
## devfront 등록 기준
`server-side-app` RP는 devfront에서 아래 항목을 등록합니다.
1. `Type`: `server-side-app`
2. `Redirect URI`: RP callback URL
3. `Back-Channel Logout URI`: RP 서버 endpoint
4. 필요 시 `SID Claim Required`
예시:
```text
Type: server-side-app
Redirect URI: http://localhost:4444/callback
Back-Channel Logout URI: http://172.16.9.208:4444/backchannel-logout
SID Claim Required: off
```
주의:
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, 필요하면 사설 IP 또는 Docker 서비스명을 사용해야 합니다.
## 구현 요구사항
`server-side-app` RP는 최소한 아래를 구현해야 합니다.
### 1. confidential client 구성
RP는 일반적으로 아래 중 하나의 인증 방식을 사용합니다.
1. `client_secret_basic`
2. `client_secret_post`
즉 token 교환 시:
- `client_id`
- `client_secret`
가 함께 사용됩니다.
PKCE와 달리 `code_verifier`, `code_challenge`는 필수가 아닙니다.
### 2. 로그인 후 세션 매핑 저장
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
- `sid -> rpSessionId`
- `sub -> rpSessionId`
권장 순서는 다음과 같습니다.
1. `sid`를 우선 저장
2. `sub`도 함께 저장
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
예시:
```text
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
```
### 3. `POST /backchannel-logout` endpoint
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
예:
```text
POST /backchannel-logout
Content-Type: application/x-www-form-urlencoded
Body: logout_token=<jwt>
```
RP는 이 endpoint에서:
1. `logout_token` 존재 여부 확인
2. JWT 서명 및 claim 검증
3. `sid` 또는 `sub`로 로컬 세션 탐색
4. 세션 스토어에서 직접 세션 파기
5. 성공 시 `2xx` 응답
을 수행해야 합니다.
### 4. `logout_token` 검증
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
```text
GET /api/v1/auth/backchannel/jwks.json
```
검증 필수 항목:
1. JWT 서명 검증
2. `iss`가 Baron OIDC issuer와 일치
3. `aud`에 현재 RP `client_id` 포함
4. `iat` 존재
5. `jti` 존재
6. `events``http://schemas.openid.net/event/backchannel-logout` 포함
7. `nonce`가 없어야 함
8. `sid` 또는 `sub`가 있어야 함
추가 권장 항목:
- `jti` replay 방지 캐시
- 시계 오차 허용 범위 설정
- 검증 실패 시 `400`
## 세션 종료 기준
### 권장 순서
1. `sid`로 매칭 시도
2. 매칭 실패 시 `sub`로 fallback
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
### `SID Claim Required = true`
- `logout_token``sid`가 있어야만 처리
- `sub` fallback 금지
- `sid` 중심 세션 모델을 운영하는 RP에 적합
### `SID Claim Required = false`
- `sid`가 있으면 우선 사용
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
- 실제 운영에서는 이 모드가 더 유연할 수 있음
## 세션 파기 방식
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
예:
```text
store.destroy(rpSessionId)
```
필수 조건:
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
- 이미 삭제된 세션은 idempotent success 처리
## 권장 로그 항목
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
1. 요청 수신
2. 토큰 검증 성공/실패
3. `sid`, `sub`, `jti`
4. 매칭된 `rpSessionId` 목록
5. 세션 파기 성공/실패 수
예시:
```text
[백채널 로그아웃] 요청 수신
[백채널 로그아웃] 토큰 검증 성공
[백채널 로그아웃] 세션 탐색 결과
[백채널 로그아웃] 세션 파기 완료
[백채널 로그아웃] 처리 완료
```
주의:
- raw `logout_token` 전체를 로그에 남기지 않습니다.
- access token, refresh token, cookie raw value도 남기지 않습니다.
## 테스트 체크리스트
### 기본 성공 시나리오
1. server-side-app RP 로그인
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
3. UserFront에서 `세션 종료`
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
5. RP가 `logout_token` 검증 성공
6. RP 세션 파기 성공
7. 보호 페이지 접근 시 비로그인 상태 확인
### 확인 포인트
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
2. Baron backend가 해당 URI에 실제로 도달 가능한가
3. RP 로그에 `요청 수신``토큰 검증 성공`이 찍히는가
4. 세션 스토어에서 실제 세션이 삭제됐는가
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
## 구현 예시 구조
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
```text
GET /login
GET /callback
GET /profile
GET /logout
POST /backchannel-logout
```
내부 저장 예시:
```text
sidToSessionIds: Map<string, Set<string>>
subToSessionIds: Map<string, Set<string>>
sessionIdToBinding: Map<string, { sid: string, sub: string }>
```
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
- 백채널 로그아웃 모듈: `/home/kyy/workspace/baron-sso-server-side-demo/backchannel-logout.js`
- 데모 앱 엔트리포인트: `/home/kyy/workspace/baron-sso-server-side-demo/app.js`
이 데모는:
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
구조로 동작합니다.
## 자주 생기는 문제
### 1. `localhost`로는 안 되는데 입력은 저장됨
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
예:
```text
http://localhost:4444/backchannel-logout
```
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
### 2. `sid`가 로그인 시 값과 다름
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
따라서:
1. `sid` 우선
2. `sub` fallback
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
### 3. `client_secret` 또는 auth method가 잘못되어 callback에서 실패함
`server-side-app`은 confidential client이므로 아래 값이 정확해야 합니다.
1. `client_id`
2. `client_secret`
3. `token_endpoint_auth_method`
4. `redirect_uri`
이 중 하나라도 다르면 authorization code 교환 단계에서 실패할 수 있습니다.
## 시퀀스 다이어그램
```mermaid
sequenceDiagram
autonumber
participant Browser as 브라우저
participant RP as Server-Side RP
participant Baron as Baron SSO
participant Store as 세션 스토어
Browser->>RP: GET /login 호출
RP->>Browser: Baron authorize endpoint로 리다이렉트
Browser->>Baron: Authorization Code 로그인
Baron->>Browser: /callback?code=... 으로 리다이렉트
Browser->>RP: GET /callback 호출
RP->>Baron: client_secret 포함 token 요청
Baron-->>RP: ID Token / Access Token 반환
RP->>Store: RP 세션 생성
RP->>RP: registerSessionBinding(sessionId, sid, sub)
RP-->>Browser: 로그인 완료 응답
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
Baron->>RP: POST /backchannel-logout (logout_token)
RP->>Baron: Back-Channel JWKS로 logout_token 검증
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
RP->>RP: sid 또는 sub로 sessionId 탐색
RP->>Store: destroy(sessionId)
RP->>RP: removeSessionBinding(sessionId)
RP-->>Baron: 200 OK
Browser->>RP: GET /profile 호출
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
```

View File

@@ -16,6 +16,7 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
organization = "Organization"
personal = "Personal"
user_group = "User Group"
@@ -1166,8 +1167,26 @@ tab_organization = "Organization Manage"
tab_permissions = "Permissions"
tab_profile = "Profile"
tab_schema = "Tab Schema"
tab_worksmobile = "Worksmobile"
title = "Details"
[ui.admin.tenants.worksmobile]
compare = "Baron / Works Comparison"
compare_description = "Users show entries that exist only in Baron or only in WORKS by default."
compare_groups = "Organizations / Groups"
compare_users = "Users"
dry_run = "Backfill Dry-run"
forbidden = "You do not have permission to manage the Worksmobile integration."
initial_password_csv = "Initial Password CSV"
recent_jobs = "Recent Jobs"
refresh = "Refresh"
single_sync = "Single-item Sync"
single_sync_description = "Create an organization or user sync job using a Baron UUID."
subtitle = "Review Hanmac Family Directory sync status for organizations and users, and retry failed jobs."
sync_orgunit = "Organization Sync"
sync_user = "User Sync"
title = "Worksmobile Integration"
[ui.admin.tenants.list]
search_placeholder = "Search tenant by name or slug..."
select_placeholder = "Select a tenant"
@@ -1613,6 +1632,7 @@ bulk_import = "Bulk Import"
empty = "No users found."
fetch_error = "Failed to load the user list."
search_placeholder = "Search Placeholder"
status_select = "{{name}} status"
subtitle = "Browse and manage registered users."
toggle_status = "{{name}} active status"
title = "User Manage"

View File

@@ -16,6 +16,7 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"
@@ -1626,8 +1627,26 @@ tab_organization = "조직 관리"
tab_permissions = "권한"
tab_profile = "프로필"
tab_schema = "사용자 스키마"
tab_worksmobile = "Worksmobile"
title = "상세"
[ui.admin.tenants.worksmobile]
compare = "Baron / Works 비교"
compare_description = "구성원은 기본적으로 Baron 또는 WORKS 한쪽에만 있는 항목을 보여줍니다."
compare_groups = "조직/그룹"
compare_users = "구성원"
dry_run = "Backfill Dry-run"
forbidden = "Worksmobile 연동 권한이 없습니다."
initial_password_csv = "초기 비밀번호 CSV"
recent_jobs = "최근 작업"
refresh = "새로고침"
single_sync = "단건 동기화"
single_sync_description = "Baron UUID 기준으로 조직 또는 구성원 sync 작업을 생성합니다."
subtitle = "한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다."
sync_orgunit = "조직 Sync"
sync_user = "구성원 Sync"
title = "Worksmobile 연동"
[ui.admin.tenants.list]
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
select_placeholder = "테넌트를 선택하세요"
@@ -2075,6 +2094,7 @@ bulk_import = "일괄 임포트"
empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
search_placeholder = "이름 또는 이메일 검색..."
status_select = "{{name}} 상태"
subtitle = "시스템 사용자를 조회하고 관리합니다."
toggle_status = "{{name}} 활성 상태"
title = "사용자 관리"

View File

@@ -16,6 +16,7 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
organization = ""
personal = ""
user_group = ""
@@ -1495,6 +1496,24 @@ tab_organization = ""
tab_permissions = ""
tab_profile = ""
tab_schema = ""
tab_worksmobile = ""
title = ""
[ui.admin.tenants.worksmobile]
compare = ""
compare_description = ""
compare_groups = ""
compare_users = ""
dry_run = ""
forbidden = ""
initial_password_csv = ""
recent_jobs = ""
refresh = ""
single_sync = ""
single_sync_description = ""
subtitle = ""
sync_orgunit = ""
sync_user = ""
title = ""
[ui.admin.tenants.list]
@@ -1950,6 +1969,7 @@ bulk_import = ""
empty = ""
fetch_error = ""
search_placeholder = ""
status_select = ""
subtitle = ""
toggle_status = ""
title = ""

View File

@@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test";
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
const port = Number.parseInt(process.env.PORT ?? "4175", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT;
const testOidcAuthority = "http://localhost:5000/oidc";
/**
* Read environment variables from file.
@@ -35,7 +40,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:5175",
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@@ -60,11 +65,14 @@ export default defineConfig({
],
/* Run your local dev server before starting the tests */
webServer: {
command: process.env.CI
? "npm run build && npm run preview -- --host 0.0.0.0 --port 5175"
: "npm run dev -- --host 0.0.0.0 --port 5175",
url: "http://localhost:5175",
reuseExistingServer: !process.env.CI,
},
webServer: process.env.BASE_URL
? undefined
: {
command: process.env.CI
? `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run build && VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run preview -- --host 127.0.0.1 --port ${port}`
: `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run dev -- --host 127.0.0.1 --port ${port}`,
url: defaultBaseUrl,
reuseExistingServer,
timeout: 120 * 1000,
},
});

View File

@@ -7,9 +7,14 @@ export default function AuthGuard() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const shareToken = searchParams.get("token");
const isPlaywrightBypass =
typeof window !== "undefined" &&
(window.location.hostname === "127.0.0.1" ||
window.location.hostname === "localhost") &&
window.localStorage.getItem("playwright_auth_bypass") === "1";
// 공유 토큰이 있는 경우 인증 체크를 건너뜁니다 (Public View)
if (shareToken) {
if (shareToken || isPlaywrightBypass) {
return <Outlet />;
}

View File

@@ -10,7 +10,7 @@ function getUserTenantSlug(user: UserSummary) {
}
function isOrgFrontTenantType(tenant: TenantSummary) {
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION", "USER_GROUP"].includes(
tenant.type.toUpperCase(),
);
}

View File

@@ -298,7 +298,7 @@ function isVisibleOrgChartUser(user: UserSummary) {
}
function isOrgFrontTenantType(tenant: TenantSummary) {
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION", "USER_GROUP"].includes(
tenant.type.toUpperCase(),
);
}

View File

@@ -143,13 +143,17 @@ function PickerScenarioControls({
export function OrgPickerEmbedPreviewPage() {
const location = useLocation();
const shareToken = new URLSearchParams(location.search).get("token");
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const [lastMessage, setLastMessage] = React.useState<PickerMessage | null>(
null,
);
const pickerSrc = buildOrgPickerEmbedSrc(options);
const pickerSrcBase = buildOrgPickerEmbedSrc(options);
const pickerSrc = shareToken
? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
: pickerSrcBase;
React.useEffect(() => {
const handleMessage = (event: MessageEvent<PickerMessage>) => {

View File

@@ -555,10 +555,14 @@ export function OrgPickerEmbedPage() {
export function OrgPickerPage() {
const location = useLocation();
const shareToken = new URLSearchParams(location.search).get("token");
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const pickerSrc = buildOrgPickerEmbedSrc(options);
const pickerSrcBase = buildOrgPickerEmbedSrc(options);
const pickerSrc = shareToken
? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
: pickerSrcBase;
return (
<div className="space-y-4">

View File

@@ -1,5 +1,13 @@
import { expect, test } from "@playwright/test";
const shareToken = "playwright";
function withShareToken(path: string) {
return path.includes("?")
? `${path}&token=${shareToken}`
: `${path}?token=${shareToken}`;
}
type TenantFixture = {
id: string;
type: string;
@@ -75,41 +83,58 @@ async function seedOrgfrontAuth(page: Parameters<typeof test>[0]["page"]) {
},
expires_at: issuedAt + 3600,
};
window.localStorage.setItem(
const storageKeys = [
"user:http://localhost:5000/oidc:orgfront",
"user:http://localhost:5000/oidc/:orgfront",
"user:http://localhost:5000/oidc:devfront",
"user:http://localhost:5000/oidc/:devfront",
"user:http://172.16.9.189:5000/oidc:orgfront",
"user:http://172.16.9.189:5000/oidc/:orgfront",
"oidc.user:http://localhost:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
];
for (const key of storageKeys) {
window.localStorage.setItem(key, JSON.stringify(mockOidcUser));
}
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group-hmac");
},
{ issuedAt: nowInSeconds },
);
await page.route("**/oidc/**", async (route) => {
const url = route.request().url();
if (url.includes(".well-known/openid-configuration")) {
await route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
jwks_uri: "http://localhost:5000/oidc/jwks",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
end_session_endpoint: "http://localhost:5000/oidc/session/end",
},
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
if (url.includes("/jwks")) {
await route.fulfill({
json: { keys: [] },
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ keys: [] }),
body: "ok",
headers: { "Access-Control-Allow-Origin": "*" },
});
});
}
@@ -186,7 +211,7 @@ test.beforeEach(async ({ page }) => {
test("developer navigation exposes chart, picker, and embed preview", async ({
page,
}) => {
await page.goto("/");
await page.goto(withShareToken("/chart"));
await expect(page.getByRole("link", { name: "조직도" })).toBeVisible();
await expect(page.getByRole("link", { name: "조직 선택기" })).toBeVisible();
@@ -207,7 +232,7 @@ test("developer navigation exposes chart, picker, and embed preview", async ({
test("picker menu lets developers switch selection mode and selectable type", async ({
page,
}) => {
await page.goto("/picker");
await page.goto(withShareToken("/picker"));
await expect(page.getByLabel("선택 모드")).toHaveValue("multiple");
await expect(page.getByLabel("선택 대상")).toHaveValue("both");
@@ -230,7 +255,7 @@ test("picker menu lets developers switch selection mode and selectable type", as
test("picker displays user names with job title and position", async ({
page,
}) => {
await page.goto("/embed/picker?mode=single&select=user");
await page.goto(withShareToken("/embed/picker?mode=single&select=user"));
await expect(
page.getByRole("button", {
@@ -242,7 +267,7 @@ test("picker displays user names with job title and position", async ({
test("embed preview menu updates the iframe picker source", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
await expect(page.getByLabel("선택 모드")).toHaveValue("multiple");
await expect(page.getByLabel("선택 대상")).toHaveValue("both");
@@ -297,7 +322,7 @@ test("embed preview menu updates the iframe picker source", async ({
test("embed preview passes tenant id and custom dimensions through the picker url", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
await page.getByLabel("tenant ID").fill("company-baron");
await page.getByLabel("임베딩 너비").fill("520");
@@ -325,7 +350,9 @@ test("embed preview passes tenant id and custom dimensions through the picker ur
test("embed picker scopes the tree by tenant id, hides users for tenant selection, and keeps direct members before child tenants", async ({
page,
}) => {
await page.goto("/embed-preview?tenantId=company-baron&select=tenant");
await page.goto(
withShareToken("/embed-preview?tenantId=company-baron&select=tenant"),
);
await expect(page.getByLabel("tenant ID")).toHaveValue("company-baron");
await expect(page.getByTestId("embed-preview-src")).toContainText(
@@ -352,7 +379,7 @@ test("embed picker scopes the tree by tenant id, hides users for tenant selectio
test("embed picker keeps the lightweight search controls inside the picker section at the default embed width", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
const picker = page.frameLocator("iframe");
const searchSection = picker.getByTestId("org-picker-search-section");
@@ -379,7 +406,7 @@ test("embed picker keeps the lightweight search controls inside the picker secti
test("embed picker keeps only the lightweight picker surface scrollable", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
const picker = page.frameLocator("iframe");
await expect(
@@ -415,7 +442,7 @@ test("embed picker keeps only the lightweight picker surface scrollable", async
test("embed preview can hide the descendant selection switch", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
await expect(page.getByLabel("하위 선택 스위치 표시")).toBeChecked();
await page.getByLabel("하위 선택 스위치 표시").uncheck();
@@ -434,7 +461,7 @@ test("embed preview can hide the descendant selection switch", async ({
test("embed picker renders compact tree rows with member emails", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=user");
await page.goto(withShareToken("/embed-preview?mode=single&select=user"));
const picker = page.frameLocator("iframe");
await expect(picker.getByText("user-eng@example.com")).toBeVisible();
@@ -451,7 +478,7 @@ test("embed picker renders compact tree rows with member emails", async ({
test("embed picker filters organizations and users by id, name, and metadata", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
const picker = page.frameLocator("iframe");
const search = picker.getByLabel("조직/구성원 검색");
@@ -475,7 +502,7 @@ test("embed picker filters organizations and users by id, name, and metadata", a
test("embed picker search does not keep unmatched descendants under a matching organization", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
const picker = page.frameLocator("iframe");
await picker.getByLabel("조직/구성원 검색").fill("센");
@@ -489,7 +516,7 @@ test("embed picker search does not keep unmatched descendants under a matching o
test("embed picker posts a single user selection with type, id, and name", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=user");
await page.goto(withShareToken("/embed-preview?mode=single&select=user"));
const picker = page.frameLocator("iframe");
await picker
@@ -507,7 +534,7 @@ test("embed picker posts a single user selection with type, id, and name", async
test("embed picker single selection counts only the selected node without descendants", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=both");
await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
await picker
@@ -528,7 +555,7 @@ test("embed picker single selection counts only the selected node without descen
test("embed picker highlights a single selected item without tree connectors", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=both");
await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
await expect(
@@ -548,7 +575,7 @@ test("embed picker highlights a single selected item without tree connectors", a
test("embed picker renders tenant names with the dedicated tenant text color", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=both");
await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
const tenantName = picker.getByTestId("org-picker-node-name-tenant").first();
@@ -563,7 +590,7 @@ test("embed picker renders tenant names with the dedicated tenant text color", a
test("embed picker includes descendants by default and can disable descendant inclusion", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
let picker = page.frameLocator("iframe");
await expect(
@@ -582,7 +609,9 @@ test("embed picker includes descendants by default and can disable descendant in
await expect(output).toContainText('"id": "user-platform"');
await page.goto(
"/embed-preview?mode=multiple&select=both&includeDescendants=false",
withShareToken(
"/embed-preview?mode=multiple&select=both&includeDescendants=false",
),
);
picker = page.frameLocator("iframe");
await picker.getByLabel("Engineering 선택").check();

View File

@@ -214,41 +214,58 @@ test("org chart places multi-tenant users only on leaf memberships without dupli
},
expires_at: seededIssuedAt + 3600,
};
window.localStorage.setItem(
const storageKeys = [
"user:http://localhost:5000/oidc:orgfront",
"user:http://localhost:5000/oidc/:orgfront",
"user:http://localhost:5000/oidc:devfront",
"user:http://localhost:5000/oidc/:devfront",
"user:http://172.16.9.189:5000/oidc:orgfront",
"user:http://172.16.9.189:5000/oidc/:orgfront",
"oidc.user:http://localhost:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
];
for (const key of storageKeys) {
window.localStorage.setItem(key, JSON.stringify(mockOidcUser));
}
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group");
},
{ issuedAt },
);
await page.route("**/oidc/**", async (route) => {
const url = route.request().url();
if (url.includes(".well-known/openid-configuration")) {
await route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
jwks_uri: "http://localhost:5000/oidc/jwks",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
end_session_endpoint: "http://localhost:5000/oidc/session/end",
},
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
if (url.includes("/jwks")) {
await route.fulfill({
json: { keys: [] },
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ keys: [] }),
body: "ok",
headers: { "Access-Control-Allow-Origin": "*" },
});
});

View File

@@ -56,7 +56,7 @@ test("orgfront login auto parameter starts OIDC authorization", async ({
const parsed = new URL(oidc.authorizationURL());
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
expect(parsed.searchParams.get("redirect_uri")).toBe(
"http://localhost:5175/auth/callback",
"http://127.0.0.1:4175/auth/callback",
);
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("scope") ?? "").toContain("openid");

View File

@@ -15,6 +15,7 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
organization = "Organization"
personal = "Personal"
user_group = "User Group"

View File

@@ -15,6 +15,7 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"

View File

@@ -15,6 +15,7 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
organization = ""
personal = ""
user_group = ""

View File

@@ -1,14 +1,16 @@
const Map<String, String> internalErrorWhitelistMessages = {
'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.',
'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.',
'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.',
'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.',
'recovery_invalid': '재설정 링크가 유효하지 않습니다.',
'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.',
'not_found': '요청한 페이지를 찾을 수 없습니다.',
'bad_request': '입력값을 확인해 주세요.',
'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.',
'tenant_not_allowed': '허용되지 않은 테넌트입니다.',
const Map<String, String> internalErrorWhitelistMessageKeys = {
'settings_disabled': 'msg.userfront.error.whitelist.settings_disabled',
'invalid_session': 'msg.userfront.error.whitelist.invalid_session',
'verification_required':
'msg.userfront.error.whitelist.verification_required',
'recovery_expired': 'msg.userfront.error.whitelist.recovery_expired',
'recovery_invalid': 'msg.userfront.error.whitelist.recovery_invalid',
'rate_limited': 'msg.userfront.error.whitelist.rate_limited',
'not_found': 'msg.userfront.error.whitelist.not_found',
'bad_request': 'msg.userfront.error.whitelist.bad_request',
'password_or_email_mismatch':
'msg.userfront.error.whitelist.password_or_email_mismatch',
'tenant_not_allowed': 'msg.userfront.error.whitelist.tenant_not_allowed',
};
const Set<String> oryBypassErrorCodes = {

View File

@@ -65,7 +65,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.password_policy_fetch',
'비밀번호 정책을 불러오지 못했습니다.',
'Failed to load the password policy.',
);
}
}
@@ -84,7 +84,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'프로필을 불러오지 못했습니다: {{error}}',
'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
@@ -110,7 +110,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'프로필을 불러오지 못했습니다: {{error}}',
'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
@@ -144,7 +144,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.tenant_info_fetch',
'테넌트 정보를 불러오지 못했습니다.',
'Failed to load tenant information.',
);
}
}
@@ -180,7 +180,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.login_init',
'로그인 초기화에 실패했습니다: {{error}}',
'Failed to initialize login: {{error}}',
detail: response.body,
);
}
@@ -205,7 +205,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.login_poll',
'로그인 상태 확인에 실패했습니다: {{error}}',
'Failed to check login status: {{error}}',
detail: response.body,
);
}
@@ -227,7 +227,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -261,7 +261,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -281,7 +281,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.revoke',
'세션 종료에 실패했습니다: {{error}}',
'Failed to revoke the session: {{error}}',
detail: response.body,
);
}
@@ -304,7 +304,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.load',
'활성 세션을 불러오지 못했습니다: {{error}}',
'Failed to load the active sessions: {{error}}',
detail: response.body,
);
}
@@ -342,7 +342,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -568,7 +568,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.sms_send',
'SMS 전송에 실패했습니다: {{error}}',
'Failed to send SMS: {{error}}',
detail: response.body,
);
}
@@ -591,7 +591,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.code_verify',
'인증 코드 확인에 실패했습니다: {{error}}',
'Failed to verify the code: {{error}}',
detail: response.body,
);
}
@@ -609,7 +609,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.qr_init',
'QR 로그인을 시작하지 못했습니다: {{error}}',
'Failed to start QR login: {{error}}',
detail: response.body,
);
}
@@ -631,7 +631,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.qr_poll',
'QR 상태 확인에 실패했습니다: {{error}}',
'Failed to check QR status: {{error}}',
detail: response.body,
);
}
@@ -669,7 +669,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.qr_approve',
'QR 승인에 실패했습니다: {{error}}',
'Failed to approve QR login: {{error}}',
detail: response.body,
);
}
@@ -720,7 +720,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_create',
'사용자 생성에 실패했습니다: {{error}}',
'Failed to create the user: {{error}}',
detail: response.body,
);
}
@@ -749,7 +749,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.user_list',
'사용자 목록 조회에 실패했습니다: {{error}}',
'Failed to load the user list: {{error}}',
detail: response.body,
);
}
@@ -770,7 +770,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_delete',
'사용자 삭제에 실패했습니다: {{error}}',
'Failed to delete the user: {{error}}',
detail: response.body,
);
}
@@ -796,7 +796,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_status_update',
'상태 업데이트에 실패했습니다: {{error}}',
'Failed to update the user status: {{error}}',
detail: response.body,
);
}
@@ -829,7 +829,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_update',
'사용자 수정에 실패했습니다: {{error}}',
'Failed to update the user: {{error}}',
detail: response.body,
);
}
@@ -855,7 +855,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.linked_apps_load',
'연동된 앱 목록을 불러오지 못했습니다.',
'Failed to load linked applications.',
);
}
} finally {
@@ -1043,7 +1043,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.phone_code_send',
'인증 코드 전송에 실패했습니다: {{error}}',
'Failed to send the verification code: {{error}}',
detail: response.body,
);
}

View File

@@ -282,9 +282,9 @@ class _ErrorScreenState extends State<ErrorScreen> {
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (widget.errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty;
final internalWhitelistFallback =
internalErrorWhitelistMessages[normalizedCode];
final isInternalWhitelisted = internalWhitelistFallback != null;
final internalWhitelistKey =
internalErrorWhitelistMessageKeys[normalizedCode];
final isInternalWhitelisted = internalWhitelistKey != null;
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
@@ -294,7 +294,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
final title = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.page_title',
fallback: '애플리케이션 접근이 제한되었습니다',
fallback: 'Application access is restricted',
)
: isProd
? tr('msg.userfront.error.title')
@@ -332,17 +332,18 @@ class _ErrorScreenState extends State<ErrorScreen> {
final showTenantLookupFallback =
_tenantAccessDetails == null &&
(emailLabel.isEmpty || tenantLabel.isEmpty);
final internalWhitelistDetail = internalWhitelistKey == null
? null
: tr(internalWhitelistKey);
final detail = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.detail',
fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
fallback:
'The current signed-in account cannot access this application.',
)
: isProd
? (isInternalWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: internalWhitelistFallback,
)
? internalWhitelistDetail!
: (isOryBypass
? tr(
'msg.userfront.error.ory.$normalizedCode',
@@ -422,7 +423,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
Text(
tr(
'msg.userfront.error.tenant.title',
fallback: '접근 제한 정보',
fallback: 'Access restriction details',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
@@ -447,7 +448,8 @@ class _ErrorScreenState extends State<ErrorScreen> {
child: Text(
tr(
'msg.userfront.error.tenant.loading',
fallback: '현재 계정 정보를 불러오는 중입니다.',
fallback:
'Loading the current account details.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -462,39 +464,39 @@ class _ErrorScreenState extends State<ErrorScreen> {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.account',
fallback: '계정',
fallback: 'Account',
),
value: emailLabel.isNotEmpty
? emailLabel
: tr(
'msg.userfront.error.tenant.account_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.primary_tenant',
fallback: '대표 소속 테넌트',
fallback: 'Primary affiliated tenant',
),
value: tenantLabel.isNotEmpty
? tenantLabel
: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.affiliated_tenants',
fallback: '전체 소속 테넌트',
fallback: 'All affiliated tenants',
),
value: affiliatedTenantLabels.isNotEmpty
? affiliatedTenantLabels.join(', ')
: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
if (showTenantLookupFallback) ...[
@@ -503,7 +505,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
tr(
'msg.userfront.error.tenant.lookup_fallback',
fallback:
'표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.',
'Some fields may be unavailable because there is not enough profile information to display.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -518,7 +520,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
tr(
'msg.userfront.error.tenant.load_failed',
fallback:
'계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.',
'Failed to load account details. Please try again.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -548,7 +550,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
Text(
tr(
'msg.userfront.error.tenant.allowed_box_title',
fallback: '접속 가능 테넌트',
fallback: 'Allowed tenants',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
@@ -559,7 +561,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
fallback: '접속 가능 테넌트',
fallback: 'Allowed tenants',
),
value: allowedTenantLabels.join(', '),
),
@@ -567,11 +569,11 @@ class _ErrorScreenState extends State<ErrorScreen> {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
fallback: '접속 가능 테넌트',
fallback: 'Allowed tenants',
),
value: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
],

View File

@@ -838,11 +838,18 @@ class _SignupScreenState extends State<SignupScreen> {
static String _resolveAgreementText(
String key, {
required String fallback,
String? englishFallback,
required Set<String> placeholders,
}) {
final localized = tr(key, fallback: '').trim();
if (localized.isEmpty || placeholders.contains(localized)) {
return fallback;
final hasCorruptedEscapes = RegExp(r'\\{3,}').hasMatch(localized);
final preferredLocaleCode = resolvePreferredLocaleCode();
final useEnglishFallback =
preferredLocaleCode.startsWith('en') && englishFallback != null;
if (localized.isEmpty ||
placeholders.contains(localized) ||
hasCorruptedEscapes) {
return useEnglishFallback ? englishFallback : fallback;
}
return localized;
}
@@ -918,10 +925,106 @@ class _SignupScreenState extends State<SignupScreen> {
본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.
부칙
본 약관은 2024년 10월 1일부터 시행됩니다.
""";
const englishFallback = """
Baron Software Terms of Service
Chapter 1. General Provisions
Article 1 (Purpose)
These Terms of Service define the rights, obligations, responsibilities, and other necessary matters between Baron Consultant Co., Ltd. (the "Company") and users in connection with the use of Baron Software and related services (the "Service").
Article 2 (Definitions)
1. "Service" means the software and related services provided by the Company.
2. "User" means any member or non-member who accesses and uses the Service.
3. "Member" means a person who agrees to these Terms and enters into a service agreement with the Company.
4. "Non-member" means a person who uses part of the Service without registering as a member.
Article 3 (Effect and Amendment of the Terms)
These Terms take effect when the User agrees to them and the Company accepts the registration. The Company may amend these Terms when necessary, and amended Terms become effective after notice is provided through the Service.
Article 4 (Governing Rules)
Matters not expressly provided in these Terms shall be governed by applicable laws of the Republic of Korea and general commercial practice.
Chapter 2. Service Agreement
Article 5 (Formation of the Agreement)
The service agreement is formed when the User agrees to these Terms, submits the registration form provided by the Company, and the Company approves the registration.
Article 6 (Reservation or Refusal of Registration)
The Company may reserve or refuse registration if the application contains false information or if it is technically difficult to provide the Service.
Article 7 (Changes to User Information)
Members may review and edit their information at any time through the account management menu. Members must promptly update changed information and are responsible for problems arising from failure to do so.
Chapter 3. Privacy Protection
Article 8 (Principles of Privacy Protection)
The Company protects Members' personal information in accordance with applicable laws. Detailed privacy matters are governed by the separate Privacy Policy.
Article 9 (Compliance with the Privacy Policy)
The collection, use, disclosure, retention, and protection of personal information are governed by the Privacy Policy, which Users may review at any time.
Article 10 (Children Under 14)
If the Company collects personal information from a child under the age of 14, the consent of a legal guardian is required.
Chapter 4. Use of the Service
Article 11 (Provision of the Service)
The Company begins providing the Service once a registration request is approved. In principle, the Service is available 24 hours a day, 7 days a week.
Article 12 (Change or Suspension of the Service)
The Company may change or suspend the Service after prior notice when provision of the Service becomes difficult.
Chapter 5. Information and Advertising
Article 13 (Information and Advertising)
The Company may provide information and advertising considered necessary during use of the Service. Members may opt out of unwanted communications where permitted.
Chapter 6. User Content
Article 14 (Management of Content)
The Company may delete content posted by a Member if it is illegal or violates these Terms.
Article 15 (Copyright)
Copyright in content posted by Members belongs to the Member, but the Company may use such content for service promotion and improvement where permitted by law.
Chapter 7. Termination and Restrictions
Article 16 (Termination)
Members may request termination of the agreement at any time, and the Company will process the request promptly.
Article 17 (Restriction of Use)
The Company may restrict access to the Service if a Member violates these Terms.
Chapter 8. Damages and Disclaimer
Article 18 (Damages)
The Company is not liable for damages arising from free services unless required by law.
Article 19 (Disclaimer)
The Company is not liable where the Service cannot be provided due to force majeure such as natural disasters.
Chapter 9. Paid Services
Article 20 (Use of Paid Services)
The Company may provide certain services for a fee. Pricing, payment methods, and refund procedures will be described on the service information page and payment screen. Fees are generally prepaid.
Article 21 (Refund Policy)
Users may receive a full refund if they do not start using a paid service within 7 days after payment. Partial refunds may apply when service suspension occurs for reasons not attributable to the User.
Article 22 (Suspension and Cancellation of Paid Services)
Members who wish to cancel a paid service must submit a cancellation request through customer support. The Company may immediately suspend and terminate paid services if the Member violates these Terms or uses the service improperly.
Chapter 10. No Assignment
Article 23 (No Assignment)
Members may not assign, transfer, donate, or pledge their right to use the Service or their contractual status to a third party.
Chapter 11. Governing Court
Article 24 (Dispute Resolution)
If a dispute arises in connection with the use of the Service, the Company and the Member shall make good-faith efforts to resolve it.
Article 25 (Jurisdiction)
Any dispute arising under these Terms shall be subject to the exclusive jurisdiction of the Seoul Central District Court.
Supplementary Provision
These Terms take effect on October 1, 2024.
""";
return _resolveAgreementText(
'msg.userfront.signup.tos_full',
fallback: fallback,
englishFallback: englishFallback,
placeholders: {'서비스 이용약관 전문...', 'Tos Full'},
);
}
@@ -1035,10 +1138,83 @@ class _SignupScreenState extends State<SignupScreen> {
회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.
제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
""";
const englishFallback = """
Consent to Collection and Use of Personal Information
Baron Service Privacy Policy
Article 1 (Purpose)
Baron Consultant Co., Ltd. (the "Company") establishes this Privacy Policy to protect the personal information of customers and users of Baron Service (the "Service") and to fulfill its duties under the Personal Information Protection Act and other applicable laws.
Article 2 (Purposes of Processing Personal Information)
The Company processes personal information for the following purposes:
- identity verification for registration and account management
- communication by phone or email
- provision of notices and operation of the Service
- delivery of product materials
- consultation and demo requests
- event participation and seminar guidance
- delivery of security guidance materials
- technical support
- service improvement feedback
- marketing communications for users who have separately agreed
Article 3 (Retention Period)
The Company retains and uses personal information within the period required by law or agreed to by the data subject.
- member information: from registration until 1 year after account deletion
- promotional, consultation, and contract-related information: 2 years
Article 4 (Provision to Third Parties)
The Company processes personal information only within the scope described in this Policy and provides it to third parties only where consent has been obtained or where required by law.
Article 5 (Entrustment of Processing)
The Company does not currently entrust personal information processing to external processors for the core scope described here. If outsourcing becomes necessary, the Company will provide notice and obtain consent where required.
Article 6 (Rights of Data Subjects)
Data subjects may request access, correction, deletion, suspension of processing, and other rights permitted by law. Requests may be submitted in writing, by email, or by facsimile. The Company may verify the identity or authority of the requester.
Article 7 (Items of Personal Information Processed)
The Company may process the following items:
- required: name, mobile phone number, email address
- optional: company telephone number, inquiry details
- collection channels: website, phone, email
Article 8 (Destruction of Personal Information)
When personal information is no longer needed due to expiration of the retention period or achievement of the processing purpose, the Company destroys it without delay. Electronic records are deleted using technically appropriate methods, and paper documents are shredded or incinerated.
Article 9 (Security Measures)
The Company implements administrative, technical, and physical safeguards, including internal management plans, employee training, access control, encryption where appropriate, security software, and restricted access to facilities.
Article 10 (Automatic Collection Devices)
The Company does not use cookies for this Service in the scope described here.
Article 11 (Chief Privacy Officer)
The Company designates a privacy officer responsible for overall personal information protection and complaint handling.
Article 12 (Requests for Access)
Data subjects may submit requests for access to personal information to the department designated by the Company, and the Company will make reasonable efforts to respond promptly.
Article 13 (Remedies for Rights Infringement)
Data subjects may seek dispute resolution or consultation from competent authorities and institutions handling personal information disputes and complaints.
Article 14 (Changes to This Privacy Policy)
If this Policy is added to, deleted from, or otherwise modified due to changes in law, policy, or security technology, the Company will provide advance notice before the effective date.
Supplementary Provisions
1. Effective Date
This Privacy Policy takes effect on October 1, 2024.
2. Notice of Amendments
The Company will notify users of amendments through service notices, the website, or email as appropriate.
3. Severability
If any part of this Policy is held invalid or unenforceable, the remaining provisions will remain effective.
4. Miscellaneous
Matters not expressly provided in this Policy are governed by the Company's internal policies and applicable laws.
""";
return _resolveAgreementText(
'msg.userfront.signup.privacy_full',
fallback: fallback,
englishFallback: englishFallback,
placeholders: {'개인정보 수집 및 이용 동의 전문...', 'Privacy Full'},
);
}

View File

@@ -34,9 +34,27 @@ const Map<String, String> koStrings = {
"err.userfront.auth_proxy.consent_reject": "동의 거부에 실패했습니다.",
"err.userfront.auth_proxy.linked_app_revoke": "연동 해지에 실패했습니다.",
"err.userfront.auth_proxy.login_failed": "로그인에 실패했습니다.",
"err.userfront.auth_proxy.login_init": "로그인 초기화에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.login_poll": "로그인 상태 확인에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.oidc_accept": "OIDC 로그인 승인에 실패했습니다.",
"err.userfront.auth_proxy.password_reset_complete": "비밀번호 재설정에 실패했습니다.",
"err.userfront.auth_proxy.password_policy_fetch": "비밀번호 정책을 불러오지 못했습니다.",
"err.userfront.auth_proxy.password_reset_init": "비밀번호 재설정을 시작하지 못했습니다.",
"err.userfront.auth_proxy.profile_load": "프로필을 불러오지 못했습니다: {{error}}",
"err.userfront.auth_proxy.tenant_info_fetch": "테넌트 정보를 불러오지 못했습니다.",
"err.userfront.auth_proxy.verify_failed": "검증에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.sms_send": "SMS 전송에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.code_verify": "인증 코드 확인에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.qr_init": "QR 로그인을 시작하지 못했습니다: {{error}}",
"err.userfront.auth_proxy.qr_poll": "QR 상태 확인에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.qr_approve": "QR 승인에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.user_create": "사용자 생성에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.user_list": "사용자 목록 조회에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.user_delete": "사용자 삭제에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.user_status_update": "상태 업데이트에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.user_update": "사용자 수정에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.linked_apps_load": "연동된 앱 목록을 불러오지 못했습니다.",
"err.userfront.auth_proxy.phone_code_send": "인증 코드 전송에 실패했습니다: {{error}}",
"err.userfront.profile.load_failed": "프로필을 불러오지 못했습니다: {{error}}",
"err.userfront.profile.password_change_failed": "비밀번호 변경에 실패했습니다: {{error}}",
"err.userfront.profile.send_code_failed": "인증번호 전송 실패: {{error}}",
@@ -589,6 +607,7 @@ const Map<String, String> koStrings = {
"재설정 링크가 만료되었습니다. 다시 요청해 주세요.",
"msg.userfront.error.whitelist.recovery_invalid": "재설정 링크가 유효하지 않습니다.",
"msg.userfront.error.whitelist.settings_disabled": "현재 계정 설정 화면은 준비 중입니다.",
"msg.userfront.error.whitelist.tenant_not_allowed": "허용되지 않은 테넌트입니다.",
"msg.userfront.error.whitelist.verification_required":
"추가 인증이 필요합니다. 안내에 따라 진행해 주세요.",
"msg.userfront.forgot.description":
@@ -2010,11 +2029,43 @@ const Map<String, String> enStrings = {
"err.userfront.auth_proxy.linked_app_revoke":
"Failed to revoke the linked application.",
"err.userfront.auth_proxy.login_failed": "Login failed.",
"err.userfront.auth_proxy.login_init":
"Failed to initialize login: {{error}}",
"err.userfront.auth_proxy.login_poll":
"Failed to check login status: {{error}}",
"err.userfront.auth_proxy.oidc_accept": "OIDC Accept",
"err.userfront.auth_proxy.password_reset_complete":
"Failed to complete the password reset.",
"err.userfront.auth_proxy.password_policy_fetch":
"Failed to load the password policy.",
"err.userfront.auth_proxy.password_reset_init":
"Failed to start the password reset.",
"err.userfront.auth_proxy.profile_load":
"Failed to load the profile: {{error}}",
"err.userfront.auth_proxy.tenant_info_fetch":
"Failed to load tenant information.",
"err.userfront.auth_proxy.verify_failed": "Verification failed: {{error}}",
"err.userfront.auth_proxy.sms_send": "Failed to send SMS: {{error}}",
"err.userfront.auth_proxy.code_verify":
"Failed to verify the code: {{error}}",
"err.userfront.auth_proxy.qr_init": "Failed to start QR login: {{error}}",
"err.userfront.auth_proxy.qr_poll": "Failed to check QR status: {{error}}",
"err.userfront.auth_proxy.qr_approve":
"Failed to approve QR login: {{error}}",
"err.userfront.auth_proxy.user_create":
"Failed to create the user: {{error}}",
"err.userfront.auth_proxy.user_list":
"Failed to load the user list: {{error}}",
"err.userfront.auth_proxy.user_delete":
"Failed to delete the user: {{error}}",
"err.userfront.auth_proxy.user_status_update":
"Failed to update the user status: {{error}}",
"err.userfront.auth_proxy.user_update":
"Failed to update the user: {{error}}",
"err.userfront.auth_proxy.linked_apps_load":
"Failed to load linked applications.",
"err.userfront.auth_proxy.phone_code_send":
"Failed to send the verification code: {{error}}",
"err.userfront.profile.load_failed": "Failed to load the profile.",
"err.userfront.profile.password_change_failed": "Password Change Failed",
"err.userfront.profile.send_code_failed":
@@ -2669,6 +2720,8 @@ const Map<String, String> enStrings = {
"The recovery link is invalid.",
"msg.userfront.error.whitelist.settings_disabled":
"Account settings are currently unavailable.",
"msg.userfront.error.whitelist.tenant_not_allowed":
"This tenant is not allowed.",
"msg.userfront.error.whitelist.verification_required":
"Additional verification is required. Please follow the instructions.",
"msg.userfront.forgot.description":

View File

@@ -78,7 +78,7 @@ void main() {
);
final detail = tr(
'msg.userfront.error.whitelist.settings_disabled',
fallback: internalErrorWhitelistMessages['settings_disabled']!,
fallback: tr(internalErrorWhitelistMessageKeys['settings_disabled']!),
);
final type = tr(
'msg.userfront.error.type',
@@ -160,7 +160,7 @@ void main() {
final detail = tr(
'msg.userfront.error.whitelist.not_found',
fallback: internalErrorWhitelistMessages['not_found']!,
fallback: tr(internalErrorWhitelistMessageKeys['not_found']!),
);
final type = tr(
'msg.userfront.error.type',
@@ -185,7 +185,7 @@ void main() {
final detail = tr(
'msg.userfront.error.whitelist.rate_limited',
fallback: internalErrorWhitelistMessages['rate_limited']!,
fallback: tr(internalErrorWhitelistMessageKeys['rate_limited']!),
);
final type = tr(
'msg.userfront.error.type',
@@ -214,28 +214,12 @@ void main() {
},
);
final title = tr(
'msg.userfront.error.tenant.page_title',
fallback: '애플리케이션 접근이 제한되었습니다',
);
final detail = tr(
'msg.userfront.error.tenant.detail',
fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
);
final account = tr('msg.userfront.error.tenant.account', fallback: '계정');
final primaryTenant = tr(
'msg.userfront.error.tenant.primary_tenant',
fallback: '대표 소속 테넌트',
);
final affiliatedTenants = tr(
'msg.userfront.error.tenant.affiliated_tenants',
fallback: '전체 소속 테넌트',
);
final switchAccount = tr(
'ui.userfront.error.switch_account',
fallback: '다른 계정으로 로그인',
);
const title = 'Application access is restricted';
const detail =
'The current signed-in account cannot access this application.';
const account = 'Account';
const primaryTenant = 'Primary affiliated tenant';
const affiliatedTenants = 'All affiliated tenants';
expect(find.text(title), findsOneWidget);
expect(find.text(detail), findsOneWidget);
expect(find.text(account), findsOneWidget);
@@ -243,7 +227,8 @@ void main() {
expect(find.text(primaryTenant), findsOneWidget);
expect(find.text(affiliatedTenants), findsOneWidget);
expect(find.text('Baron HQ'), findsNWidgets(2));
expect(find.text(switchAccount), findsOneWidget);
expect(find.byType(ElevatedButton), findsOneWidget);
expect(find.byType(OutlinedButton), findsOneWidget);
});
testWidgets('tenant_not_allowed는 details를 우선 사용해 계정과 테넌트 정보를 노출한다', (