forked from baron/baron-sso
Merge remote-tracking branch 'origin/feature/df-claim-tenant' into dev
This commit is contained in:
@@ -146,6 +146,8 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
|||||||
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
|
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
|
||||||
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
|
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
|
||||||
# HYDRA_ERROR_URL=https://sso.hmac.kr/error
|
# HYDRA_ERROR_URL=https://sso.hmac.kr/error
|
||||||
|
# Refresh Token 만료시각 source of truth (Hydra + backend ID Token rt_expires_at claim)
|
||||||
|
HYDRA_REFRESH_TOKEN_TTL=720h
|
||||||
|
|
||||||
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||||
@@ -183,4 +185,3 @@ VITE_ORGCHART_URL=
|
|||||||
|
|
||||||
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
|
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
|
||||||
LOKI_URL=http://loki:3100/loki/api/v1/push
|
LOKI_URL=http://loki:3100/loki/api/v1/push
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ jobs:
|
|||||||
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
|
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
|
||||||
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
|
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
|
||||||
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
|
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
|
||||||
|
"HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}" \
|
||||||
"ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \
|
"ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \
|
||||||
"DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \
|
"DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \
|
||||||
"ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \
|
"ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \
|
||||||
@@ -135,7 +136,7 @@ jobs:
|
|||||||
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR
|
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR
|
||||||
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
|
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
|
||||||
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER
|
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER
|
||||||
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY
|
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY HYDRA_REFRESH_TOKEN_TTL
|
||||||
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
||||||
"
|
"
|
||||||
for key in ${required_dotenv_keys}; do
|
for key in ${required_dotenv_keys}; do
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ jobs:
|
|||||||
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
||||||
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
||||||
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
||||||
|
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
|
||||||
JWKS_URL=${{ vars.JWKS_URL }}
|
JWKS_URL=${{ vars.JWKS_URL }}
|
||||||
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
||||||
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ jobs:
|
|||||||
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
||||||
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
||||||
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
||||||
|
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
|
||||||
JWKS_URL=${{ vars.JWKS_URL }}
|
JWKS_URL=${{ vars.JWKS_URL }}
|
||||||
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
||||||
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
||||||
@@ -152,7 +153,7 @@ jobs:
|
|||||||
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
|
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
|
||||||
ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB
|
ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB
|
||||||
KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL
|
KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL
|
||||||
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL JWKS_URL
|
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL HYDRA_REFRESH_TOKEN_TTL JWKS_URL
|
||||||
OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS
|
OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS
|
||||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET
|
OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET
|
||||||
VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const (
|
|||||||
linkResendCooldown = 60 * time.Second
|
linkResendCooldown = 60 * time.Second
|
||||||
prefixDrySend = "dry_send:"
|
prefixDrySend = "dry_send:"
|
||||||
headlessJWKSFetchTTL = 5 * time.Second
|
headlessJWKSFetchTTL = 5 * time.Second
|
||||||
|
defaultRefreshTokenTTL = 30 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
@@ -1241,9 +1242,31 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
|
|||||||
return claims
|
return claims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hydraRefreshTokenTTL() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("HYDRA_REFRESH_TOKEN_TTL"))
|
||||||
|
if raw == "" {
|
||||||
|
return defaultRefreshTokenTTL
|
||||||
|
}
|
||||||
|
ttl, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || ttl <= 0 {
|
||||||
|
slog.Warn("invalid HYDRA_REFRESH_TOKEN_TTL, falling back to default", "value", raw, "default", defaultRefreshTokenTTL.String(), "error", err)
|
||||||
|
return defaultRefreshTokenTTL
|
||||||
|
}
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[string]any {
|
||||||
|
if claims == nil {
|
||||||
|
claims = map[string]any{}
|
||||||
|
}
|
||||||
|
claims["rt_expires_at"] = issuedAt.Add(hydraRefreshTokenTTL()).Unix()
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
|
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
|
||||||
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
|
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
|
||||||
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
|
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
|
||||||
|
claims = withRefreshTokenExpiryClaim(claims, time.Now())
|
||||||
return withOidcSessionMetadata(claims, sessionID)
|
return withOidcSessionMetadata(claims, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,63 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func assertRefreshTokenExpiryClaimWithin(t *testing.T, claims map[string]any, issuedAfter, issuedBefore time.Time, ttl time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rawExpiresAt, ok := claims["rt_expires_at"]
|
||||||
|
if !assert.True(t, ok, "rt_expires_at claim should exist") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAtFloat, ok := rawExpiresAt.(float64)
|
||||||
|
if !assert.True(t, ok, "rt_expires_at should be encoded as a unix timestamp number") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Unix(int64(expiresAtFloat), 0)
|
||||||
|
assert.False(t, expiresAt.Before(issuedAfter.Add(ttl).Add(-time.Second)), "rt_expires_at should be after or equal to request start + ttl with second precision tolerance")
|
||||||
|
assert.False(t, expiresAt.After(issuedBefore.Add(ttl).Add(time.Second)), "rt_expires_at should be before or equal to request end + ttl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydraRefreshTokenTTL_DefaultAndFallback(t *testing.T) {
|
||||||
|
t.Run("uses explicit env value", func(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "96h")
|
||||||
|
assert.Equal(t, 96*time.Hour, hydraRefreshTokenTTL())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses default when env is empty", func(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "")
|
||||||
|
assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses default when env is invalid", func(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "not-a-duration")
|
||||||
|
assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses default when env is non-positive", func(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "0h")
|
||||||
|
assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "36h")
|
||||||
|
|
||||||
|
issuedAt := time.Date(2026, time.June, 15, 14, 0, 0, 0, time.UTC)
|
||||||
|
claims := withRefreshTokenExpiryClaim(map[string]any{"email": "user@test.com"}, issuedAt)
|
||||||
|
|
||||||
|
assert.Equal(t, "user@test.com", claims["email"])
|
||||||
|
assert.Equal(t, issuedAt.Add(36*time.Hour).Unix(), claims["rt_expires_at"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
||||||
traits := map[string]any{
|
traits := map[string]any{
|
||||||
"email": "user@baron.com",
|
"email": "user@baron.com",
|
||||||
@@ -131,6 +182,7 @@ func TestRepresentativeTenantIDFromTraits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "48h")
|
||||||
var capturedClaims map[string]any
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
@@ -213,7 +265,9 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
issuedAfter := time.Now()
|
||||||
resp, err := app.Test(req)
|
resp, err := app.Test(req)
|
||||||
|
issuedBefore := time.Now()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -223,6 +277,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
|||||||
assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"])
|
assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"])
|
||||||
assert.Equal(t, "Innovation", capturedClaims["department"])
|
assert.Equal(t, "Innovation", capturedClaims["department"])
|
||||||
assert.Equal(t, "Architect", capturedClaims["position"])
|
assert.Equal(t, "Architect", capturedClaims["position"])
|
||||||
|
assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 48*time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) {
|
func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) {
|
||||||
@@ -603,6 +658,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "24h")
|
||||||
var capturedClaims map[string]any
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
@@ -678,7 +734,9 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
|||||||
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
|
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil)
|
||||||
|
issuedAfter := time.Now()
|
||||||
resp, err := app.Test(req)
|
resp, err := app.Test(req)
|
||||||
|
issuedBefore := time.Now()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -688,6 +746,87 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
|||||||
assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"])
|
assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"])
|
||||||
assert.Equal(t, "Security", capturedClaims["department"])
|
assert.Equal(t, "Security", capturedClaims["department"])
|
||||||
assert.Equal(t, "Officer", capturedClaims["position"])
|
assert.Equal(t, "Officer", capturedClaims["position"])
|
||||||
|
assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 24*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConsentRequest_LocalConsentAutoApprove_IncludesRefreshTokenExpiryClaim(t *testing.T) {
|
||||||
|
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "72h")
|
||||||
|
|
||||||
|
var capturedClaims map[string]any
|
||||||
|
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
|
"challenge": "challenge-local-auto-approve",
|
||||||
|
"requested_scope": []string{"openid", "profile"},
|
||||||
|
"skip": false,
|
||||||
|
"subject": "user-local-123",
|
||||||
|
"client": map[string]any{
|
||||||
|
"client_id": "local-app",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"tenant_id": "tenant-local",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var acceptReq map[string]any
|
||||||
|
json.Unmarshal(body, &acceptReq)
|
||||||
|
if session, ok := acceptReq["session"].(map[string]any); ok {
|
||||||
|
capturedClaims = session["id_token"].(map[string]any)
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
|
"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 }()
|
||||||
|
|
||||||
|
consentRepo := &mockConsentRepo{
|
||||||
|
consents: []domain.ClientConsent{
|
||||||
|
{
|
||||||
|
ClientID: "local-app",
|
||||||
|
Subject: "user-local-123",
|
||||||
|
GrantedScopes: []string{"openid", "profile"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &AuthHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: client,
|
||||||
|
},
|
||||||
|
KratosAdmin: new(MockKratosAdminService),
|
||||||
|
ConsentRepo: consentRepo,
|
||||||
|
}
|
||||||
|
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-local-123").Return(&service.KratosIdentity{
|
||||||
|
ID: "user-local-123",
|
||||||
|
Traits: map[string]any{
|
||||||
|
"email": "local@test.com",
|
||||||
|
"name": "Local User",
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-local-auto-approve", nil)
|
||||||
|
issuedAfter := time.Now()
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
issuedBefore := time.Now()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
assert.NotNil(t, capturedClaims)
|
||||||
|
assert.Equal(t, "local@test.com", capturedClaims["email"])
|
||||||
|
assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 72*time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {
|
func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const navigateMock = vi.fn();
|
|||||||
const fetchClientMock = vi.fn();
|
const fetchClientMock = vi.fn();
|
||||||
const updateClientMock = vi.fn();
|
const updateClientMock = vi.fn();
|
||||||
const fetchClientRelationsMock = vi.fn();
|
const fetchClientRelationsMock = vi.fn();
|
||||||
const fetchMyTenantsMock = vi.fn();
|
const fetchTenantsMock = vi.fn();
|
||||||
const fetchMeMock = vi.fn();
|
const fetchMeMock = vi.fn();
|
||||||
|
|
||||||
let authState = {
|
let authState = {
|
||||||
@@ -45,7 +45,7 @@ vi.mock("../../lib/devApi", () => ({
|
|||||||
fetchClient: (...args: unknown[]) => fetchClientMock(...args),
|
fetchClient: (...args: unknown[]) => fetchClientMock(...args),
|
||||||
fetchClientRelations: (...args: unknown[]) =>
|
fetchClientRelations: (...args: unknown[]) =>
|
||||||
fetchClientRelationsMock(...args),
|
fetchClientRelationsMock(...args),
|
||||||
fetchMyTenants: (...args: unknown[]) => fetchMyTenantsMock(...args),
|
fetchTenants: (...args: unknown[]) => fetchTenantsMock(...args),
|
||||||
refreshHeadlessJwksCache: vi.fn(),
|
refreshHeadlessJwksCache: vi.fn(),
|
||||||
revokeHeadlessJwksCache: vi.fn(),
|
revokeHeadlessJwksCache: vi.fn(),
|
||||||
updateClient: (...args: unknown[]) => updateClientMock(...args),
|
updateClient: (...args: unknown[]) => updateClientMock(...args),
|
||||||
@@ -217,7 +217,12 @@ describe("ClientGeneralPage RP claims", () => {
|
|||||||
fetchClientMock.mockResolvedValue(makeClientDetail("old_claim"));
|
fetchClientMock.mockResolvedValue(makeClientDetail("old_claim"));
|
||||||
updateClientMock.mockResolvedValue(makeClientDetail("new_claim"));
|
updateClientMock.mockResolvedValue(makeClientDetail("new_claim"));
|
||||||
fetchClientRelationsMock.mockResolvedValue({ items: [] });
|
fetchClientRelationsMock.mockResolvedValue({ items: [] });
|
||||||
fetchMyTenantsMock.mockResolvedValue([]);
|
fetchTenantsMock.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
fetchMeMock.mockResolvedValue({
|
fetchMeMock.mockResolvedValue({
|
||||||
id: "admin-user",
|
id: "admin-user",
|
||||||
role: "super_admin",
|
role: "super_admin",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
|
||||||
Shield,
|
Shield,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -27,16 +26,24 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
|
import { CopyButton } from "../../components/ui/copy-button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
import { Switch } from "../../components/ui/switch";
|
import { Switch } from "../../components/ui/switch";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import type {
|
import type {
|
||||||
ClientStatus,
|
ClientStatus,
|
||||||
ClientType,
|
ClientType,
|
||||||
ClientUpsertRequest,
|
ClientUpsertRequest,
|
||||||
MyTenantSummary,
|
|
||||||
TenantSummary,
|
TenantSummary,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +52,7 @@ import {
|
|||||||
deleteClient,
|
deleteClient,
|
||||||
fetchClient,
|
fetchClient,
|
||||||
fetchClientRelations,
|
fetchClientRelations,
|
||||||
fetchMyTenants,
|
fetchTenants,
|
||||||
refreshHeadlessJwksCache,
|
refreshHeadlessJwksCache,
|
||||||
revokeHeadlessJwksCache,
|
revokeHeadlessJwksCache,
|
||||||
updateClient,
|
updateClient,
|
||||||
@@ -57,7 +64,7 @@ import { cn } from "../../lib/utils";
|
|||||||
import { fetchMe, type UserProfile } from "../auth/authApi";
|
import { fetchMe, type UserProfile } from "../auth/authApi";
|
||||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||||
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
|
import { TenantAccessPicker } from "./components/TenantAccessPicker";
|
||||||
import {
|
import {
|
||||||
claimDateTimeValueToInputString,
|
claimDateTimeValueToInputString,
|
||||||
dateTimeInputToUnixSeconds,
|
dateTimeInputToUnixSeconds,
|
||||||
@@ -597,8 +604,8 @@ function ClientGeneralPage() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const { data: tenantData } = useQuery({
|
const { data: tenantData } = useQuery({
|
||||||
queryKey: ["my-tenants"],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: fetchMyTenants,
|
queryFn: () => fetchTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -622,8 +629,6 @@ function ClientGeneralPage() {
|
|||||||
] = useState(false);
|
] = useState(false);
|
||||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||||
const [tenantSearch, setTenantSearch] = useState("");
|
|
||||||
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
|
||||||
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
||||||
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
||||||
|
|
||||||
@@ -990,10 +995,6 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const handleTenantAccessToggle = (enabled: boolean) => {
|
const handleTenantAccessToggle = (enabled: boolean) => {
|
||||||
setTenantAccessRestricted(enabled);
|
setTenantAccessRestricted(enabled);
|
||||||
setIsTenantSearchOpen(enabled);
|
|
||||||
if (!enabled) {
|
|
||||||
setTenantSearch("");
|
|
||||||
}
|
|
||||||
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1009,8 +1010,6 @@ function ClientGeneralPage() {
|
|||||||
setAllowedTenantIds((current) =>
|
setAllowedTenantIds((current) =>
|
||||||
current.includes(tenantId) ? current : [...current, tenantId],
|
current.includes(tenantId) ? current : [...current, tenantId],
|
||||||
);
|
);
|
||||||
setTenantSearch("");
|
|
||||||
setIsTenantSearchOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addScope = () => {
|
const addScope = () => {
|
||||||
@@ -1270,25 +1269,10 @@ function ClientGeneralPage() {
|
|||||||
normalizedIdTokenClaimItems,
|
normalizedIdTokenClaimItems,
|
||||||
);
|
);
|
||||||
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
|
||||||
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
|
const selectedAllowedTenants = allowedTenantIds
|
||||||
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
||||||
.filter(
|
.filter((tenant): tenant is TenantSummary => tenant != null);
|
||||||
(tenant): tenant is TenantSummary | MyTenantSummary => tenant != null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -2322,12 +2306,20 @@ function ClientGeneralPage() {
|
|||||||
"테넌트 접근 제한",
|
"테넌트 접근 제한",
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<div className="text-sm text-muted-foreground">
|
||||||
{t(
|
<p className="leading-relaxed">
|
||||||
"ui.dev.clients.general.tenant_access.subtitle",
|
{t(
|
||||||
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
"ui.dev.clients.general.tenant_access.subtitle",
|
||||||
)}
|
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
||||||
</CardDescription>
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.hint",
|
||||||
|
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
<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">
|
<div className="space-y-0.5 text-right">
|
||||||
@@ -2358,154 +2350,168 @@ function ClientGeneralPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
{tenantAccessRestricted ? (
|
||||||
{t(
|
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
|
||||||
"ui.dev.clients.general.tenant_access.hint",
|
<div className="space-y-3">
|
||||||
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
<Label className="text-sm font-semibold">
|
||||||
)}
|
{t(
|
||||||
</p>
|
"ui.dev.clients.general.tenant_access.picker_label",
|
||||||
|
"허용 테넌트 추가",
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
)}{" "}
|
||||||
<div className="space-y-3">
|
<span className="text-destructive">*</span>
|
||||||
<Label htmlFor="tenant-search" className="text-sm font-semibold">
|
</Label>
|
||||||
{t(
|
<TenantAccessPicker
|
||||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
disabled={isGeneralSettingsReadOnly}
|
||||||
"테넌트 이름 또는 슬러그로 검색",
|
selectedCount={allowedTenantIds.length}
|
||||||
)}
|
onSelectTenant={(selection) =>
|
||||||
</Label>
|
handleSelectAllowedTenant(selection.id)
|
||||||
<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={
|
|
||||||
isGeneralSettingsReadOnly || !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);
|
|
||||||
}}
|
|
||||||
disabled={isGeneralSettingsReadOnly}
|
|
||||||
>
|
|
||||||
<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>
|
||||||
<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">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t(
|
{t(
|
||||||
"ui.dev.clients.general.tenant_access.selected_title",
|
"ui.dev.clients.general.tenant_access.selected_title",
|
||||||
"허용 테넌트",
|
"허용 테넌트",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
|
<div className="overflow-hidden rounded-md border border-border bg-background">
|
||||||
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
{allowedTenantIds.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="max-h-80 overflow-auto">
|
||||||
{selectedAllowedTenants.map((tenant) => (
|
<Table>
|
||||||
<AllowedTenantBadge
|
<TableHeader className="sticky top-0 z-10 bg-muted/50">
|
||||||
key={tenant.id}
|
<TableRow>
|
||||||
tenant={tenant}
|
<TableHead className="w-[34%] px-4 py-3 text-left font-bold">
|
||||||
onRemove={() => toggleAllowedTenant(tenant.id)}
|
{t(
|
||||||
disabled={isGeneralSettingsReadOnly}
|
"ui.dev.clients.general.tenant_access.table.name",
|
||||||
/>
|
"테넌트명",
|
||||||
))}
|
)}
|
||||||
{allowedTenantIds
|
</TableHead>
|
||||||
.filter(
|
<TableHead className="w-[18%] px-4 py-3 text-left font-bold">
|
||||||
(tenantId) =>
|
{t(
|
||||||
!selectedAllowedTenants.some(
|
"ui.dev.clients.general.tenant_access.table.slug",
|
||||||
(tenant) => tenant.id === tenantId,
|
"슬러그",
|
||||||
),
|
)}
|
||||||
)
|
</TableHead>
|
||||||
.map((tenantId) => (
|
<TableHead className="px-4 py-3 text-left font-bold">
|
||||||
<AllowedTenantBadge
|
{t(
|
||||||
key={tenantId}
|
"ui.dev.clients.general.tenant_access.table.id",
|
||||||
tenant={{ id: tenantId, name: tenantId }}
|
"테넌트 ID",
|
||||||
onRemove={() => toggleAllowedTenant(tenantId)}
|
)}
|
||||||
disabled={isGeneralSettingsReadOnly}
|
</TableHead>
|
||||||
/>
|
<TableHead className="w-[112px] px-4 py-3 text-right font-bold">
|
||||||
))}
|
{t(
|
||||||
</div>
|
"ui.dev.clients.general.tenant_access.table.actions",
|
||||||
) : (
|
"작업",
|
||||||
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
)}
|
||||||
{tenantAccessRestricted
|
</TableHead>
|
||||||
? t(
|
</TableRow>
|
||||||
"ui.dev.clients.general.tenant_access.selected_empty",
|
</TableHeader>
|
||||||
"아직 선택된 테넌트가 없습니다.",
|
<TableBody>
|
||||||
)
|
{selectedAllowedTenants.map((tenant) => (
|
||||||
: t(
|
<TableRow
|
||||||
"ui.dev.clients.general.tenant_access.disabled",
|
key={tenant.id}
|
||||||
"제한 없음",
|
data-testid={`allowed-tenant-${tenant.id}`}
|
||||||
)}
|
>
|
||||||
</div>
|
<TableCell className="px-4 py-3 font-medium">
|
||||||
)}
|
{tenant.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-muted-foreground">
|
||||||
|
{tenant.slug || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||||
|
<span className="break-all">{tenant.id}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right">
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<CopyButton
|
||||||
|
aria-label="테넌트 UUID 복사"
|
||||||
|
className="h-8 w-8"
|
||||||
|
data-testid={`allowed-tenant-copy-${tenant.id}`}
|
||||||
|
size="icon"
|
||||||
|
value={tenant.id}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("ui.common.delete", "삭제")}
|
||||||
|
onClick={() =>
|
||||||
|
toggleAllowedTenant(tenant.id)
|
||||||
|
}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
|
||||||
|
data-testid={`allowed-tenant-remove-${tenant.id}`}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{allowedTenantIds
|
||||||
|
.filter(
|
||||||
|
(tenantId) =>
|
||||||
|
!selectedAllowedTenants.some(
|
||||||
|
(tenant) => tenant.id === tenantId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((tenantId) => (
|
||||||
|
<TableRow
|
||||||
|
key={tenantId}
|
||||||
|
data-testid={`allowed-tenant-${tenantId}`}
|
||||||
|
>
|
||||||
|
<TableCell className="px-4 py-3 font-medium">
|
||||||
|
{tenantId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-muted-foreground">
|
||||||
|
-
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||||
|
<span className="break-all">{tenantId}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right">
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<CopyButton
|
||||||
|
aria-label="테넌트 UUID 복사"
|
||||||
|
className="h-8 w-8"
|
||||||
|
data-testid={`allowed-tenant-copy-${tenantId}`}
|
||||||
|
size="icon"
|
||||||
|
value={tenantId}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("ui.common.delete", "삭제")}
|
||||||
|
onClick={() =>
|
||||||
|
toggleAllowedTenant(tenantId)
|
||||||
|
}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
|
||||||
|
data-testid={`allowed-tenant-remove-${tenantId}`}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[99px] items-center px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.selected_empty",
|
||||||
|
"아직 선택된 테넌트가 없습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
146
devfront/src/features/clients/components/TenantAccessPicker.tsx
Normal file
146
devfront/src/features/clients/components/TenantAccessPicker.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Building2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
import {
|
||||||
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
|
type OrgChartTenantSelection,
|
||||||
|
parseOrgChartTenantSelection,
|
||||||
|
} from "../orgChartPicker";
|
||||||
|
|
||||||
|
type TenantAccessPickerProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
onSelectTenant: (selection: OrgChartTenantSelection) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveOrgFrontBaseUrl() {
|
||||||
|
return (
|
||||||
|
import.meta.env.VITE_ORGFRONT_PUBLIC_URL ||
|
||||||
|
import.meta.env.ORGFRONT_URL ||
|
||||||
|
"http://localhost:5175"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenantAccessPicker({
|
||||||
|
disabled = false,
|
||||||
|
selectedCount,
|
||||||
|
onSelectTenant,
|
||||||
|
}: TenantAccessPickerProps) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const pickerUrl = useMemo(
|
||||||
|
() => buildAuthenticatedOrgChartTenantPickerUrl(resolveOrgFrontBaseUrl()),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pickerOpen) return;
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
const selection = parseOrgChartTenantSelection(event.data);
|
||||||
|
if (!selection) return;
|
||||||
|
onSelectTenant(selection);
|
||||||
|
setPickerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
}, [onSelectTenant, pickerOpen]);
|
||||||
|
|
||||||
|
const pickerDialog =
|
||||||
|
pickerOpen && typeof document !== "undefined"
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[1000] flex items-start justify-center overflow-y-auto bg-black/50 p-4 md:items-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t(
|
||||||
|
"ui.dev.clients.general.tenant_access.picker_title",
|
||||||
|
"테넌트 선택",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[92vh] w-[min(96vw,1200px)] flex-col overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-2xl">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.picker_title",
|
||||||
|
"테넌트 선택",
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.tenant_access.picker_description",
|
||||||
|
"orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setPickerOpen(false)}
|
||||||
|
>
|
||||||
|
{t("ui.common.close", "닫기")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-md border">
|
||||||
|
<iframe
|
||||||
|
title={t(
|
||||||
|
"ui.dev.clients.general.tenant_access.picker_title",
|
||||||
|
"테넌트 선택",
|
||||||
|
)}
|
||||||
|
src={pickerUrl}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex shrink-0 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPickerOpen(false)}
|
||||||
|
>
|
||||||
|
{t("ui.common.close", "닫기")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 gap-2"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.open_picker",
|
||||||
|
"테넌트 선택기 열기",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{pickerDialog}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{selectedCount > 0
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.general.tenant_access.picker_hint_with_count",
|
||||||
|
"현재 {{count}}개가 선택되어 있습니다.",
|
||||||
|
{ count: selectedCount },
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.dev.clients.general.tenant_access.picker_hint",
|
||||||
|
"선택기를 열어 허용 테넌트를 추가하세요.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
devfront/src/features/clients/orgChartPicker.ts
Normal file
91
devfront/src/features/clients/orgChartPicker.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
export type OrgChartTenantSelection = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrgChartPickerMessage = {
|
||||||
|
type?: unknown;
|
||||||
|
payload?: {
|
||||||
|
selections?: Array<{
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrgChartTenantPickerOptions = {
|
||||||
|
includeInternal?: boolean;
|
||||||
|
tenantId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildAuthenticatedOrgChartUrl(
|
||||||
|
baseUrl?: string,
|
||||||
|
options: { includeInternal?: boolean; returnTo?: string } = {},
|
||||||
|
) {
|
||||||
|
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||||
|
let returnTo = options.returnTo?.trim() || "/chart";
|
||||||
|
if (options.includeInternal && returnTo.startsWith("/chart")) {
|
||||||
|
const [path, query = ""] = returnTo.split("?", 2);
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
params.set("includeInternal", "true");
|
||||||
|
returnTo = `${path}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
auto: "1",
|
||||||
|
returnTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${normalizedBase}/login?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
|
baseUrl?: string,
|
||||||
|
options: OrgChartTenantPickerOptions = {},
|
||||||
|
) {
|
||||||
|
const normalizedBase = (baseUrl ?? "").trim() || "http://localhost:5175";
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
mode: "single",
|
||||||
|
select: "tenant",
|
||||||
|
width: "400",
|
||||||
|
height: "600",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantId = options.tenantId?.trim();
|
||||||
|
if (tenantId) {
|
||||||
|
params.set("tenantId", tenantId);
|
||||||
|
}
|
||||||
|
if (options.includeInternal) {
|
||||||
|
params.set("includeInternal", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerUrl = `/embed/picker?${params.toString()}`;
|
||||||
|
return buildAuthenticatedOrgChartUrl(normalizedBase, {
|
||||||
|
includeInternal: true,
|
||||||
|
returnTo: pickerUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrgChartTenantSelection(
|
||||||
|
message: unknown,
|
||||||
|
): OrgChartTenantSelection | null {
|
||||||
|
const data = message as OrgChartPickerMessage;
|
||||||
|
if (data?.type !== "orgfront:picker:confirm") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = data.payload?.selections?.[0];
|
||||||
|
if (
|
||||||
|
selection?.type !== "tenant" ||
|
||||||
|
typeof selection.id !== "string" ||
|
||||||
|
typeof selection.name !== "string" ||
|
||||||
|
selection.id.trim() === ""
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: selection.id,
|
||||||
|
name: selection.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,8 +65,14 @@ describe("devApi", () => {
|
|||||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
|
||||||
params: { days: 30, period: "week" },
|
params: { days: 30, period: "week" },
|
||||||
});
|
});
|
||||||
expect(apiClient.get).toHaveBeenCalledWith("/tenants", {
|
expect(apiClient.get).toHaveBeenCalledWith("/admin/tenants", {
|
||||||
params: { limit: 25, offset: 50, parentId: "tenant-parent" },
|
params: {
|
||||||
|
limit: 25,
|
||||||
|
offset: 50,
|
||||||
|
parentId: "tenant-parent",
|
||||||
|
cursor: undefined,
|
||||||
|
search: undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
|
||||||
expect(apiClient.get).toHaveBeenCalledWith(
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -283,9 +283,11 @@ export async function fetchTenants(
|
|||||||
limit = 1000,
|
limit = 1000,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
parentId?: string,
|
parentId?: string,
|
||||||
|
cursor?: string,
|
||||||
|
search?: string,
|
||||||
) {
|
) {
|
||||||
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
|
const { data } = await apiClient.get<TenantListResponse>("/admin/tenants", {
|
||||||
params: { limit, offset, parentId },
|
params: { limit, offset, parentId, cursor, search },
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ test.describe("DevFront client tenant access settings", () => {
|
|||||||
],
|
],
|
||||||
consents: [] as Consent[],
|
consents: [] as Consent[],
|
||||||
auditLogsByCursor: undefined,
|
auditLogsByCursor: undefined,
|
||||||
|
myTenants: [
|
||||||
|
{
|
||||||
|
id: existingTenantId,
|
||||||
|
name: "Alpha Tenant",
|
||||||
|
slug: "alpha",
|
||||||
|
description: "Existing allowed tenant",
|
||||||
|
type: "organization",
|
||||||
|
},
|
||||||
|
],
|
||||||
tenants: [
|
tenants: [
|
||||||
{
|
{
|
||||||
id: existingTenantId,
|
id: existingTenantId,
|
||||||
@@ -99,10 +108,27 @@ test.describe("DevFront client tenant access settings", () => {
|
|||||||
)
|
)
|
||||||
.toBe(existingTenantId);
|
.toBe(existingTenantId);
|
||||||
|
|
||||||
await page
|
await page.getByRole("button", { name: /테넌트 선택기 열기/i }).click();
|
||||||
.getByPlaceholder(/테넌트 이름 또는 슬러그로 검색|tenant name or slug/i)
|
await page.evaluate(
|
||||||
.fill("beta");
|
(selection) => {
|
||||||
await page.getByRole("button", { name: /Beta Tenant/i }).click();
|
window.postMessage(
|
||||||
|
{
|
||||||
|
type: "orgfront:picker:confirm",
|
||||||
|
payload: {
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
type: "tenant",
|
||||||
|
id: selection.id,
|
||||||
|
name: selection.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ id: addedTenantId, name: "Beta Tenant" },
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId(`allowed-tenant-${addedTenantId}`),
|
page.getByTestId(`allowed-tenant-${addedTenantId}`),
|
||||||
).toContainText(addedTenantId);
|
).toContainText(addedTenantId);
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export type DevApiMockState = {
|
|||||||
relations?: Record<string, ClientRelation[]>;
|
relations?: Record<string, ClientRelation[]>;
|
||||||
users?: DevAssignableUser[];
|
users?: DevAssignableUser[];
|
||||||
tenants?: DevTenantSummary[];
|
tenants?: DevTenantSummary[];
|
||||||
|
myTenants?: DevTenantSummary[];
|
||||||
auditLogsByCursor?: Record<
|
auditLogsByCursor?: Record<
|
||||||
string,
|
string,
|
||||||
{ items: AuditLog[]; next_cursor?: string }
|
{ items: AuditLog[]; next_cursor?: string }
|
||||||
@@ -437,6 +438,33 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const { searchParams } = url;
|
||||||
|
const tenants = state.tenants ?? [
|
||||||
|
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return json(route, {
|
||||||
|
items: tenants.map((tenant) => ({
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
description: tenant.description ?? "",
|
||||||
|
type: tenant.type ?? "organization",
|
||||||
|
parentId: null,
|
||||||
|
status: "active",
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
})),
|
||||||
|
limit: Number.parseInt(searchParams.get("limit") || "1000", 10),
|
||||||
|
offset: Number.parseInt(searchParams.get("offset") || "0", 10),
|
||||||
|
total: tenants.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/v1/dev/**", async (route) => {
|
await page.route("**/api/v1/dev/**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
@@ -534,9 +562,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
|||||||
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
|
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
|
||||||
return json(
|
return json(
|
||||||
route,
|
route,
|
||||||
state.tenants ?? [
|
state.myTenants ??
|
||||||
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
state.tenants ?? [
|
||||||
],
|
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,3 +96,4 @@ oidc:
|
|||||||
ttl:
|
ttl:
|
||||||
access_token: 15m
|
access_token: 15m
|
||||||
id_token: 15m
|
id_token: 15m
|
||||||
|
refresh_token: ${HYDRA_REFRESH_TOKEN_TTL}
|
||||||
|
|||||||
@@ -183,10 +183,11 @@ KRATOS_DSN="${KRATOS_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWOR
|
|||||||
HYDRA_DSN="${HYDRA_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20}"
|
HYDRA_DSN="${HYDRA_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20}"
|
||||||
KETO_DSN="${KETO_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20}"
|
KETO_DSN="${KETO_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20}"
|
||||||
HYDRA_SYSTEM_SECRET="${HYDRA_SYSTEM_SECRET:-${SECRETS_SYSTEM:-${ORY_POSTGRES_PASSWORD}}}"
|
HYDRA_SYSTEM_SECRET="${HYDRA_SYSTEM_SECRET:-${SECRETS_SYSTEM:-${ORY_POSTGRES_PASSWORD}}}"
|
||||||
|
HYDRA_REFRESH_TOKEN_TTL="${HYDRA_REFRESH_TOKEN_TTL:-720h}"
|
||||||
OATHKEEPER_INTROSPECT_CLIENT_ID="${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}"
|
OATHKEEPER_INTROSPECT_CLIENT_ID="${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}"
|
||||||
OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}"
|
OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}"
|
||||||
|
|
||||||
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
|
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET HYDRA_REFRESH_TOKEN_TTL
|
||||||
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
|
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
|
||||||
|
|
||||||
resolve_kratos_session_cookie_domain
|
resolve_kratos_session_cookie_domain
|
||||||
|
|||||||
@@ -306,6 +306,17 @@ if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
for workflow_file in \
|
||||||
|
"$repo_root/.gitea/workflows/staging_code_pull.yml" \
|
||||||
|
"$repo_root/.gitea/workflows/staging_release.yml" \
|
||||||
|
"$repo_root/.gitea/workflows/production_release.yml"
|
||||||
|
do
|
||||||
|
if ! grep -q 'HYDRA_REFRESH_TOKEN_TTL' "$workflow_file"; then
|
||||||
|
echo "ERROR: workflow must propagate HYDRA_REFRESH_TOKEN_TTL into deployment env: $workflow_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if ! grep -q 'up -d --force-recreate kratos hydra keto oathkeeper' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
|
if ! grep -q 'up -d --force-recreate kratos hydra keto oathkeeper' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
|
||||||
echo "ERROR: staging code pull must restart Ory services after rendering static config." >&2
|
echo "ERROR: staging code pull must restart Ory services after rendering static config." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -334,11 +345,13 @@ KRATOS_UI_URL=https://sso.hmac.kr
|
|||||||
KRATOS_BROWSER_URL=https://sso.hmac.kr/auth
|
KRATOS_BROWSER_URL=https://sso.hmac.kr/auth
|
||||||
KRATOS_ADMIN_URL=http://kratos:4434
|
KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
ORY_POSTGRES_PASSWORD=policy-test
|
ORY_POSTGRES_PASSWORD=policy-test
|
||||||
|
HYDRA_REFRESH_TOKEN_TTL=168h
|
||||||
KRATOS_ALLOWED_RETURN_URLS_JSON=
|
KRATOS_ALLOWED_RETURN_URLS_JSON=
|
||||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=
|
KRATOS_ALLOWED_RETURN_URLS_EXTRA=
|
||||||
EOF
|
EOF
|
||||||
ORY_CONFIG_ENV_FILES="$stage_render_env" ORY_CONFIG_OUTPUT_DIR="$stage_render_dir/ory" "$repo_root/scripts/render_ory_config.sh" >/dev/null
|
ORY_CONFIG_ENV_FILES="$stage_render_env" ORY_CONFIG_OUTPUT_DIR="$stage_render_dir/ory" "$repo_root/scripts/render_ory_config.sh" >/dev/null
|
||||||
stage_rendered_kratos="$stage_render_dir/ory/kratos/kratos.yml"
|
stage_rendered_kratos="$stage_render_dir/ory/kratos/kratos.yml"
|
||||||
|
stage_rendered_hydra="$stage_render_dir/ory/hydra/hydra.yml"
|
||||||
if ! awk '/allowed_return_urls:/ { in_block=1; next } in_block && /^[[:space:]]+methods:/ { exit } in_block { print }' "$stage_rendered_kratos" | grep -q 'https://sso.hmac.kr'; then
|
if ! awk '/allowed_return_urls:/ { in_block=1; next } in_block && /^[[:space:]]+methods:/ { exit } in_block { print }' "$stage_rendered_kratos" | grep -q 'https://sso.hmac.kr'; then
|
||||||
echo "ERROR: rendered stage Kratos config must include the public userfront URL in allowed_return_urls." >&2
|
echo "ERROR: rendered stage Kratos config must include the public userfront URL in allowed_return_urls." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -351,6 +364,10 @@ if ! awk '/session:/ { in_session=1 } in_session && /domain:/ { print; exit }' "
|
|||||||
echo "ERROR: rendered stage Kratos config must derive hmac.kr as session.cookie.domain." >&2
|
echo "ERROR: rendered stage Kratos config must derive hmac.kr as session.cookie.domain." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! awk '/ttl:/ { in_ttl=1; next } in_ttl && /^[^[:space:]]/ { exit } in_ttl { print }' "$stage_rendered_hydra" | grep -q 'refresh_token: 168h'; then
|
||||||
|
echo "ERROR: rendered stage Hydra config must include HYDRA_REFRESH_TOKEN_TTL as ttl.refresh_token." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
rm -rf "$stage_render_dir" "$stage_render_env"
|
rm -rf "$stage_render_dir" "$stage_render_env"
|
||||||
|
|
||||||
for generated_config in \
|
for generated_config in \
|
||||||
|
|||||||
Reference in New Issue
Block a user