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