1
0
forked from baron/baron-sso

Merge pull request 'feature/df-tenant-claim' (#646) from feature/df-tenant-claim into dev

Reviewed-on: baron/baron-sso#646
This commit is contained in:
2026-04-28 15:27:56 +09:00
36 changed files with 3066 additions and 216 deletions

View File

@@ -181,13 +181,8 @@ PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTAL
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
ifeq ($(CI),)
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'
else
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
endif
.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
@@ -254,12 +249,12 @@ code-check-userfront-lint:
code-check-front-lint:
@echo "==> adminfront biome lint/format check"
rm -rf adminfront/playwright-report adminfront/test-results
cd adminfront && npm ci
cd adminfront && npm ci --ignore-scripts
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@echo "==> devfront biome lint/format check"
rm -rf devfront/playwright-report devfront/test-results
cd devfront && npm ci
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
@@ -298,7 +293,7 @@ code-check-devfront-tests:
@mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
@status=0; \
(cd devfront && npm ci) || status=$$?; \
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \

View File

@@ -13,9 +13,9 @@
"lint:fix": "biome check . --write",
"format": "biome format . --write",
"preview": "vite preview",
"test": "npx playwright test",
"test": "node ./node_modules/playwright/cli.js test",
"test:unit": "vitest run",
"test:ui": "npx playwright test --ui",
"test:ui": "node ./node_modules/playwright/cli.js test --ui",
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
},
"dependencies": {

View File

@@ -130,7 +130,7 @@ test.describe("Tenants Management", () => {
.click();
await expect(page.getByRole("dialog")).toContainText(
/조직 테넌트.*사용자|organization tenants.*users/i,
/조직\/사용자 통합 일괄 등록|organization and user batch registration/i,
);
});

View File

@@ -1,9 +1,15 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const buildOutDir =
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist";
export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_"],
build: {
outDir: buildOutDir,
},
server: {
host: "127.0.0.1",
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],

View File

@@ -3944,6 +3944,70 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
return c.JSON(profile)
}
func (h *AuthHandler) resolveProfileForSubject(ctx context.Context, subject string) (*domain.UserProfileResponse, error) {
subject = strings.TrimSpace(subject)
if subject == "" || h.KratosAdmin == nil {
return nil, fmt.Errorf("subject profile unavailable")
}
identity, err := h.KratosAdmin.GetIdentity(ctx, subject)
if err != nil {
return nil, err
}
if identity == nil {
return nil, fmt.Errorf("identity not found")
}
profile := h.mapKratosIdentityToProfile(identity.ID, identity.Traits)
if profile == nil {
return nil, fmt.Errorf("failed to map identity profile")
}
return h.hydrateResolvedProfile(ctx, profile), nil
}
func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domain.UserProfileResponse) *domain.UserProfileResponse {
if profile == nil {
return nil
}
profile.Role = domain.NormalizeRole(profile.Role)
if profile.Role == "" {
profile.Role = domain.RoleUser
}
if h.TenantService != nil {
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
if tenant, err := h.TenantService.GetTenant(ctx, *profile.TenantID); err == nil {
profile.Tenant = tenant
}
}
if profile.Tenant == nil && profile.CompanyCode != "" {
if tenant, err := h.TenantService.GetTenantBySlug(ctx, profile.CompanyCode); err == nil && tenant != nil {
profile.Tenant = tenant
if profile.TenantID == nil || *profile.TenantID == "" {
profile.TenantID = &tenant.ID
}
}
}
}
if h.TenantService != nil {
if profile.Role == domain.RoleTenantAdmin {
manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID)
if err == nil {
profile.ManageableTenants = manageable
}
}
joined, err := h.TenantService.ListJoinedTenants(ctx, profile.ID)
if err == nil {
profile.JoinedTenants = joined
}
}
return profile
}
// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
return h.resolveCurrentProfile(c)
@@ -5120,6 +5184,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
slog.Error("failed to get hydra consent request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
}
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
// [DEBUG] Hydra 응답 상세 로깅
slog.Info("GetConsentRequest Debug",
@@ -5130,6 +5195,17 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
"scopes", consentRequest.RequestedScope,
)
profile, err := h.resolveCurrentProfile(c)
if (err != nil || profile == nil) && consentRequest.Subject != "" {
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
profile = fallbackProfile
err = nil
}
}
if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
return nil
}
// [New] 로컬 DB에서 기존 동의 내역 확인 (강제 자동 승인 전략)
// Hydra가 skip을 주지 않더라도, 우리 DB에 이미 기록이 있다면 승인 처리함
if !consentRequest.Skip && h.ConsentRepo != nil && consentRequest.Subject != "" {
@@ -5316,6 +5392,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
slog.Error("failed to get hydra consent request before accepting", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
}
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
// 2. 스코프 필터링 (사용자가 선택한 것만 허용)
if len(req.GrantScope) > 0 {
@@ -5332,6 +5409,18 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
}
consentRequest.RequestedScope = filteredScopes
}
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
profile, err := h.resolveCurrentProfile(c)
if (err != nil || profile == nil) && consentRequest.Subject != "" {
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
profile = fallbackProfile
err = nil
}
}
if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
return nil
}
// 3. Hydra에 승인 요청
if consentRequest.Subject == "" {
@@ -5470,6 +5559,19 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
}
}
profile, err := h.resolveCurrentProfile(c)
if (err != nil || profile == nil) && loginReq != nil && strings.TrimSpace(loginReq.Subject) != "" {
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), loginReq.Subject); fallbackErr == nil {
profile = fallbackProfile
err = nil
}
}
if loginReq != nil {
if enforceClientTenantAccess(c, h.TenantService, loginReq.Client, profile, err) {
return nil
}
}
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
@@ -5520,6 +5622,10 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
}
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
return profile, nil
}
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
@@ -5607,35 +5713,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
delete(profile.Metadata, "_used_identifier") // Cleanup
}
// Fetch Tenant Metadata if missing
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
profile.Tenant = tenant
}
}
if profile.Tenant == nil && profile.CompanyCode != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
profile.Tenant = tenant
if profile.TenantID == nil || *profile.TenantID == "" {
profile.TenantID = &tenant.ID
}
}
}
// [New] Fetch manageable and joined tenants
if h.TenantService != nil {
if profile.Role == domain.RoleTenantAdmin {
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
if err == nil {
profile.ManageableTenants = manageable
}
}
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
if err == nil {
profile.JoinedTenants = joined
}
}
profile = h.hydrateResolvedProfile(c.Context(), profile)
// 4. Save to Redis Cache (Short TTL)
// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key

View File

@@ -1,8 +1,10 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -13,6 +15,121 @@ import (
"github.com/stretchr/testify/mock"
)
// --- Mocks ---
type MockKratosAdminServiceForConsent struct {
mock.Mock
}
func (m *MockKratosAdminServiceForConsent) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
args := m.Called(ctx, identifier)
return args.String(0), args.Error(1)
}
func (m *MockKratosAdminServiceForConsent) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*service.KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminServiceForConsent) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
return nil, nil
}
func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
return nil, nil
}
func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]interface{}) (*service.KratosIdentity, error) {
return nil, nil
}
func (m *MockKratosAdminServiceForConsent) DeleteIdentity(ctx context.Context, identityID string) error {
return nil
}
func (m *MockKratosAdminServiceForConsent) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
return nil
}
func (m *MockKratosAdminServiceForConsent) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
return nil, nil
}
func (m *MockKratosAdminServiceForConsent) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
return nil, nil
}
func (m *MockKratosAdminServiceForConsent) DeleteSession(ctx context.Context, sessionID string) error {
return nil
}
func (m *MockKratosAdminServiceForConsent) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
return "", nil
}
type MockTenantServiceForConsent struct {
mock.Mock
}
func (m *MockTenantServiceForConsent) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantServiceForConsent) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
return nil, 0, nil
}
func (m *MockTenantServiceForConsent) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForConsent) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForConsent) ApproveTenant(ctx context.Context, id string) error {
return nil
}
func (m *MockTenantServiceForConsent) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID)
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantServiceForConsent) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID)
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantServiceForConsent) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
return false, nil
}
func (m *MockTenantServiceForConsent) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForConsent) SetKetoService(keto service.KetoService) {}
func (m *MockTenantServiceForConsent) DeleteTenantsBulk(ctx context.Context, ids []string) error {
return nil
}
// --- Test Helpers ---
func newConsentTestApp(h *AuthHandler) *fiber.App {
@@ -69,6 +186,87 @@ func TestGetConsentRequest_Normal(t *testing.T) {
assert.Equal(t, false, body["skip"])
}
func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-scope" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"challenge": "challenge-tenant-scope",
"requested_scope": []string{"openid", "profile"},
"skip": false,
"subject": "user-123",
"client": map[string]interface{}{
"client_id": "client-app",
"client_name": "Test App",
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-allow"},
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
},
}), 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 }()
mockTenantSvc := &MockTenantServiceForConsent{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
// Mock profile resolution to allow tenant access
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@example.com",
},
}, nil)
mockTenantSvc.On("GetTenant", mock.Anything, "tenant-allow").Return(&domain.Tenant{
ID: "tenant-allow",
Slug: "tenant-allow",
Name: "Allowed Tenant",
}, nil)
// Mock hydration calls
mockTenantSvc.On("ListJoinedTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{
{ID: "tenant-allow", Slug: "tenant-allow", Name: "Allowed Tenant"},
}, nil)
mockTenantSvc.On("ListManageableTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{}, nil)
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
TenantService: mockTenantSvc,
KratosAdmin: mockKratosAdmin,
}
app := newConsentTestApp(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-tenant-scope", nil)
req.Header.Set("X-Mock-Role", "user")
req.Header.Set("X-Tenant-ID", "tenant-allow")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, []interface{}{"openid", "tenant", "profile"}, body["requested_scope"])
scopeDetails := body["scope_details"].(map[string]interface{})
tenantDetail := scopeDetails["tenant"].(map[string]interface{})
assert.Equal(t, true, tenantDetail["mandatory"])
}
func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// Hydra: Get Consent Request
@@ -107,16 +305,17 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
defer func() { http.DefaultClient = origDefault }()
consentRepo := &mockConsentRepo{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService), // Reusing MockKratosAdminService if defined or use MockKratosAdminServiceShared
KratosAdmin: mockKratosAdmin,
ConsentRepo: consentRepo,
}
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@test.com",
@@ -143,7 +342,8 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
"requested_scope": []string{"openid", "profile"},
"subject": "user-123",
"client": map[string]interface{}{
"client_id": "client-app",
"client_id": "client-app",
"client_name": "Test App",
},
}), nil
}
@@ -170,17 +370,18 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
auditRepo := &mockAuditRepo{}
consentRepo := &mockConsentRepo{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService),
KratosAdmin: mockKratosAdmin,
AuditRepo: auditRepo,
ConsentRepo: consentRepo,
}
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@test.com",
@@ -202,3 +403,88 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
assert.Equal(t, 1, len(auditRepo.logs))
}
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
t.Setenv("APP_ENV", "dev")
var capturedGrantScopes []string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"challenge": "challenge-tenant-accept",
"requested_scope": []string{"openid", "profile"},
"subject": "user-123",
"client": map[string]interface{}{
"client_id": "client-app",
"metadata": map[string]any{
"tenant_id": "tenant-abc",
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-abc"},
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
},
}), nil
}
if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"id": "user-123",
"traits": map[string]interface{}{
"email": "user@test.com",
},
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
var payload map[string]any
assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
for _, scope := range payload["grant_scope"].([]interface{}) {
capturedGrantScopes = append(capturedGrantScopes, scope.(string))
}
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"redirect_to": "http://rp/cb",
}), 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 }()
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: mockKratosAdmin,
}
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@test.com",
},
}, nil)
app := newConsentTestApp(h)
body, _ := json.Marshal(map[string]interface{}{
"consent_challenge": "challenge-tenant-accept",
"grant_scope": []string{"openid", "profile"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Mock-Role", "user")
req.Header.Set("X-Tenant-ID", "tenant-abc")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes)
}

View File

@@ -0,0 +1,424 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/response"
"baron-sso-backend/internal/service"
"encoding/json"
"errors"
"sort"
"strings"
"github.com/gofiber/fiber/v2"
)
const (
clientTenantAccessRestrictedKey = "tenant_access_restricted"
clientAllowedTenantsKey = "allowed_tenants"
)
func normalizeClientTenantAccessMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
if metadata == nil {
metadata = map[string]interface{}{}
}
restricted := readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey)
allowedTenants := normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])
ownerTenantID := normalizeMetadataString(metadata["tenant_id"])
if len(allowedTenants) > 0 {
restricted = true
}
if !restricted {
delete(metadata, clientAllowedTenantsKey)
metadata[clientTenantAccessRestrictedKey] = false
return metadata, nil
}
if ownerTenantID != "" {
allowedTenants = append(allowedTenants, ownerTenantID)
}
allowedTenants = uniqueSortedStrings(allowedTenants)
if len(allowedTenants) == 0 {
return nil, errors.New("allowed_tenants is required when tenant_access_restricted is enabled")
}
metadata[clientTenantAccessRestrictedKey] = true
metadata[clientAllowedTenantsKey] = allowedTenants
return metadata, nil
}
func clientTenantAccessRestricted(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
if readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey) {
return true
}
return len(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) > 0
}
func clientAllowedTenants(metadata map[string]interface{}) []string {
if metadata == nil {
return nil
}
if !clientTenantAccessRestricted(metadata) {
return nil
}
return uniqueSortedStrings(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey]))
}
func normalizeMetadataStringSlice(raw any) []string {
switch value := raw.(type) {
case []string:
return uniqueSortedStrings(value)
case []any:
items := make([]string, 0, len(value))
for _, item := range value {
if s, ok := item.(string); ok {
items = append(items, s)
}
}
return uniqueSortedStrings(items)
default:
return nil
}
}
func normalizeMetadataString(raw any) string {
s, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
func uniqueSortedStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
sort.Strings(out)
return out
}
func clientTenantAccessAllowed(profile *domain.UserProfileResponse, client domain.HydraClient) bool {
if !clientTenantAccessRestricted(client.Metadata) {
return true
}
allowed := clientAllowedTenants(client.Metadata)
if len(allowed) == 0 {
return false
}
keys := manageableTenantKeysFromProfile(profile)
if len(keys) == 0 {
return false
}
for _, tenantID := range allowed {
if _, ok := keys[strings.ToLower(strings.TrimSpace(tenantID))]; ok {
return true
}
}
return false
}
type tenantAccessDeniedDetails struct {
Account tenantAccessDeniedAccount `json:"account"`
CurrentTenant tenantAccessDeniedTenant `json:"current_tenant"`
AffiliatedTenants []tenantAccessDeniedTenant `json:"affiliated_tenants,omitempty"`
AllowedTenants []tenantAccessDeniedTenant `json:"allowed_tenants,omitempty"`
}
type tenantAccessDeniedAccount struct {
Email string `json:"email,omitempty"`
}
type tenantAccessDeniedTenant struct {
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
Name string `json:"name,omitempty"`
Identifier string `json:"identifier,omitempty"`
}
func tenantNotAllowedError(c *fiber.Ctx, details tenantAccessDeniedDetails) error {
return response.ErrorWithDetails(
c,
fiber.StatusForbidden,
"tenant_not_allowed",
"허용되지 않은 테넌트입니다.",
details,
)
}
func isClientTenantAccessAllowed(profile *domain.UserProfileResponse, client domain.HydraClient) bool {
if profile == nil {
return false
}
return clientTenantAccessAllowed(profile, client)
}
func enforceClientTenantAccess(c *fiber.Ctx, tenantSvc service.TenantService, client domain.HydraClient, profile *domain.UserProfileResponse, resolveErr error) bool {
if !clientTenantAccessRestricted(client.Metadata) {
return false
}
details := buildTenantAccessDeniedDetails(c, tenantSvc, client, profile)
if resolveErr != nil || profile == nil {
_ = tenantNotAllowedError(c, details)
return true
}
if !clientTenantAccessAllowed(profile, client) {
_ = tenantNotAllowedError(c, details)
return true
}
return false
}
func buildTenantAccessDeniedDetails(c *fiber.Ctx, tenantSvc service.TenantService, client domain.HydraClient, profile *domain.UserProfileResponse) tenantAccessDeniedDetails {
details := tenantAccessDeniedDetails{
Account: tenantAccessDeniedAccount{Email: strings.TrimSpace(profileEmail(profile))},
CurrentTenant: resolveCurrentTenantDetails(c, tenantSvc, profile),
AffiliatedTenants: resolveAffiliatedTenantDetails(c, tenantSvc, profile),
}
for _, identifier := range clientAllowedTenants(client.Metadata) {
details.AllowedTenants = append(details.AllowedTenants, resolveAllowedTenantDetails(c, tenantSvc, identifier))
}
return details
}
func resolveAffiliatedTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse) []tenantAccessDeniedTenant {
if profile == nil {
return nil
}
seen := make(map[string]struct{})
out := make([]tenantAccessDeniedTenant, 0, len(profile.JoinedTenants)+1)
appendTenant := func(tenant tenantAccessDeniedTenant) {
key := strings.ToLower(firstNonEmptyString(tenant.ID, tenant.Slug, tenant.Identifier, tenant.Name))
if key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, tenant)
}
appendTenant(resolveCurrentTenantDetails(c, tenantSvc, profile))
for _, joined := range profile.JoinedTenants {
appendTenant(tenantAccessDeniedTenant{
ID: strings.TrimSpace(joined.ID),
Slug: strings.TrimSpace(joined.Slug),
Name: strings.TrimSpace(joined.Name),
Identifier: firstNonEmptyString(strings.TrimSpace(joined.Slug), strings.TrimSpace(joined.ID)),
})
}
return out
}
func resolveCurrentTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse) tenantAccessDeniedTenant {
if profile == nil {
return tenantAccessDeniedTenant{}
}
if profile.Tenant != nil {
return tenantAccessDeniedTenant{
ID: strings.TrimSpace(profile.Tenant.ID),
Slug: strings.TrimSpace(profile.Tenant.Slug),
Name: strings.TrimSpace(profile.Tenant.Name),
Identifier: firstNonEmptyString(strings.TrimSpace(profile.Tenant.Slug), strings.TrimSpace(profile.Tenant.ID)),
}
}
if tenantSvc != nil {
if profile.TenantID != nil && strings.TrimSpace(*profile.TenantID) != "" {
if tenant, err := tenantSvc.GetTenant(c.Context(), strings.TrimSpace(*profile.TenantID)); err == nil && tenant != nil {
return tenantAccessDeniedTenant{
ID: strings.TrimSpace(tenant.ID),
Slug: strings.TrimSpace(tenant.Slug),
Name: strings.TrimSpace(tenant.Name),
Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID)),
}
}
}
if strings.TrimSpace(profile.CompanyCode) != "" {
if tenant, err := tenantSvc.GetTenantBySlug(c.Context(), strings.TrimSpace(profile.CompanyCode)); err == nil && tenant != nil {
return tenantAccessDeniedTenant{
ID: strings.TrimSpace(tenant.ID),
Slug: strings.TrimSpace(tenant.Slug),
Name: strings.TrimSpace(tenant.Name),
Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID)),
}
}
}
}
return tenantAccessDeniedTenant{
ID: strings.TrimSpace(pointerValue(profile.TenantID)),
Slug: strings.TrimSpace(profile.CompanyCode),
Identifier: firstNonEmptyString(strings.TrimSpace(profile.CompanyCode), strings.TrimSpace(pointerValue(profile.TenantID))),
}
}
func resolveAllowedTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, identifier string) tenantAccessDeniedTenant {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return tenantAccessDeniedTenant{}
}
if tenantSvc != nil {
if tenant, err := tenantSvc.GetTenant(c.Context(), identifier); err == nil && tenant != nil {
return tenantAccessDeniedTenant{
ID: strings.TrimSpace(tenant.ID),
Slug: strings.TrimSpace(tenant.Slug),
Name: strings.TrimSpace(tenant.Name),
Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID), identifier),
}
}
if tenant, err := tenantSvc.GetTenantBySlug(c.Context(), identifier); err == nil && tenant != nil {
return tenantAccessDeniedTenant{
ID: strings.TrimSpace(tenant.ID),
Slug: strings.TrimSpace(tenant.Slug),
Name: strings.TrimSpace(tenant.Name),
Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID), identifier),
}
}
}
return tenantAccessDeniedTenant{Identifier: identifier}
}
func profileEmail(profile *domain.UserProfileResponse) string {
if profile == nil {
return ""
}
return profile.Email
}
func pointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
type clientStructuredScope struct {
Name string `json:"name"`
Mandatory bool `json:"mandatory"`
Locked bool `json:"locked"`
}
func mergeRequestedScopesWithClientRequirements(client domain.HydraClient, requested []string) []string {
combined := make([]string, 0, len(requested)+2)
combined = append(combined, requested...)
combined = append(combined, requiredClientScopes(client)...)
return normalizeScopesInConsentOrder(combined)
}
func normalizeScopesInConsentOrder(scopes []string) []string {
combined := make([]string, 0, len(scopes))
combined = append(combined, scopes...)
seen := make(map[string]struct{}, len(combined))
out := make([]string, 0, len(combined))
appendIfPresent := func(scope string) {
scope = strings.TrimSpace(scope)
if scope == "" {
return
}
if _, ok := seen[scope]; ok {
return
}
for _, candidate := range combined {
if strings.TrimSpace(candidate) != scope {
continue
}
seen[scope] = struct{}{}
out = append(out, scope)
return
}
}
appendIfPresent("openid")
appendIfPresent("tenant")
for _, scope := range combined {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
out = append(out, scope)
}
return out
}
func requiredClientScopes(client domain.HydraClient) []string {
required := make([]string, 0, 4)
if clientTenantAccessRestricted(client.Metadata) {
required = append(required, "tenant")
}
if client.Metadata == nil {
return normalizeScopesInConsentOrder(required)
}
rawStructuredScopes, ok := client.Metadata["structured_scopes"]
if !ok || rawStructuredScopes == nil {
return normalizeScopesInConsentOrder(required)
}
rawBytes, err := json.Marshal(rawStructuredScopes)
if err != nil {
return normalizeScopesInConsentOrder(required)
}
var scopes []clientStructuredScope
if err := json.Unmarshal(rawBytes, &scopes); err != nil {
return normalizeScopesInConsentOrder(required)
}
for _, scope := range scopes {
name := strings.TrimSpace(scope.Name)
if name == "" {
continue
}
if scope.Mandatory || scope.Locked {
required = append(required, name)
}
}
return normalizeScopesInConsentOrder(required)
}

View File

@@ -0,0 +1,386 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestCreateClient_NormalizesTenantAccessMetadata(t *testing.T) {
var captured domain.HydraClient
ownerTenantID := "tenant-owner"
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.NoError(t, json.Unmarshal(body, &captured))
return httpJSONAny(r, http.StatusCreated, map[string]any{
"client_id": captured.ClientID,
"client_name": captured.ClientName,
"redirect_uris": captured.RedirectURIs,
"grant_types": captured.GrantTypes,
"response_types": captured.ResponseTypes,
"scope": captured.Scope,
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
"metadata": captured.Metadata,
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleSuperAdmin,
TenantID: &ownerTenantID,
})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"id": "client-tenant",
"name": "Tenant Client",
"type": "pkce",
"redirectUris": []string{"https://rp.example.com/cb"},
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-b", "tenant-a", "tenant-b"},
},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.True(t, clientTenantAccessRestricted(captured.Metadata))
assert.Equal(t, []string{"tenant-a", "tenant-b", "tenant-owner"}, clientAllowedTenants(captured.Metadata))
}
func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) {
hydraCalled := false
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
hydraCalled = true
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"id": "client-tenant",
"name": "Tenant Client",
"type": "pkce",
"redirectUris": []string{"https://rp.example.com/cb"},
"metadata": map[string]any{
"tenant_access_restricted": true,
},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.False(t, hydraCalled)
}
func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T) {
client := domain.HydraClient{
Metadata: map[string]any{
"tenant_access_restricted": true,
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
}
merged := mergeRequestedScopesWithClientRequirements(client, []string{"openid", "profile"})
assert.Equal(t, []string{"openid", "tenant", "profile"}, merged)
}
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant":
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-tenant",
"requested_scope": []string{"openid", "profile"},
"skip": false,
"subject": "user-123",
"client": map[string]any{
"client_id": "client-tenant",
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-b"},
},
},
}), nil
case r.URL.Host == "kratos.test" && r.URL.Path == "/sessions/whoami":
return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]any{
"id": "user-123",
"traits": map[string]any{
"email": "user@test.com",
"tenant_id": "tenant-a",
},
},
}), nil
default:
return httpJSONAny(r, http.StatusNotFound, nil), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := fiber.New()
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
t.Setenv("APP_ENV", "dev")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-tenant", nil)
req.Header.Set("X-Mock-Role", "user")
req.Header.Set("X-Tenant-ID", "tenant-a")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
var body map[string]any
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, "tenant_not_allowed", body["code"])
details, ok := body["details"].(map[string]any)
assert.True(t, ok)
account, ok := details["account"].(map[string]any)
assert.True(t, ok)
assert.NotEmpty(t, account["email"])
currentTenant, ok := details["current_tenant"].(map[string]any)
assert.True(t, ok)
assert.NotEmpty(t, currentTenant["identifier"])
}
func TestGetConsentRequest_DeniesRestrictedClientWhenProfileResolutionFails(t *testing.T) {
acceptCalled := false
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-profile-missing":
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-profile-missing",
"requested_scope": []string{"openid", "profile"},
"skip": false,
"subject": "user-123",
"client": map[string]any{
"client_id": "client-tenant",
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-b"},
},
},
}), nil
case r.URL.Path == "/oauth2/auth/requests/consent/accept":
acceptCalled = true
return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb",
}), nil
default:
return httpJSONAny(r, http.StatusNotFound, nil), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: func() service.KratosAdminService {
mockKratos := new(MockKratosAdminService)
mockKratos.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@test.com",
"tenant_id": "tenant-a",
"companyCode": "tenant-a",
},
}, nil).Once()
return mockKratos
}(),
TenantService: func() service.TenantService {
tenantSvc := new(MockTenantService)
tenantSvc.On("GetTenant", mock.Anything, "tenant-a").Return(&domain.Tenant{
ID: "tenant-a",
Slug: "tenant-a",
Name: "Tenant A",
}, nil).Twice()
tenantSvc.On("ListJoinedTenants", mock.Anything, "user-123").Return([]domain.Tenant{
{ID: "tenant-a", Slug: "tenant-a", Name: "Tenant A"},
{ID: "tenant-c", Slug: "tenant-c", Name: "Tenant C"},
}, nil).Once()
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError).Once()
tenantSvc.On("GetTenantBySlug", mock.Anything, "tenant-b").Return(&domain.Tenant{
ID: "tenant-b-id",
Slug: "tenant-b",
Name: "Tenant B",
}, nil).Once()
return tenantSvc
}(),
ConsentRepo: &mockConsentRepo{
consents: []domain.ClientConsent{
{
ClientID: "client-tenant",
Subject: "user-123",
GrantedScopes: []string{"openid", "profile"},
},
},
},
}
app := fiber.New()
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-profile-missing", nil)
req.Header.Set("Cookie", "ory_kratos_session=invalid-session")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.False(t, acceptCalled)
var body map[string]any
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, "tenant_not_allowed", body["code"])
details, ok := body["details"].(map[string]any)
assert.True(t, ok)
account, ok := details["account"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "user@test.com", account["email"])
currentTenant, ok := details["current_tenant"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "Tenant A", currentTenant["name"])
affiliatedTenants, ok := details["affiliated_tenants"].([]any)
assert.True(t, ok)
assert.Len(t, affiliatedTenants, 2)
}
func TestAcceptOidcLoginRequest_DeniesTenantAccess(t *testing.T) {
app := fiber.New()
app.Get("/deny", func(c *fiber.Ctx) error {
tenantID := "tenant-a"
profile := &domain.UserProfileResponse{
ID: "user-123",
Role: domain.RoleUser,
Email: "user@test.com",
TenantID: &tenantID,
CompanyCode: "tenant-a",
JoinedTenants: []domain.Tenant{
{ID: "tenant-a", Slug: "tenant-a", Name: "Tenant A"},
{ID: "tenant-c", Slug: "tenant-c", Name: "Tenant C"},
},
}
client := domain.HydraClient{
ClientID: "client-tenant",
Metadata: map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-b"},
},
}
tenantSvc := new(MockTenantService)
tenantSvc.On("GetTenant", mock.Anything, "tenant-a").Return(&domain.Tenant{
ID: "tenant-a",
Slug: "tenant-a",
Name: "Tenant A",
}, nil).Twice()
tenantSvc.On("GetTenant", mock.Anything, "tenant-b").Return(nil, assert.AnError).Once()
tenantSvc.On("GetTenantBySlug", mock.Anything, "tenant-b").Return(&domain.Tenant{
ID: "tenant-b-id",
Slug: "tenant-b",
Name: "Tenant B",
}, nil).Once()
enforceClientTenantAccess(c, tenantSvc, client, profile, nil)
return nil
})
req := httptest.NewRequest(http.MethodGet, "/deny", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
var body map[string]any
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, "tenant_not_allowed", body["code"])
details, ok := body["details"].(map[string]any)
assert.True(t, ok)
account, ok := details["account"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "user@test.com", account["email"])
currentTenant, ok := details["current_tenant"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "Tenant A", currentTenant["name"])
affiliatedTenants, ok := details["affiliated_tenants"].([]any)
assert.True(t, ok)
assert.Len(t, affiliatedTenants, 2)
allowedTenants, ok := details["allowed_tenants"].([]any)
assert.True(t, ok)
assert.Len(t, allowedTenants, 1)
allowedTenant, ok := allowedTenants[0].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "Tenant B", allowedTenant["name"])
}

View File

@@ -155,6 +155,22 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]
return results, nil
}
func (m *mockConsentRepo) ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error) {
seen := map[string]struct{}{}
subjects := make([]string, 0, len(m.consents))
for _, consent := range m.consents {
if consent.ClientID != clientID {
continue
}
if _, ok := seen[consent.Subject]; ok {
continue
}
seen[consent.Subject] = struct{}{}
subjects = append(subjects, consent.Subject)
}
return subjects, nil
}
func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error) {
for _, consent := range m.consents {
if consent.ClientID == clientID && consent.Subject == subject {
@@ -167,6 +183,17 @@ func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*
func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error { return nil }
func (m *mockConsentRepo) DeleteByClient(ctx context.Context, clientID string) error {
filtered := m.consents[:0]
for _, consent := range m.consents {
if consent.ClientID != clientID {
filtered = append(filtered, consent)
}
}
m.consents = filtered
return nil
}
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
for _, consent := range m.consents {

View File

@@ -618,6 +618,47 @@ func isProtectedSystemClientID(clientID string) bool {
return ok
}
func tenantAccessPolicyChanged(before, after map[string]interface{}) bool {
if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) {
return true
}
beforeAllowed := clientAllowedTenants(before)
afterAllowed := clientAllowedTenants(after)
if len(beforeAllowed) != len(afterAllowed) {
return true
}
for i := range beforeAllowed {
if beforeAllowed[i] != afterAllowed[i] {
return true
}
}
return false
}
func (h *DevHandler) revokeClientConsentsForPolicyChange(ctx context.Context, clientID string) error {
if h.ConsentRepo == nil || h.Hydra == nil {
return nil
}
subjects, err := h.ConsentRepo.ListSubjectsByClient(ctx, clientID)
if err != nil {
return err
}
for _, subject := range subjects {
subject = strings.TrimSpace(subject)
if subject == "" {
continue
}
if err := h.Hydra.RevokeConsentSessions(ctx, subject, clientID); err != nil {
return err
}
}
return h.ConsentRepo.DeleteByClient(ctx, clientID)
}
func isProtectedSystemClient(client domain.HydraClient) bool {
return isProtectedSystemClientID(client.ClientID)
}
@@ -1528,6 +1569,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
}
metadata["status"] = status
metadata["created_at"] = time.Now().Format(time.RFC3339)
var err error
metadata, err = normalizeClientTenantAccessMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" {
@@ -1716,6 +1762,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
metadata["status"] = status
}
metadata, err = normalizeClientTenantAccessMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
resolvedClientType := currentSummary.Type
if clientType != "" {
resolvedClientType = clientType
@@ -1758,6 +1808,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error())
}
tenantPolicyChanged := tenantAccessPolicyChanged(current.Metadata, updated.Metadata)
h.setAuditDetailsExtra(c, map[string]any{
"action": "UPDATE_CLIENT",
@@ -1779,6 +1830,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if tenantPolicyChanged {
if err := h.revokeClientConsentsForPolicyChange(c.Context(), clientID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to revoke existing consents after tenant policy update: "+err.Error())
}
}
h.syncHeadlessJWKSCache(c.Context(), *updatedClient, "client_update")
if updatedClient.ClientSecret != "" {

View File

@@ -1662,6 +1662,167 @@ func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) {
assert.False(t, hasRequestObjectAlg)
}
func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.T) {
var revokedSubjects []string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
ClientID: "client-1",
ClientName: "Tenant Guarded App",
RedirectURIs: []string{"https://rp.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
Scope: "openid tenant profile email",
TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-a"},
},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
var updated domain.HydraClient
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.NoError(t, json.Unmarshal(body, &updated))
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": updated.ClientID,
"client_name": updated.ClientName,
"redirect_uris": updated.RedirectURIs,
"grant_types": updated.GrantTypes,
"response_types": updated.ResponseTypes,
"scope": updated.Scope,
"token_endpoint_auth_method": updated.TokenEndpointAuthMethod,
"metadata": updated.Metadata,
}), nil
}
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
revokedSubjects = append(revokedSubjects, r.URL.Query().Get("subject"))
assert.Equal(t, "client-1", r.URL.Query().Get("client"))
return httpResponse(r, http.StatusNoContent, ""), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
consentRepo := &mockConsentRepo{
consents: []domain.ClientConsent{
{ClientID: "client-1", Subject: "user-1"},
{ClientID: "client-1", Subject: "user-2"},
{ClientID: "other-client", Subject: "user-3"},
},
}
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra.public",
HTTPClient: &http.Client{Transport: transport},
},
ConsentRepo: consentRepo,
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-b"},
},
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.ElementsMatch(t, []string{"user-1", "user-2"}, revokedSubjects)
assert.Len(t, consentRepo.consents, 1)
assert.Equal(t, "other-client", consentRepo.consents[0].ClientID)
}
func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.T) {
revoked := false
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
ClientID: "client-1",
ClientName: "Tenant Guarded App",
RedirectURIs: []string{"https://rp.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
Scope: "openid tenant profile email",
TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-a"},
},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
var updated domain.HydraClient
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.NoError(t, json.Unmarshal(body, &updated))
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": updated.ClientID,
"client_name": updated.ClientName,
"redirect_uris": updated.RedirectURIs,
"grant_types": updated.GrantTypes,
"response_types": updated.ResponseTypes,
"scope": updated.Scope,
"token_endpoint_auth_method": updated.TokenEndpointAuthMethod,
"metadata": updated.Metadata,
}), nil
}
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
revoked = true
return httpResponse(r, http.StatusNoContent, ""), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
consentRepo := &mockConsentRepo{
consents: []domain.ClientConsent{
{ClientID: "client-1", Subject: "user-1"},
},
}
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra.public",
HTTPClient: &http.Client{Transport: transport},
},
ConsentRepo: consentRepo,
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"name": "Renamed App",
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.False(t, revoked)
assert.Len(t, consentRepo.consents, 1)
}
func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) {
privateKey, jwks := mustHeadlessRSAJWK(t)
_ = privateKey

View File

@@ -13,47 +13,47 @@ import (
// Ory 계열(kratos/hydra) 공급자 문자열을 정규화하기 위한 매핑.
var providerAliases = map[string]string{
"ory": "ory",
"hydra": "ory",
"kratos": "ory",
"ory-kratos": "ory",
"ory_hydra": "ory",
"ory_kratos": "ory",
"ory": "ory",
"hydra": "ory",
"kratos": "ory",
"ory-kratos": "ory",
"ory_hydra": "ory",
"ory_kratos": "ory",
}
// getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
// InitializeProvider는 환경 설정을 기반으로 IDP 공급자를 생성하고 반환합니다.
// 이것은 IdentityProvider 인터페이스의 팩토리 역할을 합니다.
func InitializeProvider() (domain.IdentityProvider, error) {
rawProviders := getEnv("IDP_PROVIDER", "ory")
providers := strings.Split(rawProviders, ",")
slog.Info("Initializing IDP chain", "providers", rawProviders)
rawProviders := getEnv("IDP_PROVIDER", "ory")
providers := strings.Split(rawProviders, ",")
slog.Info("Initializing IDP chain", "providers", rawProviders)
var initialized []domain.IdentityProvider
for _, p := range providers {
providerName := strings.TrimSpace(strings.ToLower(p))
if canonical, ok := providerAliases[providerName]; ok {
providerName = canonical
}
var initialized []domain.IdentityProvider
for _, p := range providers {
providerName := strings.TrimSpace(strings.ToLower(p))
if canonical, ok := providerAliases[providerName]; ok {
providerName = canonical
}
switch providerName {
case "ory":
// Kratos/Hydra 주 공급자
oryProvider := service.NewOryProvider()
initialized = append(initialized, oryProvider)
switch providerName {
case "ory":
// Kratos/Hydra 주 공급자
oryProvider := service.NewOryProvider()
initialized = append(initialized, oryProvider)
default:
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
}
}
default:
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
}
}
if len(initialized) == 0 {
return nil, fmt.Errorf("no valid IDP_PROVIDER entries configured from: %s", rawProviders)
}

View File

@@ -11,9 +11,11 @@ import (
type ClientConsentRepository interface {
Upsert(ctx context.Context, consent *domain.ClientConsent) error
Delete(ctx context.Context, subject, clientID string) error
DeleteByClient(ctx context.Context, clientID string) error
List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error)
ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error)
Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error)
}
@@ -27,7 +29,7 @@ func NewClientConsentRepository(db *gorm.DB) ClientConsentRepository {
func (r *clientConsentRepo) Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error) {
var consent domain.ClientConsent
err := r.db.WithContext(ctx).Unscoped().
err := r.db.WithContext(ctx).
Where("client_id = ? AND subject = ?", clientID, subject).
First(&consent).Error
if err != nil {
@@ -56,6 +58,12 @@ func (r *clientConsentRepo) Delete(ctx context.Context, subject, clientID string
Delete(&domain.ClientConsent{}).Error
}
func (r *clientConsentRepo) DeleteByClient(ctx context.Context, clientID string) error {
return r.db.WithContext(ctx).
Where("client_id = ?", clientID).
Delete(&domain.ClientConsent{}).Error
}
func (r *clientConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
var consents []domain.ClientConsentWithTenantInfo
var total int64
@@ -117,3 +125,14 @@ func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) (
Find(&consents).Error
return consents, err
}
func (r *clientConsentRepo) ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error) {
var subjects []string
err := r.db.WithContext(ctx).Unscoped().
Model(&domain.ClientConsent{}).
Distinct("subject").
Where("client_id = ?", clientID).
Order("subject ASC").
Pluck("subject", &subjects).Error
return subjects, err
}

View File

@@ -0,0 +1,31 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/lib/pq"
"github.com/stretchr/testify/assert"
)
func TestClientConsentRepository_Find_IgnoresSoftDeletedConsent(t *testing.T) {
repo := NewClientConsentRepository(testDB)
ctx := context.Background()
consent := &domain.ClientConsent{
ClientID: "client-soft-delete",
Subject: "user-soft-delete",
GrantedScopes: pq.StringArray{"openid", "profile"},
}
err := repo.Upsert(ctx, consent)
assert.NoError(t, err)
err = repo.Delete(ctx, consent.Subject, consent.ClientID)
assert.NoError(t, err)
found, err := repo.Find(ctx, consent.ClientID, consent.Subject)
assert.NoError(t, err)
assert.Nil(t, found)
}

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
}
// Auto-migrate
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{})
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}

View File

@@ -2,14 +2,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
Check,
ExternalLink,
Info,
Plus,
Save,
Search,
Shield,
Sparkles,
Trash2,
Upload,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
@@ -31,6 +34,7 @@ import {
createClient,
deleteClient,
fetchClient,
fetchMyTenants,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
updateClient,
@@ -40,6 +44,8 @@ import type {
ClientStatus,
ClientType,
ClientUpsertRequest,
MyTenantSummary,
TenantSummary,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
@@ -50,6 +56,7 @@ interface ScopeItem {
name: string;
description: string;
mandatory: boolean;
locked?: boolean;
}
type SecurityProfile = "private" | "pkce";
@@ -131,6 +138,10 @@ function ClientGeneralPage() {
queryFn: () => fetchClient(clientId as string),
enabled: !isCreate,
});
const { data: tenantData } = useQuery({
queryKey: ["my-tenants"],
queryFn: fetchMyTenants,
});
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -142,6 +153,10 @@ function ClientGeneralPage() {
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
const [tenantSearch, setTenantSearch] = useState("");
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
@@ -158,12 +173,18 @@ function ClientGeneralPage() {
},
{
id: "2",
name: "tenant",
description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"),
mandatory: false,
},
{
id: "3",
name: "profile",
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
mandatory: false,
},
{
id: "3",
id: "4",
name: "email",
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
mandatory: false,
@@ -185,6 +206,16 @@ function ClientGeneralPage() {
const headlessEnabled = !!metadata.headless_login_enabled;
setHeadlessLoginEnabled(headlessEnabled);
const restrictedTenants = Array.isArray(metadata.allowed_tenants)
? metadata.allowed_tenants
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
setTenantAccessRestricted(
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
);
setAllowedTenantIds(restrictedTenants);
const savedAuthMethod =
client.tokenEndpointAuthMethod ||
@@ -230,15 +261,25 @@ function ClientGeneralPage() {
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
if (savedScopes && Array.isArray(savedScopes)) {
setScopes(savedScopes);
setScopes(
normalizeScopesForTenantAccess(
savedScopes,
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
),
);
} else {
setScopes(
client.scopes.map((s, idx) => ({
id: String(idx + 1),
name: s,
description: "",
mandatory: s === "openid",
})),
normalizeScopesForTenantAccess(
client.scopes.map((s, idx) => ({
id: String(idx + 1),
name: s,
description: "",
mandatory: s === "openid",
})),
restrictedTenants.length > 0 ||
metadata.tenant_access_restricted === true,
),
);
}
}, [data]);
@@ -279,6 +320,81 @@ function ClientGeneralPage() {
}
};
const tenantScopeDescription = t(
"msg.dev.clients.scopes.tenant",
"소속 테넌트 정보 접근",
);
const buildTenantScope = (id: string): ScopeItem => ({
id,
name: "tenant",
description: tenantScopeDescription,
mandatory: true,
locked: true,
});
function normalizeScopesForTenantAccess(
nextScopes: ScopeItem[],
restricted: boolean,
): ScopeItem[] {
const normalized = nextScopes.map((scope) => {
if (scope.name.trim() !== "tenant") {
return scope;
}
return {
...scope,
description: scope.description || tenantScopeDescription,
mandatory: restricted,
locked: restricted,
};
});
if (
restricted &&
!normalized.some((scope) => scope.name.trim() === "tenant")
) {
normalized.push(buildTenantScope(`tenant-${Date.now()}`));
}
const openidScopes = normalized.filter(
(scope) => scope.name.trim() === "openid",
);
const tenantScopes = normalized.filter(
(scope) => scope.name.trim() === "tenant",
);
const remainingScopes = normalized.filter((scope) => {
const name = scope.name.trim();
return name !== "openid" && name !== "tenant";
});
return [...openidScopes, ...tenantScopes, ...remainingScopes];
}
const handleTenantAccessToggle = (enabled: boolean) => {
setTenantAccessRestricted(enabled);
setIsTenantSearchOpen(enabled);
if (!enabled) {
setTenantSearch("");
}
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
};
const toggleAllowedTenant = (tenantId: string) => {
setAllowedTenantIds((current) =>
current.includes(tenantId)
? current.filter((id) => id !== tenantId)
: [...current, tenantId],
);
};
const handleSelectAllowedTenant = (tenantId: string) => {
setAllowedTenantIds((current) =>
current.includes(tenantId) ? current : [...current, tenantId],
);
setTenantSearch("");
setIsTenantSearchOpen(true);
};
const addScope = () => {
const newId = String(Date.now());
setScopes([
@@ -292,15 +408,23 @@ function ClientGeneralPage() {
field: K,
value: ScopeItem[K],
) => {
setScopes(
scopes.map((scope) =>
scope.id === id ? { ...scope, [field]: value } : scope,
),
setScopes((current) =>
current.map((scope) => {
if (scope.id !== id) {
return scope;
}
if (scope.locked) {
return scope;
}
return { ...scope, [field]: value };
}),
);
};
const removeScope = (id: string) => {
setScopes(scopes.filter((s) => s.id !== id));
setScopes((current) =>
current.filter((scope) => scope.id !== id || scope.locked === true),
);
};
const handleStatusChange = (nextStatus: ClientStatus) => {
@@ -391,7 +515,35 @@ function ClientGeneralPage() {
}
}
if (tenantAccessRestricted && allowedTenantIds.length === 0) {
validationErrors.push(
t(
"ui.dev.clients.general.tenant_access.validation_required",
"테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.",
),
);
}
const hasValidationErrors = validationErrors.length > 0;
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
tenantData ?? [];
const filteredTenants = tenantOptions.filter((tenant) => {
if (!normalizedTenantSearch) {
return true;
}
const searchable =
`${tenant.name} ${tenant.slug} ${tenant.description ?? ""} ${tenant.type ?? ""}`.toLowerCase();
return searchable.includes(normalizedTenantSearch);
});
const tenantSuggestions = filteredTenants
.filter((tenant) => !allowedTenantIds.includes(tenant.id))
.slice(0, 8);
const selectedAllowedTenants = allowedTenantIds
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
.filter(
(tenant): tenant is TenantSummary | MyTenantSummary => tenant != null,
);
const refreshHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
@@ -467,7 +619,16 @@ function ClientGeneralPage() {
);
}
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
const normalizedScopes = normalizeScopesForTenantAccess(
scopes,
tenantAccessRestricted,
);
const normalizedAllowedTenantIds = Array.from(
new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)),
);
const scopeNames = normalizedScopes
.map((scope) => scope.name.trim())
.filter(Boolean);
const effectiveTokenEndpointAuthMethod =
clientType === "pkce" && headlessLoginEnabled
@@ -487,7 +648,7 @@ function ClientGeneralPage() {
metadata: {
description,
logo_url: trimmedLogoUrl,
structured_scopes: scopes,
structured_scopes: normalizedScopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method:
@@ -498,6 +659,10 @@ function ClientGeneralPage() {
clientType === "pkce" && headlessLoginEnabled
? trimmedJwksUri
: undefined,
tenant_access_restricted: tenantAccessRestricted,
allowed_tenants: tenantAccessRestricted
? normalizedAllowedTenantIds
: [],
},
};
@@ -972,7 +1137,10 @@ function ClientGeneralPage() {
{scopes.map((s) => (
<tr
key={s.id}
className="hover:bg-muted/30 transition-colors"
className={cn(
"transition-colors",
s.locked ? "bg-primary/5" : "hover:bg-muted/30",
)}
>
<td className="px-4 py-3">
<Input
@@ -985,6 +1153,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.name_placeholder",
"e.g. profile",
)}
disabled={s.locked}
/>
</td>
<td className="px-4 py-3">
@@ -998,6 +1167,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.description_placeholder",
"권한에 대한 설명",
)}
disabled={s.locked}
/>
</td>
<td className="px-4 py-3 text-center">
@@ -1007,6 +1177,7 @@ function ClientGeneralPage() {
onCheckedChange={(checked) =>
updateScope(s.id, "mandatory", checked)
}
disabled={s.locked}
/>
</div>
</td>
@@ -1016,6 +1187,7 @@ function ClientGeneralPage() {
size="icon"
onClick={() => removeScope(s.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={s.locked}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -1041,6 +1213,222 @@ function ClientGeneralPage() {
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-bold">
{t(
"ui.dev.clients.general.tenant_access.title",
"테넌트 접근 제한",
)}
</CardTitle>
<CardDescription>
{t(
"ui.dev.clients.general.tenant_access.subtitle",
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-0.5 text-right">
<p className="text-sm font-semibold">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.enabled",
"제한 있음",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.title",
"테넌트 접근 제한",
)}
</p>
</div>
<Switch
checked={tenantAccessRestricted}
onCheckedChange={handleTenantAccessToggle}
id="tenant-access-toggle"
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.hint",
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
)}
</p>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-3">
<Label htmlFor="tenant-search" className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="tenant-search"
value={tenantSearch}
onFocus={() => {
if (tenantAccessRestricted) {
setIsTenantSearchOpen(true);
}
}}
onBlur={() => {
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
}}
onChange={(e) => {
setTenantSearch(e.target.value);
if (tenantAccessRestricted) {
setIsTenantSearchOpen(true);
}
}}
placeholder={t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
className="pl-10"
disabled={!tenantAccessRestricted}
/>
{tenantAccessRestricted && isTenantSearchOpen && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
{tenantSuggestions.length > 0 ? (
tenantSuggestions.map((tenant) => (
<button
key={tenant.id}
type="button"
className="flex w-full items-start justify-between gap-3 border-b border-border/40 px-4 py-3 text-left transition hover:bg-muted/40 last:border-b-0"
onMouseDown={(event) => {
event.preventDefault();
handleSelectAllowedTenant(tenant.id);
}}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{tenant.name}
</span>
<Badge variant="outline" className="text-[11px]">
{tenant.slug}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">
{tenant.description || tenant.type}
</p>
</div>
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</button>
))
) : (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.empty",
"검색 결과가 없습니다.",
)}
</div>
)}
</div>
)}
</div>
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.autocomplete_hint",
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.selected_title",
"허용 테넌트",
)}
</Label>
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedAllowedTenants.map((tenant) => (
<Badge
key={tenant.id}
variant="secondary"
className="gap-2 px-3 py-1.5"
>
<Check className="h-3.5 w-3.5" />
<span className="max-w-44 truncate">{tenant.name}</span>
<span className="text-[11px] text-muted-foreground">
{tenant.slug}
</span>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenant.id)}
className="text-muted-foreground transition hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
{allowedTenantIds
.filter(
(tenantId) =>
!selectedAllowedTenants.some(
(tenant) => tenant.id === tenantId,
),
)
.map((tenantId) => (
<Badge
key={tenantId}
variant="secondary"
className="gap-2 px-3 py-1.5"
>
<Check className="h-3.5 w-3.5" />
<span className="max-w-44 truncate">{tenantId}</span>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenantId)}
className="text-muted-foreground transition hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
</div>
) : (
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.selected_empty",
"아직 선택된 테넌트가 없습니다.",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 3. Security Settings */}
<Card className="glass-panel">
<CardHeader className="pb-3">

View File

@@ -23,6 +23,28 @@ export type ClientListResponse = {
offset: number;
};
export type TenantSummary = {
id: string;
type: string;
parentId?: string | null;
name: string;
slug: string;
description: string;
status: string;
domains?: string[];
config?: Record<string, unknown>;
memberCount: number;
createdAt: string;
updatedAt: string;
};
export type TenantListResponse = {
items: TenantSummary[];
limit: number;
offset: number;
total: number;
};
export type DevStats = {
total_clients: number;
active_sessions: number;
@@ -188,6 +210,17 @@ export async function fetchDevStats() {
return data;
}
export async function fetchTenants(
limit = 1000,
offset = 0,
parentId?: string,
) {
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
params: { limit, offset, parentId },
});
return data;
}
export async function fetchClient(clientId: string) {
const { data } = await apiClient.get<ClientDetailResponse>(
`/dev/clients/${clientId}`,
@@ -376,14 +409,11 @@ export async function fetchDevAuditLogs(
return data;
}
export type TenantSummary = {
id: string;
name: string;
slug: string;
};
export type MyTenantSummary = Pick<TenantSummary, "id" | "name" | "slug"> &
Partial<TenantSummary>;
export async function fetchMyTenants() {
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
const { data } = await apiClient.get<MyTenantSummary[]>("/dev/my-tenants");
return data;
}

View File

@@ -419,6 +419,7 @@ help = "Enter the redirect URIs. You can modify them in the Federation tab after
[msg.dev.clients.general.scopes]
empty = "No scopes registered."
subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim"
[msg.dev.clients.general.security]
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
@@ -1436,6 +1437,20 @@ description = "Scope Description"
mandatory = "Mandatory"
name = "Scope Name"
delete = "Delete"
tenant = "Tenant"
[ui.dev.clients.general.tenant_access]
title = "Tenant access restriction"
subtitle = "Limit this RP so only approved tenants can access it."
enabled = "Restricted"
disabled = "Unrestricted"
search_placeholder = "Search by tenant name or slug"
selected_title = "Allowed tenants"
selected_empty = "No tenants selected yet."
empty = "No tenants match your search."
hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant."
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
[ui.dev.clients.general.security]
private = "Server Side App"

View File

@@ -419,6 +419,7 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동
[msg.dev.clients.general.scopes]
empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.security]
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
@@ -1435,6 +1436,20 @@ description = "설명"
mandatory = "필수"
name = "스코프 이름"
delete = "삭제"
tenant = "테넌트"
[ui.dev.clients.general.tenant_access]
title = "테넌트 접근 제한"
subtitle = "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다."
enabled = "제한 있음"
disabled = "제한 없음"
search_placeholder = "테넌트 이름 또는 슬러그로 검색"
selected_title = "허용 테넌트"
selected_empty = "아직 선택된 테넌트가 없습니다."
empty = "검색 결과가 없습니다."
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
[ui.dev.clients.general.security]
private = "Server side App"

View File

@@ -465,6 +465,7 @@ help = ""
[msg.dev.clients.general.scopes]
empty = ""
subtitle = ""
tenant = ""
[msg.dev.clients.general.security]
private_help = ""
@@ -1518,6 +1519,20 @@ description = ""
mandatory = ""
name = ""
delete = ""
tenant = ""
[ui.dev.clients.general.tenant_access]
title = ""
subtitle = ""
enabled = ""
disabled = ""
search_placeholder = ""
selected_title = ""
selected_empty = ""
empty = ""
hint = ""
autocomplete_hint = ""
validation_required = ""
[ui.dev.clients.general.security]
private = ""

View File

@@ -650,6 +650,22 @@ title_generic = "An error occurred."
title_with_code = "Error: {{code}}"
type = "Error type: {{type}}"
[msg.userfront.error.tenant]
account = "Account"
account_unknown = "Unknown"
affiliated_tenants = "All affiliated tenants"
allowed_box_title = "Allowed tenants"
allowed_tenants = "Allowed tenants"
detail = "The currently signed-in account cannot access this application."
load_failed = "We could not confirm the account details. Please try again."
loading = "Loading the current account details."
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
page_title = "Access to this application is restricted"
primary_tenant = "Primary affiliated tenant"
tenant = "Tenant"
tenant_unknown = "Unknown"
title = "Access restriction details"
[msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "The user denied the consent request."
@@ -2239,6 +2255,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "Go Home"
go_login = "Go Login"
switch_account = "Sign in with another account"
[ui.userfront.forgot]
heading = "Forgot your password?"

View File

@@ -208,6 +208,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}"
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
@@ -456,6 +472,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"
@@ -1088,6 +1105,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}"
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "사용자가 동의를 거부했습니다."
@@ -2638,6 +2671,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"

View File

@@ -83,6 +83,22 @@ title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.forgot]
description = ""
dry_send = ""
@@ -331,6 +347,7 @@ windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
switch_account = ""
[ui.userfront.forgot]
heading = ""
@@ -963,6 +980,22 @@ title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
@@ -2513,6 +2546,7 @@ windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
switch_account = ""
[ui.userfront.forgot]
heading = ""

View File

@@ -4,19 +4,15 @@ set -euo pipefail
job_name="${1:-adminfront-tests}"
mkdir -p reports
rm -rf adminfront/node_modules
if [ -n "${CI:-}" ]; then
playwright_install_cmd=(npx playwright install --with-deps)
playwright_install_desc="npx playwright install --with-deps"
else
playwright_install_cmd=(npx playwright install)
playwright_install_desc="npx playwright install"
fi
playwright_install_cmd=(npx playwright install --with-deps)
playwright_install_desc="npx playwright install --with-deps"
set +e
(
cd adminfront
npm ci
npm ci --ignore-scripts
) 2>&1 | tee reports/adminfront-install.log
install_exit_code=${PIPESTATUS[0]}
set -e
@@ -31,7 +27,7 @@ if [ "$install_exit_code" -ne 0 ]; then
echo "- Exit Code: \`$install_exit_code\`"
echo
echo "## Command"
echo "\`cd adminfront && npm ci\`"
echo "\`cd adminfront && npm ci --ignore-scripts\`"
echo
echo "## Install Log Tail (last 200 lines)"
echo '```text'
@@ -70,11 +66,12 @@ if [ "$provision_exit_code" -ne 0 ]; then
fi
set +e
port="$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"
port="${PORT:-5180}"
echo "==> adminfront using PORT=$port"
(
cd adminfront
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" npm test
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
node ./node_modules/playwright/cli.js test
) 2>&1 | tee reports/adminfront-test.log
test_exit_code=${PIPESTATUS[0]}
set -e
@@ -89,9 +86,9 @@ if [ "$test_exit_code" -ne 0 ]; then
echo
echo "## Commands"
echo "1. \`cd adminfront\`"
echo "2. \`npm ci\`"
echo "2. \`npm ci --ignore-scripts\`"
echo "3. \`${playwright_install_desc}\`"
echo "4. \`npm test\`"
echo "4. \`node ./node_modules/playwright/cli.js test\`"
echo
echo "## Log Tail (last 200 lines)"
echo '```text'

View File

@@ -137,6 +137,22 @@ title_generic = "An error occurred."
title_with_code = "Error: {code}"
type = "Error type: {type}"
[msg.userfront.error.tenant]
account = "Account"
account_unknown = "Unknown"
affiliated_tenants = "All affiliated tenants"
allowed_box_title = "Allowed tenants"
allowed_tenants = "Allowed tenants"
detail = "The currently signed-in account cannot access this application."
load_failed = "We could not confirm the account details. Please try again."
loading = "Loading the current account details."
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
page_title = "Access to this application is restricted"
primary_tenant = "Primary affiliated tenant"
tenant = "Tenant"
tenant_unknown = "Unknown"
title = "Access restriction details"
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "The user denied the consent request."
@@ -506,6 +522,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "Go Home"
go_login = "Go Login"
switch_account = "Sign in with another account"
[ui.userfront.forgot]
heading = "Forgot your password?"

View File

@@ -78,6 +78,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}"
type = "오류 종류: {type}"
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
@@ -190,6 +206,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"
@@ -344,6 +361,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}"
type = "오류 종류: {type}"
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "사용자가 동의를 거부했습니다."
@@ -711,6 +744,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"

View File

@@ -50,6 +50,22 @@ title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.forgot]
description = ""
dry_send = ""
@@ -162,6 +178,7 @@ windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
switch_account = ""
[ui.userfront.forgot]
heading = ""
@@ -316,6 +333,22 @@ title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
@@ -683,6 +716,7 @@ windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
switch_account = ""
[ui.userfront.forgot]
heading = ""

View File

@@ -8,6 +8,7 @@ const Map<String, String> internalErrorWhitelistMessages = {
'not_found': '요청한 페이지를 찾을 수 없습니다.',
'bad_request': '입력값을 확인해 주세요.',
'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.',
'tenant_not_allowed': '허용되지 않은 테넌트입니다.',
};
const Set<String> oryBypassErrorCodes = {

View File

@@ -396,8 +396,14 @@ class AuthProxyService {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'),
final rawDetails = errorBody['details'];
throw AuthProxyException(
errorCode: (errorBody['code'] ?? '').toString(),
message:
(errorBody['error'] ??
tr('err.userfront.auth_proxy.consent_fetch'))
.toString(),
details: rawDetails is Map<String, dynamic> ? rawDetails : null,
);
}
} finally {
@@ -1105,3 +1111,18 @@ class AuthProxyService {
}
}
}
class AuthProxyException implements Exception {
final String errorCode;
final String message;
final Map<String, dynamic>? details;
const AuthProxyException({
required this.errorCode,
required this.message,
this.details,
});
@override
String toString() => message;
}

View File

@@ -0,0 +1,5 @@
import 'package:userfront/core/services/auth_proxy_service.dart';
bool shouldRouteConsentErrorToErrorScreen(Object error) {
return error is AuthProxyException && error.errorCode == 'tenant_not_allowed';
}

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
@@ -5,11 +7,18 @@ import 'package:userfront/core/i18n/locale_utils.dart';
import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/web_window.dart';
import 'package:userfront/core/ui/toast_service.dart';
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
class ConsentScreen extends StatefulWidget {
final String consentChallenge;
final Future<Map<String, dynamic>> Function(String consentChallenge)?
consentInfoLoader;
const ConsentScreen({super.key, required this.consentChallenge});
const ConsentScreen({
super.key,
required this.consentChallenge,
this.consentInfoLoader,
});
@override
State<ConsentScreen> createState() => _ConsentScreenState();
@@ -93,9 +102,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async {
try {
final info = await AuthProxyService.getConsentInfo(
widget.consentChallenge,
);
final loader =
widget.consentInfoLoader ?? AuthProxyService.getConsentInfo;
final info = await loader(widget.consentChallenge);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) {
@@ -139,6 +148,35 @@ class _ConsentScreenState extends State<ConsentScreen> {
_consentInfo = info;
_isLoading = false;
});
} on AuthProxyException catch (e) {
if (shouldRouteConsentErrorToErrorScreen(e)) {
if (!mounted) {
return;
}
final localeCode =
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
final target = buildLocalizedPath(
localeCode,
Uri(
path: '/error',
queryParameters: {
'error': e.errorCode,
'error_description': e.message,
if (e.details != null) 'details': jsonEncode(e.details),
},
),
);
context.go(target);
return;
}
setState(() {
_error = tr(
'msg.userfront.consent.load_error',
fallback: 'Failed to load consent information: {{error}}',
params: {'error': e.message},
);
_isLoading = false;
});
} catch (e) {
setState(() {
_error = tr(

View File

@@ -1,16 +1,21 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/error_whitelist.dart';
import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/logout_service.dart';
import '../../../core/widgets/theme_toggle_button.dart';
import 'package:userfront/i18n.dart';
class ErrorScreen extends StatelessWidget {
class ErrorScreen extends StatefulWidget {
final String? errorId;
final String? errorCode;
final String? description;
final bool? isProdOverride;
final Future<Map<String, dynamic>> Function()? sessionProfileLoader;
final Map<String, dynamic>? tenantAccessDetails;
const ErrorScreen({
super.key,
@@ -18,24 +23,280 @@ class ErrorScreen extends StatelessWidget {
this.errorCode,
this.description,
this.isProdOverride,
this.sessionProfileLoader,
this.tenantAccessDetails,
});
@override
State<ErrorScreen> createState() => _ErrorScreenState();
}
class _ErrorScreenState extends State<ErrorScreen> {
Map<String, dynamic>? _sessionProfile;
bool _isLoadingSessionProfile = false;
String? _sessionProfileError;
bool get _isTenantAccessBlocked =>
(widget.errorCode ?? '').trim() == 'tenant_not_allowed';
@override
void initState() {
super.initState();
if (_isTenantAccessBlocked && _shouldLoadSessionProfile()) {
unawaited(_loadSessionProfile());
}
}
Map<String, dynamic>? get _tenantAccessDetails => widget.tenantAccessDetails;
bool _shouldLoadSessionProfile() {
final details = _tenantAccessDetails;
if (details == null) {
return true;
}
final hasAccount = _extractAccountEmail(details).isNotEmpty;
final hasTenant = _extractCurrentTenantLabel(details).isNotEmpty;
return !hasAccount || !hasTenant;
}
Future<void> _loadSessionProfile() async {
if (_isLoadingSessionProfile) {
return;
}
setState(() {
_isLoadingSessionProfile = true;
_sessionProfileError = null;
});
try {
final loader = widget.sessionProfileLoader ?? AuthProxyService.getMe;
final profile = await loader();
if (!mounted) {
return;
}
setState(() {
_sessionProfile = profile;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_sessionProfileError = error.toString();
});
} finally {
if (mounted) {
setState(() {
_isLoadingSessionProfile = false;
});
}
}
}
String _extractTenantLabel(Map<String, dynamic>? profile) {
if (profile == null) {
return '';
}
final tenant = profile['tenant'];
if (tenant is Map) {
final name = tenant['name']?.toString().trim() ?? '';
if (name.isNotEmpty) {
return name;
}
final slug = tenant['slug']?.toString().trim() ?? '';
if (slug.isNotEmpty) {
return slug;
}
}
final joinedTenants = profile['joinedTenants'];
if (joinedTenants is List) {
for (final item in joinedTenants) {
if (item is Map) {
final name = item['name']?.toString().trim() ?? '';
if (name.isNotEmpty) {
return name;
}
final slug = item['slug']?.toString().trim() ?? '';
if (slug.isNotEmpty) {
return slug;
}
}
}
}
final companyCode = profile['companyCode']?.toString().trim() ?? '';
if (companyCode.isNotEmpty) {
return companyCode;
}
return '';
}
String _extractCurrentTenantLabel(Map<String, dynamic>? details) {
if (details == null) {
return '';
}
final tenant = details['current_tenant'];
if (tenant is! Map) {
return '';
}
final name = tenant['name']?.toString().trim() ?? '';
if (name.isNotEmpty) {
return name;
}
final slug = tenant['slug']?.toString().trim() ?? '';
if (slug.isNotEmpty) {
return slug;
}
final identifier = tenant['identifier']?.toString().trim() ?? '';
if (identifier.isNotEmpty) {
return identifier;
}
final id = tenant['id']?.toString().trim() ?? '';
return id;
}
String _extractAccountEmail(Map<String, dynamic>? details) {
if (details == null) {
return '';
}
final account = details['account'];
if (account is! Map) {
return '';
}
return account['email']?.toString().trim() ?? '';
}
List<String> _extractAllowedTenantLabels(Map<String, dynamic>? details) {
if (details == null) {
return const [];
}
final raw = details['allowed_tenants'];
if (raw is! List) {
return const [];
}
final labels = <String>[];
for (final item in raw) {
if (item is! Map) {
continue;
}
final label =
item['name']?.toString().trim() ??
item['slug']?.toString().trim() ??
item['identifier']?.toString().trim() ??
item['id']?.toString().trim() ??
'';
if (label.isNotEmpty) {
labels.add(label);
}
}
return labels;
}
List<String> _extractAffiliatedTenantLabelsFromDetails(
Map<String, dynamic>? details,
) {
if (details == null) {
return const [];
}
final raw = details['affiliated_tenants'];
if (raw is! List) {
return const [];
}
final labels = <String>[];
for (final item in raw) {
if (item is! Map) {
continue;
}
final label =
item['name']?.toString().trim() ??
item['slug']?.toString().trim() ??
item['identifier']?.toString().trim() ??
item['id']?.toString().trim() ??
'';
if (label.isNotEmpty) {
labels.add(label);
}
}
return labels;
}
List<String> _extractAffiliatedTenantLabelsFromProfile(
Map<String, dynamic>? profile,
) {
if (profile == null) {
return const [];
}
final labels = <String>[];
final seen = <String>{};
void appendLabel(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty || seen.contains(trimmed)) {
return;
}
seen.add(trimmed);
labels.add(trimmed);
}
final joinedTenants = profile['joinedTenants'];
if (joinedTenants is List) {
for (final item in joinedTenants) {
if (item is Map) {
appendLabel(item['name']?.toString() ?? '');
appendLabel(item['slug']?.toString() ?? '');
}
}
}
final tenant = _extractTenantLabel(profile);
if (tenant.isNotEmpty) {
appendLabel(tenant);
}
final companyCode = profile['companyCode']?.toString().trim() ?? '';
if (companyCode.isNotEmpty) {
appendLabel(companyCode);
}
return labels;
}
Future<void> _switchAccount() async {
await LogoutService().logout();
if (!mounted) {
return;
}
context.go(buildLocalizedSigninPath(Uri.base));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim();
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (widget.errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty;
final internalWhitelistFallback =
internalErrorWhitelistMessages[normalizedCode];
final isInternalWhitelisted = internalWhitelistFallback != null;
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
final errorType = isProd
? (isKnownProdCode ? normalizedCode : 'unknown_error')
: (hasCode ? normalizedCode : 'unknown_error');
final title = isProd
final title = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.page_title',
fallback: '애플리케이션 접근이 제한되었습니다',
)
: isProd
? tr('msg.userfront.error.title')
: (hasCode
? tr(
@@ -43,7 +304,40 @@ class ErrorScreen extends StatelessWidget {
params: {'code': normalizedCode},
)
: tr('msg.userfront.error.title_generic'));
final detail = isProd
final tenantLabelFromDetails = _extractCurrentTenantLabel(
_tenantAccessDetails,
);
final tenantLabel = tenantLabelFromDetails.isNotEmpty
? tenantLabelFromDetails
: _extractTenantLabel(_sessionProfile);
final emailFromDetails = _extractAccountEmail(_tenantAccessDetails);
final emailLabel = emailFromDetails.isNotEmpty
? emailFromDetails
: (_sessionProfile?['email']?.toString().trim() ?? '');
final affiliatedTenantLabels =
_extractAffiliatedTenantLabelsFromDetails(
_tenantAccessDetails,
).isNotEmpty
? _extractAffiliatedTenantLabelsFromDetails(_tenantAccessDetails)
: _extractAffiliatedTenantLabelsFromProfile(_sessionProfile);
final allowedTenantLabels = _extractAllowedTenantLabels(
_tenantAccessDetails,
);
final isLoadingTenantContext =
_isLoadingSessionProfile && _tenantAccessDetails == null;
final hasTenantLookupFailure =
_sessionProfileError != null &&
_sessionProfileError!.isNotEmpty &&
_tenantAccessDetails == null;
final showTenantLookupFallback =
_tenantAccessDetails == null &&
(emailLabel.isEmpty || tenantLabel.isEmpty);
final detail = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.detail',
fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
)
: isProd
? (isInternalWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
@@ -52,112 +346,312 @@ class ErrorScreen extends StatelessWidget {
: (isOryBypass
? tr(
'msg.userfront.error.ory.$normalizedCode',
fallback: (description?.isNotEmpty == true)
? description
fallback: (widget.description?.isNotEmpty == true)
? widget.description
: tr('msg.userfront.error.detail_request'),
)
: tr('msg.userfront.error.detail_contact')))
: ((description?.isNotEmpty == true)
? description!
: ((widget.description?.isNotEmpty == true)
? widget.description!
: (hasCode
? tr('msg.userfront.error.detail_generic')
: tr('msg.userfront.error.detail_request')));
return Scaffold(
backgroundColor: colorScheme.surfaceContainerLowest,
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 24),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight - 48,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Card(
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
),
const ThemeToggleButton(compact: true),
],
),
),
const SizedBox(height: 12),
Text(
detail,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
if (isTenantAccessBlocked) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.error.tenant.title',
fallback: '접근 제한 정보',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
if (isLoadingTenantContext)
Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
tr(
'msg.userfront.error.tenant.loading',
fallback: '현재 계정 정보를 불러오는 중입니다.',
),
style: theme.textTheme.bodySmall
?.copyWith(
color: colorScheme
.onSurfaceVariant,
),
),
),
],
)
else ...[
_InfoRow(
label: tr(
'msg.userfront.error.tenant.account',
fallback: '계정',
),
value: emailLabel.isNotEmpty
? emailLabel
: tr(
'msg.userfront.error.tenant.account_unknown',
fallback: '알 수 없음',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.primary_tenant',
fallback: '대표 소속 테넌트',
),
value: tenantLabel.isNotEmpty
? tenantLabel
: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.affiliated_tenants',
fallback: '전체 소속 테넌트',
),
value: affiliatedTenantLabels.isNotEmpty
? affiliatedTenantLabels.join(', ')
: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
),
),
if (showTenantLookupFallback) ...[
const SizedBox(height: 12),
Text(
tr(
'msg.userfront.error.tenant.lookup_fallback',
fallback:
'표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.',
),
style: theme.textTheme.bodySmall
?.copyWith(
color:
colorScheme.onSurfaceVariant,
),
),
],
if (hasTenantLookupFailure) ...[
const SizedBox(height: 12),
Text(
tr(
'msg.userfront.error.tenant.load_failed',
fallback:
'계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.',
),
style: theme.textTheme.bodySmall
?.copyWith(
color:
colorScheme.onSurfaceVariant,
),
),
],
],
],
),
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.error.tenant.allowed_box_title',
fallback: '접속 가능 테넌트',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
if (allowedTenantLabels.isNotEmpty) ...[
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
fallback: '접속 가능 테넌트',
),
value: allowedTenantLabels.join(', '),
),
] else ...[
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
fallback: '접속 가능 테넌트',
),
value: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
),
),
],
],
),
),
],
const SizedBox(height: 12),
Text(
tr(
'msg.userfront.error.type',
params: {'type': errorType},
),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (widget.errorId != null &&
widget.errorId!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
tr(
'msg.userfront.error.id',
params: {'id': widget.errorId!},
),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ElevatedButton(
onPressed: isTenantAccessBlocked
? _switchAccount
: () => context.go('/login'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
isTenantAccessBlocked
? tr('ui.userfront.error.switch_account')
: tr('ui.userfront.error.go_login'),
),
),
OutlinedButton(
onPressed: () => context.go(
buildLocalizedHomePath(Uri.base),
),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: BorderSide(color: colorScheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(tr('ui.userfront.error.go_home')),
),
],
),
],
),
const ThemeToggleButton(compact: true),
],
),
const SizedBox(height: 12),
Text(
detail,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 12),
Text(
tr('msg.userfront.error.type', params: {'type': errorType}),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (errorId != null && errorId!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
tr('msg.userfront.error.id', params: {'id': errorId!}),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ElevatedButton(
onPressed: () => context.go('/login'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(tr('ui.userfront.error.go_login')),
),
OutlinedButton(
onPressed: () =>
context.go(buildLocalizedHomePath(Uri.base)),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: BorderSide(color: colorScheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(tr('ui.userfront.error.go_home')),
),
],
),
],
),
),
),
),
@@ -166,3 +660,39 @@ class ErrorScreen extends StatelessWidget {
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
);
}
}

View File

@@ -503,6 +503,21 @@ const Map<String, String> koStrings = {
"msg.userfront.error.title_generic": "오류가 발생했습니다",
"msg.userfront.error.title_with_code": "오류: {{code}}",
"msg.userfront.error.type": "오류 종류: {{type}}",
"msg.userfront.error.tenant.account": "계정",
"msg.userfront.error.tenant.account_unknown": "알 수 없음",
"msg.userfront.error.tenant.affiliated_tenants": "전체 소속 테넌트",
"msg.userfront.error.tenant.allowed_tenants": "접속 가능 테넌트",
"msg.userfront.error.tenant.allowed_box_title": "접속 가능 테넌트",
"msg.userfront.error.tenant.detail": "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.",
"msg.userfront.error.tenant.load_failed": "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.",
"msg.userfront.error.tenant.lookup_fallback":
"표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.",
"msg.userfront.error.tenant.page_title": "애플리케이션 접근이 제한되었습니다",
"msg.userfront.error.tenant.loading": "현재 계정 정보를 불러오는 중입니다.",
"msg.userfront.error.tenant.primary_tenant": "대표 소속 테넌트",
"msg.userfront.error.tenant.tenant": "소속 테넌트",
"msg.userfront.error.tenant.tenant_unknown": "알 수 없음",
"msg.userfront.error.tenant.title": "접근 제한 정보",
"msg.userfront.error.whitelist.\"\$normalizedCode\"": "{{error}}",
"msg.userfront.error.whitelist.bad_request": "입력값을 확인해 주세요.",
"msg.userfront.error.whitelist.invalid_session": "세션이 만료되었습니다. 다시 로그인해 주세요.",
@@ -514,6 +529,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":
@@ -1738,6 +1754,7 @@ const Map<String, String> koStrings = {
"ui.userfront.device.windows": "Desktop(Windows)",
"ui.userfront.error.go_home": "홈으로 이동",
"ui.userfront.error.go_login": "로그인으로 이동",
"ui.userfront.error.switch_account": "다른 계정으로 로그인",
"ui.userfront.forgot.heading": "비밀번호를 잊으셨나요?",
"ui.userfront.forgot.input_label": "이메일 또는 휴대폰 번호",
"ui.userfront.forgot.submit": "재설정 링크 전송",
@@ -2436,6 +2453,24 @@ const Map<String, String> enStrings = {
"msg.userfront.error.title_generic": "An error occurred.",
"msg.userfront.error.title_with_code": "Error: {{code}}",
"msg.userfront.error.type": "Error type: {{type}}",
"msg.userfront.error.tenant.account": "Account",
"msg.userfront.error.tenant.account_unknown": "Unknown",
"msg.userfront.error.tenant.affiliated_tenants": "All affiliated tenants",
"msg.userfront.error.tenant.allowed_tenants": "Allowed tenants",
"msg.userfront.error.tenant.allowed_box_title": "Allowed tenants",
"msg.userfront.error.tenant.detail":
"The currently signed-in account cannot access this application.",
"msg.userfront.error.tenant.load_failed":
"We could not confirm the account details. Please try again.",
"msg.userfront.error.tenant.lookup_fallback":
"Some fields could not be verified because the access context was incomplete.",
"msg.userfront.error.tenant.page_title":
"Access to this application is restricted",
"msg.userfront.error.tenant.loading": "Loading the current account details.",
"msg.userfront.error.tenant.primary_tenant": "Primary affiliated tenant",
"msg.userfront.error.tenant.tenant": "Tenant",
"msg.userfront.error.tenant.tenant_unknown": "Unknown",
"msg.userfront.error.tenant.title": "Access restriction details",
"msg.userfront.error.whitelist.\"\$normalizedCode\"": "{{error}}",
"msg.userfront.error.whitelist.bad_request": "Please check your input.",
"msg.userfront.error.whitelist.invalid_session":
@@ -2452,6 +2487,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":
@@ -3752,6 +3789,7 @@ const Map<String, String> enStrings = {
"ui.userfront.device.windows": "Desktop(Windows)",
"ui.userfront.error.go_home": "Go Home",
"ui.userfront.error.go_login": "Go Login",
"ui.userfront.error.switch_account": "Sign in with another account",
"ui.userfront.forgot.heading": "Forgot your password?",
"ui.userfront.forgot.input_label": "Email address or phone number",
"ui.userfront.forgot.submit": "Send reset link",

View File

@@ -1,4 +1,6 @@
// ignore_for_file: avoid_print
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -38,6 +40,19 @@ import 'i18n.dart';
final _log = Logger('Main');
Map<String, dynamic>? _decodeErrorDetails(String? raw) {
if (raw == null || raw.trim().isEmpty) {
return null;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return decoded;
}
} catch (_) {}
return null;
}
void _attemptRecoveryFromNullCheck({
required Object exception,
StackTrace? stackTrace,
@@ -398,6 +413,7 @@ final _router = GoRouter(
errorCode: params['error'],
description:
params['error_description'] ?? params['message'],
tenantAccessDetails: _decodeErrorDetails(params['details']),
),
);
},

View File

@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
void main() {
test('tenant_not_allowed consent error routes to dedicated error screen', () {
const error = AuthProxyException(
errorCode: 'tenant_not_allowed',
message: '허용되지 않은 테넌트입니다.',
);
expect(shouldRouteConsentErrorToErrorScreen(error), isTrue);
});
test('generic consent error stays on consent screen', () {
const error = AuthProxyException(
errorCode: 'forbidden',
message: '동의 정보를 가져오지 못했습니다.',
);
expect(shouldRouteConsentErrorToErrorScreen(error), isFalse);
});
}

View File

@@ -9,6 +9,8 @@ Future<void> _pumpErrorScreen(
String? errorCode,
String? description,
bool? isProdOverride,
Future<Map<String, dynamic>> Function()? sessionProfileLoader,
Map<String, dynamic>? tenantAccessDetails,
}) async {
await tester.pumpWidget(
MaterialApp(
@@ -16,6 +18,8 @@ Future<void> _pumpErrorScreen(
errorCode: errorCode,
description: description,
isProdOverride: isProdOverride,
sessionProfileLoader: sessionProfileLoader,
tenantAccessDetails: tenantAccessDetails,
),
),
);
@@ -193,4 +197,79 @@ void main() {
expect(find.text(type), findsOneWidget);
expect(find.text('원문 메시지'), findsNothing);
});
testWidgets('tenant_not_allowed는 전용 차단 정보를 노출한다', (
WidgetTester tester,
) async {
await _pumpErrorScreen(
tester,
errorCode: 'tenant_not_allowed',
description: '원문 메시지',
isProdOverride: true,
sessionProfileLoader: () async {
return {
'email': 'employee@example.com',
'tenant': {'name': 'Baron HQ', 'slug': 'baron-hq'},
};
},
);
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: '다른 계정으로 로그인',
);
expect(find.text(title), findsOneWidget);
expect(find.text(detail), findsOneWidget);
expect(find.text(account), findsOneWidget);
expect(find.text('employee@example.com'), findsOneWidget);
expect(find.text(primaryTenant), findsOneWidget);
expect(find.text(affiliatedTenants), findsOneWidget);
expect(find.text('Baron HQ'), findsNWidgets(2));
expect(find.text(switchAccount), findsOneWidget);
});
testWidgets('tenant_not_allowed는 details를 우선 사용해 계정과 테넌트 정보를 노출한다', (
WidgetTester tester,
) async {
await _pumpErrorScreen(
tester,
errorCode: 'tenant_not_allowed',
isProdOverride: true,
tenantAccessDetails: {
'account': {'email': 'dyddus1210@gmail.com'},
'current_tenant': {'name': 'test1 company', 'slug': 'test1-company'},
'affiliated_tenants': [
{'name': 'test1 company', 'slug': 'test1-company'},
{'name': 'test2 company', 'slug': 'test-company'},
],
'allowed_tenants': [
{'name': 'test4', 'slug': 'test4'},
],
},
);
expect(find.text('dyddus1210@gmail.com'), findsOneWidget);
expect(find.text('test1 company'), findsOneWidget);
expect(find.text('test1 company, test2 company'), findsOneWidget);
expect(find.text('test4'), findsOneWidget);
expect(find.text('알 수 없음'), findsNothing);
});
}