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:
11
Makefile
11
Makefile
@@ -181,13 +181,8 @@ PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTAL
|
||||
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
|
||||
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
|
||||
|
||||
ifeq ($(CI),)
|
||||
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
|
||||
else
|
||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
|
||||
endif
|
||||
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||
|
||||
@@ -254,12 +249,12 @@ code-check-userfront-lint:
|
||||
code-check-front-lint:
|
||||
@echo "==> adminfront biome lint/format check"
|
||||
rm -rf adminfront/playwright-report adminfront/test-results
|
||||
cd adminfront && npm ci
|
||||
cd adminfront && npm ci --ignore-scripts
|
||||
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
@echo "==> devfront biome lint/format check"
|
||||
rm -rf devfront/playwright-report devfront/test-results
|
||||
cd devfront && npm ci
|
||||
cd devfront && npm ci --ignore-scripts
|
||||
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
@@ -298,7 +293,7 @@ code-check-devfront-tests:
|
||||
@mkdir -p reports/devfront
|
||||
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
|
||||
@status=0; \
|
||||
(cd devfront && npm ci) || status=$$?; \
|
||||
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
"lint:fix": "biome check . --write",
|
||||
"format": "biome format . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "npx playwright test",
|
||||
"test": "node ./node_modules/playwright/cli.js test",
|
||||
"test:unit": "vitest run",
|
||||
"test:ui": "npx playwright test --ui",
|
||||
"test:ui": "node ./node_modules/playwright/cli.js test --ui",
|
||||
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -130,7 +130,7 @@ test.describe("Tenants Management", () => {
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toContainText(
|
||||
/조직 테넌트.*사용자|organization tenants.*users/i,
|
||||
/조직\/사용자 통합 일괄 등록|organization and user batch registration/i,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const buildOutDir =
|
||||
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
envPrefix: ["VITE_", "USERFRONT_"],
|
||||
build: {
|
||||
outDir: buildOutDir,
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||
|
||||
@@ -3944,6 +3944,70 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
||||
return c.JSON(profile)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveProfileForSubject(ctx context.Context, subject string) (*domain.UserProfileResponse, error) {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" || h.KratosAdmin == nil {
|
||||
return nil, fmt.Errorf("subject profile unavailable")
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if identity == nil {
|
||||
return nil, fmt.Errorf("identity not found")
|
||||
}
|
||||
|
||||
profile := h.mapKratosIdentityToProfile(identity.ID, identity.Traits)
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("failed to map identity profile")
|
||||
}
|
||||
return h.hydrateResolvedProfile(ctx, profile), nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domain.UserProfileResponse) *domain.UserProfileResponse {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
profile.Role = domain.NormalizeRole(profile.Role)
|
||||
if profile.Role == "" {
|
||||
profile.Role = domain.RoleUser
|
||||
}
|
||||
|
||||
if h.TenantService != nil {
|
||||
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
|
||||
if tenant, err := h.TenantService.GetTenant(ctx, *profile.TenantID); err == nil {
|
||||
profile.Tenant = tenant
|
||||
}
|
||||
}
|
||||
if profile.Tenant == nil && profile.CompanyCode != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(ctx, profile.CompanyCode); err == nil && tenant != nil {
|
||||
profile.Tenant = tenant
|
||||
if profile.TenantID == nil || *profile.TenantID == "" {
|
||||
profile.TenantID = &tenant.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if h.TenantService != nil {
|
||||
if profile.Role == domain.RoleTenantAdmin {
|
||||
manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID)
|
||||
if err == nil {
|
||||
profile.ManageableTenants = manageable
|
||||
}
|
||||
}
|
||||
|
||||
joined, err := h.TenantService.ListJoinedTenants(ctx, profile.ID)
|
||||
if err == nil {
|
||||
profile.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
|
||||
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||
return h.resolveCurrentProfile(c)
|
||||
@@ -5120,6 +5184,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
slog.Error("failed to get hydra consent request", "error", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
|
||||
}
|
||||
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
|
||||
|
||||
// [DEBUG] Hydra 응답 상세 로깅
|
||||
slog.Info("GetConsentRequest Debug",
|
||||
@@ -5130,6 +5195,17 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
"scopes", consentRequest.RequestedScope,
|
||||
)
|
||||
|
||||
profile, err := h.resolveCurrentProfile(c)
|
||||
if (err != nil || profile == nil) && consentRequest.Subject != "" {
|
||||
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
|
||||
profile = fallbackProfile
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// [New] 로컬 DB에서 기존 동의 내역 확인 (강제 자동 승인 전략)
|
||||
// Hydra가 skip을 주지 않더라도, 우리 DB에 이미 기록이 있다면 승인 처리함
|
||||
if !consentRequest.Skip && h.ConsentRepo != nil && consentRequest.Subject != "" {
|
||||
@@ -5316,6 +5392,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
slog.Error("failed to get hydra consent request before accepting", "error", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
|
||||
}
|
||||
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
|
||||
|
||||
// 2. 스코프 필터링 (사용자가 선택한 것만 허용)
|
||||
if len(req.GrantScope) > 0 {
|
||||
@@ -5332,6 +5409,18 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
consentRequest.RequestedScope = filteredScopes
|
||||
}
|
||||
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
|
||||
|
||||
profile, err := h.resolveCurrentProfile(c)
|
||||
if (err != nil || profile == nil) && consentRequest.Subject != "" {
|
||||
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
|
||||
profile = fallbackProfile
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. Hydra에 승인 요청
|
||||
if consentRequest.Subject == "" {
|
||||
@@ -5470,6 +5559,19 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
profile, err := h.resolveCurrentProfile(c)
|
||||
if (err != nil || profile == nil) && loginReq != nil && strings.TrimSpace(loginReq.Subject) != "" {
|
||||
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), loginReq.Subject); fallbackErr == nil {
|
||||
profile = fallbackProfile
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if loginReq != nil {
|
||||
if enforceClientTenantAccess(c, h.TenantService, loginReq.Client, profile, err) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
subject, err := h.resolveConsentSubject(c)
|
||||
if err != nil || subject == "" {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
||||
@@ -5520,6 +5622,10 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
|
||||
|
||||
@@ -5607,35 +5713,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
delete(profile.Metadata, "_used_identifier") // Cleanup
|
||||
}
|
||||
|
||||
// Fetch Tenant Metadata if missing
|
||||
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
|
||||
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
|
||||
profile.Tenant = tenant
|
||||
}
|
||||
}
|
||||
if profile.Tenant == nil && profile.CompanyCode != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
|
||||
profile.Tenant = tenant
|
||||
if profile.TenantID == nil || *profile.TenantID == "" {
|
||||
profile.TenantID = &tenant.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Fetch manageable and joined tenants
|
||||
if h.TenantService != nil {
|
||||
if profile.Role == domain.RoleTenantAdmin {
|
||||
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.ManageableTenants = manageable
|
||||
}
|
||||
}
|
||||
|
||||
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
profile = h.hydrateResolvedProfile(c.Context(), profile)
|
||||
|
||||
// 4. Save to Redis Cache (Short TTL)
|
||||
// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,6 +15,121 @@ import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
type MockKratosAdminServiceForConsent struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
args := m.Called(ctx, identifier)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*service.KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]interface{}) (*service.KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceForConsent) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type MockTenantServiceForConsent struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ApproveTenant(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) SetKetoService(keto service.KetoService) {}
|
||||
|
||||
func (m *MockTenantServiceForConsent) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Test Helpers ---
|
||||
|
||||
func newConsentTestApp(h *AuthHandler) *fiber.App {
|
||||
@@ -69,6 +186,87 @@ func TestGetConsentRequest_Normal(t *testing.T) {
|
||||
assert.Equal(t, false, body["skip"])
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-scope" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"challenge": "challenge-tenant-scope",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"skip": false,
|
||||
"subject": "user-123",
|
||||
"client": map[string]interface{}{
|
||||
"client_id": "client-app",
|
||||
"client_name": "Test App",
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-allow"},
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
mockTenantSvc := &MockTenantServiceForConsent{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
// Mock profile resolution to allow tenant access
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, "tenant-allow").Return(&domain.Tenant{
|
||||
ID: "tenant-allow",
|
||||
Slug: "tenant-allow",
|
||||
Name: "Allowed Tenant",
|
||||
}, nil)
|
||||
|
||||
// Mock hydration calls
|
||||
mockTenantSvc.On("ListJoinedTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{
|
||||
{ID: "tenant-allow", Slug: "tenant-allow", Name: "Allowed Tenant"},
|
||||
}, nil)
|
||||
mockTenantSvc.On("ListManageableTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{}, nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
TenantService: mockTenantSvc,
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
}
|
||||
app := newConsentTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-tenant-scope", nil)
|
||||
req.Header.Set("X-Mock-Role", "user")
|
||||
req.Header.Set("X-Tenant-ID", "tenant-allow")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
|
||||
assert.Equal(t, []interface{}{"openid", "tenant", "profile"}, body["requested_scope"])
|
||||
scopeDetails := body["scope_details"].(map[string]interface{})
|
||||
tenantDetail := scopeDetails["tenant"].(map[string]interface{})
|
||||
assert.Equal(t, true, tenantDetail["mandatory"])
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
// Hydra: Get Consent Request
|
||||
@@ -107,16 +305,17 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
consentRepo := &mockConsentRepo{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService), // Reusing MockKratosAdminService if defined or use MockKratosAdminServiceShared
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
ConsentRepo: consentRepo,
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
@@ -143,7 +342,8 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]interface{}{
|
||||
"client_id": "client-app",
|
||||
"client_id": "client-app",
|
||||
"client_name": "Test App",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
@@ -170,17 +370,18 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
consentRepo := &mockConsentRepo{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
AuditRepo: auditRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
@@ -202,3 +403,88 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 1, len(auditRepo.logs))
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "dev")
|
||||
|
||||
var capturedGrantScopes []string
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"challenge": "challenge-tenant-accept",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]interface{}{
|
||||
"client_id": "client-app",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-abc",
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-abc"},
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/admin/identities/user-123" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"id": "user-123",
|
||||
"traits": map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
|
||||
var payload map[string]any
|
||||
assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||
for _, scope := range payload["grant_scope"].([]interface{}) {
|
||||
capturedGrantScopes = append(capturedGrantScopes, scope.(string))
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"redirect_to": "http://rp/cb",
|
||||
}), nil
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = client
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
}
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := newConsentTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"consent_challenge": "challenge-tenant-accept",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Mock-Role", "user")
|
||||
req.Header.Set("X-Tenant-ID", "tenant-abc")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes)
|
||||
}
|
||||
|
||||
424
backend/internal/handler/client_tenant_access.go
Normal file
424
backend/internal/handler/client_tenant_access.go
Normal 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)
|
||||
}
|
||||
386
backend/internal/handler/client_tenant_access_test.go
Normal file
386
backend/internal/handler/client_tenant_access_test.go
Normal 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"])
|
||||
}
|
||||
@@ -155,6 +155,22 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error) {
|
||||
seen := map[string]struct{}{}
|
||||
subjects := make([]string, 0, len(m.consents))
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[consent.Subject]; ok {
|
||||
continue
|
||||
}
|
||||
seen[consent.Subject] = struct{}{}
|
||||
subjects = append(subjects, consent.Subject)
|
||||
}
|
||||
return subjects, nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error) {
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID == clientID && consent.Subject == subject {
|
||||
@@ -167,6 +183,17 @@ func (m *mockConsentRepo) Find(ctx context.Context, clientID, subject string) (*
|
||||
|
||||
func (m *mockConsentRepo) Delete(ctx context.Context, subject, clientID string) error { return nil }
|
||||
|
||||
func (m *mockConsentRepo) DeleteByClient(ctx context.Context, clientID string) error {
|
||||
filtered := m.consents[:0]
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID != clientID {
|
||||
filtered = append(filtered, consent)
|
||||
}
|
||||
}
|
||||
m.consents = filtered
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
|
||||
for _, consent := range m.consents {
|
||||
|
||||
@@ -618,6 +618,47 @@ func isProtectedSystemClientID(clientID string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func tenantAccessPolicyChanged(before, after map[string]interface{}) bool {
|
||||
if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) {
|
||||
return true
|
||||
}
|
||||
|
||||
beforeAllowed := clientAllowedTenants(before)
|
||||
afterAllowed := clientAllowedTenants(after)
|
||||
if len(beforeAllowed) != len(afterAllowed) {
|
||||
return true
|
||||
}
|
||||
for i := range beforeAllowed {
|
||||
if beforeAllowed[i] != afterAllowed[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *DevHandler) revokeClientConsentsForPolicyChange(ctx context.Context, clientID string) error {
|
||||
if h.ConsentRepo == nil || h.Hydra == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
subjects, err := h.ConsentRepo.ListSubjectsByClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, subject := range subjects {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.Hydra.RevokeConsentSessions(ctx, subject, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return h.ConsentRepo.DeleteByClient(ctx, clientID)
|
||||
}
|
||||
|
||||
func isProtectedSystemClient(client domain.HydraClient) bool {
|
||||
return isProtectedSystemClientID(client.ClientID)
|
||||
}
|
||||
@@ -1528,6 +1569,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
metadata["status"] = status
|
||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||
var err error
|
||||
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||
if tokenAuthMethod == "" {
|
||||
@@ -1716,6 +1762,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
metadata["status"] = status
|
||||
}
|
||||
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
resolvedClientType := currentSummary.Type
|
||||
if clientType != "" {
|
||||
resolvedClientType = clientType
|
||||
@@ -1758,6 +1808,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
||||
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
||||
}
|
||||
tenantPolicyChanged := tenantAccessPolicyChanged(current.Metadata, updated.Metadata)
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "UPDATE_CLIENT",
|
||||
@@ -1779,6 +1830,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if tenantPolicyChanged {
|
||||
if err := h.revokeClientConsentsForPolicyChange(c.Context(), clientID); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to revoke existing consents after tenant policy update: "+err.Error())
|
||||
}
|
||||
}
|
||||
h.syncHeadlessJWKSCache(c.Context(), *updatedClient, "client_update")
|
||||
|
||||
if updatedClient.ClientSecret != "" {
|
||||
|
||||
@@ -1662,6 +1662,167 @@ func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) {
|
||||
assert.False(t, hasRequestObjectAlg)
|
||||
}
|
||||
|
||||
func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.T) {
|
||||
var revokedSubjects []string
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
|
||||
ClientID: "client-1",
|
||||
ClientName: "Tenant Guarded App",
|
||||
RedirectURIs: []string{"https://rp.example.com/callback"},
|
||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||
ResponseTypes: []string{"code"},
|
||||
Scope: "openid tenant profile email",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-a"},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||
var updated domain.HydraClient
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, json.Unmarshal(body, &updated))
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": updated.ClientID,
|
||||
"client_name": updated.ClientName,
|
||||
"redirect_uris": updated.RedirectURIs,
|
||||
"grant_types": updated.GrantTypes,
|
||||
"response_types": updated.ResponseTypes,
|
||||
"scope": updated.Scope,
|
||||
"token_endpoint_auth_method": updated.TokenEndpointAuthMethod,
|
||||
"metadata": updated.Metadata,
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
revokedSubjects = append(revokedSubjects, r.URL.Query().Get("subject"))
|
||||
assert.Equal(t, "client-1", r.URL.Query().Get("client"))
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
consentRepo := &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{ClientID: "client-1", Subject: "user-1"},
|
||||
{ClientID: "client-1", Subject: "user-2"},
|
||||
{ClientID: "other-client", Subject: "user-3"},
|
||||
},
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-b"},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.ElementsMatch(t, []string{"user-1", "user-2"}, revokedSubjects)
|
||||
assert.Len(t, consentRepo.consents, 1)
|
||||
assert.Equal(t, "other-client", consentRepo.consents[0].ClientID)
|
||||
}
|
||||
|
||||
func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.T) {
|
||||
revoked := false
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
|
||||
ClientID: "client-1",
|
||||
ClientName: "Tenant Guarded App",
|
||||
RedirectURIs: []string{"https://rp.example.com/callback"},
|
||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||
ResponseTypes: []string{"code"},
|
||||
Scope: "openid tenant profile email",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-a"},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||
var updated domain.HydraClient
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, json.Unmarshal(body, &updated))
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": updated.ClientID,
|
||||
"client_name": updated.ClientName,
|
||||
"redirect_uris": updated.RedirectURIs,
|
||||
"grant_types": updated.GrantTypes,
|
||||
"response_types": updated.ResponseTypes,
|
||||
"scope": updated.Scope,
|
||||
"token_endpoint_auth_method": updated.TokenEndpointAuthMethod,
|
||||
"metadata": updated.Metadata,
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
revoked = true
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
consentRepo := &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{ClientID: "client-1", Subject: "user-1"},
|
||||
},
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Renamed App",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.False(t, revoked)
|
||||
assert.Len(t, consentRepo.consents, 1)
|
||||
}
|
||||
|
||||
func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) {
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
_ = privateKey
|
||||
|
||||
@@ -13,47 +13,47 @@ import (
|
||||
|
||||
// Ory 계열(kratos/hydra) 공급자 문자열을 정규화하기 위한 매핑.
|
||||
var providerAliases = map[string]string{
|
||||
"ory": "ory",
|
||||
"hydra": "ory",
|
||||
"kratos": "ory",
|
||||
"ory-kratos": "ory",
|
||||
"ory_hydra": "ory",
|
||||
"ory_kratos": "ory",
|
||||
"ory": "ory",
|
||||
"hydra": "ory",
|
||||
"kratos": "ory",
|
||||
"ory-kratos": "ory",
|
||||
"ory_hydra": "ory",
|
||||
"ory_kratos": "ory",
|
||||
}
|
||||
|
||||
// getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// InitializeProvider는 환경 설정을 기반으로 IDP 공급자를 생성하고 반환합니다.
|
||||
// 이것은 IdentityProvider 인터페이스의 팩토리 역할을 합니다.
|
||||
func InitializeProvider() (domain.IdentityProvider, error) {
|
||||
rawProviders := getEnv("IDP_PROVIDER", "ory")
|
||||
providers := strings.Split(rawProviders, ",")
|
||||
slog.Info("Initializing IDP chain", "providers", rawProviders)
|
||||
rawProviders := getEnv("IDP_PROVIDER", "ory")
|
||||
providers := strings.Split(rawProviders, ",")
|
||||
slog.Info("Initializing IDP chain", "providers", rawProviders)
|
||||
|
||||
var initialized []domain.IdentityProvider
|
||||
for _, p := range providers {
|
||||
providerName := strings.TrimSpace(strings.ToLower(p))
|
||||
if canonical, ok := providerAliases[providerName]; ok {
|
||||
providerName = canonical
|
||||
}
|
||||
var initialized []domain.IdentityProvider
|
||||
for _, p := range providers {
|
||||
providerName := strings.TrimSpace(strings.ToLower(p))
|
||||
if canonical, ok := providerAliases[providerName]; ok {
|
||||
providerName = canonical
|
||||
}
|
||||
|
||||
switch providerName {
|
||||
case "ory":
|
||||
// Kratos/Hydra 주 공급자
|
||||
oryProvider := service.NewOryProvider()
|
||||
initialized = append(initialized, oryProvider)
|
||||
switch providerName {
|
||||
case "ory":
|
||||
// Kratos/Hydra 주 공급자
|
||||
oryProvider := service.NewOryProvider()
|
||||
initialized = append(initialized, oryProvider)
|
||||
|
||||
default:
|
||||
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
|
||||
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
|
||||
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
|
||||
}
|
||||
}
|
||||
if len(initialized) == 0 {
|
||||
return nil, fmt.Errorf("no valid IDP_PROVIDER entries configured from: %s", rawProviders)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
type ClientConsentRepository interface {
|
||||
Upsert(ctx context.Context, consent *domain.ClientConsent) error
|
||||
Delete(ctx context.Context, subject, clientID string) error
|
||||
DeleteByClient(ctx context.Context, clientID string) error
|
||||
List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
|
||||
ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
|
||||
ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error)
|
||||
ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error)
|
||||
Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error)
|
||||
}
|
||||
|
||||
@@ -27,7 +29,7 @@ func NewClientConsentRepository(db *gorm.DB) ClientConsentRepository {
|
||||
|
||||
func (r *clientConsentRepo) Find(ctx context.Context, clientID, subject string) (*domain.ClientConsent, error) {
|
||||
var consent domain.ClientConsent
|
||||
err := r.db.WithContext(ctx).Unscoped().
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("client_id = ? AND subject = ?", clientID, subject).
|
||||
First(&consent).Error
|
||||
if err != nil {
|
||||
@@ -56,6 +58,12 @@ func (r *clientConsentRepo) Delete(ctx context.Context, subject, clientID string
|
||||
Delete(&domain.ClientConsent{}).Error
|
||||
}
|
||||
|
||||
func (r *clientConsentRepo) DeleteByClient(ctx context.Context, clientID string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("client_id = ?", clientID).
|
||||
Delete(&domain.ClientConsent{}).Error
|
||||
}
|
||||
|
||||
func (r *clientConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||
var consents []domain.ClientConsentWithTenantInfo
|
||||
var total int64
|
||||
@@ -117,3 +125,14 @@ func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) (
|
||||
Find(&consents).Error
|
||||
return consents, err
|
||||
}
|
||||
|
||||
func (r *clientConsentRepo) ListSubjectsByClient(ctx context.Context, clientID string) ([]string, error) {
|
||||
var subjects []string
|
||||
err := r.db.WithContext(ctx).Unscoped().
|
||||
Model(&domain.ClientConsent{}).
|
||||
Distinct("subject").
|
||||
Where("client_id = ?", clientID).
|
||||
Order("subject ASC").
|
||||
Pluck("subject", &subjects).Error
|
||||
return subjects, err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
// Auto-migrate
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{})
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to migrate database: %s", err)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Plus,
|
||||
Save,
|
||||
Search,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
createClient,
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
fetchMyTenants,
|
||||
refreshHeadlessJwksCache,
|
||||
revokeHeadlessJwksCache,
|
||||
updateClient,
|
||||
@@ -40,6 +44,8 @@ import type {
|
||||
ClientStatus,
|
||||
ClientType,
|
||||
ClientUpsertRequest,
|
||||
MyTenantSummary,
|
||||
TenantSummary,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -50,6 +56,7 @@ interface ScopeItem {
|
||||
name: string;
|
||||
description: string;
|
||||
mandatory: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
type SecurityProfile = "private" | "pkce";
|
||||
@@ -131,6 +138,10 @@ function ClientGeneralPage() {
|
||||
queryFn: () => fetchClient(clientId as string),
|
||||
enabled: !isCreate,
|
||||
});
|
||||
const { data: tenantData } = useQuery({
|
||||
queryKey: ["my-tenants"],
|
||||
queryFn: fetchMyTenants,
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -142,6 +153,10 @@ function ClientGeneralPage() {
|
||||
const [status, setStatus] = useState<ClientStatus>("active");
|
||||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||
const [redirectUris, setRedirectUris] = useState("");
|
||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
||||
|
||||
// Public Key Registration States
|
||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||
@@ -158,12 +173,18 @@ function ClientGeneralPage() {
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "tenant",
|
||||
description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"),
|
||||
mandatory: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "profile",
|
||||
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
|
||||
mandatory: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
id: "4",
|
||||
name: "email",
|
||||
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
||||
mandatory: false,
|
||||
@@ -185,6 +206,16 @@ function ClientGeneralPage() {
|
||||
|
||||
const headlessEnabled = !!metadata.headless_login_enabled;
|
||||
setHeadlessLoginEnabled(headlessEnabled);
|
||||
const restrictedTenants = Array.isArray(metadata.allowed_tenants)
|
||||
? metadata.allowed_tenants
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
setTenantAccessRestricted(
|
||||
restrictedTenants.length > 0 ||
|
||||
metadata.tenant_access_restricted === true,
|
||||
);
|
||||
setAllowedTenantIds(restrictedTenants);
|
||||
|
||||
const savedAuthMethod =
|
||||
client.tokenEndpointAuthMethod ||
|
||||
@@ -230,15 +261,25 @@ function ClientGeneralPage() {
|
||||
|
||||
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
||||
if (savedScopes && Array.isArray(savedScopes)) {
|
||||
setScopes(savedScopes);
|
||||
setScopes(
|
||||
normalizeScopesForTenantAccess(
|
||||
savedScopes,
|
||||
restrictedTenants.length > 0 ||
|
||||
metadata.tenant_access_restricted === true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setScopes(
|
||||
client.scopes.map((s, idx) => ({
|
||||
id: String(idx + 1),
|
||||
name: s,
|
||||
description: "",
|
||||
mandatory: s === "openid",
|
||||
})),
|
||||
normalizeScopesForTenantAccess(
|
||||
client.scopes.map((s, idx) => ({
|
||||
id: String(idx + 1),
|
||||
name: s,
|
||||
description: "",
|
||||
mandatory: s === "openid",
|
||||
})),
|
||||
restrictedTenants.length > 0 ||
|
||||
metadata.tenant_access_restricted === true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [data]);
|
||||
@@ -279,6 +320,81 @@ function ClientGeneralPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const tenantScopeDescription = t(
|
||||
"msg.dev.clients.scopes.tenant",
|
||||
"소속 테넌트 정보 접근",
|
||||
);
|
||||
|
||||
const buildTenantScope = (id: string): ScopeItem => ({
|
||||
id,
|
||||
name: "tenant",
|
||||
description: tenantScopeDescription,
|
||||
mandatory: true,
|
||||
locked: true,
|
||||
});
|
||||
|
||||
function normalizeScopesForTenantAccess(
|
||||
nextScopes: ScopeItem[],
|
||||
restricted: boolean,
|
||||
): ScopeItem[] {
|
||||
const normalized = nextScopes.map((scope) => {
|
||||
if (scope.name.trim() !== "tenant") {
|
||||
return scope;
|
||||
}
|
||||
return {
|
||||
...scope,
|
||||
description: scope.description || tenantScopeDescription,
|
||||
mandatory: restricted,
|
||||
locked: restricted,
|
||||
};
|
||||
});
|
||||
|
||||
if (
|
||||
restricted &&
|
||||
!normalized.some((scope) => scope.name.trim() === "tenant")
|
||||
) {
|
||||
normalized.push(buildTenantScope(`tenant-${Date.now()}`));
|
||||
}
|
||||
|
||||
const openidScopes = normalized.filter(
|
||||
(scope) => scope.name.trim() === "openid",
|
||||
);
|
||||
const tenantScopes = normalized.filter(
|
||||
(scope) => scope.name.trim() === "tenant",
|
||||
);
|
||||
const remainingScopes = normalized.filter((scope) => {
|
||||
const name = scope.name.trim();
|
||||
return name !== "openid" && name !== "tenant";
|
||||
});
|
||||
|
||||
return [...openidScopes, ...tenantScopes, ...remainingScopes];
|
||||
}
|
||||
|
||||
const handleTenantAccessToggle = (enabled: boolean) => {
|
||||
setTenantAccessRestricted(enabled);
|
||||
setIsTenantSearchOpen(enabled);
|
||||
if (!enabled) {
|
||||
setTenantSearch("");
|
||||
}
|
||||
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
||||
};
|
||||
|
||||
const toggleAllowedTenant = (tenantId: string) => {
|
||||
setAllowedTenantIds((current) =>
|
||||
current.includes(tenantId)
|
||||
? current.filter((id) => id !== tenantId)
|
||||
: [...current, tenantId],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllowedTenant = (tenantId: string) => {
|
||||
setAllowedTenantIds((current) =>
|
||||
current.includes(tenantId) ? current : [...current, tenantId],
|
||||
);
|
||||
setTenantSearch("");
|
||||
setIsTenantSearchOpen(true);
|
||||
};
|
||||
|
||||
const addScope = () => {
|
||||
const newId = String(Date.now());
|
||||
setScopes([
|
||||
@@ -292,15 +408,23 @@ function ClientGeneralPage() {
|
||||
field: K,
|
||||
value: ScopeItem[K],
|
||||
) => {
|
||||
setScopes(
|
||||
scopes.map((scope) =>
|
||||
scope.id === id ? { ...scope, [field]: value } : scope,
|
||||
),
|
||||
setScopes((current) =>
|
||||
current.map((scope) => {
|
||||
if (scope.id !== id) {
|
||||
return scope;
|
||||
}
|
||||
if (scope.locked) {
|
||||
return scope;
|
||||
}
|
||||
return { ...scope, [field]: value };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeScope = (id: string) => {
|
||||
setScopes(scopes.filter((s) => s.id !== id));
|
||||
setScopes((current) =>
|
||||
current.filter((scope) => scope.id !== id || scope.locked === true),
|
||||
);
|
||||
};
|
||||
|
||||
const handleStatusChange = (nextStatus: ClientStatus) => {
|
||||
@@ -391,7 +515,35 @@ function ClientGeneralPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantAccessRestricted && allowedTenantIds.length === 0) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"ui.dev.clients.general.tenant_access.validation_required",
|
||||
"테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const hasValidationErrors = validationErrors.length > 0;
|
||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||||
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
|
||||
tenantData ?? [];
|
||||
const filteredTenants = tenantOptions.filter((tenant) => {
|
||||
if (!normalizedTenantSearch) {
|
||||
return true;
|
||||
}
|
||||
const searchable =
|
||||
`${tenant.name} ${tenant.slug} ${tenant.description ?? ""} ${tenant.type ?? ""}`.toLowerCase();
|
||||
return searchable.includes(normalizedTenantSearch);
|
||||
});
|
||||
const tenantSuggestions = filteredTenants
|
||||
.filter((tenant) => !allowedTenantIds.includes(tenant.id))
|
||||
.slice(0, 8);
|
||||
const selectedAllowedTenants = allowedTenantIds
|
||||
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
||||
.filter(
|
||||
(tenant): tenant is TenantSummary | MyTenantSummary => tenant != null,
|
||||
);
|
||||
|
||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -467,7 +619,16 @@ function ClientGeneralPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||
const normalizedScopes = normalizeScopesForTenantAccess(
|
||||
scopes,
|
||||
tenantAccessRestricted,
|
||||
);
|
||||
const normalizedAllowedTenantIds = Array.from(
|
||||
new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)),
|
||||
);
|
||||
const scopeNames = normalizedScopes
|
||||
.map((scope) => scope.name.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const effectiveTokenEndpointAuthMethod =
|
||||
clientType === "pkce" && headlessLoginEnabled
|
||||
@@ -487,7 +648,7 @@ function ClientGeneralPage() {
|
||||
metadata: {
|
||||
description,
|
||||
logo_url: trimmedLogoUrl,
|
||||
structured_scopes: scopes,
|
||||
structured_scopes: normalizedScopes,
|
||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||
headless_login_enabled: headlessLoginEnabled,
|
||||
headless_token_endpoint_auth_method:
|
||||
@@ -498,6 +659,10 @@ function ClientGeneralPage() {
|
||||
clientType === "pkce" && headlessLoginEnabled
|
||||
? trimmedJwksUri
|
||||
: undefined,
|
||||
tenant_access_restricted: tenantAccessRestricted,
|
||||
allowed_tenants: tenantAccessRestricted
|
||||
? normalizedAllowedTenantIds
|
||||
: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -972,7 +1137,10 @@ function ClientGeneralPage() {
|
||||
{scopes.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
s.locked ? "bg-primary/5" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Input
|
||||
@@ -985,6 +1153,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.scopes.name_placeholder",
|
||||
"e.g. profile",
|
||||
)}
|
||||
disabled={s.locked}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -998,6 +1167,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.scopes.description_placeholder",
|
||||
"권한에 대한 설명",
|
||||
)}
|
||||
disabled={s.locked}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
@@ -1007,6 +1177,7 @@ function ClientGeneralPage() {
|
||||
onCheckedChange={(checked) =>
|
||||
updateScope(s.id, "mandatory", checked)
|
||||
}
|
||||
disabled={s.locked}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1016,6 +1187,7 @@ function ClientGeneralPage() {
|
||||
size="icon"
|
||||
onClick={() => removeScope(s.id)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={s.locked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1041,6 +1213,222 @@ function ClientGeneralPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.title",
|
||||
"테넌트 접근 제한",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.subtitle",
|
||||
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="space-y-0.5 text-right">
|
||||
<p className="text-sm font-semibold">
|
||||
{tenantAccessRestricted
|
||||
? t(
|
||||
"ui.dev.clients.general.tenant_access.enabled",
|
||||
"제한 있음",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.general.tenant_access.disabled",
|
||||
"제한 없음",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.title",
|
||||
"테넌트 접근 제한",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={tenantAccessRestricted}
|
||||
onCheckedChange={handleTenantAccessToggle}
|
||||
id="tenant-access-toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.hint",
|
||||
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="tenant-search" className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="tenant-search"
|
||||
value={tenantSearch}
|
||||
onFocus={() => {
|
||||
if (tenantAccessRestricted) {
|
||||
setIsTenantSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setTenantSearch(e.target.value);
|
||||
if (tenantAccessRestricted) {
|
||||
setIsTenantSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
className="pl-10"
|
||||
disabled={!tenantAccessRestricted}
|
||||
/>
|
||||
{tenantAccessRestricted && isTenantSearchOpen && (
|
||||
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
|
||||
{tenantSuggestions.length > 0 ? (
|
||||
tenantSuggestions.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
className="flex w-full items-start justify-between gap-3 border-b border-border/40 px-4 py-3 text-left transition hover:bg-muted/40 last:border-b-0"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
handleSelectAllowedTenant(tenant.id);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{tenant.slug}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{tenant.description || tenant.type}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.empty",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
{tenantAccessRestricted
|
||||
? t(
|
||||
"ui.dev.clients.general.tenant_access.autocomplete_hint",
|
||||
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.general.tenant_access.disabled",
|
||||
"제한 없음",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.selected_title",
|
||||
"허용 테넌트",
|
||||
)}
|
||||
</Label>
|
||||
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
|
||||
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAllowedTenants.map((tenant) => (
|
||||
<Badge
|
||||
key={tenant.id}
|
||||
variant="secondary"
|
||||
className="gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="max-w-44 truncate">{tenant.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenant.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{allowedTenantIds
|
||||
.filter(
|
||||
(tenantId) =>
|
||||
!selectedAllowedTenants.some(
|
||||
(tenant) => tenant.id === tenantId,
|
||||
),
|
||||
)
|
||||
.map((tenantId) => (
|
||||
<Badge
|
||||
key={tenantId}
|
||||
variant="secondary"
|
||||
className="gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="max-w-44 truncate">{tenantId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenantId)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{tenantAccessRestricted
|
||||
? t(
|
||||
"ui.dev.clients.general.tenant_access.selected_empty",
|
||||
"아직 선택된 테넌트가 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.general.tenant_access.disabled",
|
||||
"제한 없음",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Security Settings */}
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
@@ -23,6 +23,28 @@ export type ClientListResponse = {
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type TenantSummary = {
|
||||
id: string;
|
||||
type: string;
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
status: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TenantListResponse = {
|
||||
items: TenantSummary[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type DevStats = {
|
||||
total_clients: number;
|
||||
active_sessions: number;
|
||||
@@ -188,6 +210,17 @@ export async function fetchDevStats() {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchTenants(
|
||||
limit = 1000,
|
||||
offset = 0,
|
||||
parentId?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
|
||||
params: { limit, offset, parentId },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchClient(clientId: string) {
|
||||
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||
`/dev/clients/${clientId}`,
|
||||
@@ -376,14 +409,11 @@ export async function fetchDevAuditLogs(
|
||||
return data;
|
||||
}
|
||||
|
||||
export type TenantSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
export type MyTenantSummary = Pick<TenantSummary, "id" | "name" | "slug"> &
|
||||
Partial<TenantSummary>;
|
||||
|
||||
export async function fetchMyTenants() {
|
||||
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
|
||||
const { data } = await apiClient.get<MyTenantSummary[]>("/dev/my-tenants");
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -419,6 +419,7 @@ help = "Enter the redirect URIs. You can modify them in the Federation tab after
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = "No scopes registered."
|
||||
subtitle = "Define the permission scopes this application can request."
|
||||
tenant = "Tenant access claim"
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||
@@ -1436,6 +1437,20 @@ description = "Scope Description"
|
||||
mandatory = "Mandatory"
|
||||
name = "Scope Name"
|
||||
delete = "Delete"
|
||||
tenant = "Tenant"
|
||||
|
||||
[ui.dev.clients.general.tenant_access]
|
||||
title = "Tenant access restriction"
|
||||
subtitle = "Limit this RP so only approved tenants can access it."
|
||||
enabled = "Restricted"
|
||||
disabled = "Unrestricted"
|
||||
search_placeholder = "Search by tenant name or slug"
|
||||
selected_title = "Allowed tenants"
|
||||
selected_empty = "No tenants selected yet."
|
||||
empty = "No tenants match your search."
|
||||
hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant."
|
||||
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
|
||||
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server Side App"
|
||||
|
||||
@@ -419,6 +419,7 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = "등록된 스코프가 없습니다."
|
||||
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||
tenant = "소속 테넌트 정보 접근"
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||
@@ -1435,6 +1436,20 @@ description = "설명"
|
||||
mandatory = "필수"
|
||||
name = "스코프 이름"
|
||||
delete = "삭제"
|
||||
tenant = "테넌트"
|
||||
|
||||
[ui.dev.clients.general.tenant_access]
|
||||
title = "테넌트 접근 제한"
|
||||
subtitle = "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다."
|
||||
enabled = "제한 있음"
|
||||
disabled = "제한 없음"
|
||||
search_placeholder = "테넌트 이름 또는 슬러그로 검색"
|
||||
selected_title = "허용 테넌트"
|
||||
selected_empty = "아직 선택된 테넌트가 없습니다."
|
||||
empty = "검색 결과가 없습니다."
|
||||
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
|
||||
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server side App"
|
||||
|
||||
@@ -465,6 +465,7 @@ help = ""
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = ""
|
||||
subtitle = ""
|
||||
tenant = ""
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
private_help = ""
|
||||
@@ -1518,6 +1519,20 @@ description = ""
|
||||
mandatory = ""
|
||||
name = ""
|
||||
delete = ""
|
||||
tenant = ""
|
||||
|
||||
[ui.dev.clients.general.tenant_access]
|
||||
title = ""
|
||||
subtitle = ""
|
||||
enabled = ""
|
||||
disabled = ""
|
||||
search_placeholder = ""
|
||||
selected_title = ""
|
||||
selected_empty = ""
|
||||
empty = ""
|
||||
hint = ""
|
||||
autocomplete_hint = ""
|
||||
validation_required = ""
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = ""
|
||||
|
||||
@@ -650,6 +650,22 @@ title_generic = "An error occurred."
|
||||
title_with_code = "Error: {{code}}"
|
||||
type = "Error type: {{type}}"
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = "Account"
|
||||
account_unknown = "Unknown"
|
||||
affiliated_tenants = "All affiliated tenants"
|
||||
allowed_box_title = "Allowed tenants"
|
||||
allowed_tenants = "Allowed tenants"
|
||||
detail = "The currently signed-in account cannot access this application."
|
||||
load_failed = "We could not confirm the account details. Please try again."
|
||||
loading = "Loading the current account details."
|
||||
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
|
||||
page_title = "Access to this application is restricted"
|
||||
primary_tenant = "Primary affiliated tenant"
|
||||
tenant = "Tenant"
|
||||
tenant_unknown = "Unknown"
|
||||
title = "Access restriction details"
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
"$normalizedCode" = "{{error}}"
|
||||
access_denied = "The user denied the consent request."
|
||||
@@ -2239,6 +2255,7 @@ windows = "Desktop(Windows)"
|
||||
[ui.userfront.error]
|
||||
go_home = "Go Home"
|
||||
go_login = "Go Login"
|
||||
switch_account = "Sign in with another account"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "Forgot your password?"
|
||||
|
||||
@@ -208,6 +208,22 @@ title_generic = "오류가 발생했습니다"
|
||||
title_with_code = "오류: {{code}}"
|
||||
type = "오류 종류: {{type}}"
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = "계정"
|
||||
account_unknown = "알 수 없음"
|
||||
affiliated_tenants = "전체 소속 테넌트"
|
||||
allowed_box_title = "접속 가능 테넌트"
|
||||
allowed_tenants = "접속 가능 테넌트"
|
||||
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
|
||||
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
|
||||
loading = "현재 계정 정보를 불러오는 중입니다."
|
||||
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
|
||||
page_title = "애플리케이션 접근이 제한되었습니다"
|
||||
primary_tenant = "대표 소속 테넌트"
|
||||
tenant = "소속 테넌트"
|
||||
tenant_unknown = "알 수 없음"
|
||||
title = "접근 제한 정보"
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
||||
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
|
||||
@@ -456,6 +472,7 @@ windows = "Desktop(Windows)"
|
||||
[ui.userfront.error]
|
||||
go_home = "홈으로 이동"
|
||||
go_login = "로그인으로 이동"
|
||||
switch_account = "다른 계정으로 로그인"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "비밀번호를 잊으셨나요?"
|
||||
@@ -1088,6 +1105,22 @@ title_generic = "오류가 발생했습니다"
|
||||
title_with_code = "오류: {{code}}"
|
||||
type = "오류 종류: {{type}}"
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = "계정"
|
||||
account_unknown = "알 수 없음"
|
||||
affiliated_tenants = "전체 소속 테넌트"
|
||||
allowed_box_title = "접속 가능 테넌트"
|
||||
allowed_tenants = "접속 가능 테넌트"
|
||||
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
|
||||
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
|
||||
loading = "현재 계정 정보를 불러오는 중입니다."
|
||||
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
|
||||
page_title = "애플리케이션 접근이 제한되었습니다"
|
||||
primary_tenant = "대표 소속 테넌트"
|
||||
tenant = "소속 테넌트"
|
||||
tenant_unknown = "알 수 없음"
|
||||
title = "접근 제한 정보"
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
"$normalizedCode" = "{{error}}"
|
||||
access_denied = "사용자가 동의를 거부했습니다."
|
||||
@@ -2638,6 +2671,7 @@ windows = "Desktop(Windows)"
|
||||
[ui.userfront.error]
|
||||
go_home = "홈으로 이동"
|
||||
go_login = "로그인으로 이동"
|
||||
switch_account = "다른 계정으로 로그인"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "비밀번호를 잊으셨나요?"
|
||||
|
||||
@@ -83,6 +83,22 @@ title_generic = ""
|
||||
title_with_code = ""
|
||||
type = ""
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = ""
|
||||
account_unknown = ""
|
||||
affiliated_tenants = ""
|
||||
allowed_box_title = ""
|
||||
allowed_tenants = ""
|
||||
detail = ""
|
||||
load_failed = ""
|
||||
loading = ""
|
||||
lookup_fallback = ""
|
||||
page_title = ""
|
||||
primary_tenant = ""
|
||||
tenant = ""
|
||||
tenant_unknown = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = ""
|
||||
dry_send = ""
|
||||
@@ -331,6 +347,7 @@ windows = ""
|
||||
[ui.userfront.error]
|
||||
go_home = ""
|
||||
go_login = ""
|
||||
switch_account = ""
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = ""
|
||||
@@ -963,6 +980,22 @@ title_generic = ""
|
||||
title_with_code = ""
|
||||
type = ""
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = ""
|
||||
account_unknown = ""
|
||||
affiliated_tenants = ""
|
||||
allowed_box_title = ""
|
||||
allowed_tenants = ""
|
||||
detail = ""
|
||||
load_failed = ""
|
||||
loading = ""
|
||||
lookup_fallback = ""
|
||||
page_title = ""
|
||||
primary_tenant = ""
|
||||
tenant = ""
|
||||
tenant_unknown = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
"$normalizedCode" = ""
|
||||
access_denied = ""
|
||||
@@ -2513,6 +2546,7 @@ windows = ""
|
||||
[ui.userfront.error]
|
||||
go_home = ""
|
||||
go_login = ""
|
||||
switch_account = ""
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = ""
|
||||
|
||||
@@ -4,19 +4,15 @@ set -euo pipefail
|
||||
job_name="${1:-adminfront-tests}"
|
||||
|
||||
mkdir -p reports
|
||||
rm -rf adminfront/node_modules
|
||||
|
||||
if [ -n "${CI:-}" ]; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
else
|
||||
playwright_install_cmd=(npx playwright install)
|
||||
playwright_install_desc="npx playwright install"
|
||||
fi
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
|
||||
set +e
|
||||
(
|
||||
cd adminfront
|
||||
npm ci
|
||||
npm ci --ignore-scripts
|
||||
) 2>&1 | tee reports/adminfront-install.log
|
||||
install_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
@@ -31,7 +27,7 @@ if [ "$install_exit_code" -ne 0 ]; then
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd adminfront && npm ci\`"
|
||||
echo "\`cd adminfront && npm ci --ignore-scripts\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
@@ -70,11 +66,12 @@ if [ "$provision_exit_code" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
set +e
|
||||
port="$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"
|
||||
port="${PORT:-5180}"
|
||||
echo "==> adminfront using PORT=$port"
|
||||
(
|
||||
cd adminfront
|
||||
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" npm test
|
||||
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
||||
node ./node_modules/playwright/cli.js test
|
||||
) 2>&1 | tee reports/adminfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
@@ -89,9 +86,9 @@ if [ "$test_exit_code" -ne 0 ]; then
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd adminfront\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "2. \`npm ci --ignore-scripts\`"
|
||||
echo "3. \`${playwright_install_desc}\`"
|
||||
echo "4. \`npm test\`"
|
||||
echo "4. \`node ./node_modules/playwright/cli.js test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
|
||||
@@ -137,6 +137,22 @@ title_generic = "An error occurred."
|
||||
title_with_code = "Error: {code}"
|
||||
type = "Error type: {type}"
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = "Account"
|
||||
account_unknown = "Unknown"
|
||||
affiliated_tenants = "All affiliated tenants"
|
||||
allowed_box_title = "Allowed tenants"
|
||||
allowed_tenants = "Allowed tenants"
|
||||
detail = "The currently signed-in account cannot access this application."
|
||||
load_failed = "We could not confirm the account details. Please try again."
|
||||
loading = "Loading the current account details."
|
||||
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
|
||||
page_title = "Access to this application is restricted"
|
||||
primary_tenant = "Primary affiliated tenant"
|
||||
tenant = "Tenant"
|
||||
tenant_unknown = "Unknown"
|
||||
title = "Access restriction details"
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
"$normalizedCode" = "{error}"
|
||||
access_denied = "The user denied the consent request."
|
||||
@@ -506,6 +522,7 @@ windows = "Desktop(Windows)"
|
||||
[ui.userfront.error]
|
||||
go_home = "Go Home"
|
||||
go_login = "Go Login"
|
||||
switch_account = "Sign in with another account"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "Forgot your password?"
|
||||
|
||||
@@ -78,6 +78,22 @@ title_generic = "오류가 발생했습니다"
|
||||
title_with_code = "오류: {code}"
|
||||
type = "오류 종류: {type}"
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = "계정"
|
||||
account_unknown = "알 수 없음"
|
||||
affiliated_tenants = "전체 소속 테넌트"
|
||||
allowed_box_title = "접속 가능 테넌트"
|
||||
allowed_tenants = "접속 가능 테넌트"
|
||||
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
|
||||
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
|
||||
loading = "현재 계정 정보를 불러오는 중입니다."
|
||||
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
|
||||
page_title = "애플리케이션 접근이 제한되었습니다"
|
||||
primary_tenant = "대표 소속 테넌트"
|
||||
tenant = "소속 테넌트"
|
||||
tenant_unknown = "알 수 없음"
|
||||
title = "접근 제한 정보"
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
||||
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
|
||||
@@ -190,6 +206,7 @@ windows = "Desktop(Windows)"
|
||||
[ui.userfront.error]
|
||||
go_home = "홈으로 이동"
|
||||
go_login = "로그인으로 이동"
|
||||
switch_account = "다른 계정으로 로그인"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "비밀번호를 잊으셨나요?"
|
||||
@@ -344,6 +361,22 @@ title_generic = "오류가 발생했습니다"
|
||||
title_with_code = "오류: {code}"
|
||||
type = "오류 종류: {type}"
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = "계정"
|
||||
account_unknown = "알 수 없음"
|
||||
affiliated_tenants = "전체 소속 테넌트"
|
||||
allowed_box_title = "접속 가능 테넌트"
|
||||
allowed_tenants = "접속 가능 테넌트"
|
||||
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
|
||||
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
|
||||
loading = "현재 계정 정보를 불러오는 중입니다."
|
||||
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
|
||||
page_title = "애플리케이션 접근이 제한되었습니다"
|
||||
primary_tenant = "대표 소속 테넌트"
|
||||
tenant = "소속 테넌트"
|
||||
tenant_unknown = "알 수 없음"
|
||||
title = "접근 제한 정보"
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
"$normalizedCode" = "{error}"
|
||||
access_denied = "사용자가 동의를 거부했습니다."
|
||||
@@ -711,6 +744,7 @@ windows = "Desktop(Windows)"
|
||||
[ui.userfront.error]
|
||||
go_home = "홈으로 이동"
|
||||
go_login = "로그인으로 이동"
|
||||
switch_account = "다른 계정으로 로그인"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "비밀번호를 잊으셨나요?"
|
||||
|
||||
@@ -50,6 +50,22 @@ title_generic = ""
|
||||
title_with_code = ""
|
||||
type = ""
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = ""
|
||||
account_unknown = ""
|
||||
affiliated_tenants = ""
|
||||
allowed_box_title = ""
|
||||
allowed_tenants = ""
|
||||
detail = ""
|
||||
load_failed = ""
|
||||
loading = ""
|
||||
lookup_fallback = ""
|
||||
page_title = ""
|
||||
primary_tenant = ""
|
||||
tenant = ""
|
||||
tenant_unknown = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = ""
|
||||
dry_send = ""
|
||||
@@ -162,6 +178,7 @@ windows = ""
|
||||
[ui.userfront.error]
|
||||
go_home = ""
|
||||
go_login = ""
|
||||
switch_account = ""
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = ""
|
||||
@@ -316,6 +333,22 @@ title_generic = ""
|
||||
title_with_code = ""
|
||||
type = ""
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = ""
|
||||
account_unknown = ""
|
||||
affiliated_tenants = ""
|
||||
allowed_box_title = ""
|
||||
allowed_tenants = ""
|
||||
detail = ""
|
||||
load_failed = ""
|
||||
loading = ""
|
||||
lookup_fallback = ""
|
||||
page_title = ""
|
||||
primary_tenant = ""
|
||||
tenant = ""
|
||||
tenant_unknown = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
"$normalizedCode" = ""
|
||||
access_denied = ""
|
||||
@@ -683,6 +716,7 @@ windows = ""
|
||||
[ui.userfront.error]
|
||||
go_home = ""
|
||||
go_login = ""
|
||||
switch_account = ""
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = ""
|
||||
|
||||
@@ -8,6 +8,7 @@ const Map<String, String> internalErrorWhitelistMessages = {
|
||||
'not_found': '요청한 페이지를 찾을 수 없습니다.',
|
||||
'bad_request': '입력값을 확인해 주세요.',
|
||||
'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.',
|
||||
'tenant_not_allowed': '허용되지 않은 테넌트입니다.',
|
||||
};
|
||||
|
||||
const Set<String> oryBypassErrorCodes = {
|
||||
|
||||
@@ -396,8 +396,14 @@ class AuthProxyService {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'),
|
||||
final rawDetails = errorBody['details'];
|
||||
throw AuthProxyException(
|
||||
errorCode: (errorBody['code'] ?? '').toString(),
|
||||
message:
|
||||
(errorBody['error'] ??
|
||||
tr('err.userfront.auth_proxy.consent_fetch'))
|
||||
.toString(),
|
||||
details: rawDetails is Map<String, dynamic> ? rawDetails : null,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -1105,3 +1111,18 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AuthProxyException implements Exception {
|
||||
final String errorCode;
|
||||
final String message;
|
||||
final Map<String, dynamic>? details;
|
||||
|
||||
const AuthProxyException({
|
||||
required this.errorCode,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
@@ -5,11 +7,18 @@ import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
|
||||
|
||||
class ConsentScreen extends StatefulWidget {
|
||||
final String consentChallenge;
|
||||
final Future<Map<String, dynamic>> Function(String consentChallenge)?
|
||||
consentInfoLoader;
|
||||
|
||||
const ConsentScreen({super.key, required this.consentChallenge});
|
||||
const ConsentScreen({
|
||||
super.key,
|
||||
required this.consentChallenge,
|
||||
this.consentInfoLoader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConsentScreen> createState() => _ConsentScreenState();
|
||||
@@ -93,9 +102,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final info = await AuthProxyService.getConsentInfo(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
final loader =
|
||||
widget.consentInfoLoader ?? AuthProxyService.getConsentInfo;
|
||||
final info = await loader(widget.consentChallenge);
|
||||
|
||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||
if (info['redirectTo'] != null) {
|
||||
@@ -139,6 +148,35 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
_consentInfo = info;
|
||||
_isLoading = false;
|
||||
});
|
||||
} on AuthProxyException catch (e) {
|
||||
if (shouldRouteConsentErrorToErrorScreen(e)) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final localeCode =
|
||||
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
||||
final target = buildLocalizedPath(
|
||||
localeCode,
|
||||
Uri(
|
||||
path: '/error',
|
||||
queryParameters: {
|
||||
'error': e.errorCode,
|
||||
'error_description': e.message,
|
||||
if (e.details != null) 'details': jsonEncode(e.details),
|
||||
},
|
||||
),
|
||||
);
|
||||
context.go(target);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.load_error',
|
||||
fallback: 'Failed to load consent information: {{error}}',
|
||||
params: {'error': e.message},
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/constants/error_whitelist.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/logout_service.dart';
|
||||
import '../../../core/widgets/theme_toggle_button.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ErrorScreen extends StatelessWidget {
|
||||
class ErrorScreen extends StatefulWidget {
|
||||
final String? errorId;
|
||||
final String? errorCode;
|
||||
final String? description;
|
||||
final bool? isProdOverride;
|
||||
final Future<Map<String, dynamic>> Function()? sessionProfileLoader;
|
||||
final Map<String, dynamic>? tenantAccessDetails;
|
||||
|
||||
const ErrorScreen({
|
||||
super.key,
|
||||
@@ -18,24 +23,280 @@ class ErrorScreen extends StatelessWidget {
|
||||
this.errorCode,
|
||||
this.description,
|
||||
this.isProdOverride,
|
||||
this.sessionProfileLoader,
|
||||
this.tenantAccessDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorScreen> createState() => _ErrorScreenState();
|
||||
}
|
||||
|
||||
class _ErrorScreenState extends State<ErrorScreen> {
|
||||
Map<String, dynamic>? _sessionProfile;
|
||||
bool _isLoadingSessionProfile = false;
|
||||
String? _sessionProfileError;
|
||||
|
||||
bool get _isTenantAccessBlocked =>
|
||||
(widget.errorCode ?? '').trim() == 'tenant_not_allowed';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isTenantAccessBlocked && _shouldLoadSessionProfile()) {
|
||||
unawaited(_loadSessionProfile());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? get _tenantAccessDetails => widget.tenantAccessDetails;
|
||||
|
||||
bool _shouldLoadSessionProfile() {
|
||||
final details = _tenantAccessDetails;
|
||||
if (details == null) {
|
||||
return true;
|
||||
}
|
||||
final hasAccount = _extractAccountEmail(details).isNotEmpty;
|
||||
final hasTenant = _extractCurrentTenantLabel(details).isNotEmpty;
|
||||
return !hasAccount || !hasTenant;
|
||||
}
|
||||
|
||||
Future<void> _loadSessionProfile() async {
|
||||
if (_isLoadingSessionProfile) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = true;
|
||||
_sessionProfileError = null;
|
||||
});
|
||||
try {
|
||||
final loader = widget.sessionProfileLoader ?? AuthProxyService.getMe;
|
||||
final profile = await loader();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfile = profile;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfileError = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _extractTenantLabel(Map<String, dynamic>? profile) {
|
||||
if (profile == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final tenant = profile['tenant'];
|
||||
if (tenant is Map) {
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
final name = item['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = item['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final companyCode = profile['companyCode']?.toString().trim() ?? '';
|
||||
if (companyCode.isNotEmpty) {
|
||||
return companyCode;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractCurrentTenantLabel(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final tenant = details['current_tenant'];
|
||||
if (tenant is! Map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
final identifier = tenant['identifier']?.toString().trim() ?? '';
|
||||
if (identifier.isNotEmpty) {
|
||||
return identifier;
|
||||
}
|
||||
final id = tenant['id']?.toString().trim() ?? '';
|
||||
return id;
|
||||
}
|
||||
|
||||
String _extractAccountEmail(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final account = details['account'];
|
||||
if (account is! Map) {
|
||||
return '';
|
||||
}
|
||||
return account['email']?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
List<String> _extractAllowedTenantLabels(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['allowed_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromDetails(
|
||||
Map<String, dynamic>? details,
|
||||
) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['affiliated_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromProfile(
|
||||
Map<String, dynamic>? profile,
|
||||
) {
|
||||
if (profile == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void appendLabel(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || seen.contains(trimmed)) {
|
||||
return;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
labels.add(trimmed);
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
appendLabel(item['name']?.toString() ?? '');
|
||||
appendLabel(item['slug']?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final tenant = _extractTenantLabel(profile);
|
||||
if (tenant.isNotEmpty) {
|
||||
appendLabel(tenant);
|
||||
}
|
||||
|
||||
final companyCode = profile['companyCode']?.toString().trim() ?? '';
|
||||
if (companyCode.isNotEmpty) {
|
||||
appendLabel(companyCode);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
Future<void> _switchAccount() async {
|
||||
await LogoutService().logout();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
|
||||
final normalizedCode = (errorCode ?? '').trim();
|
||||
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
|
||||
final normalizedCode = (widget.errorCode ?? '').trim();
|
||||
final hasCode = normalizedCode.isNotEmpty;
|
||||
final internalWhitelistFallback =
|
||||
internalErrorWhitelistMessages[normalizedCode];
|
||||
final isInternalWhitelisted = internalWhitelistFallback != null;
|
||||
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
|
||||
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
|
||||
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
|
||||
final errorType = isProd
|
||||
? (isKnownProdCode ? normalizedCode : 'unknown_error')
|
||||
: (hasCode ? normalizedCode : 'unknown_error');
|
||||
final title = isProd
|
||||
final title = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.page_title',
|
||||
fallback: '애플리케이션 접근이 제한되었습니다',
|
||||
)
|
||||
: isProd
|
||||
? tr('msg.userfront.error.title')
|
||||
: (hasCode
|
||||
? tr(
|
||||
@@ -43,7 +304,40 @@ class ErrorScreen extends StatelessWidget {
|
||||
params: {'code': normalizedCode},
|
||||
)
|
||||
: tr('msg.userfront.error.title_generic'));
|
||||
final detail = isProd
|
||||
final tenantLabelFromDetails = _extractCurrentTenantLabel(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final tenantLabel = tenantLabelFromDetails.isNotEmpty
|
||||
? tenantLabelFromDetails
|
||||
: _extractTenantLabel(_sessionProfile);
|
||||
final emailFromDetails = _extractAccountEmail(_tenantAccessDetails);
|
||||
final emailLabel = emailFromDetails.isNotEmpty
|
||||
? emailFromDetails
|
||||
: (_sessionProfile?['email']?.toString().trim() ?? '');
|
||||
final affiliatedTenantLabels =
|
||||
_extractAffiliatedTenantLabelsFromDetails(
|
||||
_tenantAccessDetails,
|
||||
).isNotEmpty
|
||||
? _extractAffiliatedTenantLabelsFromDetails(_tenantAccessDetails)
|
||||
: _extractAffiliatedTenantLabelsFromProfile(_sessionProfile);
|
||||
final allowedTenantLabels = _extractAllowedTenantLabels(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final isLoadingTenantContext =
|
||||
_isLoadingSessionProfile && _tenantAccessDetails == null;
|
||||
final hasTenantLookupFailure =
|
||||
_sessionProfileError != null &&
|
||||
_sessionProfileError!.isNotEmpty &&
|
||||
_tenantAccessDetails == null;
|
||||
final showTenantLookupFallback =
|
||||
_tenantAccessDetails == null &&
|
||||
(emailLabel.isEmpty || tenantLabel.isEmpty);
|
||||
final detail = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.detail',
|
||||
fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
|
||||
)
|
||||
: isProd
|
||||
? (isInternalWhitelisted
|
||||
? tr(
|
||||
'msg.userfront.error.whitelist.$normalizedCode',
|
||||
@@ -52,112 +346,312 @@ class ErrorScreen extends StatelessWidget {
|
||||
: (isOryBypass
|
||||
? tr(
|
||||
'msg.userfront.error.ory.$normalizedCode',
|
||||
fallback: (description?.isNotEmpty == true)
|
||||
? description
|
||||
fallback: (widget.description?.isNotEmpty == true)
|
||||
? widget.description
|
||||
: tr('msg.userfront.error.detail_request'),
|
||||
)
|
||||
: tr('msg.userfront.error.detail_contact')))
|
||||
: ((description?.isNotEmpty == true)
|
||||
? description!
|
||||
: ((widget.description?.isNotEmpty == true)
|
||||
? widget.description!
|
||||
: (hasCode
|
||||
? tr('msg.userfront.error.detail_generic')
|
||||
: tr('msg.userfront.error.detail_request')));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 48,
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const ThemeToggleButton(compact: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (isTenantAccessBlocked) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.title',
|
||||
fallback: '접근 제한 정보',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (isLoadingTenantContext)
|
||||
Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.loading',
|
||||
fallback: '현재 계정 정보를 불러오는 중입니다.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.account',
|
||||
fallback: '계정',
|
||||
),
|
||||
value: emailLabel.isNotEmpty
|
||||
? emailLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.account_unknown',
|
||||
fallback: '알 수 없음',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.primary_tenant',
|
||||
fallback: '대표 소속 테넌트',
|
||||
),
|
||||
value: tenantLabel.isNotEmpty
|
||||
? tenantLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: '알 수 없음',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.affiliated_tenants',
|
||||
fallback: '전체 소속 테넌트',
|
||||
),
|
||||
value: affiliatedTenantLabels.isNotEmpty
|
||||
? affiliatedTenantLabels.join(', ')
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: '알 수 없음',
|
||||
),
|
||||
),
|
||||
if (showTenantLookupFallback) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.lookup_fallback',
|
||||
fallback:
|
||||
'표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasTenantLookupFailure) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.load_failed',
|
||||
fallback:
|
||||
'계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.allowed_box_title',
|
||||
fallback: '접속 가능 테넌트',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (allowedTenantLabels.isNotEmpty) ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: '접속 가능 테넌트',
|
||||
),
|
||||
value: allowedTenantLabels.join(', '),
|
||||
),
|
||||
] else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: '접속 가능 테넌트',
|
||||
),
|
||||
value: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: '알 수 없음',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.type',
|
||||
params: {'type': errorType},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (widget.errorId != null &&
|
||||
widget.errorId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.id',
|
||||
params: {'id': widget.errorId!},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: isTenantAccessBlocked
|
||||
? _switchAccount
|
||||
: () => context.go('/login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isTenantAccessBlocked
|
||||
? tr('ui.userfront.error.switch_account')
|
||||
: tr('ui.userfront.error.go_login'),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go(
|
||||
buildLocalizedHomePath(Uri.base),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.userfront.error.go_home')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const ThemeToggleButton(compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr('msg.userfront.error.type', params: {'type': errorType}),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (errorId != null && errorId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr('msg.userfront.error.id', params: {'id': errorId!}),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.userfront.error.go_login')),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () =>
|
||||
context.go(buildLocalizedHomePath(Uri.base)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.userfront.error.go_home')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -166,3 +660,39 @@ class ErrorScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,6 +503,21 @@ const Map<String, String> koStrings = {
|
||||
"msg.userfront.error.title_generic": "오류가 발생했습니다",
|
||||
"msg.userfront.error.title_with_code": "오류: {{code}}",
|
||||
"msg.userfront.error.type": "오류 종류: {{type}}",
|
||||
"msg.userfront.error.tenant.account": "계정",
|
||||
"msg.userfront.error.tenant.account_unknown": "알 수 없음",
|
||||
"msg.userfront.error.tenant.affiliated_tenants": "전체 소속 테넌트",
|
||||
"msg.userfront.error.tenant.allowed_tenants": "접속 가능 테넌트",
|
||||
"msg.userfront.error.tenant.allowed_box_title": "접속 가능 테넌트",
|
||||
"msg.userfront.error.tenant.detail": "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.",
|
||||
"msg.userfront.error.tenant.load_failed": "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.",
|
||||
"msg.userfront.error.tenant.lookup_fallback":
|
||||
"표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.",
|
||||
"msg.userfront.error.tenant.page_title": "애플리케이션 접근이 제한되었습니다",
|
||||
"msg.userfront.error.tenant.loading": "현재 계정 정보를 불러오는 중입니다.",
|
||||
"msg.userfront.error.tenant.primary_tenant": "대표 소속 테넌트",
|
||||
"msg.userfront.error.tenant.tenant": "소속 테넌트",
|
||||
"msg.userfront.error.tenant.tenant_unknown": "알 수 없음",
|
||||
"msg.userfront.error.tenant.title": "접근 제한 정보",
|
||||
"msg.userfront.error.whitelist.\"\$normalizedCode\"": "{{error}}",
|
||||
"msg.userfront.error.whitelist.bad_request": "입력값을 확인해 주세요.",
|
||||
"msg.userfront.error.whitelist.invalid_session": "세션이 만료되었습니다. 다시 로그인해 주세요.",
|
||||
@@ -514,6 +529,7 @@ const Map<String, String> koStrings = {
|
||||
"재설정 링크가 만료되었습니다. 다시 요청해 주세요.",
|
||||
"msg.userfront.error.whitelist.recovery_invalid": "재설정 링크가 유효하지 않습니다.",
|
||||
"msg.userfront.error.whitelist.settings_disabled": "현재 계정 설정 화면은 준비 중입니다.",
|
||||
"msg.userfront.error.whitelist.tenant_not_allowed": "허용되지 않은 테넌트입니다.",
|
||||
"msg.userfront.error.whitelist.verification_required":
|
||||
"추가 인증이 필요합니다. 안내에 따라 진행해 주세요.",
|
||||
"msg.userfront.forgot.description":
|
||||
@@ -1738,6 +1754,7 @@ const Map<String, String> koStrings = {
|
||||
"ui.userfront.device.windows": "Desktop(Windows)",
|
||||
"ui.userfront.error.go_home": "홈으로 이동",
|
||||
"ui.userfront.error.go_login": "로그인으로 이동",
|
||||
"ui.userfront.error.switch_account": "다른 계정으로 로그인",
|
||||
"ui.userfront.forgot.heading": "비밀번호를 잊으셨나요?",
|
||||
"ui.userfront.forgot.input_label": "이메일 또는 휴대폰 번호",
|
||||
"ui.userfront.forgot.submit": "재설정 링크 전송",
|
||||
@@ -2436,6 +2453,24 @@ const Map<String, String> enStrings = {
|
||||
"msg.userfront.error.title_generic": "An error occurred.",
|
||||
"msg.userfront.error.title_with_code": "Error: {{code}}",
|
||||
"msg.userfront.error.type": "Error type: {{type}}",
|
||||
"msg.userfront.error.tenant.account": "Account",
|
||||
"msg.userfront.error.tenant.account_unknown": "Unknown",
|
||||
"msg.userfront.error.tenant.affiliated_tenants": "All affiliated tenants",
|
||||
"msg.userfront.error.tenant.allowed_tenants": "Allowed tenants",
|
||||
"msg.userfront.error.tenant.allowed_box_title": "Allowed tenants",
|
||||
"msg.userfront.error.tenant.detail":
|
||||
"The currently signed-in account cannot access this application.",
|
||||
"msg.userfront.error.tenant.load_failed":
|
||||
"We could not confirm the account details. Please try again.",
|
||||
"msg.userfront.error.tenant.lookup_fallback":
|
||||
"Some fields could not be verified because the access context was incomplete.",
|
||||
"msg.userfront.error.tenant.page_title":
|
||||
"Access to this application is restricted",
|
||||
"msg.userfront.error.tenant.loading": "Loading the current account details.",
|
||||
"msg.userfront.error.tenant.primary_tenant": "Primary affiliated tenant",
|
||||
"msg.userfront.error.tenant.tenant": "Tenant",
|
||||
"msg.userfront.error.tenant.tenant_unknown": "Unknown",
|
||||
"msg.userfront.error.tenant.title": "Access restriction details",
|
||||
"msg.userfront.error.whitelist.\"\$normalizedCode\"": "{{error}}",
|
||||
"msg.userfront.error.whitelist.bad_request": "Please check your input.",
|
||||
"msg.userfront.error.whitelist.invalid_session":
|
||||
@@ -2452,6 +2487,8 @@ const Map<String, String> enStrings = {
|
||||
"The recovery link is invalid.",
|
||||
"msg.userfront.error.whitelist.settings_disabled":
|
||||
"Account settings are currently unavailable.",
|
||||
"msg.userfront.error.whitelist.tenant_not_allowed":
|
||||
"This tenant is not allowed.",
|
||||
"msg.userfront.error.whitelist.verification_required":
|
||||
"Additional verification is required. Please follow the instructions.",
|
||||
"msg.userfront.forgot.description":
|
||||
@@ -3752,6 +3789,7 @@ const Map<String, String> enStrings = {
|
||||
"ui.userfront.device.windows": "Desktop(Windows)",
|
||||
"ui.userfront.error.go_home": "Go Home",
|
||||
"ui.userfront.error.go_login": "Go Login",
|
||||
"ui.userfront.error.switch_account": "Sign in with another account",
|
||||
"ui.userfront.forgot.heading": "Forgot your password?",
|
||||
"ui.userfront.forgot.input_label": "Email address or phone number",
|
||||
"ui.userfront.forgot.submit": "Send reset link",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -38,6 +40,19 @@ import 'i18n.dart';
|
||||
|
||||
final _log = Logger('Main');
|
||||
|
||||
Map<String, dynamic>? _decodeErrorDetails(String? raw) {
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _attemptRecoveryFromNullCheck({
|
||||
required Object exception,
|
||||
StackTrace? stackTrace,
|
||||
@@ -398,6 +413,7 @@ final _router = GoRouter(
|
||||
errorCode: params['error'],
|
||||
description:
|
||||
params['error_description'] ?? params['message'],
|
||||
tenantAccessDetails: _decodeErrorDetails(params['details']),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
23
userfront/test/consent_error_routing_test.dart
Normal file
23
userfront/test/consent_error_routing_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,8 @@ Future<void> _pumpErrorScreen(
|
||||
String? errorCode,
|
||||
String? description,
|
||||
bool? isProdOverride,
|
||||
Future<Map<String, dynamic>> Function()? sessionProfileLoader,
|
||||
Map<String, dynamic>? tenantAccessDetails,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -16,6 +18,8 @@ Future<void> _pumpErrorScreen(
|
||||
errorCode: errorCode,
|
||||
description: description,
|
||||
isProdOverride: isProdOverride,
|
||||
sessionProfileLoader: sessionProfileLoader,
|
||||
tenantAccessDetails: tenantAccessDetails,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -193,4 +197,79 @@ void main() {
|
||||
expect(find.text(type), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tenant_not_allowed는 전용 차단 정보를 노출한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'tenant_not_allowed',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
sessionProfileLoader: () async {
|
||||
return {
|
||||
'email': 'employee@example.com',
|
||||
'tenant': {'name': 'Baron HQ', 'slug': 'baron-hq'},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
final title = tr(
|
||||
'msg.userfront.error.tenant.page_title',
|
||||
fallback: '애플리케이션 접근이 제한되었습니다',
|
||||
);
|
||||
final detail = tr(
|
||||
'msg.userfront.error.tenant.detail',
|
||||
fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
|
||||
);
|
||||
final account = tr('msg.userfront.error.tenant.account', fallback: '계정');
|
||||
final primaryTenant = tr(
|
||||
'msg.userfront.error.tenant.primary_tenant',
|
||||
fallback: '대표 소속 테넌트',
|
||||
);
|
||||
final affiliatedTenants = tr(
|
||||
'msg.userfront.error.tenant.affiliated_tenants',
|
||||
fallback: '전체 소속 테넌트',
|
||||
);
|
||||
final switchAccount = tr(
|
||||
'ui.userfront.error.switch_account',
|
||||
fallback: '다른 계정으로 로그인',
|
||||
);
|
||||
|
||||
expect(find.text(title), findsOneWidget);
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text(account), findsOneWidget);
|
||||
expect(find.text('employee@example.com'), findsOneWidget);
|
||||
expect(find.text(primaryTenant), findsOneWidget);
|
||||
expect(find.text(affiliatedTenants), findsOneWidget);
|
||||
expect(find.text('Baron HQ'), findsNWidgets(2));
|
||||
expect(find.text(switchAccount), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tenant_not_allowed는 details를 우선 사용해 계정과 테넌트 정보를 노출한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'tenant_not_allowed',
|
||||
isProdOverride: true,
|
||||
tenantAccessDetails: {
|
||||
'account': {'email': 'dyddus1210@gmail.com'},
|
||||
'current_tenant': {'name': 'test1 company', 'slug': 'test1-company'},
|
||||
'affiliated_tenants': [
|
||||
{'name': 'test1 company', 'slug': 'test1-company'},
|
||||
{'name': 'test2 company', 'slug': 'test-company'},
|
||||
],
|
||||
'allowed_tenants': [
|
||||
{'name': 'test4', 'slug': 'test4'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(find.text('dyddus1210@gmail.com'), findsOneWidget);
|
||||
expect(find.text('test1 company'), findsOneWidget);
|
||||
expect(find.text('test1 company, test2 company'), findsOneWidget);
|
||||
expect(find.text('test4'), findsOneWidget);
|
||||
expect(find.text('알 수 없음'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user