diff --git a/Makefile b/Makefile index 2f11547d..0c688341 100644 --- a/Makefile +++ b/Makefile @@ -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; \ diff --git a/adminfront/package.json b/adminfront/package.json index 0fdd12d0..742cba61 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -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": { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 066d59ae..cebfe201 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -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, ); }); diff --git a/adminfront/vite.config.ts b/adminfront/vite.config.ts index 8176399c..345eba0b 100644 --- a/adminfront/vite.config.ts +++ b/adminfront/vite.config.ts @@ -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"], diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 03d87b7f..428445df 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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 diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 305a38bb..2d701319 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -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) +} diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go new file mode 100644 index 00000000..097be621 --- /dev/null +++ b/backend/internal/handler/client_tenant_access.go @@ -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) +} diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go new file mode 100644 index 00000000..914f726b --- /dev/null +++ b/backend/internal/handler/client_tenant_access_test.go @@ -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"]) +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 1ff6fbc2..af462748 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -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 { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 1a0b8a95..814e498b 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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 != "" { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 8bbc2b50..293c0342 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -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 diff --git a/backend/internal/idp/factory.go b/backend/internal/idp/factory.go index b4c59782..1b75b958 100644 --- a/backend/internal/idp/factory.go +++ b/backend/internal/idp/factory.go @@ -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) } diff --git a/backend/internal/repository/client_consent_repository.go b/backend/internal/repository/client_consent_repository.go index 5e38742d..5555f079 100644 --- a/backend/internal/repository/client_consent_repository.go +++ b/backend/internal/repository/client_consent_repository.go @@ -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 +} diff --git a/backend/internal/repository/client_consent_repository_test.go b/backend/internal/repository/client_consent_repository_test.go new file mode 100644 index 00000000..2e7569f7 --- /dev/null +++ b/backend/internal/repository/client_consent_repository_test.go @@ -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) +} diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index 1ae356d4..fbfbf02a 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -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) } diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 152480e2..2d625e5c 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -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("active"); const [initialStatus, setInitialStatus] = useState("active"); const [redirectUris, setRedirectUris] = useState(""); + const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false); + const [allowedTenantIds, setAllowedTenantIds] = useState([]); + 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 = + 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) => ( @@ -998,6 +1167,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.scopes.description_placeholder", "권한에 대한 설명", )} + disabled={s.locked} /> @@ -1007,6 +1177,7 @@ function ClientGeneralPage() { onCheckedChange={(checked) => updateScope(s.id, "mandatory", checked) } + disabled={s.locked} /> @@ -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} > @@ -1041,6 +1213,222 @@ function ClientGeneralPage() { + + +
+
+ + {t( + "ui.dev.clients.general.tenant_access.title", + "테넌트 접근 제한", + )} + + + {t( + "ui.dev.clients.general.tenant_access.subtitle", + "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.", + )} + +
+
+
+

+ {tenantAccessRestricted + ? t( + "ui.dev.clients.general.tenant_access.enabled", + "제한 있음", + ) + : t( + "ui.dev.clients.general.tenant_access.disabled", + "제한 없음", + )} +

+

+ {t( + "ui.dev.clients.general.tenant_access.title", + "테넌트 접근 제한", + )} +

+
+ +
+
+
+ +

+ {t( + "ui.dev.clients.general.tenant_access.hint", + "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.", + )} +

+ +
+
+ +
+ + { + 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 && ( +
+ {tenantSuggestions.length > 0 ? ( + tenantSuggestions.map((tenant) => ( + + )) + ) : ( +
+ {t( + "ui.dev.clients.general.tenant_access.empty", + "검색 결과가 없습니다.", + )} +
+ )} +
+ )} +
+
+ {tenantAccessRestricted + ? t( + "ui.dev.clients.general.tenant_access.autocomplete_hint", + "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.", + ) + : t( + "ui.dev.clients.general.tenant_access.disabled", + "제한 없음", + )} +
+
+ +
+ +
+ {tenantAccessRestricted && allowedTenantIds.length > 0 ? ( +
+ {selectedAllowedTenants.map((tenant) => ( + + + {tenant.name} + + {tenant.slug} + + + + ))} + {allowedTenantIds + .filter( + (tenantId) => + !selectedAllowedTenants.some( + (tenant) => tenant.id === tenantId, + ), + ) + .map((tenantId) => ( + + + {tenantId} + + + ))} +
+ ) : ( +
+ {tenantAccessRestricted + ? t( + "ui.dev.clients.general.tenant_access.selected_empty", + "아직 선택된 테넌트가 없습니다.", + ) + : t( + "ui.dev.clients.general.tenant_access.disabled", + "제한 없음", + )} +
+ )} +
+
+
+
+
+ {/* 3. Security Settings */} diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 2935c2ba..0a6574f2 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -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; + 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("/tenants", { + params: { limit, offset, parentId }, + }); + return data; +} + export async function fetchClient(clientId: string) { const { data } = await apiClient.get( `/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 & + Partial; export async function fetchMyTenants() { - const { data } = await apiClient.get("/dev/my-tenants"); + const { data } = await apiClient.get("/dev/my-tenants"); return data; } diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index e68489f4..8212652f 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -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" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 6837d1ae..72729806 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -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" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index a62e6746..3ce81004 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -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 = "" diff --git a/locales/en.toml b/locales/en.toml index fa972e5f..3ac1c447 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -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?" diff --git a/locales/ko.toml b/locales/ko.toml index 75acd90b..23104155 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -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 = "비밀번호를 잊으셨나요?" diff --git a/locales/template.toml b/locales/template.toml index a600bedb..5e5e9665 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -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 = "" diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index fe56cfc3..2237686d 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -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' diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index acc22ea8..24907de8 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -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?" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 24f7ce3b..ae1855a4 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -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 = "비밀번호를 잊으셨나요?" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 8f8518a4..08c4fb36 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -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 = "" diff --git a/userfront/lib/core/constants/error_whitelist.dart b/userfront/lib/core/constants/error_whitelist.dart index 07a4ee87..af4e8558 100644 --- a/userfront/lib/core/constants/error_whitelist.dart +++ b/userfront/lib/core/constants/error_whitelist.dart @@ -8,6 +8,7 @@ const Map internalErrorWhitelistMessages = { 'not_found': '요청한 페이지를 찾을 수 없습니다.', 'bad_request': '입력값을 확인해 주세요.', 'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.', + 'tenant_not_allowed': '허용되지 않은 테넌트입니다.', }; const Set oryBypassErrorCodes = { diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 59f3f0d5..651ddd23 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -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 ? rawDetails : null, ); } } finally { @@ -1105,3 +1111,18 @@ class AuthProxyService { } } } + +class AuthProxyException implements Exception { + final String errorCode; + final String message; + final Map? details; + + const AuthProxyException({ + required this.errorCode, + required this.message, + this.details, + }); + + @override + String toString() => message; +} diff --git a/userfront/lib/features/auth/domain/consent_error_routing.dart b/userfront/lib/features/auth/domain/consent_error_routing.dart new file mode 100644 index 00000000..3526352b --- /dev/null +++ b/userfront/lib/features/auth/domain/consent_error_routing.dart @@ -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'; +} diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 46a0ed82..cddf6c5a 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -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> Function(String consentChallenge)? + consentInfoLoader; - const ConsentScreen({super.key, required this.consentChallenge}); + const ConsentScreen({ + super.key, + required this.consentChallenge, + this.consentInfoLoader, + }); @override State createState() => _ConsentScreenState(); @@ -93,9 +102,9 @@ class _ConsentScreenState extends State { Future _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 { _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( diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index 7977c0b6..016d3fe4 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -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> Function()? sessionProfileLoader; + final Map? 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 createState() => _ErrorScreenState(); +} + +class _ErrorScreenState extends State { + Map? _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? 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 _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? 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? 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? details) { + if (details == null) { + return ''; + } + final account = details['account']; + if (account is! Map) { + return ''; + } + return account['email']?.toString().trim() ?? ''; + } + + List _extractAllowedTenantLabels(Map? details) { + if (details == null) { + return const []; + } + final raw = details['allowed_tenants']; + if (raw is! List) { + return const []; + } + + final labels = []; + 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 _extractAffiliatedTenantLabelsFromDetails( + Map? details, + ) { + if (details == null) { + return const []; + } + final raw = details['affiliated_tenants']; + if (raw is! List) { + return const []; + } + + final labels = []; + 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 _extractAffiliatedTenantLabelsFromProfile( + Map? profile, + ) { + if (profile == null) { + return const []; + } + + final labels = []; + final seen = {}; + + 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 _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, + ), + ), + ), + ], + ); + } +} diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 8d7f7d8e..1e94aa0e 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -503,6 +503,21 @@ const Map 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 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 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 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 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 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", diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index f33ef9f0..ac1a801c 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -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? _decodeErrorDetails(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return null; + } + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + 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']), ), ); }, diff --git a/userfront/test/consent_error_routing_test.dart b/userfront/test/consent_error_routing_test.dart new file mode 100644 index 00000000..4373e3d1 --- /dev/null +++ b/userfront/test/consent_error_routing_test.dart @@ -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); + }); +} diff --git a/userfront/test/error_screen_test.dart b/userfront/test/error_screen_test.dart index e80c7454..6c38c57d 100644 --- a/userfront/test/error_screen_test.dart +++ b/userfront/test/error_screen_test.dart @@ -9,6 +9,8 @@ Future _pumpErrorScreen( String? errorCode, String? description, bool? isProdOverride, + Future> Function()? sessionProfileLoader, + Map? tenantAccessDetails, }) async { await tester.pumpWidget( MaterialApp( @@ -16,6 +18,8 @@ Future _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); + }); }