forked from baron/baron-sso
Merge pull request 'feature/df-custom-claim' (#692) from feature/df-custom-claim into dev
Reviewed-on: baron/baron-sso#692
This commit is contained in:
@@ -570,10 +570,7 @@ function TenantListPage() {
|
||||
<span>{tenant.name}</span>
|
||||
{isSeedTenant(tenant) && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t(
|
||||
"ui.admin.tenants.seed_badge",
|
||||
"초기 설정",
|
||||
)}
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -103,9 +103,7 @@ test.describe("Seed tenant protection", () => {
|
||||
|
||||
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
|
||||
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
|
||||
await expect(
|
||||
normalRow.getByRole("button", { name: /삭제/ }),
|
||||
).toBeEnabled();
|
||||
await expect(normalRow.getByRole("button", { name: /삭제/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("disables delete action on seed tenant profile", async ({ page }) => {
|
||||
|
||||
@@ -119,6 +119,7 @@ test.describe("Tenants Management", () => {
|
||||
|
||||
test("should export and import tenant CSV without organization/user combined import", async ({
|
||||
page,
|
||||
browserName,
|
||||
}, testInfo) => {
|
||||
let exportRequested = false;
|
||||
let exportUrl = "";
|
||||
@@ -213,8 +214,11 @@ test.describe("Tenants Management", () => {
|
||||
/갱신 1|Updated 1/i,
|
||||
);
|
||||
expect(importRequested).toBe(true);
|
||||
if (testInfo.project.name !== "webkit") {
|
||||
expect(importBody).toContain("tenant-alpha-id");
|
||||
expect(importBody).toContain('filename="tenants.csv"');
|
||||
if (browserName !== "webkit") {
|
||||
if (testInfo.project.name !== "webkit") {
|
||||
expect(importBody).toContain("tenant-alpha-id");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -572,6 +572,7 @@ func main() {
|
||||
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
||||
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
||||
auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS)
|
||||
|
||||
// Signup Routes
|
||||
signup := auth.Group("/signup")
|
||||
|
||||
@@ -6,29 +6,34 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
||||
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||
MetadataHeadlessJWKS = "headless_jwks"
|
||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||
MetadataAutoLoginSupported = "auto_login_supported"
|
||||
MetadataAutoLoginURL = "auto_login_url"
|
||||
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
||||
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||
MetadataHeadlessJWKS = "headless_jwks"
|
||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||
MetadataIDTokenClaims = "id_token_claims"
|
||||
MetadataBackChannelLogoutURI = "backchannel_logout_uri"
|
||||
MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
|
||||
MetadataAutoLoginSupported = "auto_login_supported"
|
||||
MetadataAutoLoginURL = "auto_login_url"
|
||||
)
|
||||
|
||||
type HydraClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
SkipConsent *bool `json:"skip_consent,omitempty"`
|
||||
JWKSUri string `json:"jwks_uri,omitempty"`
|
||||
JWKS interface{} `json:"jwks,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
SkipConsent *bool `json:"skip_consent,omitempty"`
|
||||
JWKSUri string `json:"jwks_uri,omitempty"`
|
||||
JWKS interface{} `json:"jwks,omitempty"`
|
||||
BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
|
||||
BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||
@@ -86,6 +91,29 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *HydraClient) BackchannelLogoutURI() string {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataBackChannelLogoutURI].(string); ok {
|
||||
if value := strings.TrimSpace(raw); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.BackChannelLogoutURI)
|
||||
}
|
||||
|
||||
func (c *HydraClient) BackchannelLogoutSessionRequiredValue() bool {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataBackChannelLogoutSessionRequired].(bool); ok {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
if c.BackChannelLogoutSessionRequired != nil {
|
||||
return *c.BackChannelLogoutSessionRequired
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type HydraConsentRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RequestedScope []string `json:"requested_scope"`
|
||||
|
||||
@@ -94,6 +94,7 @@ type AuthHandler struct {
|
||||
AuditRepo domain.AuditRepository
|
||||
OathkeeperRepo domain.OathkeeperLogRepository
|
||||
Hydra *service.HydraAdminService
|
||||
BackchannelLogout *service.BackchannelLogoutService
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
KetoOutboxRepo repository.KetoOutboxRepository
|
||||
@@ -221,21 +222,26 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D
|
||||
}
|
||||
|
||||
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler {
|
||||
backchannelLogout, err := service.NewBackchannelLogoutService()
|
||||
if err != nil {
|
||||
slog.Warn("failed to initialize backchannel logout service", "error", err)
|
||||
}
|
||||
return &AuthHandler{
|
||||
SmsService: service.NewSmsService(),
|
||||
EmailService: service.NewEmailService(),
|
||||
RedisService: redisService,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
||||
KratosAdmin: kratos,
|
||||
IdpProvider: idpProvider,
|
||||
AuditRepo: auditRepo,
|
||||
OathkeeperRepo: oathkeeperRepo,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
KetoOutboxRepo: ketoOutboxRepo,
|
||||
UserRepo: userRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
SmsService: service.NewSmsService(),
|
||||
EmailService: service.NewEmailService(),
|
||||
RedisService: redisService,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
||||
KratosAdmin: kratos,
|
||||
IdpProvider: idpProvider,
|
||||
AuditRepo: auditRepo,
|
||||
OathkeeperRepo: oathkeeperRepo,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
BackchannelLogout: backchannelLogout,
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
KetoOutboxRepo: ketoOutboxRepo,
|
||||
UserRepo: userRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,6 +1164,60 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
|
||||
return claims
|
||||
}
|
||||
|
||||
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
|
||||
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
|
||||
return withOidcSessionMetadata(claims, sessionID)
|
||||
}
|
||||
|
||||
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any {
|
||||
if baseClaims == nil {
|
||||
baseClaims = map[string]any{}
|
||||
}
|
||||
if metadata == nil {
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
|
||||
if !ok || rawClaims == nil {
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
|
||||
if err != nil {
|
||||
slog.Warn("failed to normalize configured id token claims", "error", err)
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
rpClaims, _ := baseClaims["rp_claims"].(map[string]any)
|
||||
if rpClaims == nil {
|
||||
rpClaims = map[string]any{}
|
||||
}
|
||||
|
||||
for _, claim := range normalizedClaims {
|
||||
value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType)
|
||||
if err != nil {
|
||||
slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if claim.Namespace == "rp_claims" {
|
||||
rpClaims[claim.Key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := baseClaims[claim.Key]; exists {
|
||||
continue
|
||||
}
|
||||
baseClaims[claim.Key] = value
|
||||
}
|
||||
|
||||
if len(rpClaims) > 0 {
|
||||
baseClaims["rp_claims"] = rpClaims
|
||||
}
|
||||
return baseClaims
|
||||
}
|
||||
|
||||
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||
if claims == nil {
|
||||
claims = map[string]any{}
|
||||
@@ -5294,6 +5354,8 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"status": "success",
|
||||
"message": "Link revoked successfully",
|
||||
@@ -5362,8 +5424,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
tenantID = tid
|
||||
}
|
||||
}
|
||||
sessionClaims := withOidcSessionMetadata(
|
||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
||||
sessionClaims := composeOIDCSessionClaims(
|
||||
consentRequest.Client,
|
||||
identity.Traits,
|
||||
consentRequest.RequestedScope,
|
||||
tenantID,
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
@@ -5392,8 +5457,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
sessionClaims := withOidcSessionMetadata(
|
||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
||||
sessionClaims := composeOIDCSessionClaims(
|
||||
consentRequest.Client,
|
||||
identity.Traits,
|
||||
consentRequest.RequestedScope,
|
||||
tenantID,
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
@@ -5575,8 +5643,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
sessionClaims := withOidcSessionMetadata(
|
||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
||||
sessionClaims := composeOIDCSessionClaims(
|
||||
consentRequest.Client,
|
||||
identity.Traits,
|
||||
consentRequest.RequestedScope,
|
||||
tenantID,
|
||||
currentSessionID,
|
||||
)
|
||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
@@ -7705,6 +7776,7 @@ func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error {
|
||||
if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions")
|
||||
}
|
||||
h.triggerBackchannelLogoutForSession(c.Context(), c, profile.ID, targetSessionID)
|
||||
|
||||
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
@@ -8124,6 +8196,129 @@ func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) triggerBackchannelLogoutForSession(ctx context.Context, c *fiber.Ctx, userID string, sessionID string) {
|
||||
if h == nil || h.Hydra == nil {
|
||||
return
|
||||
}
|
||||
|
||||
clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)]
|
||||
for _, clientID := range clientIDs {
|
||||
h.triggerBackchannelLogoutForClient(ctx, c, userID, clientID, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) triggerBackchannelLogoutForClient(ctx context.Context, c *fiber.Ctx, userID string, clientID string, sessionID string) {
|
||||
if h == nil || h.Hydra == nil || h.BackchannelLogout == nil {
|
||||
return
|
||||
}
|
||||
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if clientID == "" || userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := h.Hydra.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, "", 0, "client_lookup_failed")
|
||||
return
|
||||
}
|
||||
if client == nil {
|
||||
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "client_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
endpoint := client.BackchannelLogoutURI()
|
||||
if endpoint == "" {
|
||||
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "uri_not_configured")
|
||||
return
|
||||
}
|
||||
if client.BackchannelLogoutSessionRequiredValue() && sessionID == "" {
|
||||
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, endpoint, 0, "sid_required")
|
||||
return
|
||||
}
|
||||
|
||||
logoutToken, err := h.BackchannelLogout.BuildLogoutToken(clientID, userID, sessionID)
|
||||
if err != nil {
|
||||
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, 0, "token_build_failed")
|
||||
return
|
||||
}
|
||||
|
||||
statusCode, err := h.BackchannelLogout.SendLogoutToken(ctx, endpoint, logoutToken)
|
||||
if err != nil {
|
||||
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, statusCode, "request_failed")
|
||||
return
|
||||
}
|
||||
|
||||
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.sent", userID, clientID, sessionID, endpoint, statusCode, "")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) writeBackchannelLogoutAuditLog(c *fiber.Ctx, eventType string, userID string, clientID string, sessionID string, endpoint string, statusCode int, reason string) {
|
||||
if h == nil || h.AuditRepo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
endpointHost := ""
|
||||
if endpoint != "" {
|
||||
if parsed, err := url.Parse(endpoint); err == nil {
|
||||
endpointHost = parsed.Host
|
||||
}
|
||||
}
|
||||
|
||||
details := map[string]any{
|
||||
"client_id": strings.TrimSpace(clientID),
|
||||
"session_id": strings.TrimSpace(sessionID),
|
||||
"endpoint_host": strings.TrimSpace(endpointHost),
|
||||
"status_code": statusCode,
|
||||
"retry_count": 0,
|
||||
"logout_issuer": h.BackchannelLogout.Issuer(),
|
||||
}
|
||||
if reason != "" {
|
||||
details["reason"] = reason
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if strings.HasSuffix(eventType, ".failed") {
|
||||
status = "failure"
|
||||
} else if strings.HasSuffix(eventType, ".skipped") {
|
||||
status = "skipped"
|
||||
}
|
||||
|
||||
ipAddress := ""
|
||||
userAgent := ""
|
||||
if c != nil {
|
||||
ipAddress = extractClientIPFromHeaders(c)
|
||||
userAgent = strings.TrimSpace(c.Get("User-Agent"))
|
||||
}
|
||||
|
||||
_ = h.AuditRepo.Create(&domain.AuditLog{
|
||||
EventID: fmt.Sprintf("backchannel-logout-%d", time.Now().UnixNano()),
|
||||
Timestamp: time.Now().UTC(),
|
||||
UserID: strings.TrimSpace(userID),
|
||||
SessionID: strings.TrimSpace(sessionID),
|
||||
EventType: eventType,
|
||||
Status: status,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
Details: string(raw),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetBackchannelLogoutJWKS(c *fiber.Ctx) error {
|
||||
if h == nil || h.BackchannelLogout == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "backchannel logout jwks unavailable")
|
||||
}
|
||||
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
|
||||
c.Set(fiber.HeaderCacheControl, "no-store")
|
||||
return c.JSON(h.BackchannelLogout.PublicJWKS())
|
||||
}
|
||||
|
||||
func looksLikeInternalUserAgent(userAgent string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(userAgent))
|
||||
if normalized == "" {
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -53,6 +56,69 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
assert.Equal(t, 1, len(auditRepo.logs))
|
||||
}
|
||||
|
||||
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
|
||||
t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
|
||||
|
||||
var receivedBody string
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"identity": map[string]interface{}{"id": "user-123"},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "app-1",
|
||||
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Host == "rp.example.com" && r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(raw)
|
||||
return httpResponse(r, http.StatusNoContent, ""), 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 }()
|
||||
|
||||
backchannelLogout, err := service.NewBackchannelLogoutService()
|
||||
assert.NoError(t, err)
|
||||
backchannelLogout.HTTPClient = client
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
BackchannelLogout: backchannelLogout,
|
||||
AuditRepo: auditRepo,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil)
|
||||
req.Header.Set("Cookie", "valid")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.True(t, strings.Contains(receivedBody, "logout_token="))
|
||||
|
||||
values, err := url.ParseQuery(receivedBody)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, values.Get("logout_token"))
|
||||
|
||||
assert.Len(t, auditRepo.logs, 2)
|
||||
assert.Equal(t, "backchannel_logout.sent", auditRepo.logs[1].EventType)
|
||||
}
|
||||
|
||||
func TestListRpHistory_Aggregation(t *testing.T) {
|
||||
now := time.Now()
|
||||
auditRepo := &mockAuditRepo{
|
||||
|
||||
@@ -363,3 +363,110 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||
assert.Equal(t, "Security", capturedClaims["department"])
|
||||
assert.Equal(t, "Officer", capturedClaims["position"])
|
||||
}
|
||||
|
||||
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
var capturedClaims map[string]interface{}
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"challenge": "challenge-configured-claims",
|
||||
"requested_scope": []string{"openid", "profile"},
|
||||
"subject": "user-789",
|
||||
"client": map[string]interface{}{
|
||||
"client_id": "client-configured-claims",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-claims",
|
||||
"id_token_claims": []map[string]interface{}{
|
||||
{
|
||||
"namespace": "top_level",
|
||||
"key": "locale",
|
||||
"value": "ko-KR",
|
||||
"valueType": "text",
|
||||
},
|
||||
{
|
||||
"namespace": "top_level",
|
||||
"key": "email",
|
||||
"value": "should-not-override@example.com",
|
||||
"valueType": "text",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "tier",
|
||||
"value": "2",
|
||||
"valueType": "number",
|
||||
},
|
||||
{
|
||||
"namespace": "rp_claims",
|
||||
"key": "features",
|
||||
"value": "[\"sso\",\"claims\"]",
|
||||
"valueType": "array",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var acceptReq map[string]interface{}
|
||||
json.Unmarshal(body, &acceptReq)
|
||||
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
|
||||
capturedClaims = session["id_token"].(map[string]interface{})
|
||||
}
|
||||
|
||||
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 }()
|
||||
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
KratosAdmin: new(MockKratosAdminService),
|
||||
}
|
||||
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-789").Return(&service.KratosIdentity{
|
||||
ID: "user-789",
|
||||
Traits: map[string]interface{}{
|
||||
"email": "real-user@example.com",
|
||||
"name": "Configured User",
|
||||
"tenant-claims": map[string]interface{}{
|
||||
"department": "Platform",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"consent_challenge": "challenge-configured-claims",
|
||||
"grant_scope": []string{"openid", "profile"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.NotNil(t, capturedClaims)
|
||||
assert.Equal(t, "real-user@example.com", capturedClaims["email"])
|
||||
assert.Equal(t, "ko-KR", capturedClaims["locale"])
|
||||
assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"])
|
||||
|
||||
rpClaims, ok := capturedClaims["rp_claims"].(map[string]interface{})
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, float64(2), rpClaims["tier"])
|
||||
assert.Equal(t, []interface{}{"sso", "claims"}, rpClaims["features"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -500,6 +502,108 @@ func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDeleteMySession_SendsBackchannelLogoutTokenWhenClientConfigured(t *testing.T) {
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
|
||||
|
||||
var receivedBody string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch r.URL.Host {
|
||||
case "kratos.test":
|
||||
if r.URL.Path == "/sessions/whoami" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "current-sid",
|
||||
"authenticated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"identity": map[string]any{
|
||||
"id": "user-123",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"name": "User",
|
||||
"role": "user",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
case "hydra.test":
|
||||
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/devfront" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "devfront",
|
||||
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
|
||||
}), nil
|
||||
}
|
||||
case "rp.example.com":
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(raw)
|
||||
return httpResponse(r, http.StatusNoContent, ""), nil
|
||||
}
|
||||
}
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})}
|
||||
setDefaultHTTPClientForTest(t, client.Transport)
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
||||
{ID: "target-sid", Active: true},
|
||||
}, nil).Once()
|
||||
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
|
||||
ID: "target-sid",
|
||||
Active: true,
|
||||
}, nil).Once()
|
||||
mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
|
||||
|
||||
backchannelLogout, err := service.NewBackchannelLogoutService()
|
||||
assert.NoError(t, err)
|
||||
backchannelLogout.HTTPClient = client
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
h := &AuthHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
AuditRepo: auditRepo,
|
||||
BackchannelLogout: backchannelLogout,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
}
|
||||
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
|
||||
UserID: "user-123",
|
||||
EventType: "POST /api/v1/auth/oidc/login/accept",
|
||||
SessionID: "target-sid",
|
||||
Details: `{"client_id":"devfront","client_name":"Devfront"}`,
|
||||
})
|
||||
|
||||
app := fiber.New()
|
||||
app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
|
||||
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||
req.Header.Set("User-Agent", "session-test-agent")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.True(t, strings.Contains(receivedBody, "logout_token="))
|
||||
|
||||
values, err := url.ParseQuery(receivedBody)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, values.Get("logout_token"))
|
||||
|
||||
foundBackchannelAudit := false
|
||||
for _, log := range auditRepo.logs {
|
||||
if log.EventType == "backchannel_logout.sent" {
|
||||
foundBackchannelAudit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundBackchannelAudit)
|
||||
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) {
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
var hydraRevokeCalls int
|
||||
|
||||
@@ -94,19 +94,21 @@ type devStatsResponse struct {
|
||||
}
|
||||
|
||||
type clientSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
RedirectURIs []string `json:"redirectUris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
||||
SkipConsent bool `json:"skipConsent"`
|
||||
JwksUri string `json:"jwksUri,omitempty"`
|
||||
Jwks interface{} `json:"jwks,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
RedirectURIs []string `json:"redirectUris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
||||
SkipConsent bool `json:"skipConsent"`
|
||||
JwksUri string `json:"jwksUri,omitempty"`
|
||||
Jwks interface{} `json:"jwks,omitempty"`
|
||||
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
|
||||
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type clientListResponse struct {
|
||||
@@ -179,19 +181,28 @@ type consentListResponse struct {
|
||||
}
|
||||
|
||||
type clientUpsertRequest struct {
|
||||
ID *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Status *string `json:"status"`
|
||||
RedirectURIs *[]string `json:"redirectUris"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
GrantTypes *[]string `json:"grantTypes"`
|
||||
ResponseTypes *[]string `json:"responseTypes"`
|
||||
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
||||
SkipConsent *bool `json:"skipConsent"`
|
||||
JwksUri *string `json:"jwksUri"`
|
||||
Jwks interface{} `json:"jwks"`
|
||||
Metadata *map[string]interface{} `json:"metadata"`
|
||||
ID *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Status *string `json:"status"`
|
||||
RedirectURIs *[]string `json:"redirectUris"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
GrantTypes *[]string `json:"grantTypes"`
|
||||
ResponseTypes *[]string `json:"responseTypes"`
|
||||
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
||||
SkipConsent *bool `json:"skipConsent"`
|
||||
JwksUri *string `json:"jwksUri"`
|
||||
Jwks interface{} `json:"jwks"`
|
||||
BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
|
||||
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
|
||||
Metadata *map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type normalizedIDTokenClaim struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
ValueType string `json:"valueType"`
|
||||
}
|
||||
|
||||
var protectedSystemClientIDs = map[string]struct{}{
|
||||
@@ -745,6 +756,24 @@ func mapRelationTupleSummary(tuple service.RelationTuple, identity *service.Krat
|
||||
return summary
|
||||
}
|
||||
|
||||
func dedupeRelationTuples(tuples []service.RelationTuple) []service.RelationTuple {
|
||||
if len(tuples) <= 1 {
|
||||
return tuples
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(tuples))
|
||||
deduped := make([]service.RelationTuple, 0, len(tuples))
|
||||
for _, tuple := range tuples {
|
||||
key := strings.TrimSpace(tuple.Relation) + "\x00" + strings.TrimSpace(tuple.SubjectID)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
deduped = append(deduped, tuple)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID == "" {
|
||||
@@ -1203,6 +1232,7 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
tuples = dedupeRelationTuples(tuples)
|
||||
for _, tuple := range tuples {
|
||||
var identity *service.KratosIdentity
|
||||
if tuple.SubjectID != "" && h.KratosAdmin != nil {
|
||||
@@ -1518,12 +1548,16 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
canChangeStatusByPermit := h.canOperateClientByPermit(c, profile, summary, "change_status")
|
||||
if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatusByPermit {
|
||||
canEditConfigByPermit := h.canOperateClientByPermit(c, profile, summary, "edit_config")
|
||||
canChangeStatus := canChangeStatusByPermit || canEditConfigByPermit
|
||||
if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatus {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
||||
}
|
||||
|
||||
if summary.Type == "private" && !h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") {
|
||||
if !canChangeStatusByPermit {
|
||||
if summary.Type == "private" &&
|
||||
!h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") &&
|
||||
!h.canBypassPrivateClientRestriction(c, profile, summary, "edit_config") {
|
||||
if !canChangeStatus {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||
}
|
||||
}
|
||||
@@ -1649,14 +1683,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if tenantID != "" {
|
||||
metadata["tenant_id"] = tenantID
|
||||
}
|
||||
var err error
|
||||
metadata["status"] = status
|
||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||
var err error
|
||||
backchannelLogoutURI := strings.TrimSpace(valueOr(req.BackchannelLogoutURI, ""))
|
||||
backchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, false)
|
||||
metadata, err = normalizeBackchannelLogoutMetadata(metadata, backchannelLogoutURI, backchannelLogoutSessionRequired)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
||||
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
@@ -1681,17 +1721,19 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
)
|
||||
|
||||
clientReq := domain.HydraClient{
|
||||
ClientID: clientID,
|
||||
ClientName: name,
|
||||
RedirectURIs: redirectURIs,
|
||||
GrantTypes: grantTypes,
|
||||
ResponseTypes: responseTypes,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
TokenEndpointAuthMethod: tokenAuthMethod,
|
||||
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
|
||||
JWKSUri: jwksURI,
|
||||
JWKS: jwks,
|
||||
Metadata: metadata,
|
||||
ClientID: clientID,
|
||||
ClientName: name,
|
||||
RedirectURIs: redirectURIs,
|
||||
GrantTypes: grantTypes,
|
||||
ResponseTypes: responseTypes,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
TokenEndpointAuthMethod: tokenAuthMethod,
|
||||
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
|
||||
JWKSUri: jwksURI,
|
||||
JWKS: jwks,
|
||||
BackChannelLogoutURI: backchannelLogoutURI,
|
||||
BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired),
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
@@ -1715,23 +1757,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
// [New] Automatically grant admin permission to the creator in Keto
|
||||
if h.KetoOutbox != nil && profile != nil {
|
||||
subject := "User:" + profile.ID
|
||||
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: created.ClientID,
|
||||
Relation: "admins",
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
if err != nil {
|
||||
if err := h.grantCreatorAdminRelation(c, created.ClientID, subject); err != nil {
|
||||
slog.Warn("failed to grant automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID)
|
||||
}
|
||||
if h.Keto != nil {
|
||||
if err := h.Keto.CreateRelation(c.Context(), "RelyingParty", created.ClientID, "admins", subject); err != nil {
|
||||
slog.Warn("failed to grant immediate admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret in metadata for later retrieval
|
||||
@@ -1798,8 +1828,8 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
if !canAccessClientByLegacyScope(profile, currentSummary) && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
||||
if !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: edit_config permission is required")
|
||||
}
|
||||
|
||||
clientType := ""
|
||||
@@ -1848,11 +1878,21 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
metadata["status"] = status
|
||||
}
|
||||
resolvedBackchannelLogoutURI := valueOr(req.BackchannelLogoutURI, current.BackchannelLogoutURI())
|
||||
resolvedBackchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, current.BackchannelLogoutSessionRequiredValue())
|
||||
metadata, err = normalizeBackchannelLogoutMetadata(
|
||||
metadata,
|
||||
resolvedBackchannelLogoutURI,
|
||||
resolvedBackchannelLogoutSessionRequired,
|
||||
)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
||||
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
@@ -1883,17 +1923,19 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true))
|
||||
|
||||
updated := domain.HydraClient{
|
||||
ClientID: current.ClientID,
|
||||
ClientName: valueOr(req.Name, current.ClientName),
|
||||
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
||||
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
||||
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
||||
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
||||
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
||||
SkipConsent: boolPtr(resolvedSkipConsent),
|
||||
JWKSUri: resolvedJWKSURI,
|
||||
JWKS: resolvedJWKS,
|
||||
Metadata: metadata,
|
||||
ClientID: current.ClientID,
|
||||
ClientName: valueOr(req.Name, current.ClientName),
|
||||
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
||||
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
||||
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
||||
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
||||
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
||||
SkipConsent: boolPtr(resolvedSkipConsent),
|
||||
JWKSUri: resolvedJWKSURI,
|
||||
JWKS: resolvedJWKS,
|
||||
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
|
||||
BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired),
|
||||
Metadata: metadata,
|
||||
}
|
||||
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
||||
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
||||
@@ -2633,19 +2675,21 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
}
|
||||
|
||||
return clientSummary{
|
||||
ID: client.ClientID,
|
||||
Name: name,
|
||||
Type: clientType,
|
||||
Status: status,
|
||||
CreatedAt: createdAt,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
Scopes: scopes,
|
||||
ClientSecret: clientSecret,
|
||||
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
|
||||
SkipConsent: valueOrBool(client.SkipConsent, true),
|
||||
JwksUri: client.JWKSUri,
|
||||
Jwks: client.JWKS,
|
||||
Metadata: client.Metadata,
|
||||
ID: client.ClientID,
|
||||
Name: name,
|
||||
Type: clientType,
|
||||
Status: status,
|
||||
CreatedAt: createdAt,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
Scopes: scopes,
|
||||
ClientSecret: clientSecret,
|
||||
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
|
||||
SkipConsent: valueOrBool(client.SkipConsent, true),
|
||||
JwksUri: client.JWKSUri,
|
||||
Jwks: client.JWKS,
|
||||
BackchannelLogoutURI: client.BackchannelLogoutURI(),
|
||||
BackchannelLogoutSessionRequired: client.BackchannelLogoutSessionRequiredValue(),
|
||||
Metadata: client.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2665,6 +2709,58 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) {
|
||||
if metadata == nil {
|
||||
metadata = map[string]interface{}{}
|
||||
}
|
||||
|
||||
trimmedURI := strings.TrimSpace(logoutURI)
|
||||
if err := validateBackchannelLogoutURI(trimmedURI); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if trimmedURI == "" {
|
||||
delete(metadata, domain.MetadataBackChannelLogoutURI)
|
||||
delete(metadata, domain.MetadataBackChannelLogoutSessionRequired)
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
metadata[domain.MetadataBackChannelLogoutURI] = trimmedURI
|
||||
metadata[domain.MetadataBackChannelLogoutSessionRequired] = sessionRequired
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func validateBackchannelLogoutURI(raw string) error {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed == nil {
|
||||
return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL")
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL")
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return fmt.Errorf("backchannelLogoutUri must not include a fragment")
|
||||
}
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host == "localhost" || host == "127.0.0.1" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("backchannelLogoutUri must use https outside localhost development")
|
||||
default:
|
||||
return fmt.Errorf("backchannelLogoutUri must use http or https")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
||||
if metadata == nil {
|
||||
return metadata, nil
|
||||
@@ -2752,6 +2848,174 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
||||
if metadata == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawClaims, exists := metadata[domain.MetadataIDTokenClaims]
|
||||
if !exists || rawClaims == nil {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
normalized, err := normalizeIDTokenClaims(rawClaims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata[domain.MetadataIDTokenClaims] = normalized
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, error) {
|
||||
rawList, ok := rawClaims.([]interface{})
|
||||
if !ok {
|
||||
if typedList, ok := rawClaims.([]map[string]interface{}); ok {
|
||||
rawList = make([]interface{}, 0, len(typedList))
|
||||
for _, item := range typedList {
|
||||
rawList = append(rawList, item)
|
||||
}
|
||||
} else if typedList, ok := rawClaims.([]map[string]any); ok {
|
||||
rawList = make([]interface{}, 0, len(typedList))
|
||||
for _, item := range typedList {
|
||||
rawList = append(rawList, item)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("metadata.id_token_claims must be an array")
|
||||
}
|
||||
}
|
||||
|
||||
normalized := make([]normalizedIDTokenClaim, 0, len(rawList))
|
||||
seen := make(map[string]struct{}, len(rawList))
|
||||
|
||||
for _, item := range rawList {
|
||||
record, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
if typedRecord, ok := item.(map[string]any); ok {
|
||||
record = make(map[string]interface{}, len(typedRecord))
|
||||
for key, value := range typedRecord {
|
||||
record[key] = value
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("metadata.id_token_claims items must be objects")
|
||||
}
|
||||
}
|
||||
|
||||
namespace := strings.TrimSpace(readInterfaceString(record["namespace"], "top_level"))
|
||||
if namespace == "" {
|
||||
namespace = "top_level"
|
||||
}
|
||||
if namespace != "top_level" && namespace != "rp_claims" {
|
||||
return nil, fmt.Errorf("metadata.id_token_claims namespace must be top_level or rp_claims: %s", namespace)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(readInterfaceString(record["key"], ""))
|
||||
if key == "" {
|
||||
return nil, errors.New("metadata.id_token_claims key is required")
|
||||
}
|
||||
if namespace == "top_level" && key == "rp_claims" {
|
||||
return nil, errors.New("metadata.id_token_claims top-level key rp_claims is reserved")
|
||||
}
|
||||
|
||||
valueType := strings.TrimSpace(readInterfaceString(record["valueType"], "text"))
|
||||
if valueType == "" {
|
||||
valueType = "text"
|
||||
}
|
||||
switch valueType {
|
||||
case "text", "number", "boolean", "array", "object":
|
||||
default:
|
||||
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(readInterfaceString(record["value"], ""))
|
||||
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
|
||||
return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err)
|
||||
}
|
||||
|
||||
signature := namespace + ":" + key
|
||||
if _, exists := seen[signature]; exists {
|
||||
return nil, fmt.Errorf("metadata.id_token_claims contains duplicate key: %s.%s", namespace, key)
|
||||
}
|
||||
seen[signature] = struct{}{}
|
||||
|
||||
normalized = append(normalized, normalizedIDTokenClaim{
|
||||
Namespace: namespace,
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
})
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func readInterfaceString(value interface{}, fallback string) string {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
|
||||
trimmed := strings.TrimSpace(rawValue)
|
||||
|
||||
switch valueType {
|
||||
case "text":
|
||||
return trimmed, nil
|
||||
case "number":
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("number value is required")
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("number value must be a finite number")
|
||||
}
|
||||
return parsed, nil
|
||||
case "boolean":
|
||||
switch strings.ToLower(trimmed) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true, nil
|
||||
case "false", "0", "no", "off":
|
||||
return false, nil
|
||||
default:
|
||||
return nil, errors.New("boolean value must be true/false")
|
||||
}
|
||||
case "array":
|
||||
if trimmed == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var parsed []any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, errors.New("array value must be valid JSON array")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
parts := strings.Split(trimmed, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
values = append(values, part)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
case "object":
|
||||
if trimmed == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, errors.New("object value must be valid JSON object")
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported claim value type: %s", valueType)
|
||||
}
|
||||
}
|
||||
|
||||
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
|
||||
if req.Jwks != nil {
|
||||
return true
|
||||
@@ -3120,8 +3384,8 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
|
||||
return
|
||||
}
|
||||
subject := "User:" + strings.TrimSpace(userID)
|
||||
for _, relation := range []string{"view_dev_console", "grant_dev_permissions"} {
|
||||
if !h.hasDirectTenantRelation(c, tenantID, relation, subject) {
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
if h.hasDirectTenantRelation(c, tenantID, relation, subject) {
|
||||
continue
|
||||
}
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
@@ -3129,16 +3393,46 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
|
||||
Object: tenantID,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
if h.Keto != nil {
|
||||
if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil {
|
||||
slog.Warn("failed to grant immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.hasDirectTenantRelation(c, tenantID, "developer_console_grant_manager", subject) {
|
||||
return
|
||||
}
|
||||
|
||||
func (h *DevHandler) grantCreatorAdminRelation(c *fiber.Ctx, clientID string, subject string) error {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
subject = strings.TrimSpace(subject)
|
||||
if clientID == "" || subject == "" {
|
||||
return nil
|
||||
}
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: "developer_console_grant_manager",
|
||||
|
||||
if h.Keto != nil {
|
||||
existing, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, "admins", subject)
|
||||
if err == nil && len(existing) > 0 {
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
if createErr := h.Keto.CreateRelation(c.Context(), "RelyingParty", clientID, "admins", subject); createErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
slog.Warn("failed to grant immediate admin permission to creator; falling back to outbox", "clientID", clientID, "subject", subject, "error", createErr)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("failed to check existing admin relation for creator; falling back to outbox", "clientID", clientID, "subject", subject, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.KetoOutbox == nil {
|
||||
return nil
|
||||
}
|
||||
return h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: clientID,
|
||||
Relation: "admins",
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
@@ -3157,6 +3451,11 @@ func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
if h.Keto != nil {
|
||||
if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil {
|
||||
slog.Warn("failed to revoke immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("UpdateClient should enforce tenant isolation", func(t *testing.T) {
|
||||
t.Run("UpdateClient should require direct edit permission within tenant isolation", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -209,11 +209,11 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
"client_name": "Updated Name",
|
||||
})
|
||||
|
||||
// Case 1: Same tenant
|
||||
// Case 1: Same tenant but no direct edit_config permission
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// Case 2: Different tenant
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))
|
||||
|
||||
@@ -453,6 +453,72 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
|
||||
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, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"redirect_uris": []string{
|
||||
"http://localhost/cb",
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
"tenant_id": "tenant-1",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||
t.Fatalf("hydra update should not be called without edit_config permission")
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
Metadata: map[string]any{
|
||||
"managed_client_ids": []any{"client-1"},
|
||||
},
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "App One Updated",
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-1"},
|
||||
},
|
||||
})
|
||||
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.StatusForbidden, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
@@ -634,6 +700,67 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res clientDetailResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
assert.Equal(t, "inactive", res.Client.Status)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
|
||||
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, map[string]interface{}{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "inactive",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -1053,17 +1180,9 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil)
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil)
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "RelyingParty" &&
|
||||
entry.Object == "client-1" &&
|
||||
entry.Relation == "admins" &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
@@ -1072,7 +1191,7 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
||||
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
KetoOutbox: new(devMockKetoOutboxRepository),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
@@ -1099,6 +1218,134 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil).Once()
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(assert.AnError).Once()
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "RelyingParty" &&
|
||||
entry.Object == "client-1" &&
|
||||
entry.Relation == "admins" &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
assert.NoError(t, h.grantCreatorAdminRelation(c, "client-1", "User:user-1"))
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Once()
|
||||
mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
|
||||
}
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
expectedRelation := relation
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "Tenant" &&
|
||||
entry.Object == "tenant-a" &&
|
||||
entry.Relation == expectedRelation &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil).Once()
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a")
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").
|
||||
Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Once()
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: new(devMockKetoOutboxRepository),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a")
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
|
||||
}
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
expectedRelation := relation
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "Tenant" &&
|
||||
entry.Object == "tenant-a" &&
|
||||
entry.Relation == expectedRelation &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionDelete
|
||||
})).Return(nil).Once()
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
h.revokeDeveloperGrantRelation(c, "user-1", "tenant-a")
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -1449,6 +1696,144 @@ func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
|
||||
assert.False(t, hydraCalled)
|
||||
}
|
||||
|
||||
func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
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",
|
||||
PublicURL: "http://hydra.public",
|
||||
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: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Claims App",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"https://rp.example.com/callback"},
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"id": "claim-1",
|
||||
"namespace": "top_level",
|
||||
"key": "locale",
|
||||
"value": " ko-KR ",
|
||||
"valueType": "text",
|
||||
},
|
||||
{
|
||||
"id": "claim-2",
|
||||
"namespace": "rp_claims",
|
||||
"key": "tier",
|
||||
"value": "2",
|
||||
"valueType": "number",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]interface{})
|
||||
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
||||
first, ok := claims[0].(map[string]interface{})
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "top_level", first["namespace"])
|
||||
assert.Equal(t, "locale", first["key"])
|
||||
assert.Equal(t, "ko-KR", first["value"])
|
||||
assert.Equal(t, "text", first["valueType"])
|
||||
_, hasID := first["id"]
|
||||
assert.False(t, hasID)
|
||||
}
|
||||
|
||||
second, ok := claims[1].(map[string]interface{})
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "rp_claims", second["namespace"])
|
||||
assert.Equal(t, "tier", second["key"])
|
||||
assert.Equal(t, "2", second["value"])
|
||||
assert.Equal(t, "number", second["valueType"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClient_RejectsInvalidIDTokenClaimsMetadata(t *testing.T) {
|
||||
hydraCalled := false
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
hydraCalled = true
|
||||
return httpJSONAny(r, http.StatusCreated, map[string]any{}), nil
|
||||
})},
|
||||
},
|
||||
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.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Claims App",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"https://rp.example.com/callback"},
|
||||
"metadata": map[string]any{
|
||||
"id_token_claims": []map[string]any{
|
||||
{
|
||||
"namespace": "top_level",
|
||||
"key": "rp_claims",
|
||||
"value": "forbidden",
|
||||
"valueType": "text",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(bodyBytes), "top-level key rp_claims is reserved")
|
||||
assert.False(t, hydraCalled)
|
||||
}
|
||||
|
||||
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
var captured domain.HydraClient
|
||||
|
||||
@@ -2252,6 +2637,75 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
|
||||
assert.Equal(t, "kyy01", result.Items[0].UserLoginID)
|
||||
}
|
||||
|
||||
func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
|
||||
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, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "").Return([]service.RelationTuple{
|
||||
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"},
|
||||
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"},
|
||||
}, nil)
|
||||
for _, relation := range []string{"creator", "config_editor", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} {
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
|
||||
}
|
||||
|
||||
mockKratos := new(devMockKratosAdmin)
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
|
||||
ID: "user-1",
|
||||
Traits: map[string]interface{}{
|
||||
"name": "Tester",
|
||||
"email": "tester@example.com",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
KratosAdmin: mockKratos,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result clientRelationListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, "admins", result.Items[0].Relation)
|
||||
assert.Equal(t, "User:user-1", result.Items[0].Subject)
|
||||
}
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/tenants/bulk", (&TenantHandler{DB: db}).DeleteTenantsBulk)
|
||||
body, _ := json.Marshal(map[string][]string{"ids": []string{seed.ID, normal.ID}})
|
||||
body, _ := json.Marshal(map[string][]string{"ids": {seed.ID, normal.ID}})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
192
backend/internal/service/backchannel_logout_service.go
Normal file
192
backend/internal/service/backchannel_logout_service.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
)
|
||||
|
||||
const backchannelLogoutEventURI = "http://schemas.openid.net/event/backchannel-logout"
|
||||
|
||||
type BackchannelLogoutService struct {
|
||||
issuer string
|
||||
keyID string
|
||||
signer jose.Signer
|
||||
publicJWK jose.JSONWebKey
|
||||
client *http.Client
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewBackchannelLogoutService() (*BackchannelLogoutService, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate backchannel logout key: %w", err)
|
||||
}
|
||||
|
||||
keyID := randomBackchannelKeyID()
|
||||
if keyID == "" {
|
||||
keyID = fmt.Sprintf("bcl-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.RS256,
|
||||
Key: jose.JSONWebKey{
|
||||
Key: privateKey,
|
||||
KeyID: keyID,
|
||||
Algorithm: string(jose.RS256),
|
||||
Use: "sig",
|
||||
},
|
||||
}, (&jose.SignerOptions{}).WithType("JWT"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize backchannel logout signer: %w", err)
|
||||
}
|
||||
|
||||
return &BackchannelLogoutService{
|
||||
issuer: resolveBackchannelLogoutIssuer(),
|
||||
keyID: keyID,
|
||||
signer: signer,
|
||||
publicJWK: jose.JSONWebKey{
|
||||
Key: &privateKey.PublicKey,
|
||||
KeyID: keyID,
|
||||
Algorithm: string(jose.RS256),
|
||||
Use: "sig",
|
||||
},
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomBackchannelKeyID() string {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func resolveBackchannelLogoutIssuer() string {
|
||||
if explicit := strings.TrimSpace(os.Getenv("BACKCHANNEL_LOGOUT_ISSUER")); explicit != "" {
|
||||
return strings.TrimRight(explicit, "/")
|
||||
}
|
||||
|
||||
if hydraPublic := strings.TrimSpace(os.Getenv("HYDRA_PUBLIC_URL")); hydraPublic != "" {
|
||||
return strings.TrimRight(hydraPublic, "/")
|
||||
}
|
||||
|
||||
if oathkeeperPublic := strings.TrimSpace(os.Getenv("OATHKEEPER_PUBLIC_URL")); oathkeeperPublic != "" {
|
||||
return strings.TrimRight(oathkeeperPublic, "/") + "/oidc"
|
||||
}
|
||||
|
||||
if userfrontURL := strings.TrimSpace(os.Getenv("USERFRONT_URL")); userfrontURL != "" {
|
||||
return strings.TrimRight(userfrontURL, "/") + "/oidc"
|
||||
}
|
||||
|
||||
return "http://localhost:5000/oidc"
|
||||
}
|
||||
|
||||
func (s *BackchannelLogoutService) Issuer() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.issuer
|
||||
}
|
||||
|
||||
func (s *BackchannelLogoutService) PublicJWKS() map[string]any {
|
||||
if s == nil {
|
||||
return map[string]any{"keys": []any{}}
|
||||
}
|
||||
return map[string]any{
|
||||
"keys": []jose.JSONWebKey{s.publicJWK.Public()},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BackchannelLogoutService) BuildLogoutToken(clientID, subject, sessionID string) (string, error) {
|
||||
if s == nil || s.signer == nil {
|
||||
return "", fmt.Errorf("backchannel logout service is unavailable")
|
||||
}
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
subject = strings.TrimSpace(subject)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if clientID == "" {
|
||||
return "", fmt.Errorf("client id is required")
|
||||
}
|
||||
if subject == "" && sessionID == "" {
|
||||
return "", fmt.Errorf("subject or session id is required")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
claims := josejwt.Claims{
|
||||
Issuer: s.issuer,
|
||||
Audience: josejwt.Audience{clientID},
|
||||
IssuedAt: josejwt.NewNumericDate(now),
|
||||
ID: fmt.Sprintf("%s-%d", s.keyID, now.UnixNano()),
|
||||
}
|
||||
if subject != "" {
|
||||
claims.Subject = subject
|
||||
}
|
||||
|
||||
extra := map[string]any{
|
||||
"events": map[string]any{
|
||||
backchannelLogoutEventURI: map[string]any{},
|
||||
},
|
||||
}
|
||||
if sessionID != "" {
|
||||
extra["sid"] = sessionID
|
||||
}
|
||||
|
||||
return josejwt.Signed(s.signer).Claims(claims).Claims(extra).Serialize()
|
||||
}
|
||||
|
||||
func (s *BackchannelLogoutService) SendLogoutToken(ctx context.Context, endpoint, logoutToken string) (int, error) {
|
||||
if s == nil {
|
||||
return 0, fmt.Errorf("backchannel logout service is unavailable")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("logout_token", logoutToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := s.client
|
||||
if s.HTTPClient != nil {
|
||||
client = s.HTTPClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, fmt.Errorf("backchannel logout endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (s *BackchannelLogoutService) MarshalPublicJWKS() ([]byte, error) {
|
||||
return json.Marshal(s.PublicJWKS())
|
||||
}
|
||||
85
backend/internal/service/backchannel_logout_service_test.go
Normal file
85
backend/internal/service/backchannel_logout_service_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
|
||||
t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
|
||||
|
||||
svc, err := NewBackchannelLogoutService()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := svc.BuildLogoutToken("client-1", "user-1", "sid-1")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
jwksRaw, err := svc.MarshalPublicJWKS()
|
||||
require.NoError(t, err)
|
||||
|
||||
var jwks struct {
|
||||
Keys []jose.JSONWebKey `json:"keys"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(jwksRaw, &jwks))
|
||||
require.Len(t, jwks.Keys, 1)
|
||||
|
||||
parsed, err := josejwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
|
||||
require.NoError(t, err)
|
||||
|
||||
var claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Aud interface{} `json:"aud"`
|
||||
Iat int64 `json:"iat"`
|
||||
Jti string `json:"jti"`
|
||||
Sid string `json:"sid"`
|
||||
Events map[string]interface{} `json:"events"`
|
||||
}
|
||||
require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims))
|
||||
|
||||
assert.Equal(t, "https://sso.example.com/oidc", claims.Issuer)
|
||||
assert.Equal(t, "user-1", claims.Subject)
|
||||
switch aud := claims.Aud.(type) {
|
||||
case string:
|
||||
assert.Equal(t, "client-1", aud)
|
||||
case []interface{}:
|
||||
assert.Len(t, aud, 1)
|
||||
assert.Equal(t, "client-1", aud[0])
|
||||
default:
|
||||
t.Fatalf("unexpected aud type: %T", claims.Aud)
|
||||
}
|
||||
assert.NotZero(t, claims.Iat)
|
||||
assert.NotEmpty(t, claims.Jti)
|
||||
assert.Equal(t, "sid-1", claims.Sid)
|
||||
_, ok := claims.Events[backchannelLogoutEventURI]
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestBackchannelLogoutService_SendLogoutToken(t *testing.T) {
|
||||
var body string
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
svc, err := NewBackchannelLogoutService()
|
||||
require.NoError(t, err)
|
||||
svc.HTTPClient = clientForHandler(handler)
|
||||
|
||||
statusCode, err := svc.SendLogoutToken(context.Background(), "https://rp.example.com/backchannel-logout", "signed-token")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, statusCode)
|
||||
assert.Equal(t, "logout_token=signed-token", body)
|
||||
}
|
||||
@@ -126,16 +126,26 @@ function AuditLogsPage() {
|
||||
const [searchClientId, setSearchClientId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
|
||||
// Use deferred values to avoid UI lag during rapid typing
|
||||
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
|
||||
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
|
||||
queryKey: [
|
||||
"dev-audit-logs",
|
||||
deferredSearchClientId,
|
||||
deferredSearchAction,
|
||||
statusFilter,
|
||||
],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchDevAuditLogs(50, pageParam, {
|
||||
client_id: searchClientId.trim() || undefined,
|
||||
action: searchAction.trim() || undefined,
|
||||
client_id: deferredSearchClientId || undefined,
|
||||
action: deferredSearchAction || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
@@ -160,14 +170,6 @@ function AuditLogsPage() {
|
||||
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
||||
};
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
const axiosError = query.error as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
@@ -227,7 +229,13 @@ function AuditLogsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -263,142 +271,167 @@ function AuditLogsPage() {
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[190px]">
|
||||
{t("ui.dev.audit.table.time", "Time")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.actor", "Actor")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.action", "Action")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[260px]">
|
||||
{t("ui.dev.audit.table.target", "Target")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.dev.audit.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 && (
|
||||
<div
|
||||
className={
|
||||
query.isFetching && !query.isFetchingNextPage
|
||||
? "opacity-50 transition-opacity"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||
</TableCell>
|
||||
<TableHead className="w-[190px]">
|
||||
{t("ui.dev.audit.table.time", "Time")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.actor", "Actor")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.action", "Action")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[260px]">
|
||||
{t("ui.dev.audit.table.target", "Target")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.dev.audit.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel = details.action || row.event_type;
|
||||
const targetValue = details.target_id || "-";
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{row.user_id || "-"}</span>
|
||||
{row.user_id ? (
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel = details.action || row.event_type;
|
||||
const targetValue = details.target_id || "-";
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{row.user_id || "-"}</span>
|
||||
{row.user_id ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(row.user_id)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{actionLabel}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetValue}</span>
|
||||
{targetValue !== "-" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(targetValue)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" ? "success" : "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(row.user_id)}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{actionLabel}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetValue}</span>
|
||||
{targetValue !== "-" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(targetValue)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded ? (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" ? "success" : "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded ? (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
Request ID: {formatValue(details.request_id)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
Request ID:{" "}
|
||||
{formatValue(details.request_id)}
|
||||
</div>
|
||||
<div>
|
||||
Method: {formatValue(details.method)}
|
||||
</div>
|
||||
<div>Path: {formatValue(details.path)}</div>
|
||||
<div>
|
||||
Tenant: {formatValue(details.tenant_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 break-all">
|
||||
<div>
|
||||
Before: {formatValue(details.before)}
|
||||
</div>
|
||||
<div>After: {formatValue(details.after)}</div>
|
||||
<div>Error: {formatValue(details.error)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>Method: {formatValue(details.method)}</div>
|
||||
<div>Path: {formatValue(details.path)}</div>
|
||||
<div>
|
||||
Tenant: {formatValue(details.tenant_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 break-all">
|
||||
<div>Before: {formatValue(details.before)}</div>
|
||||
<div>After: {formatValue(details.after)}</div>
|
||||
<div>Error: {formatValue(details.error)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{query.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
|
||||
@@ -213,11 +213,17 @@ function ClientDetailsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
// Client Secret from API
|
||||
const hasClientSecret = client.type === "private";
|
||||
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||
const clientSecret = client?.clientSecret || secretPlaceholder;
|
||||
const displaySecret =
|
||||
clientSecret === secretPlaceholder
|
||||
const clientSecret = hasClientSecret
|
||||
? client?.clientSecret || secretPlaceholder
|
||||
: t("ui.common.na", "N/A");
|
||||
const displaySecret = !hasClientSecret
|
||||
? t(
|
||||
"msg.dev.clients.details.secret_not_applicable",
|
||||
"PKCE 앱에는 Client Secret이 없습니다.",
|
||||
)
|
||||
: clientSecret === secretPlaceholder
|
||||
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||
: clientSecret;
|
||||
|
||||
@@ -327,61 +333,73 @@ function ClientDetailsPage() {
|
||||
{showSecret ? displaySecret : "••••••••••••••••"}
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
aria-label={
|
||||
showSecret
|
||||
? t(
|
||||
"ui.dev.clients.details.secret.hide",
|
||||
"비밀키 숨기기",
|
||||
{hasClientSecret ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
aria-label={
|
||||
showSecret
|
||||
? t(
|
||||
"ui.dev.clients.details.secret.hide",
|
||||
"비밀키 숨기기",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.details.secret.show",
|
||||
"비밀키 보기",
|
||||
)
|
||||
}
|
||||
>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleRotateSecret}
|
||||
disabled={rotateMutation.isPending}
|
||||
title={t(
|
||||
"ui.dev.clients.details.secret.rotate",
|
||||
"비밀키 재발급 (Rotate)",
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
rotateMutation.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={clientSecret}
|
||||
disabled={
|
||||
!showSecret && clientSecret === secretPlaceholder
|
||||
}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_client_secret",
|
||||
"Client Secret이 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.details.secret.show",
|
||||
"비밀키 보기",
|
||||
)
|
||||
}
|
||||
>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleRotateSecret}
|
||||
disabled={rotateMutation.isPending}
|
||||
title={t(
|
||||
"ui.dev.clients.details.secret.rotate",
|
||||
"비밀키 재발급 (Rotate)",
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
rotateMutation.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={clientSecret}
|
||||
disabled={
|
||||
!showSecret && clientSecret === secretPlaceholder
|
||||
}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_client_secret",
|
||||
"Client Secret이 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!hasClientSecret ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.details.secret_not_applicable",
|
||||
"PKCE 앱에는 Client Secret이 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -31,9 +32,11 @@ import { Switch } from "../../components/ui/switch";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type ClientRelation,
|
||||
createClient,
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
fetchMyTenants,
|
||||
refreshHeadlessJwksCache,
|
||||
revokeHeadlessJwksCache,
|
||||
@@ -48,6 +51,7 @@ import type {
|
||||
TenantSummary,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
@@ -59,6 +63,17 @@ interface ScopeItem {
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
type ClaimNamespace = "top_level" | "rp_claims";
|
||||
type ClaimValueType = "text" | "number" | "boolean" | "array" | "object";
|
||||
|
||||
interface IdTokenClaimItem {
|
||||
id: string;
|
||||
namespace: ClaimNamespace;
|
||||
key: string;
|
||||
value: string;
|
||||
valueType: ClaimValueType;
|
||||
}
|
||||
|
||||
type SecurityProfile = "private" | "pkce";
|
||||
type TokenEndpointAuthMethod =
|
||||
| "none"
|
||||
@@ -111,6 +126,142 @@ function readMetadataString(
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function isClaimNamespace(value: string): value is ClaimNamespace {
|
||||
return value === "top_level" || value === "rp_claims";
|
||||
}
|
||||
|
||||
function isClaimValueType(value: string): value is ClaimValueType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "array" ||
|
||||
value === "object"
|
||||
);
|
||||
}
|
||||
|
||||
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
|
||||
return {
|
||||
id,
|
||||
namespace: "top_level",
|
||||
key: "",
|
||||
value: "",
|
||||
valueType: "text",
|
||||
};
|
||||
}
|
||||
|
||||
function readIdTokenClaimsMetadata(
|
||||
metadata: Record<string, unknown>,
|
||||
): IdTokenClaimItem[] {
|
||||
const rawClaims = metadata.id_token_claims;
|
||||
if (!Array.isArray(rawClaims)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawClaims
|
||||
.map((item, index) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const namespaceValue =
|
||||
typeof record.namespace === "string" &&
|
||||
isClaimNamespace(record.namespace)
|
||||
? record.namespace
|
||||
: "top_level";
|
||||
const keyValue = typeof record.key === "string" ? record.key : "";
|
||||
const rawValue = record.value;
|
||||
const valueValue =
|
||||
typeof rawValue === "string"
|
||||
? rawValue
|
||||
: rawValue == null
|
||||
? ""
|
||||
: JSON.stringify(rawValue);
|
||||
const valueTypeValue =
|
||||
typeof record.valueType === "string" &&
|
||||
isClaimValueType(record.valueType)
|
||||
? record.valueType
|
||||
: "text";
|
||||
|
||||
return {
|
||||
id: `claim-${index + 1}`,
|
||||
namespace: namespaceValue,
|
||||
key: keyValue,
|
||||
value: valueValue,
|
||||
valueType: valueTypeValue,
|
||||
};
|
||||
})
|
||||
.filter((item): item is IdTokenClaimItem => item !== null);
|
||||
}
|
||||
|
||||
function normalizeClaimPreviewValue(
|
||||
value: string,
|
||||
valueType: ClaimValueType,
|
||||
): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (valueType === "number") {
|
||||
if (trimmed === "") return "";
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : trimmed;
|
||||
}
|
||||
|
||||
if (valueType === "boolean") {
|
||||
return ["true", "1", "yes", "on"].includes(trimmed.toLowerCase());
|
||||
}
|
||||
|
||||
if (valueType === "array") {
|
||||
if (trimmed === "") return [];
|
||||
try {
|
||||
if (trimmed.startsWith("[")) {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
} catch {
|
||||
// Fall through to comma-separated parsing.
|
||||
}
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (valueType === "object") {
|
||||
if (trimmed === "") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function buildIdTokenClaimsPreview(
|
||||
items: IdTokenClaimItem[],
|
||||
): Record<string, unknown> {
|
||||
const preview: Record<string, unknown> = {};
|
||||
const rpClaims: Record<string, unknown> = {};
|
||||
|
||||
for (const item of items) {
|
||||
const key = item.key.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = item.namespace === "rp_claims" ? rpClaims : preview;
|
||||
target[key] = normalizeClaimPreviewValue(item.value, item.valueType);
|
||||
}
|
||||
|
||||
if (Object.keys(rpClaims).length > 0) {
|
||||
preview.rp_claims = rpClaims;
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
@@ -120,6 +271,29 @@ function isValidUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidBackchannelLogoutUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hash) {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol === "https:") {
|
||||
return true;
|
||||
}
|
||||
if (url.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
@@ -128,16 +302,27 @@ function formatDateTime(value?: string) {
|
||||
}
|
||||
|
||||
function ClientGeneralPage() {
|
||||
const auth = useAuth();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const clientId = params.id;
|
||||
const isCreate = !clientId;
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const systemRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId as string),
|
||||
enabled: !isCreate,
|
||||
});
|
||||
const { data: relationData } = useQuery({
|
||||
queryKey: ["client-relations", clientId],
|
||||
queryFn: () => fetchClientRelations(clientId as string),
|
||||
enabled: !isCreate,
|
||||
retry: false,
|
||||
});
|
||||
const { data: tenantData } = useQuery({
|
||||
queryKey: ["my-tenants"],
|
||||
queryFn: fetchMyTenants,
|
||||
@@ -153,6 +338,15 @@ function ClientGeneralPage() {
|
||||
const [status, setStatus] = useState<ClientStatus>("active");
|
||||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||
const [redirectUris, setRedirectUris] = useState("");
|
||||
const [backchannelLogoutUri, setBackchannelLogoutUri] = useState("");
|
||||
const [
|
||||
backchannelLogoutSessionRequired,
|
||||
setBackchannelLogoutSessionRequired,
|
||||
] = useState(false);
|
||||
const [
|
||||
isBackchannelSessionRequiredInfoOpen,
|
||||
setIsBackchannelSessionRequiredInfoOpen,
|
||||
] = useState(false);
|
||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
@@ -192,6 +386,7 @@ function ClientGeneralPage() {
|
||||
mandatory: false,
|
||||
},
|
||||
]);
|
||||
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
@@ -205,6 +400,14 @@ function ClientGeneralPage() {
|
||||
if (typeof metadata.description === "string")
|
||||
setDescription(metadata.description);
|
||||
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
||||
setBackchannelLogoutUri(
|
||||
client.backchannelLogoutUri ||
|
||||
readMetadataString(metadata, "backchannel_logout_uri"),
|
||||
);
|
||||
setBackchannelLogoutSessionRequired(
|
||||
client.backchannelLogoutSessionRequired === true ||
|
||||
metadata.backchannel_logout_session_required === true,
|
||||
);
|
||||
setAutoLoginSupported(metadata.auto_login_supported === true);
|
||||
if (typeof metadata.auto_login_url === "string")
|
||||
setAutoLoginUrl(metadata.auto_login_url);
|
||||
@@ -287,14 +490,29 @@ function ClientGeneralPage() {
|
||||
),
|
||||
);
|
||||
}
|
||||
setIdTokenClaims(readIdTokenClaimsMetadata(metadata));
|
||||
}, [data]);
|
||||
|
||||
const securityProfile: SecurityProfile =
|
||||
clientType === "pkce" ? "pkce" : "private";
|
||||
const canEditExistingClientGeneralSettings =
|
||||
systemRole === "super_admin" ||
|
||||
relationData?.items?.some(
|
||||
(item: ClientRelation) =>
|
||||
item.subject === `User:${currentUserId}` &&
|
||||
(item.relation === "admins" || item.relation === "config_editor"),
|
||||
) === true;
|
||||
const isGeneralSettingsReadOnly =
|
||||
!isCreate && relationData != null && !canEditExistingClientGeneralSettings;
|
||||
const trimmedLogoUrl = logoUrl.trim();
|
||||
const trimmedAutoLoginUrl = autoLoginUrl.trim();
|
||||
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||||
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
||||
const trimmedBackchannelLogoutUri = backchannelLogoutUri.trim();
|
||||
const hasBackchannelLogoutUri = trimmedBackchannelLogoutUri.length > 0;
|
||||
const hasValidBackchannelLogoutUri =
|
||||
!hasBackchannelLogoutUri ||
|
||||
isValidBackchannelLogoutUrl(trimmedBackchannelLogoutUri);
|
||||
const hasValidAutoLoginUrl =
|
||||
!autoLoginSupported ||
|
||||
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
|
||||
@@ -436,6 +654,32 @@ function ClientGeneralPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const addIdTokenClaim = () => {
|
||||
setIdTokenClaims((current) => [
|
||||
...current,
|
||||
createIdTokenClaimItem(`claim-${Date.now()}`),
|
||||
]);
|
||||
};
|
||||
|
||||
const updateIdTokenClaim = <K extends keyof IdTokenClaimItem>(
|
||||
id: string,
|
||||
field: K,
|
||||
value: IdTokenClaimItem[K],
|
||||
) => {
|
||||
setIdTokenClaims((current) =>
|
||||
current.map((claim) => {
|
||||
if (claim.id !== id) {
|
||||
return claim;
|
||||
}
|
||||
return { ...claim, [field]: value };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeIdTokenClaim = (id: string) => {
|
||||
setIdTokenClaims((current) => current.filter((claim) => claim.id !== id));
|
||||
};
|
||||
|
||||
const handleStatusChange = (nextStatus: ClientStatus) => {
|
||||
setStatus(nextStatus);
|
||||
const statusLabel =
|
||||
@@ -487,6 +731,11 @@ function ClientGeneralPage() {
|
||||
"허용 알고리즘: {{algorithms}}",
|
||||
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
|
||||
);
|
||||
const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
|
||||
...claim,
|
||||
key: claim.key.trim(),
|
||||
value: claim.value.trim(),
|
||||
}));
|
||||
|
||||
if (headlessLoginEnabled) {
|
||||
if (!trimmedJwksUri) {
|
||||
@@ -541,7 +790,61 @@ function ClientGeneralPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const claimValidationErrors: string[] = [];
|
||||
const seenClaimKeys = new Set<string>();
|
||||
for (const claim of normalizedIdTokenClaims) {
|
||||
if (!claim.key) {
|
||||
claimValidationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.key_required",
|
||||
"Claim key를 입력해야 합니다.",
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (claim.key === "rp_claims" && claim.namespace === "top_level") {
|
||||
claimValidationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.reserved_key",
|
||||
"`rp_claims`는 예약된 namespace 키입니다.",
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const keySignature = `${claim.namespace}:${claim.key}`;
|
||||
if (seenClaimKeys.has(keySignature)) {
|
||||
claimValidationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.duplicate_key",
|
||||
"중복된 claim key가 있습니다: {{namespace}}.{{key}}",
|
||||
{
|
||||
namespace:
|
||||
claim.namespace === "rp_claims"
|
||||
? t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||||
"rp_claims",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_top_level",
|
||||
"top-level",
|
||||
),
|
||||
key: claim.key,
|
||||
},
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
seenClaimKeys.add(keySignature);
|
||||
}
|
||||
validationErrors.push(...claimValidationErrors);
|
||||
|
||||
const hasValidationErrors = validationErrors.length > 0;
|
||||
const idTokenClaimPreview = buildIdTokenClaimsPreview(
|
||||
normalizedIdTokenClaims,
|
||||
);
|
||||
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||||
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
|
||||
tenantData ?? [];
|
||||
@@ -635,6 +938,22 @@ function ClientGeneralPage() {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri) {
|
||||
throw new Error(
|
||||
t(
|
||||
"msg.dev.clients.general.backchannel_logout.invalid",
|
||||
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isGeneralSettingsReadOnly) {
|
||||
throw new Error(
|
||||
t(
|
||||
"msg.dev.clients.general.read_only_forbidden",
|
||||
"이 RP의 일반 설정을 수정할 권한이 없습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (autoLoginSupported && !hasValidAutoLoginUrl) {
|
||||
throw new Error(
|
||||
t(
|
||||
@@ -670,12 +989,18 @@ function ClientGeneralPage() {
|
||||
trimmedJwksUri
|
||||
? trimmedJwksUri
|
||||
: undefined,
|
||||
backchannelLogoutUri: trimmedBackchannelLogoutUri || undefined,
|
||||
backchannelLogoutSessionRequired:
|
||||
trimmedBackchannelLogoutUri !== ""
|
||||
? backchannelLogoutSessionRequired
|
||||
: false,
|
||||
metadata: {
|
||||
description,
|
||||
logo_url: trimmedLogoUrl,
|
||||
auto_login_supported: autoLoginSupported,
|
||||
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
|
||||
structured_scopes: normalizedScopes,
|
||||
id_token_claims: normalizedIdTokenClaims,
|
||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||
headless_login_enabled: headlessLoginEnabled,
|
||||
headless_token_endpoint_auth_method:
|
||||
@@ -690,6 +1015,11 @@ function ClientGeneralPage() {
|
||||
allowed_tenants: tenantAccessRestricted
|
||||
? normalizedAllowedTenantIds
|
||||
: [],
|
||||
backchannel_logout_uri: trimmedBackchannelLogoutUri || undefined,
|
||||
backchannel_logout_session_required:
|
||||
trimmedBackchannelLogoutUri !== ""
|
||||
? backchannelLogoutSessionRequired
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -879,6 +1209,14 @@ function ClientGeneralPage() {
|
||||
{!isCreate && (
|
||||
<ClientDetailTabs activeTab="settings" clientId={clientId} />
|
||||
)}
|
||||
{isGeneralSettingsReadOnly && (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
|
||||
{t(
|
||||
"msg.dev.clients.general.read_only_hint",
|
||||
"이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 1. Application Identity */}
|
||||
@@ -913,6 +1251,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.identity.name_placeholder",
|
||||
"My Awesome Application",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -930,6 +1269,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.identity.description_placeholder",
|
||||
"앱에 대한 간단한 설명을 입력하세요.",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -949,6 +1289,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.identity.logo_placeholder",
|
||||
"https://example.com/logo.png",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
@@ -1067,6 +1408,7 @@ function ClientGeneralPage() {
|
||||
size="sm"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => handleStatusChange("active")}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
@@ -1075,6 +1417,7 @@ function ClientGeneralPage() {
|
||||
size="sm"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => handleStatusChange("inactive")}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
@@ -1181,6 +1524,7 @@ function ClientGeneralPage() {
|
||||
size="sm"
|
||||
onClick={addScope}
|
||||
className="gap-2"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||||
@@ -1201,6 +1545,7 @@ function ClientGeneralPage() {
|
||||
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
||||
)}
|
||||
className="font-mono text-sm"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
@@ -1211,6 +1556,114 @@ function ClientGeneralPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 border-b border-border pb-6 mb-6">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
className="text-sm font-semibold"
|
||||
htmlFor="backchannel-logout-uri"
|
||||
>
|
||||
{t(
|
||||
"ui.dev.clients.general.backchannel_logout.uri",
|
||||
"Back-Channel Logout URI",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="backchannel-logout-uri"
|
||||
value={backchannelLogoutUri}
|
||||
onChange={(e) => setBackchannelLogoutUri(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.backchannel_logout.uri_placeholder",
|
||||
"https://rp.example.com/oidc/backchannel-logout",
|
||||
)}
|
||||
className="font-mono text-sm"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.backchannel_logout.uri_help",
|
||||
"Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다.",
|
||||
)}
|
||||
</p>
|
||||
{hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{t(
|
||||
"msg.dev.clients.general.backchannel_logout.invalid",
|
||||
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
className="text-sm font-semibold"
|
||||
htmlFor="backchannel-logout-session-required"
|
||||
>
|
||||
{t(
|
||||
"ui.dev.clients.general.backchannel_logout.session_required",
|
||||
"SID Claim Required",
|
||||
)}
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full p-0.5 transition-colors ${
|
||||
isBackchannelSessionRequiredInfoOpen
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/60 hover:text-primary"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setIsBackchannelSessionRequiredInfoOpen((prev) => !prev)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.backchannel_logout.session_required_info",
|
||||
"SID Claim Required 설명 보기",
|
||||
)}
|
||||
>
|
||||
{isBackchannelSessionRequiredInfoOpen ? (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.backchannel_logout.session_required_help",
|
||||
"RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다.",
|
||||
)}
|
||||
</p>
|
||||
{isBackchannelSessionRequiredInfoOpen ? (
|
||||
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
|
||||
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{t("ui.common.info", "상세 안내")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"msg.dev.clients.general.backchannel_logout.session_required_on",
|
||||
"켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리",
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"msg.dev.clients.general.backchannel_logout.session_required_off",
|
||||
"끄면: sid가 없어도 sub만으로 로그아웃 처리 가능",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
id="backchannel-logout-session-required"
|
||||
checked={backchannelLogoutSessionRequired}
|
||||
onCheckedChange={setBackchannelLogoutSessionRequired}
|
||||
disabled={isGeneralSettingsReadOnly || !hasBackchannelLogoutUri}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||||
@@ -1258,7 +1711,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.scopes.name_placeholder",
|
||||
"e.g. profile",
|
||||
)}
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -1272,7 +1725,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.scopes.description_placeholder",
|
||||
"권한에 대한 설명",
|
||||
)}
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
@@ -1282,7 +1735,7 @@ function ClientGeneralPage() {
|
||||
onCheckedChange={(checked) =>
|
||||
updateScope(s.id, "mandatory", checked)
|
||||
}
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1292,7 +1745,7 @@ function ClientGeneralPage() {
|
||||
size="icon"
|
||||
onClick={() => removeScope(s.id)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1359,6 +1812,7 @@ function ClientGeneralPage() {
|
||||
checked={tenantAccessRestricted}
|
||||
onCheckedChange={handleTenantAccessToggle}
|
||||
id="tenant-access-toggle"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1403,7 +1857,9 @@ function ClientGeneralPage() {
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
className="pl-10"
|
||||
disabled={!tenantAccessRestricted}
|
||||
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">
|
||||
@@ -1417,6 +1873,7 @@ function ClientGeneralPage() {
|
||||
event.preventDefault();
|
||||
handleSelectAllowedTenant(tenant.id);
|
||||
}}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1484,6 +1941,7 @@ function ClientGeneralPage() {
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenant.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -1509,6 +1967,7 @@ function ClientGeneralPage() {
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenantId)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -1534,6 +1993,258 @@ function ClientGeneralPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.title",
|
||||
"Custom Claims",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.general.id_token_claims.subtitle",
|
||||
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={addIdTokenClaim}
|
||||
className="gap-2"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.key",
|
||||
"Claim Key",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.namespace",
|
||||
"Namespace",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.value_type",
|
||||
"Value Type",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.value",
|
||||
"Value",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.delete",
|
||||
"Delete",
|
||||
)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{idTokenClaims.map((claim) => (
|
||||
<tr key={claim.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Input
|
||||
value={claim.key}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"key",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-9 font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.id_token_claims.key_placeholder",
|
||||
"e.g. locale",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
value={claim.namespace}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"namespace",
|
||||
e.target.value as ClaimNamespace,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_label",
|
||||
"Claim namespace",
|
||||
)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<option value="top_level">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_top_level",
|
||||
"top-level",
|
||||
)}
|
||||
</option>
|
||||
<option value="rp_claims">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||||
"rp_claims",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
value={claim.valueType}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"valueType",
|
||||
e.target.value as ClaimValueType,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_label",
|
||||
"Claim value type",
|
||||
)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<option value="text">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_text",
|
||||
"Text",
|
||||
)}
|
||||
</option>
|
||||
<option value="number">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_number",
|
||||
"Number",
|
||||
)}
|
||||
</option>
|
||||
<option value="boolean">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_boolean",
|
||||
"Boolean",
|
||||
)}
|
||||
</option>
|
||||
<option value="array">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_array",
|
||||
"Array",
|
||||
)}
|
||||
</option>
|
||||
<option value="object">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_object",
|
||||
"Object",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Input
|
||||
value={claim.value}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"value",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-9 font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.id_token_claims.value_placeholder",
|
||||
"Enter the claim value",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right align-top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeIdTokenClaim(claim.id)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{idTokenClaims.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.dev.clients.general.id_token_claims.empty",
|
||||
"아직 추가된 ID Token claim이 없습니다.",
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs leading-6 text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.id_token_claims.hint",
|
||||
"top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.preview_title",
|
||||
"Saved JSON Preview",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.id_token_claims.preview_hint",
|
||||
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
readOnly
|
||||
value={idTokenClaimPreviewJson}
|
||||
className="mt-4 min-h-72 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Security Settings */}
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-3">
|
||||
@@ -1638,6 +2349,7 @@ function ClientGeneralPage() {
|
||||
id="headless-login-toggle"
|
||||
checked={headlessLoginEnabled}
|
||||
onCheckedChange={handleHeadlessToggle}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2111,6 +2823,7 @@ function ClientGeneralPage() {
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={
|
||||
isGeneralSettingsReadOnly ||
|
||||
mutation.isPending ||
|
||||
isLoading ||
|
||||
name.trim() === "" ||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Info, Link2, Plus, Trash2, X } from "lucide-react";
|
||||
import { useDeferredValue, useMemo, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
@@ -47,7 +47,6 @@ const relationOptions = [
|
||||
"consent_revoker",
|
||||
"relationship_viewer",
|
||||
"audit_viewer",
|
||||
"status_operator",
|
||||
] as const;
|
||||
|
||||
type RelationOption = (typeof relationOptions)[number];
|
||||
@@ -63,6 +62,10 @@ function relationDescription(relation: RelationOption) {
|
||||
);
|
||||
}
|
||||
|
||||
function relationPermitsInfo(relation: RelationOption) {
|
||||
return t(`ui.dev.clients.relationships.option.${relation}.permits_info`, "");
|
||||
}
|
||||
|
||||
function formatUserLabel(user: DevAssignableUser) {
|
||||
const primary = user.name.trim() || user.email.trim();
|
||||
return `${primary} (${user.email.trim()})`;
|
||||
@@ -82,6 +85,7 @@ function ClientRelationsPage() {
|
||||
null,
|
||||
);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [infoRelation, setInfoRelation] = useState<RelationOption | null>(null);
|
||||
|
||||
const systemRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
@@ -308,6 +312,15 @@ function ClientRelationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInfoToggle = (
|
||||
event: React.MouseEvent,
|
||||
relation: RelationOption,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setInfoRelation((prev) => (prev === relation ? null : relation));
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
@@ -389,7 +402,14 @@ function ClientRelationsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{isRelationshipViewForbidden ? (
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.loading",
|
||||
"Loading relationships...",
|
||||
)}
|
||||
</div>
|
||||
) : isRelationshipViewForbidden ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
{relationshipViewForbiddenMessage}
|
||||
</div>
|
||||
@@ -495,46 +515,76 @@ function ClientRelationsPage() {
|
||||
const disabled =
|
||||
selectedUserExistingRelations.has(relation);
|
||||
const isSelected = selectedRelations.includes(relation);
|
||||
const isInfoVisible = infoRelation === relation;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={relation}
|
||||
className={`flex gap-3 rounded-xl border p-4 transition-all ${
|
||||
disabled
|
||||
? "border-border/60 bg-muted/30 opacity-60"
|
||||
: isSelected
|
||||
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
|
||||
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary"
|
||||
checked={isSelected || disabled}
|
||||
disabled={disabled}
|
||||
onChange={() => handleRelationToggle(relation)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className={`text-sm font-semibold ${
|
||||
isSelected && !disabled ? "text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
{relationLabel(relation)}
|
||||
<div key={relation} className="relative">
|
||||
<label
|
||||
className={`flex gap-3 rounded-xl border p-4 transition-all ${
|
||||
disabled
|
||||
? "border-border/60 bg-muted/30 opacity-60"
|
||||
: isSelected
|
||||
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
|
||||
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary"
|
||||
checked={isSelected || disabled}
|
||||
disabled={disabled}
|
||||
onChange={() => handleRelationToggle(relation)}
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div
|
||||
className={`text-sm font-semibold ${
|
||||
isSelected && !disabled ? "text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
{relationLabel(relation)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full p-0.5 transition-colors ${
|
||||
isInfoVisible
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/60 hover:text-primary"
|
||||
}`}
|
||||
onClick={(e) => handleInfoToggle(e, relation)}
|
||||
>
|
||||
{isInfoVisible ? (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{relationDescription(relation)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] uppercase tracking-wide ${
|
||||
isSelected && !disabled
|
||||
? "text-primary/80"
|
||||
: "text-muted-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{relation}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{relationDescription(relation)}
|
||||
</label>
|
||||
|
||||
{isInfoVisible && (
|
||||
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
|
||||
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{t("ui.common.info", "상세 권한 안내")}
|
||||
</div>
|
||||
{relationPermitsInfo(relation)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] uppercase tracking-wide ${
|
||||
isSelected && !disabled
|
||||
? "text-primary/80"
|
||||
: "text-muted-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{relation}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -627,8 +677,35 @@ function ClientRelationsPage() {
|
||||
<TableRow key={`${item.relation}:${item.subject}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{relationLabel(item.relation as RelationOption)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<span>
|
||||
{relationLabel(item.relation as RelationOption)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full p-0.5 transition-colors ${
|
||||
infoRelation === item.relation
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/60 hover:text-primary"
|
||||
}`}
|
||||
onClick={(e) =>
|
||||
handleInfoToggle(
|
||||
e,
|
||||
item.relation as RelationOption,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{infoRelation === item.relation && (
|
||||
<div className="animate-in fade-in slide-in-from-top-1 rounded border border-primary/20 bg-primary/5 p-2 text-[11px] leading-relaxed text-foreground max-w-[250px]">
|
||||
{relationPermitsInfo(
|
||||
item.relation as RelationOption,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{relationDescription(item.relation as RelationOption)}
|
||||
|
||||
@@ -12,6 +12,8 @@ export type ClientSummary = {
|
||||
clientSecret?: string;
|
||||
tokenEndpointAuthMethod?: string;
|
||||
jwksUri?: string;
|
||||
backchannelLogoutUri?: string;
|
||||
backchannelLogoutSessionRequired?: boolean;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -118,6 +120,8 @@ export type ClientUpsertRequest = {
|
||||
responseTypes?: string[];
|
||||
tokenEndpointAuthMethod?: string;
|
||||
jwksUri?: string;
|
||||
backchannelLogoutUri?: string;
|
||||
backchannelLogoutSessionRequired?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -324,6 +324,19 @@ loaded_count = "Loaded {{count}} rows"
|
||||
loading = "Loading audit logs..."
|
||||
subtitle = "Shows DevFront activity history within current tenant/app scope."
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = "Manage developer access requests submitted by users."
|
||||
approved = "Approved."
|
||||
cancelled = "Approval has been cancelled."
|
||||
empty = "No requests found."
|
||||
need_cancel_notes = "Please enter a reason for cancelling approval."
|
||||
need_notes = "Please enter a rejection reason."
|
||||
rejected = "Rejected."
|
||||
user_desc = "Review your request history and submit a new access request."
|
||||
|
||||
[msg.dev.request.modal]
|
||||
desc = "Please enter the reason for your request. It will be approved after administrator review."
|
||||
|
||||
[msg.dev.clients]
|
||||
load_error = "Error loading clients: {{error}}"
|
||||
loading = "Loading apps..."
|
||||
@@ -362,6 +375,7 @@ create_forbidden = "You do not have permission to create this RP. Ask an adminis
|
||||
save_error = "Save Error"
|
||||
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
|
||||
secret_rotated = "Secret Rotated"
|
||||
secret_not_applicable = "PKCE apps do not have a client secret."
|
||||
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
||||
subtitle = "Manage OIDC credentials and endpoints."
|
||||
|
||||
@@ -375,6 +389,8 @@ note = "Keep endpoints read-only, and tie secret rotation/copy actions to audit
|
||||
[msg.dev.clients.general]
|
||||
load_error = "Error loading client: {{error}}"
|
||||
loading = "Loading client..."
|
||||
read_only_forbidden = "You do not have permission to edit this RP's general settings."
|
||||
read_only_hint = "Only users with the `RP Admin` or `RP General Settings` relationship can edit this RP's general settings."
|
||||
saved = "Saved"
|
||||
save_error = "Failed to save: {{error}}"
|
||||
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
|
||||
@@ -416,11 +432,27 @@ subtitle = "Set the application name, description, and logo."
|
||||
[msg.dev.clients.general.redirect]
|
||||
help = "Enter the redirect URIs. You can modify them in the Federation tab after creation."
|
||||
|
||||
[msg.dev.clients.general.backchannel_logout]
|
||||
uri_help = "RP endpoint that receives Baron's session termination event via server-to-server POST."
|
||||
invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1."
|
||||
session_required_help = "Use this when the RP should process logout_token only if the sid claim is included."
|
||||
session_required_on = "On: process logout only when the logout_token contains a sid."
|
||||
session_required_off = "Off: process logout using sub even if sid is missing."
|
||||
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = "No scopes registered."
|
||||
subtitle = "Define the permission scopes this application can request."
|
||||
tenant = "Tenant access claim"
|
||||
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "Separate shared claims from RP-specific extension claims."
|
||||
empty = "No ID Token claims have been added yet."
|
||||
hint = "Use top-level for shared claims and rp_claims for RP-specific extension claims. Arrays accept JSON or comma-separated values, and objects accept JSON."
|
||||
preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
|
||||
key_required = "Enter a claim key."
|
||||
reserved_key = "`rp_claims` is a reserved namespace key."
|
||||
duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}"
|
||||
|
||||
[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."
|
||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||
@@ -1288,12 +1320,45 @@ scope_badge = "Scoped to /dev"
|
||||
[ui.dev.nav]
|
||||
audit_logs = "Audit Logs"
|
||||
clients = "Connected Application"
|
||||
developer_request = "Developer Access Request"
|
||||
logout = "Logout"
|
||||
|
||||
[ui.dev.audit]
|
||||
load_more = "Load more"
|
||||
title = "Audit Logs"
|
||||
|
||||
[ui.dev.request]
|
||||
admin_notes_placeholder = "Enter note (optional)..."
|
||||
cancel_approval = "Cancel Approval"
|
||||
cancel_notes_placeholder = "Enter reason for approval cancellation..."
|
||||
|
||||
[ui.dev.request.list]
|
||||
title = "Request History"
|
||||
|
||||
[ui.dev.request.modal]
|
||||
email = "Email"
|
||||
name = "Name"
|
||||
org = "Organization"
|
||||
phone = "Phone Number"
|
||||
reason = "Reason"
|
||||
reason_placeholder = "e.g. I need to create an OIDC client for internal service integration and testing."
|
||||
role = "Role"
|
||||
title = "Developer Access Request"
|
||||
|
||||
[ui.dev.request.status]
|
||||
approved = "Approved"
|
||||
cancelled = "Approval Cancelled"
|
||||
pending = "Pending"
|
||||
rejected = "Rejected"
|
||||
|
||||
[ui.dev.request.table]
|
||||
actions = "Actions"
|
||||
date = "Requested At"
|
||||
org = "Organization"
|
||||
reason = "Reason"
|
||||
status = "Status"
|
||||
user = "User"
|
||||
|
||||
[ui.dev.audit.registry]
|
||||
title = "Audit registry"
|
||||
|
||||
@@ -1426,6 +1491,12 @@ title = "Application Identity"
|
||||
label = "Redirect URIs"
|
||||
placeholder = "Placeholder"
|
||||
|
||||
[ui.dev.clients.general.backchannel_logout]
|
||||
uri = "Back-Channel Logout URI"
|
||||
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
|
||||
session_required = "SID Claim Required"
|
||||
session_required_info = "Show SID Claim Required help"
|
||||
|
||||
[ui.dev.clients.general.scopes]
|
||||
add = "Scope Add"
|
||||
description_placeholder = "Description Placeholder"
|
||||
@@ -1452,6 +1523,22 @@ hint = "Turning this on adds the tenant scope automatically and requires at leas
|
||||
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.id_token_claims]
|
||||
title = "Custom Claims"
|
||||
add = "Add Claim"
|
||||
preview_title = "Saved JSON Preview"
|
||||
namespace_label = "Claim namespace"
|
||||
namespace_top_level = "top-level"
|
||||
namespace_rp_claims = "rp_claims"
|
||||
value_type_label = "Claim value type"
|
||||
value_type_text = "Text"
|
||||
value_type_number = "Number"
|
||||
value_type_boolean = "Boolean"
|
||||
value_type_array = "Array"
|
||||
value_type_object = "Object"
|
||||
key_placeholder = "e.g. locale"
|
||||
value_placeholder = "Enter the claim value"
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server Side App"
|
||||
pkce = "PKCE"
|
||||
@@ -1505,6 +1592,7 @@ user_search_placeholder = "Search by name or email..."
|
||||
[ui.dev.clients.relationships.option.admins]
|
||||
label = "RP Admin"
|
||||
description = "Full administrator relationship for RP operations."
|
||||
permits_info = "Has full administrative control over the RP, including config editing, secret management, JWKS, consents, and relationships."
|
||||
|
||||
[ui.dev.clients.relationships.option.creator]
|
||||
label = "RP Creator"
|
||||
@@ -1513,38 +1601,47 @@ description = "Marks the operator who created this RP."
|
||||
[ui.dev.clients.relationships.option.config_editor]
|
||||
label = "RP General Settings"
|
||||
description = "Edit the name, redirect URIs, and general metadata."
|
||||
permits_info = "Can modify general RP settings such as Name, Redirect URIs, Post-Logout URIs, and metadata."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_viewer]
|
||||
label = "Secret View"
|
||||
description = "View the Client secret for this RP."
|
||||
permits_info = "Can view the Client Secret value in plain text."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_rotator]
|
||||
label = "Secret Rotation"
|
||||
description = "Rotate and reissue the client secret."
|
||||
permits_info = "Can regenerate the Client Secret or expire and rotate existing secrets."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||
label = "JWKS View"
|
||||
description = "View JWKS status, cache details, and key summaries."
|
||||
permits_info = "Can view JWKS status, cached key information, and key summaries."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_operator]
|
||||
label = "JWKS Operations"
|
||||
description = "Run operational actions such as refresh and revoke."
|
||||
permits_info = "Can perform JWKS operations such as manual refresh and key revocation."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_viewer]
|
||||
label = "Consent View"
|
||||
description = "View consent grants for this RP."
|
||||
permits_info = "Can view the history of user consents granted to this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_revoker]
|
||||
label = "Consent Revoke"
|
||||
description = "Revoke consent grants for this RP."
|
||||
permits_info = "Can revoke or cancel user consents granted to this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||
label = "Relationship View"
|
||||
description = "View direct relations assigned to this RP."
|
||||
permits_info = "Can view the list of direct relationships and administrative roles assigned to this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.audit_viewer]
|
||||
label = "Audit Log View"
|
||||
description = "View DevFront audit logs for this RP."
|
||||
permits_info = "Can view DevFront audit logs for all configuration changes and operations on this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.status_operator]
|
||||
label = "Status Change"
|
||||
|
||||
@@ -324,6 +324,19 @@ loaded_count = "로드된 로그 {{count}}건"
|
||||
loading = "감사 로그를 불러오는 중..."
|
||||
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다."
|
||||
approved = "승인되었습니다."
|
||||
cancelled = "승인이 취소되었습니다."
|
||||
empty = "신청 내역이 없습니다."
|
||||
need_cancel_notes = "승인 취소 사유를 입력해주세요."
|
||||
need_notes = "반려 사유를 입력해주세요."
|
||||
rejected = "반려되었습니다."
|
||||
user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다."
|
||||
|
||||
[msg.dev.request.modal]
|
||||
desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다."
|
||||
|
||||
[msg.dev.clients]
|
||||
deleted = "앱이 삭제되었습니다."
|
||||
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
@@ -362,6 +375,7 @@ create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게
|
||||
save_error = "저장 실패: {{error}}"
|
||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||
secret_rotated = "Client Secret이 재발급되었습니다."
|
||||
secret_not_applicable = "PKCE 앱에는 Client Secret이 없습니다."
|
||||
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
||||
subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다."
|
||||
|
||||
@@ -375,6 +389,8 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
|
||||
[msg.dev.clients.general]
|
||||
load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}"
|
||||
loading = "클라이언트 정보를 불러오는 중..."
|
||||
read_only_forbidden = "이 RP의 일반 설정을 수정할 권한이 없습니다."
|
||||
read_only_hint = "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다."
|
||||
save_error = "저장 실패: {{error}}"
|
||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||
saved = "설정이 저장되었습니다."
|
||||
@@ -416,11 +432,27 @@ subtitle = "앱 이름과 설명, 로고를 설정합니다."
|
||||
[msg.dev.clients.general.redirect]
|
||||
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다."
|
||||
|
||||
[msg.dev.clients.general.backchannel_logout]
|
||||
uri_help = "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다."
|
||||
invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다."
|
||||
session_required_help = "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다."
|
||||
session_required_on = "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리"
|
||||
session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능"
|
||||
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = "등록된 스코프가 없습니다."
|
||||
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||
tenant = "소속 테넌트 정보 접근"
|
||||
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
|
||||
empty = "아직 추가된 ID Token claim이 없습니다."
|
||||
hint = "top-level은 공통 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
|
||||
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
|
||||
key_required = "Claim key를 입력해야 합니다."
|
||||
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
|
||||
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||
@@ -1288,12 +1320,45 @@ scope_badge = "Scoped to /dev"
|
||||
[ui.dev.nav]
|
||||
audit_logs = "감사 로그"
|
||||
clients = "연동 앱"
|
||||
developer_request = "개발자 권한 신청"
|
||||
logout = "로그아웃"
|
||||
|
||||
[ui.dev.audit]
|
||||
load_more = "더 보기"
|
||||
title = "감사 로그"
|
||||
|
||||
[ui.dev.request]
|
||||
admin_notes_placeholder = "메모 입력 (선택)..."
|
||||
cancel_approval = "승인 취소"
|
||||
cancel_notes_placeholder = "승인 취소 사유 입력..."
|
||||
|
||||
[ui.dev.request.list]
|
||||
title = "신청 내역"
|
||||
|
||||
[ui.dev.request.modal]
|
||||
email = "이메일"
|
||||
name = "성함"
|
||||
org = "소속"
|
||||
phone = "전화번호"
|
||||
reason = "신청 사유"
|
||||
reason_placeholder = "예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다."
|
||||
role = "역할"
|
||||
title = "개발자 등록 신청"
|
||||
|
||||
[ui.dev.request.status]
|
||||
approved = "승인됨"
|
||||
cancelled = "승인 취소됨"
|
||||
pending = "대기 중"
|
||||
rejected = "반려됨"
|
||||
|
||||
[ui.dev.request.table]
|
||||
actions = "관리"
|
||||
date = "신청일"
|
||||
org = "소속"
|
||||
reason = "신청 사유"
|
||||
status = "상태"
|
||||
user = "사용자"
|
||||
|
||||
[ui.dev.audit.registry]
|
||||
title = "Audit registry"
|
||||
|
||||
@@ -1425,6 +1490,12 @@ title = "애플리케이션 정보"
|
||||
label = "리디렉션 URI"
|
||||
placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
|
||||
|
||||
[ui.dev.clients.general.backchannel_logout]
|
||||
uri = "Back-Channel Logout URI"
|
||||
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
|
||||
session_required = "SID Claim Required"
|
||||
session_required_info = "SID Claim Required 설명 보기"
|
||||
|
||||
[ui.dev.clients.general.scopes]
|
||||
add = "스코프 추가"
|
||||
description_placeholder = "권한에 대한 설명"
|
||||
@@ -1451,6 +1522,22 @@ hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용
|
||||
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
|
||||
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
|
||||
[ui.dev.clients.general.id_token_claims]
|
||||
title = "커스텀 클레임"
|
||||
add = "Claim 추가"
|
||||
preview_title = "저장 JSON 미리보기"
|
||||
namespace_label = "Claim 네임스페이스"
|
||||
namespace_top_level = "top-level"
|
||||
namespace_rp_claims = "rp_claims"
|
||||
value_type_label = "Claim 값 타입"
|
||||
value_type_text = "텍스트"
|
||||
value_type_number = "숫자"
|
||||
value_type_boolean = "불리언"
|
||||
value_type_array = "배열"
|
||||
value_type_object = "객체"
|
||||
key_placeholder = "예: locale"
|
||||
value_placeholder = "Claim 값을 입력하세요"
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
@@ -1504,6 +1591,7 @@ user_search_placeholder = "이름 또는 이메일 검색..."
|
||||
[ui.dev.clients.relationships.option.admins]
|
||||
label = "RP 관리자"
|
||||
description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다."
|
||||
permits_info = "RP 설정 수정, 시크릿 조회/재발급, JWKS 관리, 동의 조회/회수, 관계 조회/수정 등 모든 운영 권한을 가집니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.creator]
|
||||
label = "RP 생성자"
|
||||
@@ -1512,38 +1600,47 @@ description = "이 RP를 생성한 운영 주체를 표시합니다."
|
||||
[ui.dev.clients.relationships.option.config_editor]
|
||||
label = "RP 일반 설정"
|
||||
description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다."
|
||||
permits_info = "RP 이름, Redirect URIs, 로그아웃 URI, 메타데이터 등 일반 설정을 수정할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_viewer]
|
||||
label = "시크릿 조회"
|
||||
description = "이 RP의 Client secret을 조회합니다."
|
||||
permits_info = "RP의 Client Secret 값을 평문으로 확인할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_rotator]
|
||||
label = "시크릿 재발급"
|
||||
description = "Client secret 재발급과 회전을 수행합니다."
|
||||
permits_info = "새로운 Client Secret을 발급하거나 기존 시크릿을 만료시키고 회전시킬 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||
label = "JWKS 조회"
|
||||
description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다."
|
||||
permits_info = "RP의 공개키 설정(JWKS) 상태와 캐시된 키 정보를 조회할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_operator]
|
||||
label = "JWKS 운영"
|
||||
description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다."
|
||||
permits_info = "JWKS 캐시를 강제로 갱신하거나 등록된 키를 회수하는 등 키 관리를 수행할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_viewer]
|
||||
label = "동의 조회"
|
||||
description = "이 RP의 consent 내역을 조회합니다."
|
||||
permits_info = "사용자가 이 RP에 부여한 개인정보 제공 동의 내역을 조회할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_revoker]
|
||||
label = "동의 회수"
|
||||
description = "이 RP의 consent를 회수합니다."
|
||||
permits_info = "사용자의 동의 내역을 강제로 취소하거나 회수할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||
label = "관계 조회"
|
||||
description = "이 RP에 부여된 direct relation을 조회합니다."
|
||||
permits_info = "이 RP에 어떤 사용자가 어떤 관리 권한을 가지고 있는지 목록을 조회할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.audit_viewer]
|
||||
label = "감사 로그 조회"
|
||||
description = "이 RP의 DevFront 감사 로그를 조회합니다."
|
||||
permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에 대한 감사 로그를 조회할 수 있습니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.status_operator]
|
||||
label = "상태 변경"
|
||||
|
||||
@@ -191,11 +191,17 @@ delete_confirm = ""
|
||||
delete_success = ""
|
||||
empty = ""
|
||||
fetch_error = ""
|
||||
import_empty = ""
|
||||
import_error = ""
|
||||
import_result = ""
|
||||
missing_id = ""
|
||||
not_found = ""
|
||||
remove_sub_confirm = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.tenants.import_preview]
|
||||
description = ""
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
add_success = ""
|
||||
empty = ""
|
||||
@@ -255,9 +261,13 @@ parsed_count = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.admin.users.create]
|
||||
appointment_required = ""
|
||||
error = ""
|
||||
external_tenant_required = ""
|
||||
password_required = ""
|
||||
personal_tenant_failed = ""
|
||||
success = ""
|
||||
tenant_resolve_failed = ""
|
||||
|
||||
[msg.admin.users.create.account]
|
||||
subtitle = ""
|
||||
@@ -269,6 +279,7 @@ field_required = ""
|
||||
name_required = ""
|
||||
password_auto_help = ""
|
||||
password_manual_help = ""
|
||||
picker_description = ""
|
||||
role_help = ""
|
||||
|
||||
[msg.admin.users.create.password_generated]
|
||||
@@ -291,7 +302,9 @@ password_hint = ""
|
||||
[msg.admin.users.list]
|
||||
delete_confirm = ""
|
||||
empty = ""
|
||||
export_error = ""
|
||||
fetch_error = ""
|
||||
status_error = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.users.list.columns]
|
||||
@@ -337,14 +350,6 @@ user_desc = ""
|
||||
|
||||
[msg.dev.request.modal]
|
||||
desc = ""
|
||||
email = ""
|
||||
name = ""
|
||||
org = ""
|
||||
phone = ""
|
||||
reason = ""
|
||||
reason_placeholder = ""
|
||||
role = ""
|
||||
title = ""
|
||||
|
||||
[msg.dev.request.status]
|
||||
approved = ""
|
||||
@@ -408,6 +413,7 @@ create_forbidden = ""
|
||||
save_error = ""
|
||||
save_forbidden = ""
|
||||
secret_rotated = ""
|
||||
secret_not_applicable = ""
|
||||
secret_unavailable = ""
|
||||
subtitle = ""
|
||||
|
||||
@@ -421,11 +427,22 @@ note = ""
|
||||
[msg.dev.clients.general]
|
||||
load_error = ""
|
||||
loading = ""
|
||||
read_only_forbidden = ""
|
||||
read_only_hint = ""
|
||||
saved = ""
|
||||
save_error = ""
|
||||
save_forbidden = ""
|
||||
status_changed = ""
|
||||
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = ""
|
||||
empty = ""
|
||||
hint = ""
|
||||
preview_hint = ""
|
||||
key_required = ""
|
||||
reserved_key = ""
|
||||
duplicate_key = ""
|
||||
|
||||
[msg.dev.clients.relationships]
|
||||
subtitle = ""
|
||||
add_description = ""
|
||||
@@ -462,6 +479,13 @@ subtitle = ""
|
||||
[msg.dev.clients.general.redirect]
|
||||
help = ""
|
||||
|
||||
[msg.dev.clients.general.backchannel_logout]
|
||||
uri_help = ""
|
||||
invalid = ""
|
||||
session_required_help = ""
|
||||
session_required_on = ""
|
||||
session_required_off = ""
|
||||
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = ""
|
||||
subtitle = ""
|
||||
@@ -987,6 +1011,9 @@ user = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = ""
|
||||
csv_template = ""
|
||||
export = ""
|
||||
import = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
@@ -1116,6 +1143,15 @@ search_placeholder = ""
|
||||
title = ""
|
||||
tree_search_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = ""
|
||||
confirm = ""
|
||||
create_new = ""
|
||||
fixed_id = ""
|
||||
match = ""
|
||||
no_candidates = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = ""
|
||||
name = ""
|
||||
@@ -1124,6 +1160,7 @@ status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = ""
|
||||
id = ""
|
||||
members = ""
|
||||
name = ""
|
||||
slug = ""
|
||||
@@ -1223,6 +1260,7 @@ empty = ""
|
||||
fetch_error = ""
|
||||
search_placeholder = ""
|
||||
subtitle = ""
|
||||
toggle_status = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
@@ -1508,6 +1546,12 @@ title = ""
|
||||
label = ""
|
||||
placeholder = ""
|
||||
|
||||
[ui.dev.clients.general.backchannel_logout]
|
||||
uri = ""
|
||||
uri_placeholder = ""
|
||||
session_required = ""
|
||||
session_required_info = ""
|
||||
|
||||
[ui.dev.clients.general.scopes]
|
||||
add = ""
|
||||
description_placeholder = ""
|
||||
@@ -1534,6 +1578,22 @@ hint = ""
|
||||
autocomplete_hint = ""
|
||||
validation_required = ""
|
||||
|
||||
[ui.dev.clients.general.id_token_claims]
|
||||
title = ""
|
||||
add = ""
|
||||
preview_title = ""
|
||||
namespace_label = ""
|
||||
namespace_top_level = ""
|
||||
namespace_rp_claims = ""
|
||||
value_type_label = ""
|
||||
value_type_text = ""
|
||||
value_type_number = ""
|
||||
value_type_boolean = ""
|
||||
value_type_array = ""
|
||||
value_type_object = ""
|
||||
key_placeholder = ""
|
||||
value_placeholder = ""
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = ""
|
||||
pkce = ""
|
||||
@@ -1586,50 +1646,62 @@ user_search_placeholder = ""
|
||||
[ui.dev.clients.relationships.option.admins]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.creator]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.config_editor]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_rotator]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_operator]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_revoker]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.audit_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.status_operator]
|
||||
label = ""
|
||||
description = ""
|
||||
permits_info = ""
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = ""
|
||||
|
||||
@@ -129,6 +129,159 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
||||
});
|
||||
|
||||
test("id token claims should be persisted and restored", async ({ page }) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-claims", {
|
||||
name: "Claims app",
|
||||
metadata: {},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-claims/settings");
|
||||
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
|
||||
await page.getByPlaceholder(/e\.g\. locale|예: locale/i).fill("locale");
|
||||
await page
|
||||
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
|
||||
.first()
|
||||
.selectOption("top_level");
|
||||
await page
|
||||
.getByLabel(/Claim value type|Claim 값 타입/i)
|
||||
.first()
|
||||
.selectOption("text");
|
||||
await page
|
||||
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
|
||||
.first()
|
||||
.fill("ko-KR");
|
||||
|
||||
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
|
||||
await page
|
||||
.getByPlaceholder(/e\.g\. locale|예: locale/i)
|
||||
.nth(1)
|
||||
.fill("tier");
|
||||
await page
|
||||
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
|
||||
.nth(1)
|
||||
.selectOption("rp_claims");
|
||||
await page
|
||||
.getByLabel(/Claim value type|Claim 값 타입/i)
|
||||
.nth(1)
|
||||
.selectOption("number");
|
||||
await page
|
||||
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
|
||||
.nth(1)
|
||||
.fill("2");
|
||||
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.metadata?.id_token_claims)
|
||||
.toBeDefined();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
valueType?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.length,
|
||||
)
|
||||
.toBe(2);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
valueType?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[0]?.namespace,
|
||||
)
|
||||
.toBe("top_level");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
valueType?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[0]?.key,
|
||||
)
|
||||
.toBe("locale");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
valueType?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[1]?.namespace,
|
||||
)
|
||||
.toBe("rp_claims");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
valueType?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[1]?.key,
|
||||
)
|
||||
.toBe("tier");
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByPlaceholder(/e\.g\. locale|예: locale/i),
|
||||
).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByPlaceholder(/e\.g\. locale|예: locale/i).first(),
|
||||
).toHaveValue("locale");
|
||||
await expect(
|
||||
page.getByPlaceholder(/e\.g\. locale|예: locale/i).nth(1),
|
||||
).toHaveValue("tier");
|
||||
await expect(
|
||||
page.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i),
|
||||
).toHaveCount(2);
|
||||
await expect(
|
||||
page
|
||||
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
|
||||
.first(),
|
||||
).toHaveValue("ko-KR");
|
||||
await expect(
|
||||
page
|
||||
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
|
||||
.nth(1),
|
||||
).toHaveValue("2");
|
||||
});
|
||||
|
||||
test("pkce headless login uses jwks uri only and shows cache actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -147,7 +147,9 @@ test.describe("DevFront developer request and management", () => {
|
||||
await nameInput.fill("E2E Test RP");
|
||||
await nameInput.press("Tab");
|
||||
|
||||
const uriInput = page.locator("textarea.font-mono");
|
||||
const uriInput = page.getByRole("textbox", {
|
||||
name: /Redirect URIs|인증 콜백 URL|Callback/i,
|
||||
});
|
||||
await uriInput.fill("https://example.com/callback");
|
||||
await uriInput.press("Tab");
|
||||
|
||||
|
||||
@@ -221,6 +221,39 @@ function parseClientId(pathname: string): string {
|
||||
}
|
||||
|
||||
export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
const readMockRole = async () =>
|
||||
(
|
||||
(await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
|
||||
"rp_admin"
|
||||
).trim();
|
||||
|
||||
const buildSelfConfigEditorRelation = (): ClientRelation => ({
|
||||
relation: "config_editor",
|
||||
subject: "User:playwright-user",
|
||||
subjectType: "User",
|
||||
subjectId: "playwright-user",
|
||||
userName: "Playwright User",
|
||||
userEmail: "playwright@example.com",
|
||||
userLoginId: "playwright@example.com",
|
||||
});
|
||||
|
||||
const shouldGrantDefaultEditRelation = (role: string) =>
|
||||
role === "rp_admin" || role === "tenant_admin" || role === "super_admin";
|
||||
|
||||
const resolveClientRelations = async (clientId: string) => {
|
||||
const explicitRelations = state.relations?.[clientId];
|
||||
if (explicitRelations) {
|
||||
return explicitRelations;
|
||||
}
|
||||
|
||||
const role = await readMockRole();
|
||||
if (!shouldGrantDefaultEditRelation(role)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [buildSelfConfigEditorRelation()];
|
||||
};
|
||||
|
||||
const appendAuditLog = (
|
||||
eventType: string,
|
||||
action: string,
|
||||
@@ -431,6 +464,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
});
|
||||
|
||||
state.clients.push(created);
|
||||
if (!state.relations) {
|
||||
state.relations = {};
|
||||
}
|
||||
state.relations[created.id] = [buildSelfConfigEditorRelation()];
|
||||
appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id);
|
||||
return json(route, {
|
||||
client: created,
|
||||
@@ -451,7 +488,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
) {
|
||||
const clientId = pathname.split("/")[5] ?? "";
|
||||
return json(route, {
|
||||
items: state.relations?.[clientId] ?? [],
|
||||
items: await resolveClientRelations(clientId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -156,4 +156,4 @@
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [{ "handler": "noop" }]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -1095,6 +1095,7 @@ delete_selected = "Delete Selected"
|
||||
export_with_ids = "Include UUIDs"
|
||||
export_without_ids = "Export without UUIDs"
|
||||
import = "Import"
|
||||
seed_badge = "Seed"
|
||||
title = "Tenant Registry"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
|
||||
@@ -1188,6 +1189,7 @@ import_partial_success = "Imported some organization data successfully."
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = "Delete {{count}} selected tenants?"
|
||||
seed_delete_blocked = "Seed tenants cannot be deleted."
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = "You cannot delete your own account."
|
||||
@@ -1204,6 +1206,15 @@ title = "Tenant Members ({{count}})"
|
||||
total = "Total"
|
||||
total_label = "Total"
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = "Candidates"
|
||||
confirm = "Confirm Import"
|
||||
create_new = "Create New"
|
||||
fixed_id = "Fixed ID"
|
||||
match = "Matched Tenant"
|
||||
no_candidates = "No matching tenants found."
|
||||
title = "Import Preview"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
@@ -1339,6 +1350,7 @@ name = "Name"
|
||||
name_placeholder = "Name Placeholder"
|
||||
password = "Password"
|
||||
password_placeholder = "********"
|
||||
picker_description = "Search and select a tenant."
|
||||
phone = "Phone number"
|
||||
phone_placeholder = "010-1234-5678"
|
||||
position = "Position"
|
||||
|
||||
@@ -672,11 +672,17 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
|
||||
delete_success = "테넌트가 삭제되었습니다."
|
||||
empty = "아직 등록된 테넌트가 없습니다."
|
||||
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
||||
import_empty = "임포트 파일에 테넌트 행이 없습니다."
|
||||
import_error = "테넌트 임포트에 실패했습니다: {{error}}"
|
||||
import_result = "{{count}}개의 테넌트 행을 처리했습니다."
|
||||
missing_id = "테넌트 ID가 없습니다."
|
||||
not_found = "테넌트를 찾을 수 없습니다."
|
||||
remove_sub_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?"
|
||||
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
|
||||
|
||||
[msg.admin.tenants.import_preview]
|
||||
description = "임포트 전에 각 행의 매칭 결과를 검토하고 처리 방식을 선택하세요."
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
add_success = "관리자가 추가되었습니다."
|
||||
empty = "등록된 관리자가 없습니다."
|
||||
@@ -1564,6 +1570,7 @@ user = "TENANT MEMBER"
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
delete_selected = "선택 삭제"
|
||||
seed_badge = "초기 설정"
|
||||
title = "테넌트 목록"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
|
||||
@@ -1642,9 +1649,12 @@ import_partial_success = "일부 조직 정보를 가져왔습니다."
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
|
||||
seed_delete_blocked = "초기 설정 테넌트는 삭제할 수 없습니다."
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
|
||||
export_error = "사용자 내보내기에 실패했습니다: {{error}}"
|
||||
status_error = "사용자 상태 변경에 실패했습니다: {{error}}"
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = "API Key Registry"
|
||||
@@ -1658,6 +1668,15 @@ title = "테넌트 구성원 ({{count}})"
|
||||
total = "전체"
|
||||
total_label = "전체"
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = "후보"
|
||||
confirm = "임포트 확정"
|
||||
create_new = "새로 생성"
|
||||
fixed_id = "고정 ID"
|
||||
match = "매칭된 테넌트"
|
||||
no_candidates = "매칭 가능한 테넌트가 없습니다."
|
||||
title = "임포트 미리보기"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
@@ -1793,6 +1812,7 @@ name = "이름"
|
||||
name_placeholder = "홍길동"
|
||||
password = "비밀번호"
|
||||
password_placeholder = "********"
|
||||
picker_description = "배정할 테넌트를 검색해서 선택하세요."
|
||||
phone = "전화번호"
|
||||
phone_placeholder = "010-1234-5678"
|
||||
position = "직급"
|
||||
|
||||
@@ -1439,6 +1439,7 @@ user = ""
|
||||
[ui.admin.tenants]
|
||||
add = ""
|
||||
delete_selected = ""
|
||||
seed_badge = ""
|
||||
title = ""
|
||||
view_org_chart = ""
|
||||
|
||||
@@ -1517,9 +1518,18 @@ import_partial_success = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = ""
|
||||
import_empty = ""
|
||||
import_error = ""
|
||||
import_result = ""
|
||||
seed_delete_blocked = ""
|
||||
|
||||
[msg.admin.tenants.import_preview]
|
||||
description = ""
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = ""
|
||||
export_error = ""
|
||||
status_error = ""
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = ""
|
||||
@@ -1604,6 +1614,15 @@ search_placeholder = ""
|
||||
title = ""
|
||||
tree_search_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = ""
|
||||
confirm = ""
|
||||
create_new = ""
|
||||
fixed_id = ""
|
||||
match = ""
|
||||
no_candidates = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = ""
|
||||
name = ""
|
||||
@@ -1668,6 +1687,7 @@ name = ""
|
||||
name_placeholder = ""
|
||||
password = ""
|
||||
password_placeholder = ""
|
||||
picker_description = ""
|
||||
phone = ""
|
||||
phone_placeholder = ""
|
||||
position = ""
|
||||
|
||||
@@ -6,8 +6,40 @@ job_name="${1:-adminfront-tests}"
|
||||
mkdir -p reports
|
||||
rm -rf adminfront/node_modules
|
||||
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
is_port_available() {
|
||||
local port="$1"
|
||||
node -e '
|
||||
const net = require("net");
|
||||
const port = Number(process.argv[1]);
|
||||
const server = net.createServer();
|
||||
server.once("error", () => process.exit(1));
|
||||
server.once("listening", () => server.close(() => process.exit(0)));
|
||||
server.listen(port, "127.0.0.1");
|
||||
' "$port"
|
||||
}
|
||||
|
||||
find_available_port() {
|
||||
node -e '
|
||||
const net = require("net");
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
process.stdout.write(String(address.port));
|
||||
server.close();
|
||||
});
|
||||
'
|
||||
}
|
||||
|
||||
playwright_install_cmd=(npx playwright install)
|
||||
playwright_install_desc="npx playwright install"
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
fi
|
||||
|
||||
set +e
|
||||
(
|
||||
@@ -67,6 +99,11 @@ fi
|
||||
|
||||
set +e
|
||||
port="${PORT:-5180}"
|
||||
if ! is_port_available "$port"; then
|
||||
fallback_port="$(find_available_port)"
|
||||
echo "==> requested PORT=$port is already in use; switching to PORT=$fallback_port"
|
||||
port="$fallback_port"
|
||||
fi
|
||||
echo "==> adminfront using PORT=$port"
|
||||
(
|
||||
cd adminfront
|
||||
|
||||
Reference in New Issue
Block a user