diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 4a724071..e86c2db8 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -570,10 +570,7 @@ function TenantListPage() { {tenant.name} {isSeedTenant(tenant) && ( - {t( - "ui.admin.tenants.seed_badge", - "초기 설정", - )} + {t("ui.admin.tenants.seed_badge", "초기 설정")} )} diff --git a/adminfront/tests/tenant_seed_protection.spec.ts b/adminfront/tests/tenant_seed_protection.spec.ts index f47f2af2..a78deab6 100644 --- a/adminfront/tests/tenant_seed_protection.spec.ts +++ b/adminfront/tests/tenant_seed_protection.spec.ts @@ -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 }) => { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 4d4c37a0..1847ce68 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -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"); + } } }); diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 13cfbe2b..e8bb208c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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") diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index bcc4bd3e..952fa17d 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -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"` diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d99f5878..6f1d625b 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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 == "" { diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go index 7263b358..6119340f 100644 --- a/backend/internal/handler/auth_handler_client_test.go +++ b/backend/internal/handler/auth_handler_client_test.go @@ -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{ diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index d1e66e31..41e3f749 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -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"]) + } +} diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 8a12de2c..dfb2565e 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -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 diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 2b2dd777..1fbdb4a8 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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) + } + } } } diff --git a/backend/internal/handler/dev_handler_isolation_test.go b/backend/internal/handler/dev_handler_isolation_test.go index a73afaef..401cf035 100644 --- a/backend/internal/handler/dev_handler_isolation_test.go +++ b/backend/internal/handler/dev_handler_isolation_test.go @@ -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)) diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 1da8ab43..0eb992cb 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -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" { diff --git a/backend/internal/handler/tenant_handler_seed_delete_test.go b/backend/internal/handler/tenant_handler_seed_delete_test.go index b0bb2347..b4c1eae4 100644 --- a/backend/internal/handler/tenant_handler_seed_delete_test.go +++ b/backend/internal/handler/tenant_handler_seed_delete_test.go @@ -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) diff --git a/backend/internal/service/backchannel_logout_service.go b/backend/internal/service/backchannel_logout_service.go new file mode 100644 index 00000000..69325bc8 --- /dev/null +++ b/backend/internal/service/backchannel_logout_service.go @@ -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()) +} diff --git a/backend/internal/service/backchannel_logout_service_test.go b/backend/internal/service/backchannel_logout_service_test.go new file mode 100644 index 00000000..09e70425 --- /dev/null +++ b/backend/internal/service/backchannel_logout_service_test.go @@ -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) +} diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index 1f20529a..61dced27 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -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 >({}); 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 ( -
- {t("msg.dev.audit.loading", "Loading audit logs...")} -
- ); - } - if (query.error) { const axiosError = query.error as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { @@ -227,7 +229,13 @@ function AuditLogsPage() { -
+
{ + e.preventDefault(); + query.refetch(); + }} + className="grid gap-2 md:grid-cols-[1fr,1fr,180px]" + >
-
+
- - - - - {t("ui.dev.audit.table.time", "Time")} - - - {t("ui.dev.audit.table.actor", "Actor")} - - - {t("ui.dev.audit.table.action", "Action")} - - - {t("ui.dev.audit.table.target", "Target")} - - - {t("ui.dev.audit.table.status", "Status")} - - - - - - {logs.length === 0 && ( +
+
+ - - {t("msg.dev.audit.empty", "No audit logs found.")} - + + {t("ui.dev.audit.table.time", "Time")} + + + {t("ui.dev.audit.table.actor", "Actor")} + + + {t("ui.dev.audit.table.action", "Action")} + + + {t("ui.dev.audit.table.target", "Target")} + + + {t("ui.dev.audit.table.status", "Status")} + + - )} - {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 ( - - - - {formatDateTime(row.timestamp)} - - -
- {row.user_id || "-"} - {row.user_id ? ( + + + {query.isLoading && logs.length === 0 ? ( + + + {t("msg.dev.audit.loading", "Loading audit logs...")} + + + ) : logs.length === 0 ? ( + + + {t("msg.dev.audit.empty", "No audit logs found.")} + + + ) : ( + 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 ( + + + + {formatDateTime(row.timestamp)} + + +
+ {row.user_id || "-"} + {row.user_id ? ( + + ) : null} +
+
+ + {actionLabel} + + +
+ {targetValue} + {targetValue !== "-" ? ( + + ) : null} +
+
+ + + {row.status} + + + - ) : null} -
-
- {actionLabel} - -
- {targetValue} - {targetValue !== "-" ? ( - - ) : null} -
-
- - - {row.status} - - - - - -
- {expanded ? ( - - -
-
-
- Request ID: {formatValue(details.request_id)} +
+
+
+ Request ID:{" "} + {formatValue(details.request_id)} +
+
+ Method: {formatValue(details.method)} +
+
Path: {formatValue(details.path)}
+
+ Tenant: {formatValue(details.tenant_id)} +
+
+
+
+ Before: {formatValue(details.before)} +
+
After: {formatValue(details.after)}
+
Error: {formatValue(details.error)}
+
-
Method: {formatValue(details.method)}
-
Path: {formatValue(details.path)}
-
- Tenant: {formatValue(details.tenant_id)} -
-
-
-
Before: {formatValue(details.before)}
-
After: {formatValue(details.after)}
-
Error: {formatValue(details.error)}
-
-
- - - ) : null} - - ); - })} - -
+ + + ) : null} + + ); + }) + )} + + +
{query.hasNextPage ? (
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 82e43d43..4657e624 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -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 : "••••••••••••••••"}

- + + + toast( + t( + "msg.dev.clients.details.copy_client_secret", + "Client Secret이 복사되었습니다.", + ), ) - : t( - "ui.dev.clients.details.secret.show", - "비밀키 보기", - ) - } - > - {showSecret ? ( - - ) : ( - - )} - - - - toast( - t( - "msg.dev.clients.details.copy_client_secret", - "Client Secret이 복사되었습니다.", - ), - ) - } - /> + } + /> + + ) : null}
+ {!hasClientSecret ? ( +

+ {t( + "msg.dev.clients.details.secret_not_applicable", + "PKCE 앱에는 Client Secret이 없습니다.", + )} +

+ ) : null}
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 7c84acbe..c0d76c06 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -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, +): 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; + 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 { + const preview: Record = {}; + const rpClaims: Record = {}; + + 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 | 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("active"); const [initialStatus, setInitialStatus] = useState("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([]); const [tenantSearch, setTenantSearch] = useState(""); @@ -192,6 +386,7 @@ function ClientGeneralPage() { mandatory: false, }, ]); + const [idTokenClaims, setIdTokenClaims] = useState([]); 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 = ( + 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(); + 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 = 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 && ( )} + {isGeneralSettingsReadOnly && ( +
+ {t( + "msg.dev.clients.general.read_only_hint", + "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.", + )} +
+ )} {/* 1. Application Identity */} @@ -913,6 +1251,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.identity.name_placeholder", "My Awesome Application", )} + disabled={isGeneralSettingsReadOnly} />
@@ -930,6 +1269,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.identity.description_placeholder", "앱에 대한 간단한 설명을 입력하세요.", )} + disabled={isGeneralSettingsReadOnly} />
@@ -949,6 +1289,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.identity.logo_placeholder", "https://example.com/logo.png", )} + disabled={isGeneralSettingsReadOnly} />

{t( @@ -1067,6 +1408,7 @@ function ClientGeneralPage() { size="sm" variant={status === "active" ? "default" : "outline"} onClick={() => handleStatusChange("active")} + disabled={isGeneralSettingsReadOnly} > {t("ui.common.status.active", "활성")} @@ -1075,6 +1417,7 @@ function ClientGeneralPage() { size="sm" variant={status === "inactive" ? "default" : "outline"} onClick={() => handleStatusChange("inactive")} + disabled={isGeneralSettingsReadOnly} > {t("ui.common.status.inactive", "비활성")} @@ -1181,6 +1524,7 @@ function ClientGeneralPage() { size="sm" onClick={addScope} className="gap-2" + disabled={isGeneralSettingsReadOnly} > {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} />

{t( @@ -1211,6 +1556,114 @@ function ClientGeneralPage() { )} +

+
+ + 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} + /> +

+ {t( + "msg.dev.clients.general.backchannel_logout.uri_help", + "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다.", + )} +

+ {hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri ? ( +

+ {t( + "msg.dev.clients.general.backchannel_logout.invalid", + "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.", + )} +

+ ) : null} +
+ +
+
+
+ + +
+

+ {t( + "msg.dev.clients.general.backchannel_logout.session_required_help", + "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다.", + )} +

+ {isBackchannelSessionRequiredInfoOpen ? ( +
+
+ + {t("ui.common.info", "상세 안내")} +
+
+ {t( + "msg.dev.clients.general.backchannel_logout.session_required_on", + "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리", + )} +
+
+ {t( + "msg.dev.clients.general.backchannel_logout.session_required_off", + "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능", + )} +
+
+ ) : null} +
+ +
+
+
@@ -1258,7 +1711,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.scopes.name_placeholder", "e.g. profile", )} - disabled={s.locked} + disabled={s.locked || isGeneralSettingsReadOnly} /> @@ -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} > @@ -1359,6 +1812,7 @@ function ClientGeneralPage() { checked={tenantAccessRestricted} onCheckedChange={handleTenantAccessToggle} id="tenant-access-toggle" + disabled={isGeneralSettingsReadOnly} /> @@ -1403,7 +1857,9 @@ function ClientGeneralPage() { "테넌트 이름 또는 슬러그로 검색", )} className="pl-10" - disabled={!tenantAccessRestricted} + disabled={ + isGeneralSettingsReadOnly || !tenantAccessRestricted + } /> {tenantAccessRestricted && isTenantSearchOpen && (
@@ -1417,6 +1873,7 @@ function ClientGeneralPage() { event.preventDefault(); handleSelectAllowedTenant(tenant.id); }} + disabled={isGeneralSettingsReadOnly} >
@@ -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} > @@ -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} > @@ -1534,6 +1993,258 @@ function ClientGeneralPage() { + + +
+
+ + {t( + "ui.dev.clients.general.id_token_claims.title", + "Custom Claims", + )} + + + {t( + "msg.dev.clients.general.id_token_claims.subtitle", + "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.", + )} + +
+ +
+
+ +
+
+
+
@@ -1272,7 +1725,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.scopes.description_placeholder", "권한에 대한 설명", )} - disabled={s.locked} + disabled={s.locked || isGeneralSettingsReadOnly} /> @@ -1282,7 +1735,7 @@ function ClientGeneralPage() { onCheckedChange={(checked) => updateScope(s.id, "mandatory", checked) } - disabled={s.locked} + disabled={s.locked || isGeneralSettingsReadOnly} />
+ + + + + + + + + + + {idTokenClaims.map((claim) => ( + + + + + + + + ))} + {idTokenClaims.length === 0 && ( + + + + )} + +
+ {t( + "ui.dev.clients.general.id_token_claims.table.key", + "Claim Key", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.namespace", + "Namespace", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.value_type", + "Value Type", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.value", + "Value", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.delete", + "Delete", + )} +
+ + 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} + /> + + + + + + + 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} + /> + + +
+ {t( + "msg.dev.clients.general.id_token_claims.empty", + "아직 추가된 ID Token claim이 없습니다.", + )} +
+
+

+ {t( + "msg.dev.clients.general.id_token_claims.hint", + "top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.", + )} +

+ + +
+
+
+ +
+

+ {t( + "ui.dev.clients.general.id_token_claims.preview_title", + "Saved JSON Preview", + )} +

+

+ {t( + "msg.dev.clients.general.id_token_claims.preview_hint", + "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.", + )} +

+
+
+