1
0
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:
2026-05-04 13:28:09 +09:00
31 changed files with 3331 additions and 409 deletions

View File

@@ -570,10 +570,7 @@ function TenantListPage() {
<span>{tenant.name}</span>
{isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]">
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
{t("ui.admin.tenants.seed_badge", "초기 설정")}
</Badge>
)}
</div>

View File

@@ -103,9 +103,7 @@ test.describe("Seed tenant protection", () => {
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
await expect(
normalRow.getByRole("button", { name: /삭제/ }),
).toBeEnabled();
await expect(normalRow.getByRole("button", { name: /삭제/ })).toBeEnabled();
});
test("disables delete action on seed tenant profile", async ({ page }) => {

View File

@@ -119,6 +119,7 @@ test.describe("Tenants Management", () => {
test("should export and import tenant CSV without organization/user combined import", async ({
page,
browserName,
}, testInfo) => {
let exportRequested = false;
let exportUrl = "";
@@ -213,8 +214,11 @@ test.describe("Tenants Management", () => {
/갱신 1|Updated 1/i,
);
expect(importRequested).toBe(true);
if (testInfo.project.name !== "webkit") {
expect(importBody).toContain("tenant-alpha-id");
expect(importBody).toContain('filename="tenants.csv"');
if (browserName !== "webkit") {
if (testInfo.project.name !== "webkit") {
expect(importBody).toContain("tenant-alpha-id");
}
}
});

View File

@@ -572,6 +572,7 @@ func main() {
auth.Post("/qr/init", authHandler.InitQRLogin)
auth.Post("/qr/poll", authHandler.PollQRLogin)
auth.Post("/qr/approve", authHandler.ScanQRLogin)
auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS)
// Signup Routes
signup := auth.Group("/signup")

View File

@@ -6,29 +6,34 @@ import (
)
const (
MetadataHeadlessLoginEnabled = "headless_login_enabled"
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
MetadataHeadlessJWKSURI = "headless_jwks_uri"
MetadataHeadlessJWKS = "headless_jwks"
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
MetadataAutoLoginSupported = "auto_login_supported"
MetadataAutoLoginURL = "auto_login_url"
MetadataHeadlessLoginEnabled = "headless_login_enabled"
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
MetadataHeadlessJWKSURI = "headless_jwks_uri"
MetadataHeadlessJWKS = "headless_jwks"
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
MetadataIDTokenClaims = "id_token_claims"
MetadataBackChannelLogoutURI = "backchannel_logout_uri"
MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
MetadataAutoLoginSupported = "auto_login_supported"
MetadataAutoLoginURL = "auto_login_url"
)
type HydraClient struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` // Added
ClientURI string `json:"client_uri,omitempty"`
RedirectURIs []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
Scope string `json:"scope,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
SkipConsent *bool `json:"skip_consent,omitempty"`
JWKSUri string `json:"jwks_uri,omitempty"`
JWKS interface{} `json:"jwks,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` // Added
ClientURI string `json:"client_uri,omitempty"`
RedirectURIs []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
Scope string `json:"scope,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
SkipConsent *bool `json:"skip_consent,omitempty"`
JWKSUri string `json:"jwks_uri,omitempty"`
JWKS interface{} `json:"jwks,omitempty"`
BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (c *HydraClient) SupportsHeadlessLogin() bool {
@@ -86,6 +91,29 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool {
return false
}
func (c *HydraClient) BackchannelLogoutURI() string {
if c.Metadata != nil {
if raw, ok := c.Metadata[MetadataBackChannelLogoutURI].(string); ok {
if value := strings.TrimSpace(raw); value != "" {
return value
}
}
}
return strings.TrimSpace(c.BackChannelLogoutURI)
}
func (c *HydraClient) BackchannelLogoutSessionRequiredValue() bool {
if c.Metadata != nil {
if raw, ok := c.Metadata[MetadataBackChannelLogoutSessionRequired].(bool); ok {
return raw
}
}
if c.BackChannelLogoutSessionRequired != nil {
return *c.BackChannelLogoutSessionRequired
}
return false
}
type HydraConsentRequest struct {
Challenge string `json:"challenge"`
RequestedScope []string `json:"requested_scope"`

View File

@@ -94,6 +94,7 @@ type AuthHandler struct {
AuditRepo domain.AuditRepository
OathkeeperRepo domain.OathkeeperLogRepository
Hydra *service.HydraAdminService
BackchannelLogout *service.BackchannelLogoutService
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
@@ -221,21 +222,26 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D
}
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler {
backchannelLogout, err := service.NewBackchannelLogoutService()
if err != nil {
slog.Warn("failed to initialize backchannel logout service", "error", err)
}
return &AuthHandler{
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
RedisService: redisService,
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
KratosAdmin: kratos,
IdpProvider: idpProvider,
AuditRepo: auditRepo,
OathkeeperRepo: oathkeeperRepo,
Hydra: service.NewHydraAdminService(),
TenantService: tenantService,
KetoService: ketoService,
KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo,
ConsentRepo: consentRepo,
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
RedisService: redisService,
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
KratosAdmin: kratos,
IdpProvider: idpProvider,
AuditRepo: auditRepo,
OathkeeperRepo: oathkeeperRepo,
Hydra: service.NewHydraAdminService(),
BackchannelLogout: backchannelLogout,
TenantService: tenantService,
KetoService: ketoService,
KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo,
ConsentRepo: consentRepo,
}
}
@@ -1158,6 +1164,60 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
return claims
}
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
return withOidcSessionMetadata(claims, sessionID)
}
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any {
if baseClaims == nil {
baseClaims = map[string]any{}
}
if metadata == nil {
return baseClaims
}
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
if !ok || rawClaims == nil {
return baseClaims
}
normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
if err != nil {
slog.Warn("failed to normalize configured id token claims", "error", err)
return baseClaims
}
rpClaims, _ := baseClaims["rp_claims"].(map[string]any)
if rpClaims == nil {
rpClaims = map[string]any{}
}
for _, claim := range normalizedClaims {
value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType)
if err != nil {
slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err)
continue
}
if claim.Namespace == "rp_claims" {
rpClaims[claim.Key] = value
continue
}
if _, exists := baseClaims[claim.Key]; exists {
continue
}
baseClaims[claim.Key] = value
}
if len(rpClaims) > 0 {
baseClaims["rp_claims"] = rpClaims
}
return baseClaims
}
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
if claims == nil {
claims = map[string]any{}
@@ -5294,6 +5354,8 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
})
}
h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "success",
"message": "Link revoked successfully",
@@ -5362,8 +5424,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
tenantID = tid
}
}
sessionClaims := withOidcSessionMetadata(
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
sessionClaims := composeOIDCSessionClaims(
consentRequest.Client,
identity.Traits,
consentRequest.RequestedScope,
tenantID,
currentSessionID,
)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
@@ -5392,8 +5457,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
}
}
sessionClaims := withOidcSessionMetadata(
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
sessionClaims := composeOIDCSessionClaims(
consentRequest.Client,
identity.Traits,
consentRequest.RequestedScope,
tenantID,
currentSessionID,
)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
@@ -5575,8 +5643,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
}
}
sessionClaims := withOidcSessionMetadata(
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope, tenantID),
sessionClaims := composeOIDCSessionClaims(
consentRequest.Client,
identity.Traits,
consentRequest.RequestedScope,
tenantID,
currentSessionID,
)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
@@ -7705,6 +7776,7 @@ func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error {
if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions")
}
h.triggerBackchannelLogoutForSession(c.Context(), c, profile.ID, targetSessionID)
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
return c.JSON(fiber.Map{"status": "ok"})
@@ -8124,6 +8196,129 @@ func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID strin
return nil
}
func (h *AuthHandler) triggerBackchannelLogoutForSession(ctx context.Context, c *fiber.Ctx, userID string, sessionID string) {
if h == nil || h.Hydra == nil {
return
}
clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)]
for _, clientID := range clientIDs {
h.triggerBackchannelLogoutForClient(ctx, c, userID, clientID, sessionID)
}
}
func (h *AuthHandler) triggerBackchannelLogoutForClient(ctx context.Context, c *fiber.Ctx, userID string, clientID string, sessionID string) {
if h == nil || h.Hydra == nil || h.BackchannelLogout == nil {
return
}
clientID = strings.TrimSpace(clientID)
userID = strings.TrimSpace(userID)
sessionID = strings.TrimSpace(sessionID)
if clientID == "" || userID == "" {
return
}
client, err := h.Hydra.GetClient(ctx, clientID)
if err != nil {
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, "", 0, "client_lookup_failed")
return
}
if client == nil {
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "client_not_found")
return
}
endpoint := client.BackchannelLogoutURI()
if endpoint == "" {
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "uri_not_configured")
return
}
if client.BackchannelLogoutSessionRequiredValue() && sessionID == "" {
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, endpoint, 0, "sid_required")
return
}
logoutToken, err := h.BackchannelLogout.BuildLogoutToken(clientID, userID, sessionID)
if err != nil {
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, 0, "token_build_failed")
return
}
statusCode, err := h.BackchannelLogout.SendLogoutToken(ctx, endpoint, logoutToken)
if err != nil {
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, statusCode, "request_failed")
return
}
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.sent", userID, clientID, sessionID, endpoint, statusCode, "")
}
func (h *AuthHandler) writeBackchannelLogoutAuditLog(c *fiber.Ctx, eventType string, userID string, clientID string, sessionID string, endpoint string, statusCode int, reason string) {
if h == nil || h.AuditRepo == nil {
return
}
endpointHost := ""
if endpoint != "" {
if parsed, err := url.Parse(endpoint); err == nil {
endpointHost = parsed.Host
}
}
details := map[string]any{
"client_id": strings.TrimSpace(clientID),
"session_id": strings.TrimSpace(sessionID),
"endpoint_host": strings.TrimSpace(endpointHost),
"status_code": statusCode,
"retry_count": 0,
"logout_issuer": h.BackchannelLogout.Issuer(),
}
if reason != "" {
details["reason"] = reason
}
raw, err := json.Marshal(details)
if err != nil {
return
}
status := "success"
if strings.HasSuffix(eventType, ".failed") {
status = "failure"
} else if strings.HasSuffix(eventType, ".skipped") {
status = "skipped"
}
ipAddress := ""
userAgent := ""
if c != nil {
ipAddress = extractClientIPFromHeaders(c)
userAgent = strings.TrimSpace(c.Get("User-Agent"))
}
_ = h.AuditRepo.Create(&domain.AuditLog{
EventID: fmt.Sprintf("backchannel-logout-%d", time.Now().UnixNano()),
Timestamp: time.Now().UTC(),
UserID: strings.TrimSpace(userID),
SessionID: strings.TrimSpace(sessionID),
EventType: eventType,
Status: status,
IPAddress: ipAddress,
UserAgent: userAgent,
Details: string(raw),
})
}
func (h *AuthHandler) GetBackchannelLogoutJWKS(c *fiber.Ctx) error {
if h == nil || h.BackchannelLogout == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "backchannel logout jwks unavailable")
}
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
c.Set(fiber.HeaderCacheControl, "no-store")
return c.JSON(h.BackchannelLogout.PublicJWKS())
}
func looksLikeInternalUserAgent(userAgent string) bool {
normalized := strings.ToLower(strings.TrimSpace(userAgent))
if normalized == "" {

View File

@@ -4,8 +4,11 @@ import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -53,6 +56,69 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
assert.Equal(t, 1, len(auditRepo.logs))
}
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
var receivedBody string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{"id": "user-123"},
}), nil
}
if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpResponse(r, http.StatusNoContent, ""), nil
}
if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "app-1",
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
}), nil
}
if r.URL.Host == "rp.example.com" && r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
raw, _ := io.ReadAll(r.Body)
receivedBody = string(raw)
return httpResponse(r, http.StatusNoContent, ""), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
backchannelLogout, err := service.NewBackchannelLogoutService()
assert.NoError(t, err)
backchannelLogout.HTTPClient = client
auditRepo := &mockAuditRepo{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
BackchannelLogout: backchannelLogout,
AuditRepo: auditRepo,
}
app := fiber.New()
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil)
req.Header.Set("Cookie", "valid")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.True(t, strings.Contains(receivedBody, "logout_token="))
values, err := url.ParseQuery(receivedBody)
assert.NoError(t, err)
assert.NotEmpty(t, values.Get("logout_token"))
assert.Len(t, auditRepo.logs, 2)
assert.Equal(t, "backchannel_logout.sent", auditRepo.logs[1].EventType)
}
func TestListRpHistory_Aggregation(t *testing.T) {
now := time.Now()
auditRepo := &mockAuditRepo{

View File

@@ -363,3 +363,110 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
assert.Equal(t, "Security", capturedClaims["department"])
assert.Equal(t, "Officer", capturedClaims["position"])
}
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
var capturedClaims map[string]interface{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"challenge": "challenge-configured-claims",
"requested_scope": []string{"openid", "profile"},
"subject": "user-789",
"client": map[string]interface{}{
"client_id": "client-configured-claims",
"metadata": map[string]interface{}{
"tenant_id": "tenant-claims",
"id_token_claims": []map[string]interface{}{
{
"namespace": "top_level",
"key": "locale",
"value": "ko-KR",
"valueType": "text",
},
{
"namespace": "top_level",
"key": "email",
"value": "should-not-override@example.com",
"valueType": "text",
},
{
"namespace": "rp_claims",
"key": "tier",
"value": "2",
"valueType": "number",
},
{
"namespace": "rp_claims",
"key": "features",
"value": "[\"sso\",\"claims\"]",
"valueType": "array",
},
},
},
},
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
body, _ := io.ReadAll(r.Body)
var acceptReq map[string]interface{}
json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]interface{}); ok {
capturedClaims = session["id_token"].(map[string]interface{})
}
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService),
}
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-789").Return(&service.KratosIdentity{
ID: "user-789",
Traits: map[string]interface{}{
"email": "real-user@example.com",
"name": "Configured User",
"tenant-claims": map[string]interface{}{
"department": "Platform",
},
},
}, nil)
app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]interface{}{
"consent_challenge": "challenge-configured-claims",
"grant_scope": []string{"openid", "profile"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims)
assert.Equal(t, "real-user@example.com", capturedClaims["email"])
assert.Equal(t, "ko-KR", capturedClaims["locale"])
assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"])
rpClaims, ok := capturedClaims["rp_claims"].(map[string]interface{})
if assert.True(t, ok) {
assert.Equal(t, float64(2), rpClaims["tier"])
assert.Equal(t, []interface{}{"sso", "claims"}, rpClaims["features"])
}
}

View File

@@ -8,6 +8,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -500,6 +502,108 @@ func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t
mockKratos.AssertExpectations(t)
}
func TestDeleteMySession_SendsBackchannelLogoutTokenWhenClientConfigured(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc")
var receivedBody string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "current-sid",
"authenticated_at": time.Now().UTC().Format(time.RFC3339),
"identity": map[string]any{
"id": "user-123",
"traits": map[string]any{
"email": "user@example.com",
"name": "User",
"role": "user",
},
},
}), nil
}
case "hydra.test":
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpResponse(r, http.StatusNoContent, ""), nil
}
if r.Method == http.MethodGet && r.URL.Path == "/clients/devfront" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "devfront",
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
}), nil
}
case "rp.example.com":
if r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" {
raw, _ := io.ReadAll(r.Body)
receivedBody = string(raw)
return httpResponse(r, http.StatusNoContent, ""), nil
}
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})}
setDefaultHTTPClientForTest(t, client.Transport)
mockKratos := new(MockKratosAdminService)
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
{ID: "target-sid", Active: true},
}, nil).Once()
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
ID: "target-sid",
Active: true,
}, nil).Once()
mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
backchannelLogout, err := service.NewBackchannelLogoutService()
assert.NoError(t, err)
backchannelLogout.HTTPClient = client
auditRepo := &mockAuditRepo{}
h := &AuthHandler{
KratosAdmin: mockKratos,
AuditRepo: auditRepo,
BackchannelLogout: backchannelLogout,
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
UserID: "user-123",
EventType: "POST /api/v1/auth/oidc/login/accept",
SessionID: "target-sid",
Details: `{"client_id":"devfront","client_name":"Devfront"}`,
})
app := fiber.New()
app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
req.Header.Set("Cookie", "ory_kratos_session=valid")
req.Header.Set("User-Agent", "session-test-agent")
resp, err := app.Test(req, -1)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.True(t, strings.Contains(receivedBody, "logout_token="))
values, err := url.ParseQuery(receivedBody)
assert.NoError(t, err)
assert.NotEmpty(t, values.Get("logout_token"))
foundBackchannelAudit := false
for _, log := range auditRepo.logs {
if log.EventType == "backchannel_logout.sent" {
foundBackchannelAudit = true
break
}
}
assert.True(t, foundBackchannelAudit)
mockKratos.AssertExpectations(t)
}
func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
var hydraRevokeCalls int

View File

@@ -94,19 +94,21 @@ type devStatsResponse struct {
}
type clientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"`
ClientSecret string `json:"clientSecret,omitempty"`
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
SkipConsent bool `json:"skipConsent"`
JwksUri string `json:"jwksUri,omitempty"`
Jwks interface{} `json:"jwks,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"`
ClientSecret string `json:"clientSecret,omitempty"`
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
SkipConsent bool `json:"skipConsent"`
JwksUri string `json:"jwksUri,omitempty"`
Jwks interface{} `json:"jwks,omitempty"`
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type clientListResponse struct {
@@ -179,19 +181,28 @@ type consentListResponse struct {
}
type clientUpsertRequest struct {
ID *string `json:"id"`
Name *string `json:"name"`
Type *string `json:"type"`
Status *string `json:"status"`
RedirectURIs *[]string `json:"redirectUris"`
Scopes *[]string `json:"scopes"`
GrantTypes *[]string `json:"grantTypes"`
ResponseTypes *[]string `json:"responseTypes"`
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
SkipConsent *bool `json:"skipConsent"`
JwksUri *string `json:"jwksUri"`
Jwks interface{} `json:"jwks"`
Metadata *map[string]interface{} `json:"metadata"`
ID *string `json:"id"`
Name *string `json:"name"`
Type *string `json:"type"`
Status *string `json:"status"`
RedirectURIs *[]string `json:"redirectUris"`
Scopes *[]string `json:"scopes"`
GrantTypes *[]string `json:"grantTypes"`
ResponseTypes *[]string `json:"responseTypes"`
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
SkipConsent *bool `json:"skipConsent"`
JwksUri *string `json:"jwksUri"`
Jwks interface{} `json:"jwks"`
BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
Metadata *map[string]interface{} `json:"metadata"`
}
type normalizedIDTokenClaim struct {
Namespace string `json:"namespace"`
Key string `json:"key"`
Value string `json:"value"`
ValueType string `json:"valueType"`
}
var protectedSystemClientIDs = map[string]struct{}{
@@ -745,6 +756,24 @@ func mapRelationTupleSummary(tuple service.RelationTuple, identity *service.Krat
return summary
}
func dedupeRelationTuples(tuples []service.RelationTuple) []service.RelationTuple {
if len(tuples) <= 1 {
return tuples
}
seen := make(map[string]struct{}, len(tuples))
deduped := make([]service.RelationTuple, 0, len(tuples))
for _, tuple := range tuples {
key := strings.TrimSpace(tuple.Relation) + "\x00" + strings.TrimSpace(tuple.SubjectID)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
deduped = append(deduped, tuple)
}
return deduped
}
func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) {
clientID = strings.TrimSpace(clientID)
if clientID == "" {
@@ -1203,6 +1232,7 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
tuples = dedupeRelationTuples(tuples)
for _, tuple := range tuples {
var identity *service.KratosIdentity
if tuple.SubjectID != "" && h.KratosAdmin != nil {
@@ -1518,12 +1548,16 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
}
canChangeStatusByPermit := h.canOperateClientByPermit(c, profile, summary, "change_status")
if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatusByPermit {
canEditConfigByPermit := h.canOperateClientByPermit(c, profile, summary, "edit_config")
canChangeStatus := canChangeStatusByPermit || canEditConfigByPermit
if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatus {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
if summary.Type == "private" && !h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") {
if !canChangeStatusByPermit {
if summary.Type == "private" &&
!h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") &&
!h.canBypassPrivateClientRestriction(c, profile, summary, "edit_config") {
if !canChangeStatus {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
@@ -1649,14 +1683,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if tenantID != "" {
metadata["tenant_id"] = tenantID
}
var err error
metadata["status"] = status
metadata["created_at"] = time.Now().Format(time.RFC3339)
var err error
backchannelLogoutURI := strings.TrimSpace(valueOr(req.BackchannelLogoutURI, ""))
backchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, false)
metadata, err = normalizeBackchannelLogoutMetadata(metadata, backchannelLogoutURI, backchannelLogoutSessionRequired)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
metadata, err = normalizeClientTenantAccessMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
metadata, err = normalizeClientAutoLoginMetadata(metadata)
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
@@ -1681,17 +1721,19 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
)
clientReq := domain.HydraClient{
ClientID: clientID,
ClientName: name,
RedirectURIs: redirectURIs,
GrantTypes: grantTypes,
ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod,
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
JWKSUri: jwksURI,
JWKS: jwks,
Metadata: metadata,
ClientID: clientID,
ClientName: name,
RedirectURIs: redirectURIs,
GrantTypes: grantTypes,
ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod,
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
JWKSUri: jwksURI,
JWKS: jwks,
BackChannelLogoutURI: backchannelLogoutURI,
BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired),
Metadata: metadata,
}
h.setAuditDetailsExtra(c, map[string]any{
@@ -1715,23 +1757,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
// [New] Automatically grant admin permission to the creator in Keto
if h.KetoOutbox != nil && profile != nil {
subject := "User:" + profile.ID
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "RelyingParty",
Object: created.ClientID,
Relation: "admins",
Subject: subject,
Action: domain.KetoOutboxActionCreate,
})
if err != nil {
if err := h.grantCreatorAdminRelation(c, created.ClientID, subject); err != nil {
slog.Warn("failed to grant automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
} else {
slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID)
}
if h.Keto != nil {
if err := h.Keto.CreateRelation(c.Context(), "RelyingParty", created.ClientID, "admins", subject); err != nil {
slog.Warn("failed to grant immediate admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
}
}
}
// Store secret in metadata for later retrieval
@@ -1798,8 +1828,8 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
if !canAccessClientByLegacyScope(profile, currentSummary) && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
if !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
return errorJSON(c, fiber.StatusForbidden, "forbidden: edit_config permission is required")
}
clientType := ""
@@ -1848,11 +1878,21 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
metadata["status"] = status
}
resolvedBackchannelLogoutURI := valueOr(req.BackchannelLogoutURI, current.BackchannelLogoutURI())
resolvedBackchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, current.BackchannelLogoutSessionRequiredValue())
metadata, err = normalizeBackchannelLogoutMetadata(
metadata,
resolvedBackchannelLogoutURI,
resolvedBackchannelLogoutSessionRequired,
)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
metadata, err = normalizeClientTenantAccessMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
metadata, err = normalizeClientAutoLoginMetadata(metadata)
metadata, err = normalizeIDTokenClaimsMetadata(metadata)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
@@ -1883,17 +1923,19 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true))
updated := domain.HydraClient{
ClientID: current.ClientID,
ClientName: valueOr(req.Name, current.ClientName),
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
SkipConsent: boolPtr(resolvedSkipConsent),
JWKSUri: resolvedJWKSURI,
JWKS: resolvedJWKS,
Metadata: metadata,
ClientID: current.ClientID,
ClientName: valueOr(req.Name, current.ClientName),
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
SkipConsent: boolPtr(resolvedSkipConsent),
JWKSUri: resolvedJWKSURI,
JWKS: resolvedJWKS,
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired),
Metadata: metadata,
}
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error())
@@ -2633,19 +2675,21 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
}
return clientSummary{
ID: client.ClientID,
Name: name,
Type: clientType,
Status: status,
CreatedAt: createdAt,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
ClientSecret: clientSecret,
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
SkipConsent: valueOrBool(client.SkipConsent, true),
JwksUri: client.JWKSUri,
Jwks: client.JWKS,
Metadata: client.Metadata,
ID: client.ClientID,
Name: name,
Type: clientType,
Status: status,
CreatedAt: createdAt,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
ClientSecret: clientSecret,
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
SkipConsent: valueOrBool(client.SkipConsent, true),
JwksUri: client.JWKSUri,
Jwks: client.JWKS,
BackchannelLogoutURI: client.BackchannelLogoutURI(),
BackchannelLogoutSessionRequired: client.BackchannelLogoutSessionRequiredValue(),
Metadata: client.Metadata,
}
}
@@ -2665,6 +2709,58 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
return value
}
func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) {
if metadata == nil {
metadata = map[string]interface{}{}
}
trimmedURI := strings.TrimSpace(logoutURI)
if err := validateBackchannelLogoutURI(trimmedURI); err != nil {
return nil, err
}
if trimmedURI == "" {
delete(metadata, domain.MetadataBackChannelLogoutURI)
delete(metadata, domain.MetadataBackChannelLogoutSessionRequired)
return metadata, nil
}
metadata[domain.MetadataBackChannelLogoutURI] = trimmedURI
metadata[domain.MetadataBackChannelLogoutSessionRequired] = sessionRequired
return metadata, nil
}
func validateBackchannelLogoutURI(raw string) error {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed == nil {
return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL")
}
if parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL")
}
if parsed.Fragment != "" {
return fmt.Errorf("backchannelLogoutUri must not include a fragment")
}
switch strings.ToLower(parsed.Scheme) {
case "https":
return nil
case "http":
host := strings.ToLower(parsed.Hostname())
if host == "localhost" || host == "127.0.0.1" {
return nil
}
return fmt.Errorf("backchannelLogoutUri must use https outside localhost development")
default:
return fmt.Errorf("backchannelLogoutUri must use http or https")
}
}
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
if metadata == nil {
return metadata, nil
@@ -2752,6 +2848,174 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa
return nil
}
func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
if metadata == nil {
return nil, nil
}
rawClaims, exists := metadata[domain.MetadataIDTokenClaims]
if !exists || rawClaims == nil {
return metadata, nil
}
normalized, err := normalizeIDTokenClaims(rawClaims)
if err != nil {
return nil, err
}
metadata[domain.MetadataIDTokenClaims] = normalized
return metadata, nil
}
func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, error) {
rawList, ok := rawClaims.([]interface{})
if !ok {
if typedList, ok := rawClaims.([]map[string]interface{}); ok {
rawList = make([]interface{}, 0, len(typedList))
for _, item := range typedList {
rawList = append(rawList, item)
}
} else if typedList, ok := rawClaims.([]map[string]any); ok {
rawList = make([]interface{}, 0, len(typedList))
for _, item := range typedList {
rawList = append(rawList, item)
}
} else {
return nil, errors.New("metadata.id_token_claims must be an array")
}
}
normalized := make([]normalizedIDTokenClaim, 0, len(rawList))
seen := make(map[string]struct{}, len(rawList))
for _, item := range rawList {
record, ok := item.(map[string]interface{})
if !ok {
if typedRecord, ok := item.(map[string]any); ok {
record = make(map[string]interface{}, len(typedRecord))
for key, value := range typedRecord {
record[key] = value
}
} else {
return nil, errors.New("metadata.id_token_claims items must be objects")
}
}
namespace := strings.TrimSpace(readInterfaceString(record["namespace"], "top_level"))
if namespace == "" {
namespace = "top_level"
}
if namespace != "top_level" && namespace != "rp_claims" {
return nil, fmt.Errorf("metadata.id_token_claims namespace must be top_level or rp_claims: %s", namespace)
}
key := strings.TrimSpace(readInterfaceString(record["key"], ""))
if key == "" {
return nil, errors.New("metadata.id_token_claims key is required")
}
if namespace == "top_level" && key == "rp_claims" {
return nil, errors.New("metadata.id_token_claims top-level key rp_claims is reserved")
}
valueType := strings.TrimSpace(readInterfaceString(record["valueType"], "text"))
if valueType == "" {
valueType = "text"
}
switch valueType {
case "text", "number", "boolean", "array", "object":
default:
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
}
value := strings.TrimSpace(readInterfaceString(record["value"], ""))
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err)
}
signature := namespace + ":" + key
if _, exists := seen[signature]; exists {
return nil, fmt.Errorf("metadata.id_token_claims contains duplicate key: %s.%s", namespace, key)
}
seen[signature] = struct{}{}
normalized = append(normalized, normalizedIDTokenClaim{
Namespace: namespace,
Key: key,
Value: value,
ValueType: valueType,
})
}
return normalized, nil
}
func readInterfaceString(value interface{}, fallback string) string {
if value == nil {
return fallback
}
if text, ok := value.(string); ok {
return text
}
return fallback
}
func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
trimmed := strings.TrimSpace(rawValue)
switch valueType {
case "text":
return trimmed, nil
case "number":
if trimmed == "" {
return nil, errors.New("number value is required")
}
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, errors.New("number value must be a finite number")
}
return parsed, nil
case "boolean":
switch strings.ToLower(trimmed) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off":
return false, nil
default:
return nil, errors.New("boolean value must be true/false")
}
case "array":
if trimmed == "" {
return []string{}, nil
}
if strings.HasPrefix(trimmed, "[") {
var parsed []any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, errors.New("array value must be valid JSON array")
}
return parsed, nil
}
parts := strings.Split(trimmed, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
values = append(values, part)
}
}
return values, nil
case "object":
if trimmed == "" {
return map[string]any{}, nil
}
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, errors.New("object value must be valid JSON object")
}
return parsed, nil
default:
return nil, fmt.Errorf("unsupported claim value type: %s", valueType)
}
}
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
if req.Jwks != nil {
return true
@@ -3120,8 +3384,8 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
return
}
subject := "User:" + strings.TrimSpace(userID)
for _, relation := range []string{"view_dev_console", "grant_dev_permissions"} {
if !h.hasDirectTenantRelation(c, tenantID, relation, subject) {
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
if h.hasDirectTenantRelation(c, tenantID, relation, subject) {
continue
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
@@ -3129,16 +3393,46 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
Object: tenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionDelete,
Action: domain.KetoOutboxActionCreate,
})
if h.Keto != nil {
if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil {
slog.Warn("failed to grant immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err)
}
}
}
if h.hasDirectTenantRelation(c, tenantID, "developer_console_grant_manager", subject) {
return
}
func (h *DevHandler) grantCreatorAdminRelation(c *fiber.Ctx, clientID string, subject string) error {
clientID = strings.TrimSpace(clientID)
subject = strings.TrimSpace(subject)
if clientID == "" || subject == "" {
return nil
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "developer_console_grant_manager",
if h.Keto != nil {
existing, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, "admins", subject)
if err == nil && len(existing) > 0 {
return nil
}
if err == nil {
if createErr := h.Keto.CreateRelation(c.Context(), "RelyingParty", clientID, "admins", subject); createErr == nil {
return nil
} else {
slog.Warn("failed to grant immediate admin permission to creator; falling back to outbox", "clientID", clientID, "subject", subject, "error", createErr)
}
} else {
slog.Warn("failed to check existing admin relation for creator; falling back to outbox", "clientID", clientID, "subject", subject, "error", err)
}
}
if h.KetoOutbox == nil {
return nil
}
return h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "RelyingParty",
Object: clientID,
Relation: "admins",
Subject: subject,
Action: domain.KetoOutboxActionCreate,
})
@@ -3157,6 +3451,11 @@ func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID
Subject: subject,
Action: domain.KetoOutboxActionDelete,
})
if h.Keto != nil {
if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil {
slog.Warn("failed to revoke immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err)
}
}
}
}

View File

@@ -192,7 +192,7 @@ func TestDevHandler_Isolation(t *testing.T) {
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("UpdateClient should enforce tenant isolation", func(t *testing.T) {
t.Run("UpdateClient should require direct edit permission within tenant isolation", func(t *testing.T) {
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
@@ -209,11 +209,11 @@ func TestDevHandler_Isolation(t *testing.T) {
"client_name": "Updated Name",
})
// Case 1: Same tenant
// Case 1: Same tenant but no direct edit_config permission
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// Case 2: Different tenant
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))

View File

@@ -453,6 +453,72 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
mockKeto.AssertExpectations(t)
}
func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One",
"redirect_uris": []string{
"http://localhost/cb",
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"token_endpoint_auth_method": "none",
"metadata": map[string]any{
"status": "active",
"tenant_id": "tenant-1",
},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
t.Fatalf("hydra update should not be called without edit_config permission")
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
app := fiber.New()
tenantID := "tenant-1"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
TenantID: &tenantID,
Metadata: map[string]any{
"managed_client_ids": []any{"client-1"},
},
})
return c.Next()
})
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"name": "App One Updated",
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-1"},
},
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
mockKeto.AssertExpectations(t)
}
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
@@ -634,6 +700,67 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
app := fiber.New()
tenantID := "tenant-1"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
})
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res clientDetailResponse
_ = json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, "inactive", res.Client.Status)
mockKeto.AssertExpectations(t)
}
func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "client-1",
"client_name": "App One",
"metadata": map[string]interface{}{
"tenant_id": "tenant-1",
"status": "active",
},
}), nil
}
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "client-1",
"client_name": "App One",
"metadata": map[string]interface{}{
"tenant_id": "tenant-1",
"status": "inactive",
},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(false, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
@@ -1053,17 +1180,9 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil)
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil)
mockOutbox := new(devMockKetoOutboxRepository)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" &&
entry.Object == "client-1" &&
entry.Relation == "admins" &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
@@ -1072,7 +1191,7 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
Redis: &devMockRedisRepo{data: make(map[string]string)},
Keto: mockKeto,
KetoOutbox: mockOutbox,
KetoOutbox: new(devMockKetoOutboxRepository),
}
app := fiber.New()
@@ -1099,6 +1218,134 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockKeto.AssertExpectations(t)
}
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(assert.AnError).Once()
mockOutbox := new(devMockKetoOutboxRepository)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" &&
entry.Object == "client-1" &&
entry.Relation == "admins" &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil).Once()
h := &DevHandler{
Keto: mockKeto,
KetoOutbox: mockOutbox,
}
app := fiber.New()
app.Get("/test", func(c *fiber.Ctx) error {
assert.NoError(t, h.grantCreatorAdminRelation(c, "client-1", "User:user-1"))
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.T) {
mockKeto := new(devMockKetoService)
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
}
mockOutbox := new(devMockKetoOutboxRepository)
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
expectedRelation := relation
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "Tenant" &&
entry.Object == "tenant-a" &&
entry.Relation == expectedRelation &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil).Once()
}
h := &DevHandler{
Keto: mockKeto,
KetoOutbox: mockOutbox,
}
app := fiber.New()
app.Get("/test", func(c *fiber.Ctx) error {
h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a")
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T) {
mockKeto := new(devMockKetoService)
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").
Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Once()
}
h := &DevHandler{
Keto: mockKeto,
KetoOutbox: new(devMockKetoOutboxRepository),
}
app := fiber.New()
app.Get("/test", func(c *fiber.Ctx) error {
h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a")
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockKeto.AssertExpectations(t)
}
func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.T) {
mockKeto := new(devMockKetoService)
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
}
mockOutbox := new(devMockKetoOutboxRepository)
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
expectedRelation := relation
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "Tenant" &&
entry.Object == "tenant-a" &&
entry.Relation == expectedRelation &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
}
h := &DevHandler{
Keto: mockKeto,
KetoOutbox: mockOutbox,
}
app := fiber.New()
app.Get("/test", func(c *fiber.Ctx) error {
h.revokeDeveloperGrantRelation(c, "user-1", "tenant-a")
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
@@ -1449,6 +1696,144 @@ func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
assert.False(t, hydraCalled)
}
func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.NoError(t, json.Unmarshal(body, &captured))
return httpJSONAny(r, http.StatusCreated, map[string]any{
"client_id": captured.ClientID,
"client_name": captured.ClientName,
"redirect_uris": captured.RedirectURIs,
"grant_types": captured.GrantTypes,
"response_types": captured.ResponseTypes,
"scope": captured.Scope,
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
"metadata": captured.Metadata,
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra.public",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"name": "Claims App",
"type": "pkce",
"redirectUris": []string{"https://rp.example.com/callback"},
"metadata": map[string]any{
"id_token_claims": []map[string]any{
{
"id": "claim-1",
"namespace": "top_level",
"key": "locale",
"value": " ko-KR ",
"valueType": "text",
},
{
"id": "claim-2",
"namespace": "rp_claims",
"key": "tier",
"value": "2",
"valueType": "number",
},
},
},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]interface{})
if assert.True(t, ok) && assert.Len(t, claims, 2) {
first, ok := claims[0].(map[string]interface{})
if assert.True(t, ok) {
assert.Equal(t, "top_level", first["namespace"])
assert.Equal(t, "locale", first["key"])
assert.Equal(t, "ko-KR", first["value"])
assert.Equal(t, "text", first["valueType"])
_, hasID := first["id"]
assert.False(t, hasID)
}
second, ok := claims[1].(map[string]interface{})
if assert.True(t, ok) {
assert.Equal(t, "rp_claims", second["namespace"])
assert.Equal(t, "tier", second["key"])
assert.Equal(t, "2", second["value"])
assert.Equal(t, "number", second["valueType"])
}
}
}
func TestCreateClient_RejectsInvalidIDTokenClaimsMetadata(t *testing.T) {
hydraCalled := false
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra.public",
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
hydraCalled = true
return httpJSONAny(r, http.StatusCreated, map[string]any{}), nil
})},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"name": "Claims App",
"type": "pkce",
"redirectUris": []string{"https://rp.example.com/callback"},
"metadata": map[string]any{
"id_token_claims": []map[string]any{
{
"namespace": "top_level",
"key": "rp_claims",
"value": "forbidden",
"valueType": "text",
},
},
},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(bodyBytes), "top-level key rp_claims is reserved")
assert.False(t, hydraCalled)
}
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
var captured domain.HydraClient
@@ -2252,6 +2637,75 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
assert.Equal(t, "kyy01", result.Items[0].UserLoginID)
}
func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One",
"metadata": map[string]any{
"tenant_id": "tenant-1",
"status": "active",
},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"},
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"},
}, nil)
for _, relation := range []string{"creator", "config_editor", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} {
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
}
mockKratos := new(devMockKratosAdmin)
mockKratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
ID: "user-1",
Traits: map[string]interface{}{
"name": "Tester",
"email": "tester@example.com",
},
}, nil).Once()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
KratosAdmin: mockKratos,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
TenantID: &tenantID,
})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result clientRelationListResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
if assert.Len(t, result.Items, 1) {
assert.Equal(t, "admins", result.Items[0].Relation)
assert.Equal(t, "User:user-1", result.Items[0].Subject)
}
mockKeto.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {

View File

@@ -138,7 +138,7 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
return c.Next()
})
app.Delete("/tenants/bulk", (&TenantHandler{DB: db}).DeleteTenantsBulk)
body, _ := json.Marshal(map[string][]string{"ids": []string{seed.ID, normal.ID}})
body, _ := json.Marshal(map[string][]string{"ids": {seed.ID, normal.ID}})
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)

View 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())
}

View 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)
}

View File

@@ -126,16 +126,26 @@ function AuditLogsPage() {
const [searchClientId, setSearchClientId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
// Use deferred values to avoid UI lag during rapid typing
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const query = useInfiniteQuery({
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
queryKey: [
"dev-audit-logs",
deferredSearchClientId,
deferredSearchAction,
statusFilter,
],
queryFn: ({ pageParam }) =>
fetchDevAuditLogs(50, pageParam, {
client_id: searchClientId.trim() || undefined,
action: searchAction.trim() || undefined,
client_id: deferredSearchClientId || undefined,
action: deferredSearchAction || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
}),
initialPageParam: undefined as string | undefined,
@@ -160,14 +170,6 @@ function AuditLogsPage() {
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
};
if (query.isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.audit.loading", "Loading audit logs...")}
</div>
);
}
if (query.error) {
const axiosError = query.error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
@@ -227,7 +229,13 @@ function AuditLogsPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
<form
onSubmit={(e) => {
e.preventDefault();
query.refetch();
}}
className="grid gap-2 md:grid-cols-[1fr,1fr,180px]"
>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -263,142 +271,167 @@ function AuditLogsPage() {
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</div>
</form>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[190px]">
{t("ui.dev.audit.table.time", "Time")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.actor", "Actor")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.action", "Action")}
</TableHead>
<TableHead className="w-[260px]">
{t("ui.dev.audit.table.target", "Target")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.dev.audit.table.status", "Status")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 && (
<div
className={
query.isFetching && !query.isFetchingNextPage
? "opacity-50 transition-opacity"
: ""
}
>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("msg.dev.audit.empty", "No audit logs found.")}
</TableCell>
<TableHead className="w-[190px]">
{t("ui.dev.audit.table.time", "Time")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.actor", "Actor")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.action", "Action")}
</TableHead>
<TableHead className="w-[260px]">
{t("ui.dev.audit.table.target", "Target")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.dev.audit.table.status", "Status")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
)}
{logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel = details.action || row.event_type;
const targetValue = details.target_id || "-";
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(row.timestamp)}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span>{row.user_id || "-"}</span>
{row.user_id ? (
</TableHeader>
<TableBody>
{query.isLoading && logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="py-8 text-center text-muted-foreground"
>
{t("msg.dev.audit.loading", "Loading audit logs...")}
</TableCell>
</TableRow>
) : logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("msg.dev.audit.empty", "No audit logs found.")}
</TableCell>
</TableRow>
) : (
logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel = details.action || row.event_type;
const targetValue = details.target_id || "-";
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(row.timestamp)}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span>{row.user_id || "-"}</span>
{row.user_id ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(row.user_id)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell className="text-xs">
{actionLabel}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span className="break-all">{targetValue}</span>
{targetValue !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(targetValue)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" ? "success" : "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(row.user_id)}
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
<Copy className="h-3 w-3" />
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
) : null}
</div>
</TableCell>
<TableCell className="text-xs">{actionLabel}</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span className="break-all">{targetValue}</span>
{targetValue !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(targetValue)}
</TableCell>
</TableRow>
{expanded ? (
<TableRow className="bg-card/20">
<TableCell
colSpan={6}
className="text-xs text-muted-foreground"
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" ? "success" : "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{expanded ? (
<TableRow className="bg-card/20">
<TableCell
colSpan={6}
className="text-xs text-muted-foreground"
>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<div>
Request ID: {formatValue(details.request_id)}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<div>
Request ID:{" "}
{formatValue(details.request_id)}
</div>
<div>
Method: {formatValue(details.method)}
</div>
<div>Path: {formatValue(details.path)}</div>
<div>
Tenant: {formatValue(details.tenant_id)}
</div>
</div>
<div className="space-y-1 break-all">
<div>
Before: {formatValue(details.before)}
</div>
<div>After: {formatValue(details.after)}</div>
<div>Error: {formatValue(details.error)}</div>
</div>
</div>
<div>Method: {formatValue(details.method)}</div>
<div>Path: {formatValue(details.path)}</div>
<div>
Tenant: {formatValue(details.tenant_id)}
</div>
</div>
<div className="space-y-1 break-all">
<div>Before: {formatValue(details.before)}</div>
<div>After: {formatValue(details.after)}</div>
<div>Error: {formatValue(details.error)}</div>
</div>
</div>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})}
</TableBody>
</Table>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
{query.hasNextPage ? (
<div className="flex justify-center">

View File

@@ -213,11 +213,17 @@ function ClientDetailsPage() {
},
];
// Client Secret from API
const hasClientSecret = client.type === "private";
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = client?.clientSecret || secretPlaceholder;
const displaySecret =
clientSecret === secretPlaceholder
const clientSecret = hasClientSecret
? client?.clientSecret || secretPlaceholder
: t("ui.common.na", "N/A");
const displaySecret = !hasClientSecret
? t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)
: clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret;
@@ -327,61 +333,73 @@ function ClientDetailsPage() {
{showSecret ? displaySecret : "••••••••••••••••"}
</p>
<div className="flex gap-2 shrink-0">
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={
showSecret
? t(
"ui.dev.clients.details.secret.hide",
"비밀키 숨기기",
{hasClientSecret ? (
<>
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={
showSecret
? t(
"ui.dev.clients.details.secret.hide",
"비밀키 숨기기",
)
: t(
"ui.dev.clients.details.secret.show",
"비밀키 보기",
)
}
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="secondary"
size="icon"
onClick={handleRotateSecret}
disabled={rotateMutation.isPending}
title={t(
"ui.dev.clients.details.secret.rotate",
"비밀키 재발급 (Rotate)",
)}
>
<RefreshCw
className={cn(
"h-4 w-4",
rotateMutation.isPending && "animate-spin",
)}
/>
</Button>
<CopyButton
value={clientSecret}
disabled={
!showSecret && clientSecret === secretPlaceholder
}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_secret",
"Client Secret이 복사되었습니다.",
),
)
: t(
"ui.dev.clients.details.secret.show",
"비밀키 보기",
)
}
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="secondary"
size="icon"
onClick={handleRotateSecret}
disabled={rotateMutation.isPending}
title={t(
"ui.dev.clients.details.secret.rotate",
"비밀키 재발급 (Rotate)",
)}
>
<RefreshCw
className={cn(
"h-4 w-4",
rotateMutation.isPending && "animate-spin",
)}
/>
</Button>
<CopyButton
value={clientSecret}
disabled={
!showSecret && clientSecret === secretPlaceholder
}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_secret",
"Client Secret이 복사되었습니다.",
),
)
}
/>
}
/>
</>
) : null}
</div>
</div>
{!hasClientSecret ? (
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)}
</p>
) : null}
</div>
</CardContent>
</Card>

View File

@@ -15,6 +15,7 @@ import {
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -31,9 +32,11 @@ import { Switch } from "../../components/ui/switch";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import {
type ClientRelation,
createClient,
deleteClient,
fetchClient,
fetchClientRelations,
fetchMyTenants,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
@@ -48,6 +51,7 @@ import type {
TenantSummary,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs";
@@ -59,6 +63,17 @@ interface ScopeItem {
locked?: boolean;
}
type ClaimNamespace = "top_level" | "rp_claims";
type ClaimValueType = "text" | "number" | "boolean" | "array" | "object";
interface IdTokenClaimItem {
id: string;
namespace: ClaimNamespace;
key: string;
value: string;
valueType: ClaimValueType;
}
type SecurityProfile = "private" | "pkce";
type TokenEndpointAuthMethod =
| "none"
@@ -111,6 +126,142 @@ function readMetadataString(
return typeof value === "string" ? value : "";
}
function isClaimNamespace(value: string): value is ClaimNamespace {
return value === "top_level" || value === "rp_claims";
}
function isClaimValueType(value: string): value is ClaimValueType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "array" ||
value === "object"
);
}
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
return {
id,
namespace: "top_level",
key: "",
value: "",
valueType: "text",
};
}
function readIdTokenClaimsMetadata(
metadata: Record<string, unknown>,
): IdTokenClaimItem[] {
const rawClaims = metadata.id_token_claims;
if (!Array.isArray(rawClaims)) {
return [];
}
return rawClaims
.map((item, index) => {
if (!item || typeof item !== "object") {
return null;
}
const record = item as Record<string, unknown>;
const namespaceValue =
typeof record.namespace === "string" &&
isClaimNamespace(record.namespace)
? record.namespace
: "top_level";
const keyValue = typeof record.key === "string" ? record.key : "";
const rawValue = record.value;
const valueValue =
typeof rawValue === "string"
? rawValue
: rawValue == null
? ""
: JSON.stringify(rawValue);
const valueTypeValue =
typeof record.valueType === "string" &&
isClaimValueType(record.valueType)
? record.valueType
: "text";
return {
id: `claim-${index + 1}`,
namespace: namespaceValue,
key: keyValue,
value: valueValue,
valueType: valueTypeValue,
};
})
.filter((item): item is IdTokenClaimItem => item !== null);
}
function normalizeClaimPreviewValue(
value: string,
valueType: ClaimValueType,
): unknown {
const trimmed = value.trim();
if (valueType === "number") {
if (trimmed === "") return "";
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : trimmed;
}
if (valueType === "boolean") {
return ["true", "1", "yes", "on"].includes(trimmed.toLowerCase());
}
if (valueType === "array") {
if (trimmed === "") return [];
try {
if (trimmed.startsWith("[")) {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed : [parsed];
}
} catch {
// Fall through to comma-separated parsing.
}
return trimmed
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
if (valueType === "object") {
if (trimmed === "") return {};
try {
const parsed = JSON.parse(trimmed);
return parsed;
} catch {
return trimmed;
}
}
return trimmed;
}
function buildIdTokenClaimsPreview(
items: IdTokenClaimItem[],
): Record<string, unknown> {
const preview: Record<string, unknown> = {};
const rpClaims: Record<string, unknown> = {};
for (const item of items) {
const key = item.key.trim();
if (!key) {
continue;
}
const target = item.namespace === "rp_claims" ? rpClaims : preview;
target[key] = normalizeClaimPreviewValue(item.value, item.valueType);
}
if (Object.keys(rpClaims).length > 0) {
preview.rp_claims = rpClaims;
}
return preview;
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
@@ -120,6 +271,29 @@ function isValidUrl(value: string): boolean {
}
}
function isValidBackchannelLogoutUrl(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {
return true;
}
try {
const url = new URL(trimmed);
if (url.hash) {
return false;
}
if (url.protocol === "https:") {
return true;
}
if (url.protocol !== "http:") {
return false;
}
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
} catch {
return false;
}
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
@@ -128,16 +302,27 @@ function formatDateTime(value?: string) {
}
function ClientGeneralPage() {
const auth = useAuth();
const params = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const clientId = params.id;
const isCreate = !clientId;
const currentUserId = auth.user?.profile.sub;
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const { data, isLoading, error } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId as string),
enabled: !isCreate,
});
const { data: relationData } = useQuery({
queryKey: ["client-relations", clientId],
queryFn: () => fetchClientRelations(clientId as string),
enabled: !isCreate,
retry: false,
});
const { data: tenantData } = useQuery({
queryKey: ["my-tenants"],
queryFn: fetchMyTenants,
@@ -153,6 +338,15 @@ function ClientGeneralPage() {
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
const [backchannelLogoutUri, setBackchannelLogoutUri] = useState("");
const [
backchannelLogoutSessionRequired,
setBackchannelLogoutSessionRequired,
] = useState(false);
const [
isBackchannelSessionRequiredInfoOpen,
setIsBackchannelSessionRequiredInfoOpen,
] = useState(false);
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
const [tenantSearch, setTenantSearch] = useState("");
@@ -192,6 +386,7 @@ function ClientGeneralPage() {
mandatory: false,
},
]);
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
useEffect(() => {
if (!data) return;
@@ -205,6 +400,14 @@ function ClientGeneralPage() {
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
setBackchannelLogoutUri(
client.backchannelLogoutUri ||
readMetadataString(metadata, "backchannel_logout_uri"),
);
setBackchannelLogoutSessionRequired(
client.backchannelLogoutSessionRequired === true ||
metadata.backchannel_logout_session_required === true,
);
setAutoLoginSupported(metadata.auto_login_supported === true);
if (typeof metadata.auto_login_url === "string")
setAutoLoginUrl(metadata.auto_login_url);
@@ -287,14 +490,29 @@ function ClientGeneralPage() {
),
);
}
setIdTokenClaims(readIdTokenClaimsMetadata(metadata));
}, [data]);
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const canEditExistingClientGeneralSettings =
systemRole === "super_admin" ||
relationData?.items?.some(
(item: ClientRelation) =>
item.subject === `User:${currentUserId}` &&
(item.relation === "admins" || item.relation === "config_editor"),
) === true;
const isGeneralSettingsReadOnly =
!isCreate && relationData != null && !canEditExistingClientGeneralSettings;
const trimmedLogoUrl = logoUrl.trim();
const trimmedAutoLoginUrl = autoLoginUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
const trimmedBackchannelLogoutUri = backchannelLogoutUri.trim();
const hasBackchannelLogoutUri = trimmedBackchannelLogoutUri.length > 0;
const hasValidBackchannelLogoutUri =
!hasBackchannelLogoutUri ||
isValidBackchannelLogoutUrl(trimmedBackchannelLogoutUri);
const hasValidAutoLoginUrl =
!autoLoginSupported ||
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
@@ -436,6 +654,32 @@ function ClientGeneralPage() {
);
};
const addIdTokenClaim = () => {
setIdTokenClaims((current) => [
...current,
createIdTokenClaimItem(`claim-${Date.now()}`),
]);
};
const updateIdTokenClaim = <K extends keyof IdTokenClaimItem>(
id: string,
field: K,
value: IdTokenClaimItem[K],
) => {
setIdTokenClaims((current) =>
current.map((claim) => {
if (claim.id !== id) {
return claim;
}
return { ...claim, [field]: value };
}),
);
};
const removeIdTokenClaim = (id: string) => {
setIdTokenClaims((current) => current.filter((claim) => claim.id !== id));
};
const handleStatusChange = (nextStatus: ClientStatus) => {
setStatus(nextStatus);
const statusLabel =
@@ -487,6 +731,11 @@ function ClientGeneralPage() {
"허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
);
const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
...claim,
key: claim.key.trim(),
value: claim.value.trim(),
}));
if (headlessLoginEnabled) {
if (!trimmedJwksUri) {
@@ -541,7 +790,61 @@ function ClientGeneralPage() {
);
}
const claimValidationErrors: string[] = [];
const seenClaimKeys = new Set<string>();
for (const claim of normalizedIdTokenClaims) {
if (!claim.key) {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.key_required",
"Claim key를 입력해야 합니다.",
),
);
continue;
}
if (claim.key === "rp_claims" && claim.namespace === "top_level") {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.reserved_key",
"`rp_claims`는 예약된 namespace 키입니다.",
),
);
continue;
}
const keySignature = `${claim.namespace}:${claim.key}`;
if (seenClaimKeys.has(keySignature)) {
claimValidationErrors.push(
t(
"msg.dev.clients.general.id_token_claims.duplicate_key",
"중복된 claim key가 있습니다: {{namespace}}.{{key}}",
{
namespace:
claim.namespace === "rp_claims"
? t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)
: t(
"ui.dev.clients.general.id_token_claims.namespace_top_level",
"top-level",
),
key: claim.key,
},
),
);
continue;
}
seenClaimKeys.add(keySignature);
}
validationErrors.push(...claimValidationErrors);
const hasValidationErrors = validationErrors.length > 0;
const idTokenClaimPreview = buildIdTokenClaimsPreview(
normalizedIdTokenClaims,
);
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
tenantData ?? [];
@@ -635,6 +938,22 @@ function ClientGeneralPage() {
),
);
}
if (hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri) {
throw new Error(
t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
),
);
}
if (isGeneralSettingsReadOnly) {
throw new Error(
t(
"msg.dev.clients.general.read_only_forbidden",
"이 RP의 일반 설정을 수정할 권한이 없습니다.",
),
);
}
if (autoLoginSupported && !hasValidAutoLoginUrl) {
throw new Error(
t(
@@ -670,12 +989,18 @@ function ClientGeneralPage() {
trimmedJwksUri
? trimmedJwksUri
: undefined,
backchannelLogoutUri: trimmedBackchannelLogoutUri || undefined,
backchannelLogoutSessionRequired:
trimmedBackchannelLogoutUri !== ""
? backchannelLogoutSessionRequired
: false,
metadata: {
description,
logo_url: trimmedLogoUrl,
auto_login_supported: autoLoginSupported,
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
structured_scopes: normalizedScopes,
id_token_claims: normalizedIdTokenClaims,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method:
@@ -690,6 +1015,11 @@ function ClientGeneralPage() {
allowed_tenants: tenantAccessRestricted
? normalizedAllowedTenantIds
: [],
backchannel_logout_uri: trimmedBackchannelLogoutUri || undefined,
backchannel_logout_session_required:
trimmedBackchannelLogoutUri !== ""
? backchannelLogoutSessionRequired
: undefined,
},
};
@@ -879,6 +1209,14 @@ function ClientGeneralPage() {
{!isCreate && (
<ClientDetailTabs activeTab="settings" clientId={clientId} />
)}
{isGeneralSettingsReadOnly && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{t(
"msg.dev.clients.general.read_only_hint",
"이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.",
)}
</div>
)}
</header>
{/* 1. Application Identity */}
@@ -913,6 +1251,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.identity.name_placeholder",
"My Awesome Application",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
<div className="space-y-2">
@@ -930,6 +1269,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.identity.description_placeholder",
"앱에 대한 간단한 설명을 입력하세요.",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</div>
@@ -949,6 +1289,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.identity.logo_placeholder",
"https://example.com/logo.png",
)}
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -1067,6 +1408,7 @@ function ClientGeneralPage() {
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => handleStatusChange("active")}
disabled={isGeneralSettingsReadOnly}
>
{t("ui.common.status.active", "활성")}
</Button>
@@ -1075,6 +1417,7 @@ function ClientGeneralPage() {
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => handleStatusChange("inactive")}
disabled={isGeneralSettingsReadOnly}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
@@ -1181,6 +1524,7 @@ function ClientGeneralPage() {
size="sm"
onClick={addScope}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
@@ -1201,6 +1545,7 @@ function ClientGeneralPage() {
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
)}
className="font-mono text-sm"
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -1211,6 +1556,114 @@ function ClientGeneralPage() {
</div>
)}
<div className="space-y-4 border-b border-border pb-6 mb-6">
<div className="space-y-2">
<Label
className="text-sm font-semibold"
htmlFor="backchannel-logout-uri"
>
{t(
"ui.dev.clients.general.backchannel_logout.uri",
"Back-Channel Logout URI",
)}
</Label>
<Input
id="backchannel-logout-uri"
value={backchannelLogoutUri}
onChange={(e) => setBackchannelLogoutUri(e.target.value)}
placeholder={t(
"ui.dev.clients.general.backchannel_logout.uri_placeholder",
"https://rp.example.com/oidc/backchannel-logout",
)}
className="font-mono text-sm"
disabled={isGeneralSettingsReadOnly}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.backchannel_logout.uri_help",
"Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다.",
)}
</p>
{hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri ? (
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
)}
</p>
) : null}
</div>
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label
className="text-sm font-semibold"
htmlFor="backchannel-logout-session-required"
>
{t(
"ui.dev.clients.general.backchannel_logout.session_required",
"SID Claim Required",
)}
</Label>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
isBackchannelSessionRequiredInfoOpen
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={() =>
setIsBackchannelSessionRequiredInfoOpen((prev) => !prev)
}
aria-label={t(
"ui.dev.clients.general.backchannel_logout.session_required_info",
"SID Claim Required 설명 보기",
)}
>
{isBackchannelSessionRequiredInfoOpen ? (
<X className="h-3.5 w-3.5" />
) : (
<Info className="h-3.5 w-3.5" />
)}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.backchannel_logout.session_required_help",
"RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다.",
)}
</p>
{isBackchannelSessionRequiredInfoOpen ? (
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
<Info className="h-3 w-3" />
{t("ui.common.info", "상세 안내")}
</div>
<div>
{t(
"msg.dev.clients.general.backchannel_logout.session_required_on",
"켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리",
)}
</div>
<div>
{t(
"msg.dev.clients.general.backchannel_logout.session_required_off",
"끄면: sid가 없어도 sub만으로 로그아웃 처리 가능",
)}
</div>
</div>
) : null}
</div>
<Switch
id="backchannel-logout-session-required"
checked={backchannelLogoutSessionRequired}
onCheckedChange={setBackchannelLogoutSessionRequired}
disabled={isGeneralSettingsReadOnly || !hasBackchannelLogoutUri}
/>
</div>
</div>
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
@@ -1258,7 +1711,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.name_placeholder",
"e.g. profile",
)}
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3">
@@ -1272,7 +1725,7 @@ function ClientGeneralPage() {
"ui.dev.clients.general.scopes.description_placeholder",
"권한에 대한 설명",
)}
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 text-center">
@@ -1282,7 +1735,7 @@ function ClientGeneralPage() {
onCheckedChange={(checked) =>
updateScope(s.id, "mandatory", checked)
}
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
/>
</div>
</td>
@@ -1292,7 +1745,7 @@ function ClientGeneralPage() {
size="icon"
onClick={() => removeScope(s.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={s.locked}
disabled={s.locked || isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -1359,6 +1812,7 @@ function ClientGeneralPage() {
checked={tenantAccessRestricted}
onCheckedChange={handleTenantAccessToggle}
id="tenant-access-toggle"
disabled={isGeneralSettingsReadOnly}
/>
</div>
</div>
@@ -1403,7 +1857,9 @@ function ClientGeneralPage() {
"테넌트 이름 또는 슬러그로 검색",
)}
className="pl-10"
disabled={!tenantAccessRestricted}
disabled={
isGeneralSettingsReadOnly || !tenantAccessRestricted
}
/>
{tenantAccessRestricted && isTenantSearchOpen && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
@@ -1417,6 +1873,7 @@ function ClientGeneralPage() {
event.preventDefault();
handleSelectAllowedTenant(tenant.id);
}}
disabled={isGeneralSettingsReadOnly}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
@@ -1484,6 +1941,7 @@ function ClientGeneralPage() {
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenant.id)}
className="text-muted-foreground transition hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<X className="h-3.5 w-3.5" />
</button>
@@ -1509,6 +1967,7 @@ function ClientGeneralPage() {
aria-label={t("ui.common.delete", "삭제")}
onClick={() => toggleAllowedTenant(tenantId)}
className="text-muted-foreground transition hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<X className="h-3.5 w-3.5" />
</button>
@@ -1534,6 +1993,258 @@ function ClientGeneralPage() {
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-bold">
{t(
"ui.dev.clients.general.id_token_claims.title",
"Custom Claims",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.id_token_claims.subtitle",
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
)}
</CardDescription>
</div>
<Button
onClick={addIdTokenClaim}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
<div className="space-y-3">
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.key",
"Claim Key",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.namespace",
"Namespace",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value_type",
"Value Type",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value",
"Value",
)}
</th>
<th className="px-4 py-3 text-right font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.delete",
"Delete",
)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{idTokenClaims.map((claim) => (
<tr key={claim.id} className="hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<Input
value={claim.key}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"key",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.namespace}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"namespace",
e.target.value as ClaimNamespace,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.namespace_label",
"Claim namespace",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="top_level">
{t(
"ui.dev.clients.general.id_token_claims.namespace_top_level",
"top-level",
)}
</option>
<option value="rp_claims">
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.valueType}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"valueType",
e.target.value as ClaimValueType,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.value_type_label",
"Claim value type",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="text">
{t(
"ui.dev.clients.general.id_token_claims.value_type_text",
"Text",
)}
</option>
<option value="number">
{t(
"ui.dev.clients.general.id_token_claims.value_type_number",
"Number",
)}
</option>
<option value="boolean">
{t(
"ui.dev.clients.general.id_token_claims.value_type_boolean",
"Boolean",
)}
</option>
<option value="array">
{t(
"ui.dev.clients.general.id_token_claims.value_type_array",
"Array",
)}
</option>
<option value="object">
{t(
"ui.dev.clients.general.id_token_claims.value_type_object",
"Object",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<Input
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the claim value",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 text-right align-top">
<Button
variant="ghost"
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
{idTokenClaims.length === 0 && (
<tr>
<td
colSpan={5}
className="px-4 py-8 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.general.id_token_claims.empty",
"아직 추가된 ID Token claim이 없습니다.",
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
<p className="text-xs leading-6 text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.hint",
"top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
)}
</p>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<div>
<p className="text-sm font-semibold">
{t(
"ui.dev.clients.general.id_token_claims.preview_title",
"Saved JSON Preview",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.preview_hint",
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
)}
</p>
</div>
</div>
<Textarea
readOnly
value={idTokenClaimPreviewJson}
className="mt-4 min-h-72 font-mono text-xs"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 3. Security Settings */}
<Card className="glass-panel">
<CardHeader className="pb-3">
@@ -1638,6 +2349,7 @@ function ClientGeneralPage() {
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
disabled={isGeneralSettingsReadOnly}
/>
</div>
)}
@@ -2111,6 +2823,7 @@ function ClientGeneralPage() {
<Button
onClick={() => mutation.mutate()}
disabled={
isGeneralSettingsReadOnly ||
mutation.isPending ||
isLoading ||
name.trim() === "" ||

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
import { ArrowLeft, Info, Link2, Plus, Trash2, X } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useParams } from "react-router-dom";
@@ -47,7 +47,6 @@ const relationOptions = [
"consent_revoker",
"relationship_viewer",
"audit_viewer",
"status_operator",
] as const;
type RelationOption = (typeof relationOptions)[number];
@@ -63,6 +62,10 @@ function relationDescription(relation: RelationOption) {
);
}
function relationPermitsInfo(relation: RelationOption) {
return t(`ui.dev.clients.relationships.option.${relation}.permits_info`, "");
}
function formatUserLabel(user: DevAssignableUser) {
const primary = user.name.trim() || user.email.trim();
return `${primary} (${user.email.trim()})`;
@@ -82,6 +85,7 @@ function ClientRelationsPage() {
null,
);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [infoRelation, setInfoRelation] = useState<RelationOption | null>(null);
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
@@ -308,6 +312,15 @@ function ClientRelationsPage() {
}
};
const handleInfoToggle = (
event: React.MouseEvent,
relation: RelationOption,
) => {
event.preventDefault();
event.stopPropagation();
setInfoRelation((prev) => (prev === relation ? null : relation));
};
if (!clientId) {
return (
<div className="p-8 text-center">
@@ -389,7 +402,14 @@ function ClientRelationsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isRelationshipViewForbidden ? (
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.loading",
"Loading relationships...",
)}
</div>
) : isRelationshipViewForbidden ? (
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{relationshipViewForbiddenMessage}
</div>
@@ -495,46 +515,76 @@ function ClientRelationsPage() {
const disabled =
selectedUserExistingRelations.has(relation);
const isSelected = selectedRelations.includes(relation);
const isInfoVisible = infoRelation === relation;
return (
<label
key={relation}
className={`flex gap-3 rounded-xl border p-4 transition-all ${
disabled
? "border-border/60 bg-muted/30 opacity-60"
: isSelected
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
}`}
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary"
checked={isSelected || disabled}
disabled={disabled}
onChange={() => handleRelationToggle(relation)}
/>
<div className="space-y-1">
<div
className={`text-sm font-semibold ${
isSelected && !disabled ? "text-primary" : ""
}`}
>
{relationLabel(relation)}
<div key={relation} className="relative">
<label
className={`flex gap-3 rounded-xl border p-4 transition-all ${
disabled
? "border-border/60 bg-muted/30 opacity-60"
: isSelected
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
}`}
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary"
checked={isSelected || disabled}
disabled={disabled}
onChange={() => handleRelationToggle(relation)}
/>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div
className={`text-sm font-semibold ${
isSelected && !disabled ? "text-primary" : ""
}`}
>
{relationLabel(relation)}
</div>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
isInfoVisible
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={(e) => handleInfoToggle(e, relation)}
>
{isInfoVisible ? (
<X className="h-3.5 w-3.5" />
) : (
<Info className="h-3.5 w-3.5" />
)}
</button>
</div>
<div className="text-xs text-muted-foreground">
{relationDescription(relation)}
</div>
<div
className={`text-[11px] uppercase tracking-wide ${
isSelected && !disabled
? "text-primary/80"
: "text-muted-foreground/80"
}`}
>
{relation}
</div>
</div>
<div className="text-xs text-muted-foreground">
{relationDescription(relation)}
</label>
{isInfoVisible && (
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
<Info className="h-3 w-3" />
{t("ui.common.info", "상세 권한 안내")}
</div>
{relationPermitsInfo(relation)}
</div>
<div
className={`text-[11px] uppercase tracking-wide ${
isSelected && !disabled
? "text-primary/80"
: "text-muted-foreground/80"
}`}
>
{relation}
</div>
</div>
</label>
)}
</div>
);
})}
</div>
@@ -627,8 +677,35 @@ function ClientRelationsPage() {
<TableRow key={`${item.relation}:${item.subject}`}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{relationLabel(item.relation as RelationOption)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 font-medium">
<span>
{relationLabel(item.relation as RelationOption)}
</span>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
infoRelation === item.relation
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={(e) =>
handleInfoToggle(
e,
item.relation as RelationOption,
)
}
>
<Info className="h-3.5 w-3.5" />
</button>
</div>
{infoRelation === item.relation && (
<div className="animate-in fade-in slide-in-from-top-1 rounded border border-primary/20 bg-primary/5 p-2 text-[11px] leading-relaxed text-foreground max-w-[250px]">
{relationPermitsInfo(
item.relation as RelationOption,
)}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
{relationDescription(item.relation as RelationOption)}

View File

@@ -12,6 +12,8 @@ export type ClientSummary = {
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
backchannelLogoutUri?: string;
backchannelLogoutSessionRequired?: boolean;
redirectUris: string[];
scopes: string[];
metadata?: Record<string, unknown>;
@@ -118,6 +120,8 @@ export type ClientUpsertRequest = {
responseTypes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
backchannelLogoutUri?: string;
backchannelLogoutSessionRequired?: boolean;
metadata?: Record<string, unknown>;
};

View File

@@ -324,6 +324,19 @@ loaded_count = "Loaded {{count}} rows"
loading = "Loading audit logs..."
subtitle = "Shows DevFront activity history within current tenant/app scope."
[msg.dev.request]
admin_desc = "Manage developer access requests submitted by users."
approved = "Approved."
cancelled = "Approval has been cancelled."
empty = "No requests found."
need_cancel_notes = "Please enter a reason for cancelling approval."
need_notes = "Please enter a rejection reason."
rejected = "Rejected."
user_desc = "Review your request history and submit a new access request."
[msg.dev.request.modal]
desc = "Please enter the reason for your request. It will be approved after administrator review."
[msg.dev.clients]
load_error = "Error loading clients: {{error}}"
loading = "Loading apps..."
@@ -362,6 +375,7 @@ create_forbidden = "You do not have permission to create this RP. Ask an adminis
save_error = "Save Error"
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
secret_rotated = "Secret Rotated"
secret_not_applicable = "PKCE apps do not have a client secret."
secret_unavailable = "SECRET_NOT_AVAILABLE"
subtitle = "Manage OIDC credentials and endpoints."
@@ -375,6 +389,8 @@ note = "Keep endpoints read-only, and tie secret rotation/copy actions to audit
[msg.dev.clients.general]
load_error = "Error loading client: {{error}}"
loading = "Loading client..."
read_only_forbidden = "You do not have permission to edit this RP's general settings."
read_only_hint = "Only users with the `RP Admin` or `RP General Settings` relationship can edit this RP's general settings."
saved = "Saved"
save_error = "Failed to save: {{error}}"
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
@@ -416,11 +432,27 @@ subtitle = "Set the application name, description, and logo."
[msg.dev.clients.general.redirect]
help = "Enter the redirect URIs. You can modify them in the Federation tab after creation."
[msg.dev.clients.general.backchannel_logout]
uri_help = "RP endpoint that receives Baron's session termination event via server-to-server POST."
invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1."
session_required_help = "Use this when the RP should process logout_token only if the sid claim is included."
session_required_on = "On: process logout only when the logout_token contains a sid."
session_required_off = "Off: process logout using sub even if sid is missing."
[msg.dev.clients.general.scopes]
empty = "No scopes registered."
subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim"
[msg.dev.clients.general.id_token_claims]
subtitle = "Separate shared claims from RP-specific extension claims."
empty = "No ID Token claims have been added yet."
hint = "Use top-level for shared claims and rp_claims for RP-specific extension claims. Arrays accept JSON or comma-separated values, and objects accept JSON."
preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
key_required = "Enter a claim key."
reserved_key = "`rp_claims` is a reserved namespace key."
duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}"
[msg.dev.clients.general.security]
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
@@ -1288,12 +1320,45 @@ scope_badge = "Scoped to /dev"
[ui.dev.nav]
audit_logs = "Audit Logs"
clients = "Connected Application"
developer_request = "Developer Access Request"
logout = "Logout"
[ui.dev.audit]
load_more = "Load more"
title = "Audit Logs"
[ui.dev.request]
admin_notes_placeholder = "Enter note (optional)..."
cancel_approval = "Cancel Approval"
cancel_notes_placeholder = "Enter reason for approval cancellation..."
[ui.dev.request.list]
title = "Request History"
[ui.dev.request.modal]
email = "Email"
name = "Name"
org = "Organization"
phone = "Phone Number"
reason = "Reason"
reason_placeholder = "e.g. I need to create an OIDC client for internal service integration and testing."
role = "Role"
title = "Developer Access Request"
[ui.dev.request.status]
approved = "Approved"
cancelled = "Approval Cancelled"
pending = "Pending"
rejected = "Rejected"
[ui.dev.request.table]
actions = "Actions"
date = "Requested At"
org = "Organization"
reason = "Reason"
status = "Status"
user = "User"
[ui.dev.audit.registry]
title = "Audit registry"
@@ -1426,6 +1491,12 @@ title = "Application Identity"
label = "Redirect URIs"
placeholder = "Placeholder"
[ui.dev.clients.general.backchannel_logout]
uri = "Back-Channel Logout URI"
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
session_required = "SID Claim Required"
session_required_info = "Show SID Claim Required help"
[ui.dev.clients.general.scopes]
add = "Scope Add"
description_placeholder = "Description Placeholder"
@@ -1452,6 +1523,22 @@ hint = "Turning this on adds the tenant scope automatically and requires at leas
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
[ui.dev.clients.general.id_token_claims]
title = "Custom Claims"
add = "Add Claim"
preview_title = "Saved JSON Preview"
namespace_label = "Claim namespace"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
value_type_label = "Claim value type"
value_type_text = "Text"
value_type_number = "Number"
value_type_boolean = "Boolean"
value_type_array = "Array"
value_type_object = "Object"
key_placeholder = "e.g. locale"
value_placeholder = "Enter the claim value"
[ui.dev.clients.general.security]
private = "Server Side App"
pkce = "PKCE"
@@ -1505,6 +1592,7 @@ user_search_placeholder = "Search by name or email..."
[ui.dev.clients.relationships.option.admins]
label = "RP Admin"
description = "Full administrator relationship for RP operations."
permits_info = "Has full administrative control over the RP, including config editing, secret management, JWKS, consents, and relationships."
[ui.dev.clients.relationships.option.creator]
label = "RP Creator"
@@ -1513,38 +1601,47 @@ description = "Marks the operator who created this RP."
[ui.dev.clients.relationships.option.config_editor]
label = "RP General Settings"
description = "Edit the name, redirect URIs, and general metadata."
permits_info = "Can modify general RP settings such as Name, Redirect URIs, Post-Logout URIs, and metadata."
[ui.dev.clients.relationships.option.secret_viewer]
label = "Secret View"
description = "View the Client secret for this RP."
permits_info = "Can view the Client Secret value in plain text."
[ui.dev.clients.relationships.option.secret_rotator]
label = "Secret Rotation"
description = "Rotate and reissue the client secret."
permits_info = "Can regenerate the Client Secret or expire and rotate existing secrets."
[ui.dev.clients.relationships.option.jwks_viewer]
label = "JWKS View"
description = "View JWKS status, cache details, and key summaries."
permits_info = "Can view JWKS status, cached key information, and key summaries."
[ui.dev.clients.relationships.option.jwks_operator]
label = "JWKS Operations"
description = "Run operational actions such as refresh and revoke."
permits_info = "Can perform JWKS operations such as manual refresh and key revocation."
[ui.dev.clients.relationships.option.consent_viewer]
label = "Consent View"
description = "View consent grants for this RP."
permits_info = "Can view the history of user consents granted to this RP."
[ui.dev.clients.relationships.option.consent_revoker]
label = "Consent Revoke"
description = "Revoke consent grants for this RP."
permits_info = "Can revoke or cancel user consents granted to this RP."
[ui.dev.clients.relationships.option.relationship_viewer]
label = "Relationship View"
description = "View direct relations assigned to this RP."
permits_info = "Can view the list of direct relationships and administrative roles assigned to this RP."
[ui.dev.clients.relationships.option.audit_viewer]
label = "Audit Log View"
description = "View DevFront audit logs for this RP."
permits_info = "Can view DevFront audit logs for all configuration changes and operations on this RP."
[ui.dev.clients.relationships.option.status_operator]
label = "Status Change"

View File

@@ -324,6 +324,19 @@ loaded_count = "로드된 로그 {{count}}건"
loading = "감사 로그를 불러오는 중..."
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
[msg.dev.request]
admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다."
approved = "승인되었습니다."
cancelled = "승인이 취소되었습니다."
empty = "신청 내역이 없습니다."
need_cancel_notes = "승인 취소 사유를 입력해주세요."
need_notes = "반려 사유를 입력해주세요."
rejected = "반려되었습니다."
user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다."
[msg.dev.request.modal]
desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다."
[msg.dev.clients]
deleted = "앱이 삭제되었습니다."
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
@@ -362,6 +375,7 @@ create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게
save_error = "저장 실패: {{error}}"
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
secret_rotated = "Client Secret이 재발급되었습니다."
secret_not_applicable = "PKCE 앱에는 Client Secret이 없습니다."
secret_unavailable = "SECRET_NOT_AVAILABLE"
subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다."
@@ -375,6 +389,8 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
[msg.dev.clients.general]
load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}"
loading = "클라이언트 정보를 불러오는 중..."
read_only_forbidden = "이 RP의 일반 설정을 수정할 권한이 없습니다."
read_only_hint = "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다."
save_error = "저장 실패: {{error}}"
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
saved = "설정이 저장되었습니다."
@@ -416,11 +432,27 @@ subtitle = "앱 이름과 설명, 로고를 설정합니다."
[msg.dev.clients.general.redirect]
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다."
[msg.dev.clients.general.backchannel_logout]
uri_help = "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다."
invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다."
session_required_help = "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다."
session_required_on = "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리"
session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능"
[msg.dev.clients.general.scopes]
empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.id_token_claims]
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다."
hint = "top-level은 공통 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
[msg.dev.clients.general.security]
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
@@ -1288,12 +1320,45 @@ scope_badge = "Scoped to /dev"
[ui.dev.nav]
audit_logs = "감사 로그"
clients = "연동 앱"
developer_request = "개발자 권한 신청"
logout = "로그아웃"
[ui.dev.audit]
load_more = "더 보기"
title = "감사 로그"
[ui.dev.request]
admin_notes_placeholder = "메모 입력 (선택)..."
cancel_approval = "승인 취소"
cancel_notes_placeholder = "승인 취소 사유 입력..."
[ui.dev.request.list]
title = "신청 내역"
[ui.dev.request.modal]
email = "이메일"
name = "성함"
org = "소속"
phone = "전화번호"
reason = "신청 사유"
reason_placeholder = "예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다."
role = "역할"
title = "개발자 등록 신청"
[ui.dev.request.status]
approved = "승인됨"
cancelled = "승인 취소됨"
pending = "대기 중"
rejected = "반려됨"
[ui.dev.request.table]
actions = "관리"
date = "신청일"
org = "소속"
reason = "신청 사유"
status = "상태"
user = "사용자"
[ui.dev.audit.registry]
title = "Audit registry"
@@ -1425,6 +1490,12 @@ title = "애플리케이션 정보"
label = "리디렉션 URI"
placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
[ui.dev.clients.general.backchannel_logout]
uri = "Back-Channel Logout URI"
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
session_required = "SID Claim Required"
session_required_info = "SID Claim Required 설명 보기"
[ui.dev.clients.general.scopes]
add = "스코프 추가"
description_placeholder = "권한에 대한 설명"
@@ -1451,6 +1522,22 @@ hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
[ui.dev.clients.general.id_token_claims]
title = "커스텀 클레임"
add = "Claim 추가"
preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
value_type_label = "Claim 값 타입"
value_type_text = "텍스트"
value_type_number = "숫자"
value_type_boolean = "불리언"
value_type_array = "배열"
value_type_object = "객체"
key_placeholder = "예: locale"
value_placeholder = "Claim 값을 입력하세요"
[ui.dev.clients.general.security]
private = "Server side App"
pkce = "PKCE"
@@ -1504,6 +1591,7 @@ user_search_placeholder = "이름 또는 이메일 검색..."
[ui.dev.clients.relationships.option.admins]
label = "RP 관리자"
description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다."
permits_info = "RP 설정 수정, 시크릿 조회/재발급, JWKS 관리, 동의 조회/회수, 관계 조회/수정 등 모든 운영 권한을 가집니다."
[ui.dev.clients.relationships.option.creator]
label = "RP 생성자"
@@ -1512,38 +1600,47 @@ description = "이 RP를 생성한 운영 주체를 표시합니다."
[ui.dev.clients.relationships.option.config_editor]
label = "RP 일반 설정"
description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다."
permits_info = "RP 이름, Redirect URIs, 로그아웃 URI, 메타데이터 등 일반 설정을 수정할 수 있습니다."
[ui.dev.clients.relationships.option.secret_viewer]
label = "시크릿 조회"
description = "이 RP의 Client secret을 조회합니다."
permits_info = "RP의 Client Secret 값을 평문으로 확인할 수 있습니다."
[ui.dev.clients.relationships.option.secret_rotator]
label = "시크릿 재발급"
description = "Client secret 재발급과 회전을 수행합니다."
permits_info = "새로운 Client Secret을 발급하거나 기존 시크릿을 만료시키고 회전시킬 수 있습니다."
[ui.dev.clients.relationships.option.jwks_viewer]
label = "JWKS 조회"
description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다."
permits_info = "RP의 공개키 설정(JWKS) 상태와 캐시된 키 정보를 조회할 수 있습니다."
[ui.dev.clients.relationships.option.jwks_operator]
label = "JWKS 운영"
description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다."
permits_info = "JWKS 캐시를 강제로 갱신하거나 등록된 키를 회수하는 등 키 관리를 수행할 수 있습니다."
[ui.dev.clients.relationships.option.consent_viewer]
label = "동의 조회"
description = "이 RP의 consent 내역을 조회합니다."
permits_info = "사용자가 이 RP에 부여한 개인정보 제공 동의 내역을 조회할 수 있습니다."
[ui.dev.clients.relationships.option.consent_revoker]
label = "동의 회수"
description = "이 RP의 consent를 회수합니다."
permits_info = "사용자의 동의 내역을 강제로 취소하거나 회수할 수 있습니다."
[ui.dev.clients.relationships.option.relationship_viewer]
label = "관계 조회"
description = "이 RP에 부여된 direct relation을 조회합니다."
permits_info = "이 RP에 어떤 사용자가 어떤 관리 권한을 가지고 있는지 목록을 조회할 수 있습니다."
[ui.dev.clients.relationships.option.audit_viewer]
label = "감사 로그 조회"
description = "이 RP의 DevFront 감사 로그를 조회합니다."
permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에 대한 감사 로그를 조회할 수 있습니다."
[ui.dev.clients.relationships.option.status_operator]
label = "상태 변경"

View File

@@ -191,11 +191,17 @@ delete_confirm = ""
delete_success = ""
empty = ""
fetch_error = ""
import_empty = ""
import_error = ""
import_result = ""
missing_id = ""
not_found = ""
remove_sub_confirm = ""
subtitle = ""
[msg.admin.tenants.import_preview]
description = ""
[msg.admin.tenants.admins]
add_success = ""
empty = ""
@@ -255,9 +261,13 @@ parsed_count = ""
update_success = ""
[msg.admin.users.create]
appointment_required = ""
error = ""
external_tenant_required = ""
password_required = ""
personal_tenant_failed = ""
success = ""
tenant_resolve_failed = ""
[msg.admin.users.create.account]
subtitle = ""
@@ -269,6 +279,7 @@ field_required = ""
name_required = ""
password_auto_help = ""
password_manual_help = ""
picker_description = ""
role_help = ""
[msg.admin.users.create.password_generated]
@@ -291,7 +302,9 @@ password_hint = ""
[msg.admin.users.list]
delete_confirm = ""
empty = ""
export_error = ""
fetch_error = ""
status_error = ""
subtitle = ""
[msg.admin.users.list.columns]
@@ -337,14 +350,6 @@ user_desc = ""
[msg.dev.request.modal]
desc = ""
email = ""
name = ""
org = ""
phone = ""
reason = ""
reason_placeholder = ""
role = ""
title = ""
[msg.dev.request.status]
approved = ""
@@ -408,6 +413,7 @@ create_forbidden = ""
save_error = ""
save_forbidden = ""
secret_rotated = ""
secret_not_applicable = ""
secret_unavailable = ""
subtitle = ""
@@ -421,11 +427,22 @@ note = ""
[msg.dev.clients.general]
load_error = ""
loading = ""
read_only_forbidden = ""
read_only_hint = ""
saved = ""
save_error = ""
save_forbidden = ""
status_changed = ""
[msg.dev.clients.general.id_token_claims]
subtitle = ""
empty = ""
hint = ""
preview_hint = ""
key_required = ""
reserved_key = ""
duplicate_key = ""
[msg.dev.clients.relationships]
subtitle = ""
add_description = ""
@@ -462,6 +479,13 @@ subtitle = ""
[msg.dev.clients.general.redirect]
help = ""
[msg.dev.clients.general.backchannel_logout]
uri_help = ""
invalid = ""
session_required_help = ""
session_required_on = ""
session_required_off = ""
[msg.dev.clients.general.scopes]
empty = ""
subtitle = ""
@@ -987,6 +1011,9 @@ user = ""
[ui.admin.tenants]
add = ""
csv_template = ""
export = ""
import = ""
title = ""
[ui.admin.tenants.admins]
@@ -1116,6 +1143,15 @@ search_placeholder = ""
title = ""
tree_search_placeholder = ""
[ui.admin.tenants.import_preview]
candidates = ""
confirm = ""
create_new = ""
fixed_id = ""
match = ""
no_candidates = ""
title = ""
[ui.admin.tenants.sub.table]
action = ""
name = ""
@@ -1124,6 +1160,7 @@ status = ""
[ui.admin.tenants.table]
actions = ""
id = ""
members = ""
name = ""
slug = ""
@@ -1223,6 +1260,7 @@ empty = ""
fetch_error = ""
search_placeholder = ""
subtitle = ""
toggle_status = ""
title = ""
[ui.admin.users.list.breadcrumb]
@@ -1508,6 +1546,12 @@ title = ""
label = ""
placeholder = ""
[ui.dev.clients.general.backchannel_logout]
uri = ""
uri_placeholder = ""
session_required = ""
session_required_info = ""
[ui.dev.clients.general.scopes]
add = ""
description_placeholder = ""
@@ -1534,6 +1578,22 @@ hint = ""
autocomplete_hint = ""
validation_required = ""
[ui.dev.clients.general.id_token_claims]
title = ""
add = ""
preview_title = ""
namespace_label = ""
namespace_top_level = ""
namespace_rp_claims = ""
value_type_label = ""
value_type_text = ""
value_type_number = ""
value_type_boolean = ""
value_type_array = ""
value_type_object = ""
key_placeholder = ""
value_placeholder = ""
[ui.dev.clients.general.security]
private = ""
pkce = ""
@@ -1586,50 +1646,62 @@ user_search_placeholder = ""
[ui.dev.clients.relationships.option.admins]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.creator]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.config_editor]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.secret_viewer]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.secret_rotator]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.jwks_viewer]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.jwks_operator]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.consent_viewer]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.consent_revoker]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.relationship_viewer]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.audit_viewer]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.relationships.option.status_operator]
label = ""
description = ""
permits_info = ""
[ui.dev.clients.help]
docs_body = ""

View File

@@ -129,6 +129,159 @@ test.describe("DevFront clients lifecycle", () => {
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
});
test("id token claims should be persisted and restored", async ({ page }) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-claims/settings");
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
await page.getByPlaceholder(/e\.g\. locale|예: locale/i).fill("locale");
await page
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
.first()
.selectOption("top_level");
await page
.getByLabel(/Claim value type|Claim 값 타입/i)
.first()
.selectOption("text");
await page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.first()
.fill("ko-KR");
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
await page
.getByPlaceholder(/e\.g\. locale|예: locale/i)
.nth(1)
.fill("tier");
await page
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
.nth(1)
.selectOption("rp_claims");
await page
.getByLabel(/Claim value type|Claim 값 타입/i)
.nth(1)
.selectOption("number");
await page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.nth(1)
.fill("2");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(() => state.clients[0]?.metadata?.id_token_claims)
.toBeDefined();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
namespace?: string;
key?: string;
value?: string;
valueType?: string;
}>
| undefined
)?.length,
)
.toBe(2);
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
namespace?: string;
key?: string;
value?: string;
valueType?: string;
}>
| undefined
)?.[0]?.namespace,
)
.toBe("top_level");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
namespace?: string;
key?: string;
value?: string;
valueType?: string;
}>
| undefined
)?.[0]?.key,
)
.toBe("locale");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
namespace?: string;
key?: string;
value?: string;
valueType?: string;
}>
| undefined
)?.[1]?.namespace,
)
.toBe("rp_claims");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
namespace?: string;
key?: string;
value?: string;
valueType?: string;
}>
| undefined
)?.[1]?.key,
)
.toBe("tier");
await page.reload();
await expect(
page.getByPlaceholder(/e\.g\. locale|예: locale/i),
).toHaveCount(2);
await expect(
page.getByPlaceholder(/e\.g\. locale|예: locale/i).first(),
).toHaveValue("locale");
await expect(
page.getByPlaceholder(/e\.g\. locale|예: locale/i).nth(1),
).toHaveValue("tier");
await expect(
page.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i),
).toHaveCount(2);
await expect(
page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.first(),
).toHaveValue("ko-KR");
await expect(
page
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
.nth(1),
).toHaveValue("2");
});
test("pkce headless login uses jwks uri only and shows cache actions", async ({
page,
}) => {

View File

@@ -147,7 +147,9 @@ test.describe("DevFront developer request and management", () => {
await nameInput.fill("E2E Test RP");
await nameInput.press("Tab");
const uriInput = page.locator("textarea.font-mono");
const uriInput = page.getByRole("textbox", {
name: /Redirect URIs|인증 콜백 URL|Callback/i,
});
await uriInput.fill("https://example.com/callback");
await uriInput.press("Tab");

View File

@@ -221,6 +221,39 @@ function parseClientId(pathname: string): string {
}
export async function installDevApiMock(page: Page, state: DevApiMockState) {
const readMockRole = async () =>
(
(await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
"rp_admin"
).trim();
const buildSelfConfigEditorRelation = (): ClientRelation => ({
relation: "config_editor",
subject: "User:playwright-user",
subjectType: "User",
subjectId: "playwright-user",
userName: "Playwright User",
userEmail: "playwright@example.com",
userLoginId: "playwright@example.com",
});
const shouldGrantDefaultEditRelation = (role: string) =>
role === "rp_admin" || role === "tenant_admin" || role === "super_admin";
const resolveClientRelations = async (clientId: string) => {
const explicitRelations = state.relations?.[clientId];
if (explicitRelations) {
return explicitRelations;
}
const role = await readMockRole();
if (!shouldGrantDefaultEditRelation(role)) {
return [];
}
return [buildSelfConfigEditorRelation()];
};
const appendAuditLog = (
eventType: string,
action: string,
@@ -431,6 +464,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
state.clients.push(created);
if (!state.relations) {
state.relations = {};
}
state.relations[created.id] = [buildSelfConfigEditorRelation()];
appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id);
return json(route, {
client: created,
@@ -451,7 +488,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
) {
const clientId = pathname.split("/")[5] ?? "";
return json(route, {
items: state.relations?.[clientId] ?? [],
items: await resolveClientRelations(clientId),
});
}

View File

@@ -156,4 +156,4 @@
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]
]

View File

@@ -1095,6 +1095,7 @@ delete_selected = "Delete Selected"
export_with_ids = "Include UUIDs"
export_without_ids = "Export without UUIDs"
import = "Import"
seed_badge = "Seed"
title = "Tenant Registry"
view_org_chart = "View Full Org Chart"
@@ -1188,6 +1189,7 @@ import_partial_success = "Imported some organization data successfully."
[msg.admin.tenants]
delete_bulk_confirm = "Delete {{count}} selected tenants?"
seed_delete_blocked = "Seed tenants cannot be deleted."
[msg.admin.users]
self_delete_blocked = "You cannot delete your own account."
@@ -1204,6 +1206,15 @@ title = "Tenant Members ({{count}})"
total = "Total"
total_label = "Total"
[ui.admin.tenants.import_preview]
candidates = "Candidates"
confirm = "Confirm Import"
create_new = "Create New"
fixed_id = "Fixed ID"
match = "Matched Tenant"
no_candidates = "No matching tenants found."
title = "Import Preview"
[ui.admin.tenants.members.table]
email = "EMAIL"
name = "NAME"
@@ -1339,6 +1350,7 @@ name = "Name"
name_placeholder = "Name Placeholder"
password = "Password"
password_placeholder = "********"
picker_description = "Search and select a tenant."
phone = "Phone number"
phone_placeholder = "010-1234-5678"
position = "Position"

View File

@@ -672,11 +672,17 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다."
import_empty = "임포트 파일에 테넌트 행이 없습니다."
import_error = "테넌트 임포트에 실패했습니다: {{error}}"
import_result = "{{count}}개의 테넌트 행을 처리했습니다."
missing_id = "테넌트 ID가 없습니다."
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?"
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
[msg.admin.tenants.import_preview]
description = "임포트 전에 각 행의 매칭 결과를 검토하고 처리 방식을 선택하세요."
[msg.admin.tenants.admins]
add_success = "관리자가 추가되었습니다."
empty = "등록된 관리자가 없습니다."
@@ -1564,6 +1570,7 @@ user = "TENANT MEMBER"
[ui.admin.tenants]
add = "테넌트 추가"
delete_selected = "선택 삭제"
seed_badge = "초기 설정"
title = "테넌트 목록"
view_org_chart = "전체 조직도 보기"
@@ -1642,9 +1649,12 @@ import_partial_success = "일부 조직 정보를 가져왔습니다."
[msg.admin.tenants]
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
seed_delete_blocked = "초기 설정 테넌트는 삭제할 수 없습니다."
[msg.admin.users]
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
export_error = "사용자 내보내기에 실패했습니다: {{error}}"
status_error = "사용자 상태 변경에 실패했습니다: {{error}}"
[ui.admin.apikeys.registry]
title = "API Key Registry"
@@ -1658,6 +1668,15 @@ title = "테넌트 구성원 ({{count}})"
total = "전체"
total_label = "전체"
[ui.admin.tenants.import_preview]
candidates = "후보"
confirm = "임포트 확정"
create_new = "새로 생성"
fixed_id = "고정 ID"
match = "매칭된 테넌트"
no_candidates = "매칭 가능한 테넌트가 없습니다."
title = "임포트 미리보기"
[ui.admin.tenants.members.table]
email = "EMAIL"
name = "NAME"
@@ -1793,6 +1812,7 @@ name = "이름"
name_placeholder = "홍길동"
password = "비밀번호"
password_placeholder = "********"
picker_description = "배정할 테넌트를 검색해서 선택하세요."
phone = "전화번호"
phone_placeholder = "010-1234-5678"
position = "직급"

View File

@@ -1439,6 +1439,7 @@ user = ""
[ui.admin.tenants]
add = ""
delete_selected = ""
seed_badge = ""
title = ""
view_org_chart = ""
@@ -1517,9 +1518,18 @@ import_partial_success = ""
[msg.admin.tenants]
delete_bulk_confirm = ""
import_empty = ""
import_error = ""
import_result = ""
seed_delete_blocked = ""
[msg.admin.tenants.import_preview]
description = ""
[msg.admin.users]
self_delete_blocked = ""
export_error = ""
status_error = ""
[ui.admin.apikeys.registry]
title = ""
@@ -1604,6 +1614,15 @@ search_placeholder = ""
title = ""
tree_search_placeholder = ""
[ui.admin.tenants.import_preview]
candidates = ""
confirm = ""
create_new = ""
fixed_id = ""
match = ""
no_candidates = ""
title = ""
[ui.admin.tenants.sub.table]
action = ""
name = ""
@@ -1668,6 +1687,7 @@ name = ""
name_placeholder = ""
password = ""
password_placeholder = ""
picker_description = ""
phone = ""
phone_placeholder = ""
position = ""

View File

@@ -6,8 +6,40 @@ job_name="${1:-adminfront-tests}"
mkdir -p reports
rm -rf adminfront/node_modules
playwright_install_cmd=(npx playwright install --with-deps)
playwright_install_desc="npx playwright install --with-deps"
is_port_available() {
local port="$1"
node -e '
const net = require("net");
const port = Number(process.argv[1]);
const server = net.createServer();
server.once("error", () => process.exit(1));
server.once("listening", () => server.close(() => process.exit(0)));
server.listen(port, "127.0.0.1");
' "$port"
}
find_available_port() {
node -e '
const net = require("net");
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
process.stdout.write(String(address.port));
server.close();
});
'
}
playwright_install_cmd=(npx playwright install)
playwright_install_desc="npx playwright install"
if [ "$(id -u)" -eq 0 ]; then
playwright_install_cmd=(npx playwright install --with-deps)
playwright_install_desc="npx playwright install --with-deps"
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
playwright_install_cmd=(npx playwright install --with-deps)
playwright_install_desc="npx playwright install --with-deps"
fi
set +e
(
@@ -67,6 +99,11 @@ fi
set +e
port="${PORT:-5180}"
if ! is_port_available "$port"; then
fallback_port="$(find_available_port)"
echo "==> requested PORT=$port is already in use; switching to PORT=$fallback_port"
port="$fallback_port"
fi
echo "==> adminfront using PORT=$port"
(
cd adminfront