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>
|
<span>{tenant.name}</span>
|
||||||
{isSeedTenant(tenant) && (
|
{isSeedTenant(tenant) && (
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
{t(
|
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||||
"ui.admin.tenants.seed_badge",
|
|
||||||
"초기 설정",
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,9 +103,7 @@ test.describe("Seed tenant protection", () => {
|
|||||||
|
|
||||||
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
|
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
|
||||||
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
|
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
|
||||||
await expect(
|
await expect(normalRow.getByRole("button", { name: /삭제/ })).toBeEnabled();
|
||||||
normalRow.getByRole("button", { name: /삭제/ }),
|
|
||||||
).toBeEnabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("disables delete action on seed tenant profile", async ({ page }) => {
|
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 ({
|
test("should export and import tenant CSV without organization/user combined import", async ({
|
||||||
page,
|
page,
|
||||||
|
browserName,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
let exportRequested = false;
|
let exportRequested = false;
|
||||||
let exportUrl = "";
|
let exportUrl = "";
|
||||||
@@ -213,8 +214,11 @@ test.describe("Tenants Management", () => {
|
|||||||
/갱신 1|Updated 1/i,
|
/갱신 1|Updated 1/i,
|
||||||
);
|
);
|
||||||
expect(importRequested).toBe(true);
|
expect(importRequested).toBe(true);
|
||||||
if (testInfo.project.name !== "webkit") {
|
expect(importBody).toContain('filename="tenants.csv"');
|
||||||
expect(importBody).toContain("tenant-alpha-id");
|
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/init", authHandler.InitQRLogin)
|
||||||
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
||||||
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
||||||
|
auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS)
|
||||||
|
|
||||||
// Signup Routes
|
// Signup Routes
|
||||||
signup := auth.Group("/signup")
|
signup := auth.Group("/signup")
|
||||||
|
|||||||
@@ -6,29 +6,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
||||||
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||||
MetadataHeadlessJWKS = "headless_jwks"
|
MetadataHeadlessJWKS = "headless_jwks"
|
||||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||||
MetadataAutoLoginSupported = "auto_login_supported"
|
MetadataIDTokenClaims = "id_token_claims"
|
||||||
MetadataAutoLoginURL = "auto_login_url"
|
MetadataBackChannelLogoutURI = "backchannel_logout_uri"
|
||||||
|
MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
|
||||||
|
MetadataAutoLoginSupported = "auto_login_supported"
|
||||||
|
MetadataAutoLoginURL = "auto_login_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HydraClient struct {
|
type HydraClient struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientName string `json:"client_name,omitempty"`
|
ClientName string `json:"client_name,omitempty"`
|
||||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||||
ClientURI string `json:"client_uri,omitempty"`
|
ClientURI string `json:"client_uri,omitempty"`
|
||||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||||
GrantTypes []string `json:"grant_types,omitempty"`
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
ResponseTypes []string `json:"response_types,omitempty"`
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
SkipConsent *bool `json:"skip_consent,omitempty"`
|
SkipConsent *bool `json:"skip_consent,omitempty"`
|
||||||
JWKSUri string `json:"jwks_uri,omitempty"`
|
JWKSUri string `json:"jwks_uri,omitempty"`
|
||||||
JWKS interface{} `json:"jwks,omitempty"`
|
JWKS interface{} `json:"jwks,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,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 {
|
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||||
@@ -86,6 +91,29 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
|||||||
return false
|
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 {
|
type HydraConsentRequest struct {
|
||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
RequestedScope []string `json:"requested_scope"`
|
RequestedScope []string `json:"requested_scope"`
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ type AuthHandler struct {
|
|||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
OathkeeperRepo domain.OathkeeperLogRepository
|
OathkeeperRepo domain.OathkeeperLogRepository
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
|
BackchannelLogout *service.BackchannelLogoutService
|
||||||
TenantService service.TenantService
|
TenantService service.TenantService
|
||||||
KetoService service.KetoService
|
KetoService service.KetoService
|
||||||
KetoOutboxRepo repository.KetoOutboxRepository
|
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 {
|
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{
|
return &AuthHandler{
|
||||||
SmsService: service.NewSmsService(),
|
SmsService: service.NewSmsService(),
|
||||||
EmailService: service.NewEmailService(),
|
EmailService: service.NewEmailService(),
|
||||||
RedisService: redisService,
|
RedisService: redisService,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
||||||
KratosAdmin: kratos,
|
KratosAdmin: kratos,
|
||||||
IdpProvider: idpProvider,
|
IdpProvider: idpProvider,
|
||||||
AuditRepo: auditRepo,
|
AuditRepo: auditRepo,
|
||||||
OathkeeperRepo: oathkeeperRepo,
|
OathkeeperRepo: oathkeeperRepo,
|
||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
TenantService: tenantService,
|
BackchannelLogout: backchannelLogout,
|
||||||
KetoService: ketoService,
|
TenantService: tenantService,
|
||||||
KetoOutboxRepo: ketoOutboxRepo,
|
KetoService: ketoService,
|
||||||
UserRepo: userRepo,
|
KetoOutboxRepo: ketoOutboxRepo,
|
||||||
ConsentRepo: consentRepo,
|
UserRepo: userRepo,
|
||||||
|
ConsentRepo: consentRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,6 +1164,60 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
|
|||||||
return claims
|
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 {
|
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
claims = map[string]any{}
|
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{
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Link revoked successfully",
|
"message": "Link revoked successfully",
|
||||||
@@ -5362,8 +5424,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
tenantID = tid
|
tenantID = tid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sessionClaims := withOidcSessionMetadata(
|
sessionClaims := composeOIDCSessionClaims(
|
||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
consentRequest.Client,
|
||||||
|
identity.Traits,
|
||||||
|
consentRequest.RequestedScope,
|
||||||
|
tenantID,
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
@@ -5392,8 +5457,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionClaims := withOidcSessionMetadata(
|
sessionClaims := composeOIDCSessionClaims(
|
||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
consentRequest.Client,
|
||||||
|
identity.Traits,
|
||||||
|
consentRequest.RequestedScope,
|
||||||
|
tenantID,
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
@@ -5575,8 +5643,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionClaims := withOidcSessionMetadata(
|
sessionClaims := composeOIDCSessionClaims(
|
||||||
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
|
consentRequest.Client,
|
||||||
|
identity.Traits,
|
||||||
|
consentRequest.RequestedScope,
|
||||||
|
tenantID,
|
||||||
currentSessionID,
|
currentSessionID,
|
||||||
)
|
)
|
||||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
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 {
|
if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions")
|
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)
|
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
|
||||||
return c.JSON(fiber.Map{"status": "ok"})
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
@@ -8124,6 +8196,129 @@ func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID strin
|
|||||||
return nil
|
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 {
|
func looksLikeInternalUserAgent(userAgent string) bool {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(userAgent))
|
normalized := strings.ToLower(strings.TrimSpace(userAgent))
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,6 +56,69 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
|||||||
assert.Equal(t, 1, len(auditRepo.logs))
|
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) {
|
func TestListRpHistory_Aggregation(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
auditRepo := &mockAuditRepo{
|
auditRepo := &mockAuditRepo{
|
||||||
|
|||||||
@@ -363,3 +363,110 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
|||||||
assert.Equal(t, "Security", capturedClaims["department"])
|
assert.Equal(t, "Security", capturedClaims["department"])
|
||||||
assert.Equal(t, "Officer", capturedClaims["position"])
|
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"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -500,6 +502,108 @@ func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t
|
|||||||
mockKratos.AssertExpectations(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) {
|
func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) {
|
||||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||||
var hydraRevokeCalls int
|
var hydraRevokeCalls int
|
||||||
|
|||||||
@@ -94,19 +94,21 @@ type devStatsResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientSummary struct {
|
type clientSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
RedirectURIs []string `json:"redirectUris"`
|
RedirectURIs []string `json:"redirectUris"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
ClientSecret string `json:"clientSecret,omitempty"`
|
ClientSecret string `json:"clientSecret,omitempty"`
|
||||||
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
||||||
SkipConsent bool `json:"skipConsent"`
|
SkipConsent bool `json:"skipConsent"`
|
||||||
JwksUri string `json:"jwksUri,omitempty"`
|
JwksUri string `json:"jwksUri,omitempty"`
|
||||||
Jwks interface{} `json:"jwks,omitempty"`
|
Jwks interface{} `json:"jwks,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
|
||||||
|
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientListResponse struct {
|
type clientListResponse struct {
|
||||||
@@ -179,19 +181,28 @@ type consentListResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientUpsertRequest struct {
|
type clientUpsertRequest struct {
|
||||||
ID *string `json:"id"`
|
ID *string `json:"id"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
RedirectURIs *[]string `json:"redirectUris"`
|
RedirectURIs *[]string `json:"redirectUris"`
|
||||||
Scopes *[]string `json:"scopes"`
|
Scopes *[]string `json:"scopes"`
|
||||||
GrantTypes *[]string `json:"grantTypes"`
|
GrantTypes *[]string `json:"grantTypes"`
|
||||||
ResponseTypes *[]string `json:"responseTypes"`
|
ResponseTypes *[]string `json:"responseTypes"`
|
||||||
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
||||||
SkipConsent *bool `json:"skipConsent"`
|
SkipConsent *bool `json:"skipConsent"`
|
||||||
JwksUri *string `json:"jwksUri"`
|
JwksUri *string `json:"jwksUri"`
|
||||||
Jwks interface{} `json:"jwks"`
|
Jwks interface{} `json:"jwks"`
|
||||||
Metadata *map[string]interface{} `json:"metadata"`
|
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{}{
|
var protectedSystemClientIDs = map[string]struct{}{
|
||||||
@@ -745,6 +756,24 @@ func mapRelationTupleSummary(tuple service.RelationTuple, identity *service.Krat
|
|||||||
return summary
|
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) {
|
func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) {
|
||||||
clientID = strings.TrimSpace(clientID)
|
clientID = strings.TrimSpace(clientID)
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
@@ -1203,6 +1232,7 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
tuples = dedupeRelationTuples(tuples)
|
||||||
for _, tuple := range tuples {
|
for _, tuple := range tuples {
|
||||||
var identity *service.KratosIdentity
|
var identity *service.KratosIdentity
|
||||||
if tuple.SubjectID != "" && h.KratosAdmin != nil {
|
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")
|
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")
|
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 summary.Type == "private" &&
|
||||||
if !canChangeStatusByPermit {
|
!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")
|
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 != "" {
|
if tenantID != "" {
|
||||||
metadata["tenant_id"] = tenantID
|
metadata["tenant_id"] = tenantID
|
||||||
}
|
}
|
||||||
|
var err error
|
||||||
metadata["status"] = status
|
metadata["status"] = status
|
||||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||||
var err error
|
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)
|
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
@@ -1681,17 +1721,19 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
clientReq := domain.HydraClient{
|
clientReq := domain.HydraClient{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientName: name,
|
ClientName: name,
|
||||||
RedirectURIs: redirectURIs,
|
RedirectURIs: redirectURIs,
|
||||||
GrantTypes: grantTypes,
|
GrantTypes: grantTypes,
|
||||||
ResponseTypes: responseTypes,
|
ResponseTypes: responseTypes,
|
||||||
Scope: strings.Join(scopes, " "),
|
Scope: strings.Join(scopes, " "),
|
||||||
TokenEndpointAuthMethod: tokenAuthMethod,
|
TokenEndpointAuthMethod: tokenAuthMethod,
|
||||||
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
|
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
|
||||||
JWKSUri: jwksURI,
|
JWKSUri: jwksURI,
|
||||||
JWKS: jwks,
|
JWKS: jwks,
|
||||||
Metadata: metadata,
|
BackChannelLogoutURI: backchannelLogoutURI,
|
||||||
|
BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired),
|
||||||
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.setAuditDetailsExtra(c, map[string]any{
|
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
|
// [New] Automatically grant admin permission to the creator in Keto
|
||||||
if h.KetoOutbox != nil && profile != nil {
|
if h.KetoOutbox != nil && profile != nil {
|
||||||
subject := "User:" + profile.ID
|
subject := "User:" + profile.ID
|
||||||
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
if err := h.grantCreatorAdminRelation(c, created.ClientID, subject); err != nil {
|
||||||
Namespace: "RelyingParty",
|
|
||||||
Object: created.ClientID,
|
|
||||||
Relation: "admins",
|
|
||||||
Subject: subject,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("failed to grant automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
slog.Warn("failed to grant automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID)
|
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
|
// 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")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !canAccessClientByLegacyScope(profile, currentSummary) && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
|
if !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: edit_config permission is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
clientType := ""
|
clientType := ""
|
||||||
@@ -1848,11 +1878,21 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
metadata["status"] = status
|
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)
|
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
metadata, err = normalizeClientAutoLoginMetadata(metadata)
|
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
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))
|
resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true))
|
||||||
|
|
||||||
updated := domain.HydraClient{
|
updated := domain.HydraClient{
|
||||||
ClientID: current.ClientID,
|
ClientID: current.ClientID,
|
||||||
ClientName: valueOr(req.Name, current.ClientName),
|
ClientName: valueOr(req.Name, current.ClientName),
|
||||||
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
||||||
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
||||||
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
||||||
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
||||||
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
||||||
SkipConsent: boolPtr(resolvedSkipConsent),
|
SkipConsent: boolPtr(resolvedSkipConsent),
|
||||||
JWKSUri: resolvedJWKSURI,
|
JWKSUri: resolvedJWKSURI,
|
||||||
JWKS: resolvedJWKS,
|
JWKS: resolvedJWKS,
|
||||||
Metadata: metadata,
|
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
|
||||||
|
BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired),
|
||||||
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
||||||
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
||||||
@@ -2633,19 +2675,21 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return clientSummary{
|
return clientSummary{
|
||||||
ID: client.ClientID,
|
ID: client.ClientID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: clientType,
|
Type: clientType,
|
||||||
Status: status,
|
Status: status,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
RedirectURIs: client.RedirectURIs,
|
RedirectURIs: client.RedirectURIs,
|
||||||
Scopes: scopes,
|
Scopes: scopes,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
|
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
|
||||||
SkipConsent: valueOrBool(client.SkipConsent, true),
|
SkipConsent: valueOrBool(client.SkipConsent, true),
|
||||||
JwksUri: client.JWKSUri,
|
JwksUri: client.JWKSUri,
|
||||||
Jwks: client.JWKS,
|
Jwks: client.JWKS,
|
||||||
Metadata: client.Metadata,
|
BackchannelLogoutURI: client.BackchannelLogoutURI(),
|
||||||
|
BackchannelLogoutSessionRequired: client.BackchannelLogoutSessionRequiredValue(),
|
||||||
|
Metadata: client.Metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2665,6 +2709,58 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
|||||||
return value
|
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) {
|
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return metadata, nil
|
return metadata, nil
|
||||||
@@ -2752,6 +2848,174 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa
|
|||||||
return nil
|
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 {
|
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
|
||||||
if req.Jwks != nil {
|
if req.Jwks != nil {
|
||||||
return true
|
return true
|
||||||
@@ -3120,8 +3384,8 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
subject := "User:" + strings.TrimSpace(userID)
|
subject := "User:" + strings.TrimSpace(userID)
|
||||||
for _, relation := range []string{"view_dev_console", "grant_dev_permissions"} {
|
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||||
if !h.hasDirectTenantRelation(c, tenantID, relation, subject) {
|
if h.hasDirectTenantRelation(c, tenantID, relation, subject) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
@@ -3129,16 +3393,46 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
|
|||||||
Object: tenantID,
|
Object: tenantID,
|
||||||
Relation: relation,
|
Relation: relation,
|
||||||
Subject: subject,
|
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",
|
if h.Keto != nil {
|
||||||
Object: tenantID,
|
existing, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, "admins", subject)
|
||||||
Relation: "developer_console_grant_manager",
|
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,
|
Subject: subject,
|
||||||
Action: domain.KetoOutboxActionCreate,
|
Action: domain.KetoOutboxActionCreate,
|
||||||
})
|
})
|
||||||
@@ -3157,6 +3451,11 @@ func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
|
|||||||
Subject: subject,
|
Subject: subject,
|
||||||
Action: domain.KetoOutboxActionDelete,
|
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)
|
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()
|
app := fiber.New()
|
||||||
tenantA := "tenant-a"
|
tenantA := "tenant-a"
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
@@ -209,11 +209,11 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
"client_name": "Updated Name",
|
"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 := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
// Case 2: Different tenant
|
// Case 2: Different tenant
|
||||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))
|
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)
|
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) {
|
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
@@ -634,6 +700,67 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
|||||||
|
|
||||||
mockKeto := new(devMockKetoService)
|
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", "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{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
@@ -1053,17 +1180,9 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
|||||||
|
|
||||||
mockKeto := new(devMockKetoService)
|
mockKeto := new(devMockKetoService)
|
||||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
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)
|
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{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
@@ -1072,7 +1191,7 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
|||||||
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
||||||
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
||||||
Keto: mockKeto,
|
Keto: mockKeto,
|
||||||
KetoOutbox: mockOutbox,
|
KetoOutbox: new(devMockKetoOutboxRepository),
|
||||||
}
|
}
|
||||||
|
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
@@ -1099,6 +1218,134 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
mockKeto.AssertExpectations(t)
|
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)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1449,6 +1696,144 @@ func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
|
|||||||
assert.False(t, hydraCalled)
|
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) {
|
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||||
var captured domain.HydraClient
|
var captured domain.HydraClient
|
||||||
|
|
||||||
@@ -2252,6 +2637,75 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
|
|||||||
assert.Equal(t, "kyy01", result.Items[0].UserLoginID)
|
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) {
|
func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
|
|||||||
return c.Next()
|
return c.Next()
|
||||||
})
|
})
|
||||||
app.Delete("/tenants/bulk", (&TenantHandler{DB: db}).DeleteTenantsBulk)
|
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 := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := app.Test(req)
|
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 [searchClientId, setSearchClientId] = React.useState("");
|
||||||
const [searchAction, setSearchAction] = React.useState("");
|
const [searchAction, setSearchAction] = React.useState("");
|
||||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
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<
|
const [expandedRows, setExpandedRows] = React.useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
|
queryKey: [
|
||||||
|
"dev-audit-logs",
|
||||||
|
deferredSearchClientId,
|
||||||
|
deferredSearchAction,
|
||||||
|
statusFilter,
|
||||||
|
],
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
fetchDevAuditLogs(50, pageParam, {
|
fetchDevAuditLogs(50, pageParam, {
|
||||||
client_id: searchClientId.trim() || undefined,
|
client_id: deferredSearchClientId || undefined,
|
||||||
action: searchAction.trim() || undefined,
|
action: deferredSearchAction || undefined,
|
||||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||||
}),
|
}),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
@@ -160,14 +170,6 @@ function AuditLogsPage() {
|
|||||||
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
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) {
|
if (query.error) {
|
||||||
const axiosError = query.error as AxiosError<{ error?: string }>;
|
const axiosError = query.error as AxiosError<{ error?: string }>;
|
||||||
if (axiosError.response?.status === 403) {
|
if (axiosError.response?.status === 403) {
|
||||||
@@ -227,7 +229,13 @@ function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -263,142 +271,167 @@ function AuditLogsPage() {
|
|||||||
{t("ui.common.status.failure", "Failure")}
|
{t("ui.common.status.failure", "Failure")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<Table className="table-fixed">
|
<div
|
||||||
<TableHeader>
|
className={
|
||||||
<TableRow>
|
query.isFetching && !query.isFetchingNextPage
|
||||||
<TableHead className="w-[190px]">
|
? "opacity-50 transition-opacity"
|
||||||
{t("ui.dev.audit.table.time", "Time")}
|
: ""
|
||||||
</TableHead>
|
}
|
||||||
<TableHead className="w-[180px]">
|
>
|
||||||
{t("ui.dev.audit.table.actor", "Actor")}
|
<Table className="table-fixed">
|
||||||
</TableHead>
|
<TableHeader>
|
||||||
<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 && (
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableHead className="w-[190px]">
|
||||||
colSpan={6}
|
{t("ui.dev.audit.table.time", "Time")}
|
||||||
className="text-center text-muted-foreground"
|
</TableHead>
|
||||||
>
|
<TableHead className="w-[180px]">
|
||||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
{t("ui.dev.audit.table.actor", "Actor")}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
)}
|
</TableHeader>
|
||||||
{logs.map((row, index) => {
|
<TableBody>
|
||||||
const details = parseDetails(row.details);
|
{query.isLoading && logs.length === 0 ? (
|
||||||
const actionLabel = details.action || row.event_type;
|
<TableRow>
|
||||||
const targetValue = details.target_id || "-";
|
<TableCell
|
||||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
colSpan={6}
|
||||||
const expanded = Boolean(expandedRows[rowKey]);
|
className="py-8 text-center text-muted-foreground"
|
||||||
return (
|
>
|
||||||
<React.Fragment key={rowKey}>
|
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||||
<TableRow>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
</TableRow>
|
||||||
{formatDateTime(row.timestamp)}
|
) : logs.length === 0 ? (
|
||||||
</TableCell>
|
<TableRow>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell
|
||||||
<div className="flex items-center gap-2">
|
colSpan={6}
|
||||||
<span>{row.user_id || "-"}</span>
|
className="text-center text-muted-foreground"
|
||||||
{row.user_id ? (
|
>
|
||||||
|
{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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
className="h-7 w-7 text-muted-foreground"
|
onClick={() =>
|
||||||
onClick={() => handleCopy(row.user_id)}
|
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>
|
</Button>
|
||||||
) : null}
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
</TableCell>
|
{expanded ? (
|
||||||
<TableCell className="text-xs">{actionLabel}</TableCell>
|
<TableRow className="bg-card/20">
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell
|
||||||
<div className="flex items-center gap-2">
|
colSpan={6}
|
||||||
<span className="break-all">{targetValue}</span>
|
className="text-xs text-muted-foreground"
|
||||||
{targetValue !== "-" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground"
|
|
||||||
onClick={() => handleCopy(targetValue)}
|
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
</Button>
|
<div className="space-y-1">
|
||||||
) : null}
|
<div>
|
||||||
</div>
|
Request ID:{" "}
|
||||||
</TableCell>
|
{formatValue(details.request_id)}
|
||||||
<TableCell>
|
</div>
|
||||||
<Badge
|
<div>
|
||||||
variant={
|
Method: {formatValue(details.method)}
|
||||||
row.status === "success" ? "success" : "warning"
|
</div>
|
||||||
}
|
<div>Path: {formatValue(details.path)}</div>
|
||||||
>
|
<div>
|
||||||
{row.status}
|
Tenant: {formatValue(details.tenant_id)}
|
||||||
</Badge>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-right">
|
<div className="space-y-1 break-all">
|
||||||
<Button
|
<div>
|
||||||
variant="ghost"
|
Before: {formatValue(details.before)}
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() =>
|
<div>After: {formatValue(details.after)}</div>
|
||||||
setExpandedRows((prev) => ({
|
<div>Error: {formatValue(details.error)}</div>
|
||||||
...prev,
|
</div>
|
||||||
[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>
|
</div>
|
||||||
<div>Method: {formatValue(details.method)}</div>
|
</TableCell>
|
||||||
<div>Path: {formatValue(details.path)}</div>
|
</TableRow>
|
||||||
<div>
|
) : null}
|
||||||
Tenant: {formatValue(details.tenant_id)}
|
</React.Fragment>
|
||||||
</div>
|
);
|
||||||
</div>
|
})
|
||||||
<div className="space-y-1 break-all">
|
)}
|
||||||
<div>Before: {formatValue(details.before)}</div>
|
</TableBody>
|
||||||
<div>After: {formatValue(details.after)}</div>
|
</Table>
|
||||||
<div>Error: {formatValue(details.error)}</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : null}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
{query.hasNextPage ? (
|
{query.hasNextPage ? (
|
||||||
<div className="flex justify-center">
|
<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 secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||||
const clientSecret = client?.clientSecret || secretPlaceholder;
|
const clientSecret = hasClientSecret
|
||||||
const displaySecret =
|
? client?.clientSecret || secretPlaceholder
|
||||||
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")
|
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||||
: clientSecret;
|
: clientSecret;
|
||||||
|
|
||||||
@@ -327,61 +333,73 @@ function ClientDetailsPage() {
|
|||||||
{showSecret ? displaySecret : "••••••••••••••••"}
|
{showSecret ? displaySecret : "••••••••••••••••"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
<Button
|
{hasClientSecret ? (
|
||||||
variant="secondary"
|
<>
|
||||||
size="icon"
|
<Button
|
||||||
onClick={() => setShowSecret(!showSecret)}
|
variant="secondary"
|
||||||
aria-label={
|
size="icon"
|
||||||
showSecret
|
onClick={() => setShowSecret(!showSecret)}
|
||||||
? t(
|
aria-label={
|
||||||
"ui.dev.clients.details.secret.hide",
|
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",
|
/>
|
||||||
"비밀키 보기",
|
</>
|
||||||
)
|
) : null}
|
||||||
}
|
|
||||||
>
|
|
||||||
{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이 복사되었습니다.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -31,9 +32,11 @@ import { Switch } from "../../components/ui/switch";
|
|||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
|
type ClientRelation,
|
||||||
createClient,
|
createClient,
|
||||||
deleteClient,
|
deleteClient,
|
||||||
fetchClient,
|
fetchClient,
|
||||||
|
fetchClientRelations,
|
||||||
fetchMyTenants,
|
fetchMyTenants,
|
||||||
refreshHeadlessJwksCache,
|
refreshHeadlessJwksCache,
|
||||||
revokeHeadlessJwksCache,
|
revokeHeadlessJwksCache,
|
||||||
@@ -48,6 +51,7 @@ import type {
|
|||||||
TenantSummary,
|
TenantSummary,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||||
|
|
||||||
@@ -59,6 +63,17 @@ interface ScopeItem {
|
|||||||
locked?: boolean;
|
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 SecurityProfile = "private" | "pkce";
|
||||||
type TokenEndpointAuthMethod =
|
type TokenEndpointAuthMethod =
|
||||||
| "none"
|
| "none"
|
||||||
@@ -111,6 +126,142 @@ function readMetadataString(
|
|||||||
return typeof value === "string" ? value : "";
|
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 {
|
function isValidUrl(value: string): boolean {
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
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) {
|
function formatDateTime(value?: string) {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -128,16 +302,27 @@ function formatDateTime(value?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ClientGeneralPage() {
|
function ClientGeneralPage() {
|
||||||
|
const auth = useAuth();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clientId = params.id;
|
const clientId = params.id;
|
||||||
const isCreate = !clientId;
|
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({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["client", clientId],
|
queryKey: ["client", clientId],
|
||||||
queryFn: () => fetchClient(clientId as string),
|
queryFn: () => fetchClient(clientId as string),
|
||||||
enabled: !isCreate,
|
enabled: !isCreate,
|
||||||
});
|
});
|
||||||
|
const { data: relationData } = useQuery({
|
||||||
|
queryKey: ["client-relations", clientId],
|
||||||
|
queryFn: () => fetchClientRelations(clientId as string),
|
||||||
|
enabled: !isCreate,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
const { data: tenantData } = useQuery({
|
const { data: tenantData } = useQuery({
|
||||||
queryKey: ["my-tenants"],
|
queryKey: ["my-tenants"],
|
||||||
queryFn: fetchMyTenants,
|
queryFn: fetchMyTenants,
|
||||||
@@ -153,6 +338,15 @@ function ClientGeneralPage() {
|
|||||||
const [status, setStatus] = useState<ClientStatus>("active");
|
const [status, setStatus] = useState<ClientStatus>("active");
|
||||||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||||
const [redirectUris, setRedirectUris] = useState("");
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
|
const [backchannelLogoutUri, setBackchannelLogoutUri] = useState("");
|
||||||
|
const [
|
||||||
|
backchannelLogoutSessionRequired,
|
||||||
|
setBackchannelLogoutSessionRequired,
|
||||||
|
] = useState(false);
|
||||||
|
const [
|
||||||
|
isBackchannelSessionRequiredInfoOpen,
|
||||||
|
setIsBackchannelSessionRequiredInfoOpen,
|
||||||
|
] = useState(false);
|
||||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||||
const [tenantSearch, setTenantSearch] = useState("");
|
const [tenantSearch, setTenantSearch] = useState("");
|
||||||
@@ -192,6 +386,7 @@ function ClientGeneralPage() {
|
|||||||
mandatory: false,
|
mandatory: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -205,6 +400,14 @@ function ClientGeneralPage() {
|
|||||||
if (typeof metadata.description === "string")
|
if (typeof metadata.description === "string")
|
||||||
setDescription(metadata.description);
|
setDescription(metadata.description);
|
||||||
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
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);
|
setAutoLoginSupported(metadata.auto_login_supported === true);
|
||||||
if (typeof metadata.auto_login_url === "string")
|
if (typeof metadata.auto_login_url === "string")
|
||||||
setAutoLoginUrl(metadata.auto_login_url);
|
setAutoLoginUrl(metadata.auto_login_url);
|
||||||
@@ -287,14 +490,29 @@ function ClientGeneralPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setIdTokenClaims(readIdTokenClaimsMetadata(metadata));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const securityProfile: SecurityProfile =
|
const securityProfile: SecurityProfile =
|
||||||
clientType === "pkce" ? "pkce" : "private";
|
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 trimmedLogoUrl = logoUrl.trim();
|
||||||
const trimmedAutoLoginUrl = autoLoginUrl.trim();
|
const trimmedAutoLoginUrl = autoLoginUrl.trim();
|
||||||
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||||||
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
||||||
|
const trimmedBackchannelLogoutUri = backchannelLogoutUri.trim();
|
||||||
|
const hasBackchannelLogoutUri = trimmedBackchannelLogoutUri.length > 0;
|
||||||
|
const hasValidBackchannelLogoutUri =
|
||||||
|
!hasBackchannelLogoutUri ||
|
||||||
|
isValidBackchannelLogoutUrl(trimmedBackchannelLogoutUri);
|
||||||
const hasValidAutoLoginUrl =
|
const hasValidAutoLoginUrl =
|
||||||
!autoLoginSupported ||
|
!autoLoginSupported ||
|
||||||
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
|
(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) => {
|
const handleStatusChange = (nextStatus: ClientStatus) => {
|
||||||
setStatus(nextStatus);
|
setStatus(nextStatus);
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
@@ -487,6 +731,11 @@ function ClientGeneralPage() {
|
|||||||
"허용 알고리즘: {{algorithms}}",
|
"허용 알고리즘: {{algorithms}}",
|
||||||
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
|
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
|
||||||
);
|
);
|
||||||
|
const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
|
||||||
|
...claim,
|
||||||
|
key: claim.key.trim(),
|
||||||
|
value: claim.value.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
if (headlessLoginEnabled) {
|
if (headlessLoginEnabled) {
|
||||||
if (!trimmedJwksUri) {
|
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 hasValidationErrors = validationErrors.length > 0;
|
||||||
|
const idTokenClaimPreview = buildIdTokenClaimsPreview(
|
||||||
|
normalizedIdTokenClaims,
|
||||||
|
);
|
||||||
|
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||||||
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
|
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
|
||||||
tenantData ?? [];
|
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) {
|
if (autoLoginSupported && !hasValidAutoLoginUrl) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
@@ -670,12 +989,18 @@ function ClientGeneralPage() {
|
|||||||
trimmedJwksUri
|
trimmedJwksUri
|
||||||
? trimmedJwksUri
|
? trimmedJwksUri
|
||||||
: undefined,
|
: undefined,
|
||||||
|
backchannelLogoutUri: trimmedBackchannelLogoutUri || undefined,
|
||||||
|
backchannelLogoutSessionRequired:
|
||||||
|
trimmedBackchannelLogoutUri !== ""
|
||||||
|
? backchannelLogoutSessionRequired
|
||||||
|
: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
logo_url: trimmedLogoUrl,
|
logo_url: trimmedLogoUrl,
|
||||||
auto_login_supported: autoLoginSupported,
|
auto_login_supported: autoLoginSupported,
|
||||||
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
|
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
|
||||||
structured_scopes: normalizedScopes,
|
structured_scopes: normalizedScopes,
|
||||||
|
id_token_claims: normalizedIdTokenClaims,
|
||||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||||
headless_login_enabled: headlessLoginEnabled,
|
headless_login_enabled: headlessLoginEnabled,
|
||||||
headless_token_endpoint_auth_method:
|
headless_token_endpoint_auth_method:
|
||||||
@@ -690,6 +1015,11 @@ function ClientGeneralPage() {
|
|||||||
allowed_tenants: tenantAccessRestricted
|
allowed_tenants: tenantAccessRestricted
|
||||||
? normalizedAllowedTenantIds
|
? normalizedAllowedTenantIds
|
||||||
: [],
|
: [],
|
||||||
|
backchannel_logout_uri: trimmedBackchannelLogoutUri || undefined,
|
||||||
|
backchannel_logout_session_required:
|
||||||
|
trimmedBackchannelLogoutUri !== ""
|
||||||
|
? backchannelLogoutSessionRequired
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -879,6 +1209,14 @@ function ClientGeneralPage() {
|
|||||||
{!isCreate && (
|
{!isCreate && (
|
||||||
<ClientDetailTabs activeTab="settings" clientId={clientId} />
|
<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>
|
</header>
|
||||||
|
|
||||||
{/* 1. Application Identity */}
|
{/* 1. Application Identity */}
|
||||||
@@ -913,6 +1251,7 @@ function ClientGeneralPage() {
|
|||||||
"ui.dev.clients.general.identity.name_placeholder",
|
"ui.dev.clients.general.identity.name_placeholder",
|
||||||
"My Awesome Application",
|
"My Awesome Application",
|
||||||
)}
|
)}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -930,6 +1269,7 @@ function ClientGeneralPage() {
|
|||||||
"ui.dev.clients.general.identity.description_placeholder",
|
"ui.dev.clients.general.identity.description_placeholder",
|
||||||
"앱에 대한 간단한 설명을 입력하세요.",
|
"앱에 대한 간단한 설명을 입력하세요.",
|
||||||
)}
|
)}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -949,6 +1289,7 @@ function ClientGeneralPage() {
|
|||||||
"ui.dev.clients.general.identity.logo_placeholder",
|
"ui.dev.clients.general.identity.logo_placeholder",
|
||||||
"https://example.com/logo.png",
|
"https://example.com/logo.png",
|
||||||
)}
|
)}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -1067,6 +1408,7 @@ function ClientGeneralPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={status === "active" ? "default" : "outline"}
|
variant={status === "active" ? "default" : "outline"}
|
||||||
onClick={() => handleStatusChange("active")}
|
onClick={() => handleStatusChange("active")}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.active", "활성")}
|
{t("ui.common.status.active", "활성")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1075,6 +1417,7 @@ function ClientGeneralPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={status === "inactive" ? "default" : "outline"}
|
variant={status === "inactive" ? "default" : "outline"}
|
||||||
onClick={() => handleStatusChange("inactive")}
|
onClick={() => handleStatusChange("inactive")}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.inactive", "비활성")}
|
{t("ui.common.status.inactive", "비활성")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1181,6 +1524,7 @@ function ClientGeneralPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={addScope}
|
onClick={addScope}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||||||
@@ -1201,6 +1545,7 @@ function ClientGeneralPage() {
|
|||||||
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
||||||
)}
|
)}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -1211,6 +1556,114 @@ function ClientGeneralPage() {
|
|||||||
</div>
|
</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">
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
<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",
|
"ui.dev.clients.general.scopes.name_placeholder",
|
||||||
"e.g. profile",
|
"e.g. profile",
|
||||||
)}
|
)}
|
||||||
disabled={s.locked}
|
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -1272,7 +1725,7 @@ function ClientGeneralPage() {
|
|||||||
"ui.dev.clients.general.scopes.description_placeholder",
|
"ui.dev.clients.general.scopes.description_placeholder",
|
||||||
"권한에 대한 설명",
|
"권한에 대한 설명",
|
||||||
)}
|
)}
|
||||||
disabled={s.locked}
|
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
@@ -1282,7 +1735,7 @@ function ClientGeneralPage() {
|
|||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateScope(s.id, "mandatory", checked)
|
updateScope(s.id, "mandatory", checked)
|
||||||
}
|
}
|
||||||
disabled={s.locked}
|
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1292,7 +1745,7 @@ function ClientGeneralPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeScope(s.id)}
|
onClick={() => removeScope(s.id)}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
disabled={s.locked}
|
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1359,6 +1812,7 @@ function ClientGeneralPage() {
|
|||||||
checked={tenantAccessRestricted}
|
checked={tenantAccessRestricted}
|
||||||
onCheckedChange={handleTenantAccessToggle}
|
onCheckedChange={handleTenantAccessToggle}
|
||||||
id="tenant-access-toggle"
|
id="tenant-access-toggle"
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1403,7 +1857,9 @@ function ClientGeneralPage() {
|
|||||||
"테넌트 이름 또는 슬러그로 검색",
|
"테넌트 이름 또는 슬러그로 검색",
|
||||||
)}
|
)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
disabled={!tenantAccessRestricted}
|
disabled={
|
||||||
|
isGeneralSettingsReadOnly || !tenantAccessRestricted
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{tenantAccessRestricted && isTenantSearchOpen && (
|
{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">
|
<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();
|
event.preventDefault();
|
||||||
handleSelectAllowedTenant(tenant.id);
|
handleSelectAllowedTenant(tenant.id);
|
||||||
}}
|
}}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1484,6 +1941,7 @@ function ClientGeneralPage() {
|
|||||||
aria-label={t("ui.common.delete", "삭제")}
|
aria-label={t("ui.common.delete", "삭제")}
|
||||||
onClick={() => toggleAllowedTenant(tenant.id)}
|
onClick={() => toggleAllowedTenant(tenant.id)}
|
||||||
className="text-muted-foreground transition hover:text-destructive"
|
className="text-muted-foreground transition hover:text-destructive"
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1509,6 +1967,7 @@ function ClientGeneralPage() {
|
|||||||
aria-label={t("ui.common.delete", "삭제")}
|
aria-label={t("ui.common.delete", "삭제")}
|
||||||
onClick={() => toggleAllowedTenant(tenantId)}
|
onClick={() => toggleAllowedTenant(tenantId)}
|
||||||
className="text-muted-foreground transition hover:text-destructive"
|
className="text-muted-foreground transition hover:text-destructive"
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1534,6 +1993,258 @@ function ClientGeneralPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-xl font-bold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.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 */}
|
{/* 3. Security Settings */}
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -1638,6 +2349,7 @@ function ClientGeneralPage() {
|
|||||||
id="headless-login-toggle"
|
id="headless-login-toggle"
|
||||||
checked={headlessLoginEnabled}
|
checked={headlessLoginEnabled}
|
||||||
onCheckedChange={handleHeadlessToggle}
|
onCheckedChange={handleHeadlessToggle}
|
||||||
|
disabled={isGeneralSettingsReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2111,6 +2823,7 @@ function ClientGeneralPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
disabled={
|
disabled={
|
||||||
|
isGeneralSettingsReadOnly ||
|
||||||
mutation.isPending ||
|
mutation.isPending ||
|
||||||
isLoading ||
|
isLoading ||
|
||||||
name.trim() === "" ||
|
name.trim() === "" ||
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 { useDeferredValue, useMemo, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
@@ -47,7 +47,6 @@ const relationOptions = [
|
|||||||
"consent_revoker",
|
"consent_revoker",
|
||||||
"relationship_viewer",
|
"relationship_viewer",
|
||||||
"audit_viewer",
|
"audit_viewer",
|
||||||
"status_operator",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type RelationOption = (typeof relationOptions)[number];
|
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) {
|
function formatUserLabel(user: DevAssignableUser) {
|
||||||
const primary = user.name.trim() || user.email.trim();
|
const primary = user.name.trim() || user.email.trim();
|
||||||
return `${primary} (${user.email.trim()})`;
|
return `${primary} (${user.email.trim()})`;
|
||||||
@@ -82,6 +85,7 @@ function ClientRelationsPage() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [infoRelation, setInfoRelation] = useState<RelationOption | null>(null);
|
||||||
|
|
||||||
const systemRole = resolveProfileRole(
|
const systemRole = resolveProfileRole(
|
||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
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) {
|
if (!clientId) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
@@ -389,7 +402,14 @@ function ClientRelationsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<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">
|
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||||
{relationshipViewForbiddenMessage}
|
{relationshipViewForbiddenMessage}
|
||||||
</div>
|
</div>
|
||||||
@@ -495,46 +515,76 @@ function ClientRelationsPage() {
|
|||||||
const disabled =
|
const disabled =
|
||||||
selectedUserExistingRelations.has(relation);
|
selectedUserExistingRelations.has(relation);
|
||||||
const isSelected = selectedRelations.includes(relation);
|
const isSelected = selectedRelations.includes(relation);
|
||||||
|
const isInfoVisible = infoRelation === relation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<div key={relation} className="relative">
|
||||||
key={relation}
|
<label
|
||||||
className={`flex gap-3 rounded-xl border p-4 transition-all ${
|
className={`flex gap-3 rounded-xl border p-4 transition-all ${
|
||||||
disabled
|
disabled
|
||||||
? "border-border/60 bg-muted/30 opacity-60"
|
? "border-border/60 bg-muted/30 opacity-60"
|
||||||
: isSelected
|
: isSelected
|
||||||
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
|
? "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"
|
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-1 h-4 w-4 accent-primary"
|
className="mt-1 h-4 w-4 accent-primary"
|
||||||
checked={isSelected || disabled}
|
checked={isSelected || disabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={() => handleRelationToggle(relation)}
|
onChange={() => handleRelationToggle(relation)}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<div
|
<div className="flex items-start justify-between gap-2">
|
||||||
className={`text-sm font-semibold ${
|
<div
|
||||||
isSelected && !disabled ? "text-primary" : ""
|
className={`text-sm font-semibold ${
|
||||||
}`}
|
isSelected && !disabled ? "text-primary" : ""
|
||||||
>
|
}`}
|
||||||
{relationLabel(relation)}
|
>
|
||||||
|
{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>
|
||||||
<div className="text-xs text-muted-foreground">
|
</label>
|
||||||
{relationDescription(relation)}
|
|
||||||
|
{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>
|
||||||
<div
|
)}
|
||||||
className={`text-[11px] uppercase tracking-wide ${
|
</div>
|
||||||
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}`}>
|
<TableRow key={`${item.relation}:${item.subject}`}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="font-medium">
|
<div className="flex flex-col gap-1.5">
|
||||||
{relationLabel(item.relation as RelationOption)}
|
<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>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{relationDescription(item.relation as RelationOption)}
|
{relationDescription(item.relation as RelationOption)}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type ClientSummary = {
|
|||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
tokenEndpointAuthMethod?: string;
|
tokenEndpointAuthMethod?: string;
|
||||||
jwksUri?: string;
|
jwksUri?: string;
|
||||||
|
backchannelLogoutUri?: string;
|
||||||
|
backchannelLogoutSessionRequired?: boolean;
|
||||||
redirectUris: string[];
|
redirectUris: string[];
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
@@ -118,6 +120,8 @@ export type ClientUpsertRequest = {
|
|||||||
responseTypes?: string[];
|
responseTypes?: string[];
|
||||||
tokenEndpointAuthMethod?: string;
|
tokenEndpointAuthMethod?: string;
|
||||||
jwksUri?: string;
|
jwksUri?: string;
|
||||||
|
backchannelLogoutUri?: string;
|
||||||
|
backchannelLogoutSessionRequired?: boolean;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,19 @@ loaded_count = "Loaded {{count}} rows"
|
|||||||
loading = "Loading audit logs..."
|
loading = "Loading audit logs..."
|
||||||
subtitle = "Shows DevFront activity history within current tenant/app scope."
|
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]
|
[msg.dev.clients]
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
loading = "Loading apps..."
|
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_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."
|
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_rotated = "Secret Rotated"
|
||||||
|
secret_not_applicable = "PKCE apps do not have a client secret."
|
||||||
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
||||||
subtitle = "Manage OIDC credentials and endpoints."
|
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]
|
[msg.dev.clients.general]
|
||||||
load_error = "Error loading client: {{error}}"
|
load_error = "Error loading client: {{error}}"
|
||||||
loading = "Loading client..."
|
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"
|
saved = "Saved"
|
||||||
save_error = "Failed to save: {{error}}"
|
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."
|
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]
|
[msg.dev.clients.general.redirect]
|
||||||
help = "Enter the redirect URIs. You can modify them in the Federation tab after creation."
|
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]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = "No scopes registered."
|
empty = "No scopes registered."
|
||||||
subtitle = "Define the permission scopes this application can request."
|
subtitle = "Define the permission scopes this application can request."
|
||||||
tenant = "Tenant access claim"
|
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]
|
[msg.dev.clients.general.security]
|
||||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
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]
|
[ui.dev.nav]
|
||||||
audit_logs = "Audit Logs"
|
audit_logs = "Audit Logs"
|
||||||
clients = "Connected Application"
|
clients = "Connected Application"
|
||||||
|
developer_request = "Developer Access Request"
|
||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
|
|
||||||
[ui.dev.audit]
|
[ui.dev.audit]
|
||||||
load_more = "Load more"
|
load_more = "Load more"
|
||||||
title = "Audit Logs"
|
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]
|
[ui.dev.audit.registry]
|
||||||
title = "Audit registry"
|
title = "Audit registry"
|
||||||
|
|
||||||
@@ -1426,6 +1491,12 @@ title = "Application Identity"
|
|||||||
label = "Redirect URIs"
|
label = "Redirect URIs"
|
||||||
placeholder = "Placeholder"
|
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]
|
[ui.dev.clients.general.scopes]
|
||||||
add = "Scope Add"
|
add = "Scope Add"
|
||||||
description_placeholder = "Description Placeholder"
|
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."
|
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."
|
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]
|
[ui.dev.clients.general.security]
|
||||||
private = "Server Side App"
|
private = "Server Side App"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
@@ -1505,6 +1592,7 @@ user_search_placeholder = "Search by name or email..."
|
|||||||
[ui.dev.clients.relationships.option.admins]
|
[ui.dev.clients.relationships.option.admins]
|
||||||
label = "RP Admin"
|
label = "RP Admin"
|
||||||
description = "Full administrator relationship for RP operations."
|
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]
|
[ui.dev.clients.relationships.option.creator]
|
||||||
label = "RP Creator"
|
label = "RP Creator"
|
||||||
@@ -1513,38 +1601,47 @@ description = "Marks the operator who created this RP."
|
|||||||
[ui.dev.clients.relationships.option.config_editor]
|
[ui.dev.clients.relationships.option.config_editor]
|
||||||
label = "RP General Settings"
|
label = "RP General Settings"
|
||||||
description = "Edit the name, redirect URIs, and general metadata."
|
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]
|
[ui.dev.clients.relationships.option.secret_viewer]
|
||||||
label = "Secret View"
|
label = "Secret View"
|
||||||
description = "View the Client secret for this RP."
|
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]
|
[ui.dev.clients.relationships.option.secret_rotator]
|
||||||
label = "Secret Rotation"
|
label = "Secret Rotation"
|
||||||
description = "Rotate and reissue the client secret."
|
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]
|
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||||
label = "JWKS View"
|
label = "JWKS View"
|
||||||
description = "View JWKS status, cache details, and key summaries."
|
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]
|
[ui.dev.clients.relationships.option.jwks_operator]
|
||||||
label = "JWKS Operations"
|
label = "JWKS Operations"
|
||||||
description = "Run operational actions such as refresh and revoke."
|
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]
|
[ui.dev.clients.relationships.option.consent_viewer]
|
||||||
label = "Consent View"
|
label = "Consent View"
|
||||||
description = "View consent grants for this RP."
|
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]
|
[ui.dev.clients.relationships.option.consent_revoker]
|
||||||
label = "Consent Revoke"
|
label = "Consent Revoke"
|
||||||
description = "Revoke consent grants for this RP."
|
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]
|
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||||
label = "Relationship View"
|
label = "Relationship View"
|
||||||
description = "View direct relations assigned to this RP."
|
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]
|
[ui.dev.clients.relationships.option.audit_viewer]
|
||||||
label = "Audit Log View"
|
label = "Audit Log View"
|
||||||
description = "View DevFront audit logs for this RP."
|
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]
|
[ui.dev.clients.relationships.option.status_operator]
|
||||||
label = "Status Change"
|
label = "Status Change"
|
||||||
|
|||||||
@@ -324,6 +324,19 @@ loaded_count = "로드된 로그 {{count}}건"
|
|||||||
loading = "감사 로그를 불러오는 중..."
|
loading = "감사 로그를 불러오는 중..."
|
||||||
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
|
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]
|
[msg.dev.clients]
|
||||||
deleted = "앱이 삭제되었습니다."
|
deleted = "앱이 삭제되었습니다."
|
||||||
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||||
@@ -362,6 +375,7 @@ create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게
|
|||||||
save_error = "저장 실패: {{error}}"
|
save_error = "저장 실패: {{error}}"
|
||||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||||
secret_rotated = "Client Secret이 재발급되었습니다."
|
secret_rotated = "Client Secret이 재발급되었습니다."
|
||||||
|
secret_not_applicable = "PKCE 앱에는 Client Secret이 없습니다."
|
||||||
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
||||||
subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다."
|
subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다."
|
||||||
|
|
||||||
@@ -375,6 +389,8 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
|
|||||||
[msg.dev.clients.general]
|
[msg.dev.clients.general]
|
||||||
load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}"
|
load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}"
|
||||||
loading = "클라이언트 정보를 불러오는 중..."
|
loading = "클라이언트 정보를 불러오는 중..."
|
||||||
|
read_only_forbidden = "이 RP의 일반 설정을 수정할 권한이 없습니다."
|
||||||
|
read_only_hint = "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다."
|
||||||
save_error = "저장 실패: {{error}}"
|
save_error = "저장 실패: {{error}}"
|
||||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||||
saved = "설정이 저장되었습니다."
|
saved = "설정이 저장되었습니다."
|
||||||
@@ -416,11 +432,27 @@ subtitle = "앱 이름과 설명, 로고를 설정합니다."
|
|||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다."
|
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]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = "등록된 스코프가 없습니다."
|
empty = "등록된 스코프가 없습니다."
|
||||||
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||||
tenant = "소속 테넌트 정보 접근"
|
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]
|
[msg.dev.clients.general.security]
|
||||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||||
@@ -1288,12 +1320,45 @@ scope_badge = "Scoped to /dev"
|
|||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
audit_logs = "감사 로그"
|
audit_logs = "감사 로그"
|
||||||
clients = "연동 앱"
|
clients = "연동 앱"
|
||||||
|
developer_request = "개발자 권한 신청"
|
||||||
logout = "로그아웃"
|
logout = "로그아웃"
|
||||||
|
|
||||||
[ui.dev.audit]
|
[ui.dev.audit]
|
||||||
load_more = "더 보기"
|
load_more = "더 보기"
|
||||||
title = "감사 로그"
|
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]
|
[ui.dev.audit.registry]
|
||||||
title = "Audit registry"
|
title = "Audit registry"
|
||||||
|
|
||||||
@@ -1425,6 +1490,12 @@ title = "애플리케이션 정보"
|
|||||||
label = "리디렉션 URI"
|
label = "리디렉션 URI"
|
||||||
placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
|
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]
|
[ui.dev.clients.general.scopes]
|
||||||
add = "스코프 추가"
|
add = "스코프 추가"
|
||||||
description_placeholder = "권한에 대한 설명"
|
description_placeholder = "권한에 대한 설명"
|
||||||
@@ -1451,6 +1522,22 @@ hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용
|
|||||||
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
|
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
|
||||||
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
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]
|
[ui.dev.clients.general.security]
|
||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
@@ -1504,6 +1591,7 @@ user_search_placeholder = "이름 또는 이메일 검색..."
|
|||||||
[ui.dev.clients.relationships.option.admins]
|
[ui.dev.clients.relationships.option.admins]
|
||||||
label = "RP 관리자"
|
label = "RP 관리자"
|
||||||
description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다."
|
description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다."
|
||||||
|
permits_info = "RP 설정 수정, 시크릿 조회/재발급, JWKS 관리, 동의 조회/회수, 관계 조회/수정 등 모든 운영 권한을 가집니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.creator]
|
[ui.dev.clients.relationships.option.creator]
|
||||||
label = "RP 생성자"
|
label = "RP 생성자"
|
||||||
@@ -1512,38 +1600,47 @@ description = "이 RP를 생성한 운영 주체를 표시합니다."
|
|||||||
[ui.dev.clients.relationships.option.config_editor]
|
[ui.dev.clients.relationships.option.config_editor]
|
||||||
label = "RP 일반 설정"
|
label = "RP 일반 설정"
|
||||||
description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다."
|
description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다."
|
||||||
|
permits_info = "RP 이름, Redirect URIs, 로그아웃 URI, 메타데이터 등 일반 설정을 수정할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.secret_viewer]
|
[ui.dev.clients.relationships.option.secret_viewer]
|
||||||
label = "시크릿 조회"
|
label = "시크릿 조회"
|
||||||
description = "이 RP의 Client secret을 조회합니다."
|
description = "이 RP의 Client secret을 조회합니다."
|
||||||
|
permits_info = "RP의 Client Secret 값을 평문으로 확인할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.secret_rotator]
|
[ui.dev.clients.relationships.option.secret_rotator]
|
||||||
label = "시크릿 재발급"
|
label = "시크릿 재발급"
|
||||||
description = "Client secret 재발급과 회전을 수행합니다."
|
description = "Client secret 재발급과 회전을 수행합니다."
|
||||||
|
permits_info = "새로운 Client Secret을 발급하거나 기존 시크릿을 만료시키고 회전시킬 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||||
label = "JWKS 조회"
|
label = "JWKS 조회"
|
||||||
description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다."
|
description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다."
|
||||||
|
permits_info = "RP의 공개키 설정(JWKS) 상태와 캐시된 키 정보를 조회할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.jwks_operator]
|
[ui.dev.clients.relationships.option.jwks_operator]
|
||||||
label = "JWKS 운영"
|
label = "JWKS 운영"
|
||||||
description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다."
|
description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다."
|
||||||
|
permits_info = "JWKS 캐시를 강제로 갱신하거나 등록된 키를 회수하는 등 키 관리를 수행할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.consent_viewer]
|
[ui.dev.clients.relationships.option.consent_viewer]
|
||||||
label = "동의 조회"
|
label = "동의 조회"
|
||||||
description = "이 RP의 consent 내역을 조회합니다."
|
description = "이 RP의 consent 내역을 조회합니다."
|
||||||
|
permits_info = "사용자가 이 RP에 부여한 개인정보 제공 동의 내역을 조회할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.consent_revoker]
|
[ui.dev.clients.relationships.option.consent_revoker]
|
||||||
label = "동의 회수"
|
label = "동의 회수"
|
||||||
description = "이 RP의 consent를 회수합니다."
|
description = "이 RP의 consent를 회수합니다."
|
||||||
|
permits_info = "사용자의 동의 내역을 강제로 취소하거나 회수할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||||
label = "관계 조회"
|
label = "관계 조회"
|
||||||
description = "이 RP에 부여된 direct relation을 조회합니다."
|
description = "이 RP에 부여된 direct relation을 조회합니다."
|
||||||
|
permits_info = "이 RP에 어떤 사용자가 어떤 관리 권한을 가지고 있는지 목록을 조회할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.audit_viewer]
|
[ui.dev.clients.relationships.option.audit_viewer]
|
||||||
label = "감사 로그 조회"
|
label = "감사 로그 조회"
|
||||||
description = "이 RP의 DevFront 감사 로그를 조회합니다."
|
description = "이 RP의 DevFront 감사 로그를 조회합니다."
|
||||||
|
permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에 대한 감사 로그를 조회할 수 있습니다."
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.status_operator]
|
[ui.dev.clients.relationships.option.status_operator]
|
||||||
label = "상태 변경"
|
label = "상태 변경"
|
||||||
|
|||||||
@@ -191,11 +191,17 @@ delete_confirm = ""
|
|||||||
delete_success = ""
|
delete_success = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
|
import_empty = ""
|
||||||
|
import_error = ""
|
||||||
|
import_result = ""
|
||||||
missing_id = ""
|
missing_id = ""
|
||||||
not_found = ""
|
not_found = ""
|
||||||
remove_sub_confirm = ""
|
remove_sub_confirm = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
|
[msg.admin.tenants.import_preview]
|
||||||
|
description = ""
|
||||||
|
|
||||||
[msg.admin.tenants.admins]
|
[msg.admin.tenants.admins]
|
||||||
add_success = ""
|
add_success = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
@@ -255,9 +261,13 @@ parsed_count = ""
|
|||||||
update_success = ""
|
update_success = ""
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
|
appointment_required = ""
|
||||||
error = ""
|
error = ""
|
||||||
|
external_tenant_required = ""
|
||||||
password_required = ""
|
password_required = ""
|
||||||
|
personal_tenant_failed = ""
|
||||||
success = ""
|
success = ""
|
||||||
|
tenant_resolve_failed = ""
|
||||||
|
|
||||||
[msg.admin.users.create.account]
|
[msg.admin.users.create.account]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
@@ -269,6 +279,7 @@ field_required = ""
|
|||||||
name_required = ""
|
name_required = ""
|
||||||
password_auto_help = ""
|
password_auto_help = ""
|
||||||
password_manual_help = ""
|
password_manual_help = ""
|
||||||
|
picker_description = ""
|
||||||
role_help = ""
|
role_help = ""
|
||||||
|
|
||||||
[msg.admin.users.create.password_generated]
|
[msg.admin.users.create.password_generated]
|
||||||
@@ -291,7 +302,9 @@ password_hint = ""
|
|||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
|
export_error = ""
|
||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
|
status_error = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.admin.users.list.columns]
|
[msg.admin.users.list.columns]
|
||||||
@@ -337,14 +350,6 @@ user_desc = ""
|
|||||||
|
|
||||||
[msg.dev.request.modal]
|
[msg.dev.request.modal]
|
||||||
desc = ""
|
desc = ""
|
||||||
email = ""
|
|
||||||
name = ""
|
|
||||||
org = ""
|
|
||||||
phone = ""
|
|
||||||
reason = ""
|
|
||||||
reason_placeholder = ""
|
|
||||||
role = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
[msg.dev.request.status]
|
[msg.dev.request.status]
|
||||||
approved = ""
|
approved = ""
|
||||||
@@ -408,6 +413,7 @@ create_forbidden = ""
|
|||||||
save_error = ""
|
save_error = ""
|
||||||
save_forbidden = ""
|
save_forbidden = ""
|
||||||
secret_rotated = ""
|
secret_rotated = ""
|
||||||
|
secret_not_applicable = ""
|
||||||
secret_unavailable = ""
|
secret_unavailable = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
@@ -421,11 +427,22 @@ note = ""
|
|||||||
[msg.dev.clients.general]
|
[msg.dev.clients.general]
|
||||||
load_error = ""
|
load_error = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
|
read_only_forbidden = ""
|
||||||
|
read_only_hint = ""
|
||||||
saved = ""
|
saved = ""
|
||||||
save_error = ""
|
save_error = ""
|
||||||
save_forbidden = ""
|
save_forbidden = ""
|
||||||
status_changed = ""
|
status_changed = ""
|
||||||
|
|
||||||
|
[msg.dev.clients.general.id_token_claims]
|
||||||
|
subtitle = ""
|
||||||
|
empty = ""
|
||||||
|
hint = ""
|
||||||
|
preview_hint = ""
|
||||||
|
key_required = ""
|
||||||
|
reserved_key = ""
|
||||||
|
duplicate_key = ""
|
||||||
|
|
||||||
[msg.dev.clients.relationships]
|
[msg.dev.clients.relationships]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
add_description = ""
|
add_description = ""
|
||||||
@@ -462,6 +479,13 @@ subtitle = ""
|
|||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
help = ""
|
help = ""
|
||||||
|
|
||||||
|
[msg.dev.clients.general.backchannel_logout]
|
||||||
|
uri_help = ""
|
||||||
|
invalid = ""
|
||||||
|
session_required_help = ""
|
||||||
|
session_required_on = ""
|
||||||
|
session_required_off = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.scopes]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = ""
|
empty = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
@@ -987,6 +1011,9 @@ user = ""
|
|||||||
|
|
||||||
[ui.admin.tenants]
|
[ui.admin.tenants]
|
||||||
add = ""
|
add = ""
|
||||||
|
csv_template = ""
|
||||||
|
export = ""
|
||||||
|
import = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.admins]
|
[ui.admin.tenants.admins]
|
||||||
@@ -1116,6 +1143,15 @@ search_placeholder = ""
|
|||||||
title = ""
|
title = ""
|
||||||
tree_search_placeholder = ""
|
tree_search_placeholder = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_preview]
|
||||||
|
candidates = ""
|
||||||
|
confirm = ""
|
||||||
|
create_new = ""
|
||||||
|
fixed_id = ""
|
||||||
|
match = ""
|
||||||
|
no_candidates = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.sub.table]
|
[ui.admin.tenants.sub.table]
|
||||||
action = ""
|
action = ""
|
||||||
name = ""
|
name = ""
|
||||||
@@ -1124,6 +1160,7 @@ status = ""
|
|||||||
|
|
||||||
[ui.admin.tenants.table]
|
[ui.admin.tenants.table]
|
||||||
actions = ""
|
actions = ""
|
||||||
|
id = ""
|
||||||
members = ""
|
members = ""
|
||||||
name = ""
|
name = ""
|
||||||
slug = ""
|
slug = ""
|
||||||
@@ -1223,6 +1260,7 @@ empty = ""
|
|||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
search_placeholder = ""
|
search_placeholder = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
toggle_status = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.users.list.breadcrumb]
|
[ui.admin.users.list.breadcrumb]
|
||||||
@@ -1508,6 +1546,12 @@ title = ""
|
|||||||
label = ""
|
label = ""
|
||||||
placeholder = ""
|
placeholder = ""
|
||||||
|
|
||||||
|
[ui.dev.clients.general.backchannel_logout]
|
||||||
|
uri = ""
|
||||||
|
uri_placeholder = ""
|
||||||
|
session_required = ""
|
||||||
|
session_required_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.general.scopes]
|
[ui.dev.clients.general.scopes]
|
||||||
add = ""
|
add = ""
|
||||||
description_placeholder = ""
|
description_placeholder = ""
|
||||||
@@ -1534,6 +1578,22 @@ hint = ""
|
|||||||
autocomplete_hint = ""
|
autocomplete_hint = ""
|
||||||
validation_required = ""
|
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]
|
[ui.dev.clients.general.security]
|
||||||
private = ""
|
private = ""
|
||||||
pkce = ""
|
pkce = ""
|
||||||
@@ -1586,50 +1646,62 @@ user_search_placeholder = ""
|
|||||||
[ui.dev.clients.relationships.option.admins]
|
[ui.dev.clients.relationships.option.admins]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.creator]
|
[ui.dev.clients.relationships.option.creator]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.config_editor]
|
[ui.dev.clients.relationships.option.config_editor]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.secret_viewer]
|
[ui.dev.clients.relationships.option.secret_viewer]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.secret_rotator]
|
[ui.dev.clients.relationships.option.secret_rotator]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.jwks_operator]
|
[ui.dev.clients.relationships.option.jwks_operator]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.consent_viewer]
|
[ui.dev.clients.relationships.option.consent_viewer]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.consent_revoker]
|
[ui.dev.clients.relationships.option.consent_revoker]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.audit_viewer]
|
[ui.dev.clients.relationships.option.audit_viewer]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.relationships.option.status_operator]
|
[ui.dev.clients.relationships.option.status_operator]
|
||||||
label = ""
|
label = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
[ui.dev.clients.help]
|
||||||
docs_body = ""
|
docs_body = ""
|
||||||
|
|||||||
@@ -129,6 +129,159 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
).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 ({
|
test("pkce headless login uses jwks uri only and shows cache actions", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -147,7 +147,9 @@ test.describe("DevFront developer request and management", () => {
|
|||||||
await nameInput.fill("E2E Test RP");
|
await nameInput.fill("E2E Test RP");
|
||||||
await nameInput.press("Tab");
|
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.fill("https://example.com/callback");
|
||||||
await uriInput.press("Tab");
|
await uriInput.press("Tab");
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,39 @@ function parseClientId(pathname: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
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 = (
|
const appendAuditLog = (
|
||||||
eventType: string,
|
eventType: string,
|
||||||
action: string,
|
action: string,
|
||||||
@@ -431,6 +464,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state.clients.push(created);
|
state.clients.push(created);
|
||||||
|
if (!state.relations) {
|
||||||
|
state.relations = {};
|
||||||
|
}
|
||||||
|
state.relations[created.id] = [buildSelfConfigEditorRelation()];
|
||||||
appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id);
|
appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id);
|
||||||
return json(route, {
|
return json(route, {
|
||||||
client: created,
|
client: created,
|
||||||
@@ -451,7 +488,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
|||||||
) {
|
) {
|
||||||
const clientId = pathname.split("/")[5] ?? "";
|
const clientId = pathname.split("/")[5] ?? "";
|
||||||
return json(route, {
|
return json(route, {
|
||||||
items: state.relations?.[clientId] ?? [],
|
items: await resolveClientRelations(clientId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1095,6 +1095,7 @@ delete_selected = "Delete Selected"
|
|||||||
export_with_ids = "Include UUIDs"
|
export_with_ids = "Include UUIDs"
|
||||||
export_without_ids = "Export without UUIDs"
|
export_without_ids = "Export without UUIDs"
|
||||||
import = "Import"
|
import = "Import"
|
||||||
|
seed_badge = "Seed"
|
||||||
title = "Tenant Registry"
|
title = "Tenant Registry"
|
||||||
view_org_chart = "View Full Org Chart"
|
view_org_chart = "View Full Org Chart"
|
||||||
|
|
||||||
@@ -1188,6 +1189,7 @@ import_partial_success = "Imported some organization data successfully."
|
|||||||
|
|
||||||
[msg.admin.tenants]
|
[msg.admin.tenants]
|
||||||
delete_bulk_confirm = "Delete {{count}} selected tenants?"
|
delete_bulk_confirm = "Delete {{count}} selected tenants?"
|
||||||
|
seed_delete_blocked = "Seed tenants cannot be deleted."
|
||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
self_delete_blocked = "You cannot delete your own account."
|
self_delete_blocked = "You cannot delete your own account."
|
||||||
@@ -1204,6 +1206,15 @@ title = "Tenant Members ({{count}})"
|
|||||||
total = "Total"
|
total = "Total"
|
||||||
total_label = "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]
|
[ui.admin.tenants.members.table]
|
||||||
email = "EMAIL"
|
email = "EMAIL"
|
||||||
name = "NAME"
|
name = "NAME"
|
||||||
@@ -1339,6 +1350,7 @@ name = "Name"
|
|||||||
name_placeholder = "Name Placeholder"
|
name_placeholder = "Name Placeholder"
|
||||||
password = "Password"
|
password = "Password"
|
||||||
password_placeholder = "********"
|
password_placeholder = "********"
|
||||||
|
picker_description = "Search and select a tenant."
|
||||||
phone = "Phone number"
|
phone = "Phone number"
|
||||||
phone_placeholder = "010-1234-5678"
|
phone_placeholder = "010-1234-5678"
|
||||||
position = "Position"
|
position = "Position"
|
||||||
|
|||||||
@@ -672,11 +672,17 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
|
|||||||
delete_success = "테넌트가 삭제되었습니다."
|
delete_success = "테넌트가 삭제되었습니다."
|
||||||
empty = "아직 등록된 테넌트가 없습니다."
|
empty = "아직 등록된 테넌트가 없습니다."
|
||||||
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
||||||
|
import_empty = "임포트 파일에 테넌트 행이 없습니다."
|
||||||
|
import_error = "테넌트 임포트에 실패했습니다: {{error}}"
|
||||||
|
import_result = "{{count}}개의 테넌트 행을 처리했습니다."
|
||||||
missing_id = "테넌트 ID가 없습니다."
|
missing_id = "테넌트 ID가 없습니다."
|
||||||
not_found = "테넌트를 찾을 수 없습니다."
|
not_found = "테넌트를 찾을 수 없습니다."
|
||||||
remove_sub_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?"
|
remove_sub_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?"
|
||||||
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
|
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
|
||||||
|
|
||||||
|
[msg.admin.tenants.import_preview]
|
||||||
|
description = "임포트 전에 각 행의 매칭 결과를 검토하고 처리 방식을 선택하세요."
|
||||||
|
|
||||||
[msg.admin.tenants.admins]
|
[msg.admin.tenants.admins]
|
||||||
add_success = "관리자가 추가되었습니다."
|
add_success = "관리자가 추가되었습니다."
|
||||||
empty = "등록된 관리자가 없습니다."
|
empty = "등록된 관리자가 없습니다."
|
||||||
@@ -1564,6 +1570,7 @@ user = "TENANT MEMBER"
|
|||||||
[ui.admin.tenants]
|
[ui.admin.tenants]
|
||||||
add = "테넌트 추가"
|
add = "테넌트 추가"
|
||||||
delete_selected = "선택 삭제"
|
delete_selected = "선택 삭제"
|
||||||
|
seed_badge = "초기 설정"
|
||||||
title = "테넌트 목록"
|
title = "테넌트 목록"
|
||||||
view_org_chart = "전체 조직도 보기"
|
view_org_chart = "전체 조직도 보기"
|
||||||
|
|
||||||
@@ -1642,9 +1649,12 @@ import_partial_success = "일부 조직 정보를 가져왔습니다."
|
|||||||
|
|
||||||
[msg.admin.tenants]
|
[msg.admin.tenants]
|
||||||
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
|
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
|
||||||
|
seed_delete_blocked = "초기 설정 테넌트는 삭제할 수 없습니다."
|
||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
|
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
|
||||||
|
export_error = "사용자 내보내기에 실패했습니다: {{error}}"
|
||||||
|
status_error = "사용자 상태 변경에 실패했습니다: {{error}}"
|
||||||
|
|
||||||
[ui.admin.apikeys.registry]
|
[ui.admin.apikeys.registry]
|
||||||
title = "API Key Registry"
|
title = "API Key Registry"
|
||||||
@@ -1658,6 +1668,15 @@ title = "테넌트 구성원 ({{count}})"
|
|||||||
total = "전체"
|
total = "전체"
|
||||||
total_label = "전체"
|
total_label = "전체"
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_preview]
|
||||||
|
candidates = "후보"
|
||||||
|
confirm = "임포트 확정"
|
||||||
|
create_new = "새로 생성"
|
||||||
|
fixed_id = "고정 ID"
|
||||||
|
match = "매칭된 테넌트"
|
||||||
|
no_candidates = "매칭 가능한 테넌트가 없습니다."
|
||||||
|
title = "임포트 미리보기"
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
email = "EMAIL"
|
email = "EMAIL"
|
||||||
name = "NAME"
|
name = "NAME"
|
||||||
@@ -1793,6 +1812,7 @@ name = "이름"
|
|||||||
name_placeholder = "홍길동"
|
name_placeholder = "홍길동"
|
||||||
password = "비밀번호"
|
password = "비밀번호"
|
||||||
password_placeholder = "********"
|
password_placeholder = "********"
|
||||||
|
picker_description = "배정할 테넌트를 검색해서 선택하세요."
|
||||||
phone = "전화번호"
|
phone = "전화번호"
|
||||||
phone_placeholder = "010-1234-5678"
|
phone_placeholder = "010-1234-5678"
|
||||||
position = "직급"
|
position = "직급"
|
||||||
|
|||||||
@@ -1439,6 +1439,7 @@ user = ""
|
|||||||
[ui.admin.tenants]
|
[ui.admin.tenants]
|
||||||
add = ""
|
add = ""
|
||||||
delete_selected = ""
|
delete_selected = ""
|
||||||
|
seed_badge = ""
|
||||||
title = ""
|
title = ""
|
||||||
view_org_chart = ""
|
view_org_chart = ""
|
||||||
|
|
||||||
@@ -1517,9 +1518,18 @@ import_partial_success = ""
|
|||||||
|
|
||||||
[msg.admin.tenants]
|
[msg.admin.tenants]
|
||||||
delete_bulk_confirm = ""
|
delete_bulk_confirm = ""
|
||||||
|
import_empty = ""
|
||||||
|
import_error = ""
|
||||||
|
import_result = ""
|
||||||
|
seed_delete_blocked = ""
|
||||||
|
|
||||||
|
[msg.admin.tenants.import_preview]
|
||||||
|
description = ""
|
||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
self_delete_blocked = ""
|
self_delete_blocked = ""
|
||||||
|
export_error = ""
|
||||||
|
status_error = ""
|
||||||
|
|
||||||
[ui.admin.apikeys.registry]
|
[ui.admin.apikeys.registry]
|
||||||
title = ""
|
title = ""
|
||||||
@@ -1604,6 +1614,15 @@ search_placeholder = ""
|
|||||||
title = ""
|
title = ""
|
||||||
tree_search_placeholder = ""
|
tree_search_placeholder = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.import_preview]
|
||||||
|
candidates = ""
|
||||||
|
confirm = ""
|
||||||
|
create_new = ""
|
||||||
|
fixed_id = ""
|
||||||
|
match = ""
|
||||||
|
no_candidates = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.sub.table]
|
[ui.admin.tenants.sub.table]
|
||||||
action = ""
|
action = ""
|
||||||
name = ""
|
name = ""
|
||||||
@@ -1668,6 +1687,7 @@ name = ""
|
|||||||
name_placeholder = ""
|
name_placeholder = ""
|
||||||
password = ""
|
password = ""
|
||||||
password_placeholder = ""
|
password_placeholder = ""
|
||||||
|
picker_description = ""
|
||||||
phone = ""
|
phone = ""
|
||||||
phone_placeholder = ""
|
phone_placeholder = ""
|
||||||
position = ""
|
position = ""
|
||||||
|
|||||||
@@ -6,8 +6,40 @@ job_name="${1:-adminfront-tests}"
|
|||||||
mkdir -p reports
|
mkdir -p reports
|
||||||
rm -rf adminfront/node_modules
|
rm -rf adminfront/node_modules
|
||||||
|
|
||||||
playwright_install_cmd=(npx playwright install --with-deps)
|
is_port_available() {
|
||||||
playwright_install_desc="npx playwright install --with-deps"
|
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
|
set +e
|
||||||
(
|
(
|
||||||
@@ -67,6 +99,11 @@ fi
|
|||||||
|
|
||||||
set +e
|
set +e
|
||||||
port="${PORT:-5180}"
|
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"
|
echo "==> adminfront using PORT=$port"
|
||||||
(
|
(
|
||||||
cd adminfront
|
cd adminfront
|
||||||
|
|||||||
Reference in New Issue
Block a user