1
0
forked from baron/baron-sso

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3944,6 +3944,70 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
return c.JSON(profile) 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 // GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
return h.resolveCurrentProfile(c) 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) slog.Error("failed to get hydra consent request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
} }
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
// [DEBUG] Hydra 응답 상세 로깅 // [DEBUG] Hydra 응답 상세 로깅
slog.Info("GetConsentRequest Debug", slog.Info("GetConsentRequest Debug",
@@ -5130,6 +5195,17 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
"scopes", consentRequest.RequestedScope, "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에서 기존 동의 내역 확인 (강제 자동 승인 전략) // [New] 로컬 DB에서 기존 동의 내역 확인 (강제 자동 승인 전략)
// Hydra가 skip을 주지 않더라도, 우리 DB에 이미 기록이 있다면 승인 처리함 // Hydra가 skip을 주지 않더라도, 우리 DB에 이미 기록이 있다면 승인 처리함
if !consentRequest.Skip && h.ConsentRepo != nil && consentRequest.Subject != "" { 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) slog.Error("failed to get hydra consent request before accepting", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
} }
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
// 2. 스코프 필터링 (사용자가 선택한 것만 허용) // 2. 스코프 필터링 (사용자가 선택한 것만 허용)
if len(req.GrantScope) > 0 { if len(req.GrantScope) > 0 {
@@ -5332,6 +5409,18 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
} }
consentRequest.RequestedScope = filteredScopes 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에 승인 요청 // 3. Hydra에 승인 요청
if consentRequest.Subject == "" { 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) subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" { if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") 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) { 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")) appEnv := strings.ToLower(os.Getenv("APP_ENV"))
isDev := appEnv == "dev" || appEnv == "development" || appEnv == "" 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 delete(profile.Metadata, "_used_identifier") // Cleanup
} }
// Fetch Tenant Metadata if missing profile = h.hydrateResolvedProfile(c.Context(), profile)
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
}
}
// 4. Save to Redis Cache (Short TTL) // 4. Save to Redis Cache (Short TTL)
// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key // IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key

View File

@@ -1,8 +1,10 @@
package handler package handler
import ( import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -13,6 +15,121 @@ import (
"github.com/stretchr/testify/mock" "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 --- // --- Test Helpers ---
func newConsentTestApp(h *AuthHandler) *fiber.App { func newConsentTestApp(h *AuthHandler) *fiber.App {
@@ -69,6 +186,87 @@ func TestGetConsentRequest_Normal(t *testing.T) {
assert.Equal(t, false, body["skip"]) 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) { func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// Hydra: Get Consent Request // Hydra: Get Consent Request
@@ -107,16 +305,17 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
defer func() { http.DefaultClient = origDefault }() defer func() { http.DefaultClient = origDefault }()
consentRepo := &mockConsentRepo{} consentRepo := &mockConsentRepo{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{ h := &AuthHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: client, HTTPClient: client,
}, },
KratosAdmin: new(MockKratosAdminService), // Reusing MockKratosAdminService if defined or use MockKratosAdminServiceShared KratosAdmin: mockKratosAdmin,
ConsentRepo: consentRepo, 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", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]interface{}{
"email": "user@test.com", "email": "user@test.com",
@@ -143,7 +342,8 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
"requested_scope": []string{"openid", "profile"}, "requested_scope": []string{"openid", "profile"},
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]interface{}{
"client_id": "client-app", "client_id": "client-app",
"client_name": "Test App",
}, },
}), nil }), nil
} }
@@ -170,17 +370,18 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
consentRepo := &mockConsentRepo{} consentRepo := &mockConsentRepo{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{ h := &AuthHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: client, HTTPClient: client,
}, },
KratosAdmin: new(MockKratosAdminService), KratosAdmin: mockKratosAdmin,
AuditRepo: auditRepo, AuditRepo: auditRepo,
ConsentRepo: consentRepo, 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", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]interface{}{
"email": "user@test.com", "email": "user@test.com",
@@ -202,3 +403,88 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
assert.Equal(t, 1, len(auditRepo.logs)) assert.Equal(t, 1, len(auditRepo.logs))
} }
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
t.Setenv("APP_ENV", "dev")
var capturedGrantScopes []string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"challenge": "challenge-tenant-accept",
"requested_scope": []string{"openid", "profile"},
"subject": "user-123",
"client": map[string]interface{}{
"client_id": "client-app",
"metadata": map[string]any{
"tenant_id": "tenant-abc",
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-abc"},
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
},
}), nil
}
if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"id": "user-123",
"traits": map[string]interface{}{
"email": "user@test.com",
},
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
var payload map[string]any
assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
for _, scope := range payload["grant_scope"].([]interface{}) {
capturedGrantScopes = append(capturedGrantScopes, scope.(string))
}
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: mockKratosAdmin,
}
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@test.com",
},
}, nil)
app := newConsentTestApp(h)
body, _ := json.Marshal(map[string]interface{}{
"consent_challenge": "challenge-tenant-accept",
"grant_scope": []string{"openid", "profile"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Mock-Role", "user")
req.Header.Set("X-Tenant-ID", "tenant-abc")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes)
}

View File

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

View File

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

View File

@@ -155,6 +155,22 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]
return results, nil 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) { func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error) {
for _, consent := range m.consents { for _, consent := range m.consents {
if consent.ClientID == clientID && consent.Subject == subject { 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) 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) { func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents)) results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
for _, consent := range m.consents { for _, consent := range m.consents {

View File

@@ -618,6 +618,47 @@ func isProtectedSystemClientID(clientID string) bool {
return ok 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 { func isProtectedSystemClient(client domain.HydraClient) bool {
return isProtectedSystemClientID(client.ClientID) return isProtectedSystemClientID(client.ClientID)
} }
@@ -1528,6 +1569,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
} }
metadata["status"] = status metadata["status"] = status
metadata["created_at"] = time.Now().Format(time.RFC3339) 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, "")) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" { if tokenAuthMethod == "" {
@@ -1716,6 +1762,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
} }
metadata["status"] = status metadata["status"] = status
} }
metadata, err = normalizeClientTenantAccessMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
resolvedClientType := currentSummary.Type resolvedClientType := currentSummary.Type
if clientType != "" { if clientType != "" {
resolvedClientType = clientType resolvedClientType = clientType
@@ -1758,6 +1808,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error()) return errorJSON(c, fiber.StatusForbidden, err.Error())
} }
tenantPolicyChanged := tenantAccessPolicyChanged(current.Metadata, updated.Metadata)
h.setAuditDetailsExtra(c, map[string]any{ h.setAuditDetailsExtra(c, map[string]any{
"action": "UPDATE_CLIENT", "action": "UPDATE_CLIENT",
@@ -1779,6 +1830,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) 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") h.syncHeadlessJWKSCache(c.Context(), *updatedClient, "client_update")
if updatedClient.ClientSecret != "" { if updatedClient.ClientSecret != "" {

View File

@@ -1662,6 +1662,167 @@ func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) {
assert.False(t, hasRequestObjectAlg) 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) { func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) {
privateKey, jwks := mustHeadlessRSAJWK(t) privateKey, jwks := mustHeadlessRSAJWK(t)
_ = privateKey _ = privateKey

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
} }
// Auto-migrate // 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 { if err != nil {
log.Fatalf("failed to migrate database: %s", err) log.Fatalf("failed to migrate database: %s", err)
} }

View File

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

View File

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

View File

@@ -419,6 +419,7 @@ help = "Enter the redirect URIs. You can modify them in the Federation tab after
[msg.dev.clients.general.scopes] [msg.dev.clients.general.scopes]
empty = "No scopes registered." empty = "No scopes registered."
subtitle = "Define the permission scopes this application can request." subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim"
[msg.dev.clients.general.security] [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." 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" mandatory = "Mandatory"
name = "Scope Name" name = "Scope Name"
delete = "Delete" 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] [ui.dev.clients.general.security]
private = "Server Side App" private = "Server Side App"

View File

@@ -419,6 +419,7 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동
[msg.dev.clients.general.scopes] [msg.dev.clients.general.scopes]
empty = "등록된 스코프가 없습니다." empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.security] [msg.dev.clients.general.security]
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
@@ -1435,6 +1436,20 @@ description = "설명"
mandatory = "필수" mandatory = "필수"
name = "스코프 이름" name = "스코프 이름"
delete = "삭제" 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] [ui.dev.clients.general.security]
private = "Server side App" private = "Server side App"

View File

@@ -465,6 +465,7 @@ help = ""
[msg.dev.clients.general.scopes] [msg.dev.clients.general.scopes]
empty = "" empty = ""
subtitle = "" subtitle = ""
tenant = ""
[msg.dev.clients.general.security] [msg.dev.clients.general.security]
private_help = "" private_help = ""
@@ -1518,6 +1519,20 @@ description = ""
mandatory = "" mandatory = ""
name = "" name = ""
delete = "" 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] [ui.dev.clients.general.security]
private = "" private = ""

View File

@@ -650,6 +650,22 @@ title_generic = "An error occurred."
title_with_code = "Error: {{code}}" title_with_code = "Error: {{code}}"
type = "Error type: {{type}}" 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] [msg.userfront.error.ory]
"$normalizedCode" = "{{error}}" "$normalizedCode" = "{{error}}"
access_denied = "The user denied the consent request." access_denied = "The user denied the consent request."
@@ -2239,6 +2255,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error] [ui.userfront.error]
go_home = "Go Home" go_home = "Go Home"
go_login = "Go Login" go_login = "Go Login"
switch_account = "Sign in with another account"
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "Forgot your password?" heading = "Forgot your password?"

View File

@@ -208,6 +208,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {{code}}" title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}" 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] [msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
@@ -456,6 +472,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error] [ui.userfront.error]
go_home = "홈으로 이동" go_home = "홈으로 이동"
go_login = "로그인으로 이동" go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?" heading = "비밀번호를 잊으셨나요?"
@@ -1088,6 +1105,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {{code}}" title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}" 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] [msg.userfront.error.ory]
"$normalizedCode" = "{{error}}" "$normalizedCode" = "{{error}}"
access_denied = "사용자가 동의를 거부했습니다." access_denied = "사용자가 동의를 거부했습니다."
@@ -2638,6 +2671,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error] [ui.userfront.error]
go_home = "홈으로 이동" go_home = "홈으로 이동"
go_login = "로그인으로 이동" go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?" heading = "비밀번호를 잊으셨나요?"

View File

@@ -83,6 +83,22 @@ title_generic = ""
title_with_code = "" title_with_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] [msg.userfront.forgot]
description = "" description = ""
dry_send = "" dry_send = ""
@@ -331,6 +347,7 @@ windows = ""
[ui.userfront.error] [ui.userfront.error]
go_home = "" go_home = ""
go_login = "" go_login = ""
switch_account = ""
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "" heading = ""
@@ -963,6 +980,22 @@ title_generic = ""
title_with_code = "" title_with_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] [msg.userfront.error.ory]
"$normalizedCode" = "" "$normalizedCode" = ""
access_denied = "" access_denied = ""
@@ -2513,6 +2546,7 @@ windows = ""
[ui.userfront.error] [ui.userfront.error]
go_home = "" go_home = ""
go_login = "" go_login = ""
switch_account = ""
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "" heading = ""

View File

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

View File

@@ -137,6 +137,22 @@ title_generic = "An error occurred."
title_with_code = "Error: {code}" title_with_code = "Error: {code}"
type = "Error type: {type}" 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] [msg.userfront.error.ory]
"$normalizedCode" = "{error}" "$normalizedCode" = "{error}"
access_denied = "The user denied the consent request." access_denied = "The user denied the consent request."
@@ -506,6 +522,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error] [ui.userfront.error]
go_home = "Go Home" go_home = "Go Home"
go_login = "Go Login" go_login = "Go Login"
switch_account = "Sign in with another account"
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "Forgot your password?" heading = "Forgot your password?"

View File

@@ -78,6 +78,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}" title_with_code = "오류: {code}"
type = "오류 종류: {type}" 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] [msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
@@ -190,6 +206,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error] [ui.userfront.error]
go_home = "홈으로 이동" go_home = "홈으로 이동"
go_login = "로그인으로 이동" go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?" heading = "비밀번호를 잊으셨나요?"
@@ -344,6 +361,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}" title_with_code = "오류: {code}"
type = "오류 종류: {type}" 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] [msg.userfront.error.ory]
"$normalizedCode" = "{error}" "$normalizedCode" = "{error}"
access_denied = "사용자가 동의를 거부했습니다." access_denied = "사용자가 동의를 거부했습니다."
@@ -711,6 +744,7 @@ windows = "Desktop(Windows)"
[ui.userfront.error] [ui.userfront.error]
go_home = "홈으로 이동" go_home = "홈으로 이동"
go_login = "로그인으로 이동" go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?" heading = "비밀번호를 잊으셨나요?"

View File

@@ -50,6 +50,22 @@ title_generic = ""
title_with_code = "" title_with_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] [msg.userfront.forgot]
description = "" description = ""
dry_send = "" dry_send = ""
@@ -162,6 +178,7 @@ windows = ""
[ui.userfront.error] [ui.userfront.error]
go_home = "" go_home = ""
go_login = "" go_login = ""
switch_account = ""
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "" heading = ""
@@ -316,6 +333,22 @@ title_generic = ""
title_with_code = "" title_with_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] [msg.userfront.error.ory]
"$normalizedCode" = "" "$normalizedCode" = ""
access_denied = "" access_denied = ""
@@ -683,6 +716,7 @@ windows = ""
[ui.userfront.error] [ui.userfront.error]
go_home = "" go_home = ""
go_login = "" go_login = ""
switch_account = ""
[ui.userfront.forgot] [ui.userfront.forgot]
heading = "" heading = ""

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.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/auth_proxy_service.dart';
import 'package:userfront/core/services/web_window.dart'; import 'package:userfront/core/services/web_window.dart';
import 'package:userfront/core/ui/toast_service.dart'; import 'package:userfront/core/ui/toast_service.dart';
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
class ConsentScreen extends StatefulWidget { class ConsentScreen extends StatefulWidget {
final String consentChallenge; final String consentChallenge;
final Future<Map<String, dynamic>> Function(String consentChallenge)?
consentInfoLoader;
const ConsentScreen({super.key, required this.consentChallenge}); const ConsentScreen({
super.key,
required this.consentChallenge,
this.consentInfoLoader,
});
@override @override
State<ConsentScreen> createState() => _ConsentScreenState(); State<ConsentScreen> createState() => _ConsentScreenState();
@@ -93,9 +102,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async { Future<void> _fetchConsentInfo() async {
try { try {
final info = await AuthProxyService.getConsentInfo( final loader =
widget.consentChallenge, widget.consentInfoLoader ?? AuthProxyService.getConsentInfo;
); final info = await loader(widget.consentChallenge);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동 // [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) { if (info['redirectTo'] != null) {
@@ -139,6 +148,35 @@ class _ConsentScreenState extends State<ConsentScreen> {
_consentInfo = info; _consentInfo = info;
_isLoading = false; _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) { } catch (e) {
setState(() { setState(() {
_error = tr( _error = tr(

View File

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

View File

@@ -503,6 +503,21 @@ const Map<String, String> koStrings = {
"msg.userfront.error.title_generic": "오류가 발생했습니다", "msg.userfront.error.title_generic": "오류가 발생했습니다",
"msg.userfront.error.title_with_code": "오류: {{code}}", "msg.userfront.error.title_with_code": "오류: {{code}}",
"msg.userfront.error.type": "오류 종류: {{type}}", "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.\"\$normalizedCode\"": "{{error}}",
"msg.userfront.error.whitelist.bad_request": "입력값을 확인해 주세요.", "msg.userfront.error.whitelist.bad_request": "입력값을 확인해 주세요.",
"msg.userfront.error.whitelist.invalid_session": "세션이 만료되었습니다. 다시 로그인해 주세요.", "msg.userfront.error.whitelist.invalid_session": "세션이 만료되었습니다. 다시 로그인해 주세요.",
@@ -514,6 +529,7 @@ const Map<String, String> koStrings = {
"재설정 링크가 만료되었습니다. 다시 요청해 주세요.", "재설정 링크가 만료되었습니다. 다시 요청해 주세요.",
"msg.userfront.error.whitelist.recovery_invalid": "재설정 링크가 유효하지 않습니다.", "msg.userfront.error.whitelist.recovery_invalid": "재설정 링크가 유효하지 않습니다.",
"msg.userfront.error.whitelist.settings_disabled": "현재 계정 설정 화면은 준비 중입니다.", "msg.userfront.error.whitelist.settings_disabled": "현재 계정 설정 화면은 준비 중입니다.",
"msg.userfront.error.whitelist.tenant_not_allowed": "허용되지 않은 테넌트입니다.",
"msg.userfront.error.whitelist.verification_required": "msg.userfront.error.whitelist.verification_required":
"추가 인증이 필요합니다. 안내에 따라 진행해 주세요.", "추가 인증이 필요합니다. 안내에 따라 진행해 주세요.",
"msg.userfront.forgot.description": "msg.userfront.forgot.description":
@@ -1738,6 +1754,7 @@ const Map<String, String> koStrings = {
"ui.userfront.device.windows": "Desktop(Windows)", "ui.userfront.device.windows": "Desktop(Windows)",
"ui.userfront.error.go_home": "홈으로 이동", "ui.userfront.error.go_home": "홈으로 이동",
"ui.userfront.error.go_login": "로그인으로 이동", "ui.userfront.error.go_login": "로그인으로 이동",
"ui.userfront.error.switch_account": "다른 계정으로 로그인",
"ui.userfront.forgot.heading": "비밀번호를 잊으셨나요?", "ui.userfront.forgot.heading": "비밀번호를 잊으셨나요?",
"ui.userfront.forgot.input_label": "이메일 또는 휴대폰 번호", "ui.userfront.forgot.input_label": "이메일 또는 휴대폰 번호",
"ui.userfront.forgot.submit": "재설정 링크 전송", "ui.userfront.forgot.submit": "재설정 링크 전송",
@@ -2436,6 +2453,24 @@ const Map<String, String> enStrings = {
"msg.userfront.error.title_generic": "An error occurred.", "msg.userfront.error.title_generic": "An error occurred.",
"msg.userfront.error.title_with_code": "Error: {{code}}", "msg.userfront.error.title_with_code": "Error: {{code}}",
"msg.userfront.error.type": "Error type: {{type}}", "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.\"\$normalizedCode\"": "{{error}}",
"msg.userfront.error.whitelist.bad_request": "Please check your input.", "msg.userfront.error.whitelist.bad_request": "Please check your input.",
"msg.userfront.error.whitelist.invalid_session": "msg.userfront.error.whitelist.invalid_session":
@@ -2452,6 +2487,8 @@ const Map<String, String> enStrings = {
"The recovery link is invalid.", "The recovery link is invalid.",
"msg.userfront.error.whitelist.settings_disabled": "msg.userfront.error.whitelist.settings_disabled":
"Account settings are currently unavailable.", "Account settings are currently unavailable.",
"msg.userfront.error.whitelist.tenant_not_allowed":
"This tenant is not allowed.",
"msg.userfront.error.whitelist.verification_required": "msg.userfront.error.whitelist.verification_required":
"Additional verification is required. Please follow the instructions.", "Additional verification is required. Please follow the instructions.",
"msg.userfront.forgot.description": "msg.userfront.forgot.description":
@@ -3752,6 +3789,7 @@ const Map<String, String> enStrings = {
"ui.userfront.device.windows": "Desktop(Windows)", "ui.userfront.device.windows": "Desktop(Windows)",
"ui.userfront.error.go_home": "Go Home", "ui.userfront.error.go_home": "Go Home",
"ui.userfront.error.go_login": "Go Login", "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.heading": "Forgot your password?",
"ui.userfront.forgot.input_label": "Email address or phone number", "ui.userfront.forgot.input_label": "Email address or phone number",
"ui.userfront.forgot.submit": "Send reset link", "ui.userfront.forgot.submit": "Send reset link",

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ Future<void> _pumpErrorScreen(
String? errorCode, String? errorCode,
String? description, String? description,
bool? isProdOverride, bool? isProdOverride,
Future<Map<String, dynamic>> Function()? sessionProfileLoader,
Map<String, dynamic>? tenantAccessDetails,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@@ -16,6 +18,8 @@ Future<void> _pumpErrorScreen(
errorCode: errorCode, errorCode: errorCode,
description: description, description: description,
isProdOverride: isProdOverride, isProdOverride: isProdOverride,
sessionProfileLoader: sessionProfileLoader,
tenantAccessDetails: tenantAccessDetails,
), ),
), ),
); );
@@ -193,4 +197,79 @@ void main() {
expect(find.text(type), findsOneWidget); expect(find.text(type), findsOneWidget);
expect(find.text('원문 메시지'), findsNothing); 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);
});
} }