diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index d0be623a..0923d9a4 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -12,9 +12,36 @@ type HydraClient struct { ResponseTypes []string `json:"response_types,omitempty"` Scope string `json:"scope,omitempty"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + JWKSUri string `json:"jwks_uri,omitempty"` + JWKS interface{} `json:"jwks,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } +func (c *HydraClient) IsTrustedRP() bool { + // A Trusted RP must have a public key registered (URI or Inline) + // and use private_key_jwt for token endpoint authentication. + hasPublicKey := c.JWKSUri != "" || c.JWKS != nil + isPrivateKeyJwt := c.TokenEndpointAuthMethod == "private_key_jwt" + return hasPublicKey && isPrivateKeyJwt +} + +func (c *HydraClient) IsHeadlessLoginEnabled() bool { + if !c.IsTrustedRP() { + return false + } + if c.Metadata == nil { + return false + } + val, ok := c.Metadata["headless_login_enabled"] + if !ok { + return false + } + if b, ok := val.(bool); ok { + return b + } + return false +} + type HydraConsentRequest struct { Challenge string `json:"challenge"` RequestedScope []string `json:"requested_scope"` diff --git a/backend/internal/domain/hydra_models_test.go b/backend/internal/domain/hydra_models_test.go new file mode 100644 index 00000000..7d1af640 --- /dev/null +++ b/backend/internal/domain/hydra_models_test.go @@ -0,0 +1,60 @@ +package domain + +import "testing" + +func TestHydraClient_TrustedRPFlags(t *testing.T) { + t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKS: map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + }}, + }, + Metadata: map[string]any{ + "headless_login_enabled": true, + }, + } + + if !client.IsTrustedRP() { + t.Fatalf("expected trusted rp") + } + if !client.IsHeadlessLoginEnabled() { + t.Fatalf("expected headless login enabled") + } + }) + + t.Run("jwks uri without private_key_jwt is not trusted", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "none", + JWKSUri: "https://rp.example.com/.well-known/jwks.json", + Metadata: map[string]any{ + "headless_login_enabled": true, + }, + } + + if client.IsTrustedRP() { + t.Fatalf("expected untrusted rp") + } + if client.IsHeadlessLoginEnabled() { + t.Fatalf("expected headless login disabled when client is not trusted") + } + }) + + t.Run("trusted rp without boolean metadata flag is not headless enabled", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKSUri: "https://rp.example.com/.well-known/jwks.json", + Metadata: map[string]any{ + "headless_login_enabled": "true", + }, + } + + if !client.IsTrustedRP() { + t.Fatalf("expected trusted rp") + } + if client.IsHeadlessLoginEnabled() { + t.Fatalf("expected headless login disabled for non-bool metadata") + } + }) +} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 792f1ff2..94baa6b1 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -81,15 +81,18 @@ 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"` - 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"` + JwksUri string `json:"jwksUri,omitempty"` + Jwks interface{} `json:"jwks,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type clientListResponse struct { @@ -139,6 +142,8 @@ type clientUpsertRequest struct { GrantTypes *[]string `json:"grantTypes"` ResponseTypes *[]string `json:"responseTypes"` TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"` + JwksUri *string `json:"jwksUri"` + Jwks interface{} `json:"jwks"` Metadata *map[string]interface{} `json:"metadata"` } @@ -895,6 +900,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { ResponseTypes: responseTypes, Scope: strings.Join(scopes, " "), TokenEndpointAuthMethod: tokenAuthMethod, + JWKSUri: valueOr(req.JwksUri, ""), + JWKS: req.Jwks, Metadata: metadata, } @@ -1046,8 +1053,13 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod), + JWKSUri: valueOr(req.JwksUri, current.JWKSUri), + JWKS: req.Jwks, Metadata: metadata, } + if req.Jwks == nil { + updated.JWKS = current.JWKS + } if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } @@ -1640,15 +1652,18 @@ 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, - Metadata: client.Metadata, + ID: client.ClientID, + Name: name, + Type: clientType, + Status: status, + CreatedAt: createdAt, + RedirectURIs: client.RedirectURIs, + Scopes: scopes, + ClientSecret: clientSecret, + TokenEndpointAuthMethod: client.TokenEndpointAuthMethod, + JwksUri: client.JWKSUri, + Jwks: client.JWKS, + Metadata: client.Metadata, } } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 37a55a09..334c3e57 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -610,6 +611,156 @@ func TestDevHandler_NoAuditNoAction(t *testing.T) { }) } +func TestCreateClient_TrustedRPPayloadMapping(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) + err = json.Unmarshal(body, &captured) + assert.NoError(t, err) + + 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, + "jwks": captured.JWKS, + "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": "Trusted RP App", + "type": "pkce", + "redirectUris": []string{"https://rp.example.com/callback"}, + "scopes": []string{"openid", "profile"}, + "tokenEndpointAuthMethod": "private_key_jwt", + "jwks": map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + "alg": "RS256", + "n": "AQIDBAUGBw", + "e": "AQAB", + }}, + }, + "metadata": map[string]any{ + "headless_login_enabled": true, + "request_object_signing_alg": "RS256", + }, + }) + 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) + assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.NotNil(t, captured.JWKS) + assert.True(t, captured.IsTrustedRP()) + assert.True(t, captured.IsHeadlessLoginEnabled()) + assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) + assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"]) +} + +func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { + var captured domain.HydraClient + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-trusted", + "client_name": "Trusted Before", + "redirect_uris": []string{"https://before.example.com/callback"}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": "openid profile", + "token_endpoint_auth_method": "none", + "metadata": map[string]any{ + "status": "active", + }, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + err = json.Unmarshal(body, &captured) + assert.NoError(t, err) + + return httpJSONAny(r, http.StatusOK, 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, + "jwks_uri": captured.JWKSUri, + "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.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Trusted After", + "type": "pkce", + "tokenEndpointAuthMethod": "private_key_jwt", + "jwksUri": "https://rp.example.com/.well-known/jwks.json", + "metadata": map[string]any{ + "headless_login_enabled": true, + "request_object_signing_alg": "RS256", + }, + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", 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, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) + assert.True(t, captured.IsTrustedRP()) + assert.True(t, captured.IsHeadlessLoginEnabled()) + assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) +} + func TestListAuditLogs_TenantMemberForbidden(t *testing.T) { h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 6aeedfc0..fce5a6ac 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -39,6 +39,7 @@ import type { } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; +import { tryConvertToJwks } from "../../lib/keyUtils"; interface ScopeItem { id: string; @@ -47,6 +48,49 @@ interface ScopeItem { mandatory: boolean; } +type SecurityProfile = "private" | "pkce"; +type TokenEndpointAuthMethod = + | "none" + | "client_secret_basic" + | "private_key_jwt"; + +function isTokenEndpointAuthMethod( + value: string, +): value is TokenEndpointAuthMethod { + return ( + value === "none" || + value === "client_secret_basic" || + value === "private_key_jwt" + ); +} + +function readMetadataString( + metadata: Record, + key: string, +): string { + const value = metadata[key]; + return typeof value === "string" ? value : ""; +} + +function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +function isValidJson(value: string): boolean { + if (!value.trim()) return false; + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + function ClientGeneralPage() { const params = useParams(); const navigate = useNavigate(); @@ -66,6 +110,17 @@ function ClientGeneralPage() { const [status, setStatus] = useState("active"); const [initialStatus, setInitialStatus] = useState("active"); const [redirectUris, setRedirectUris] = useState(""); + + // Public Key Registration States + const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = + useState("client_secret_basic"); + const [jwksSource, setJwksSource] = useState<"uri" | "inline">("inline"); + const [jwksUri, setJwksUri] = useState(""); + const [jwksText, setJwksText] = useState(""); + const [requestObjectSigningAlg, setRequestObjectSigningAlg] = + useState("RS256"); + const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false); + const [scopes, setScopes] = useState(() => [ { id: "1", @@ -95,12 +150,61 @@ function ClientGeneralPage() { setStatus(client.status); setInitialStatus(client.status); + const savedAuthMethod = + client.tokenEndpointAuthMethod || + (client.type === "pkce" ? "none" : "client_secret_basic"); + if (isTokenEndpointAuthMethod(savedAuthMethod)) { + setTokenEndpointAuthMethod(savedAuthMethod); + } + + if (client.jwksUri) { + setJwksUri(client.jwksUri); + setJwksSource("uri"); + } else if (client.jwks) { + setJwksText( + typeof client.jwks === "string" + ? client.jwks + : JSON.stringify(client.jwks, null, 2), + ); + setJwksSource("inline"); + } + const metadata = client.metadata ?? {}; if (typeof metadata.description === "string") setDescription(metadata.description); if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); - // Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성 + setHeadlessLoginEnabled(!!metadata.headless_login_enabled); + + // Fallbacks from metadata if top-level fields are empty + if (!client.tokenEndpointAuthMethod) { + const metaAuth = readMetadataString( + metadata, + "token_endpoint_auth_method", + ); + if (isTokenEndpointAuthMethod(metaAuth)) { + setTokenEndpointAuthMethod(metaAuth); + } + } + + if (!client.jwksUri && !client.jwks) { + const metaJwksUri = readMetadataString(metadata, "jwks_uri"); + if (metaJwksUri) { + setJwksUri(metaJwksUri); + setJwksSource("uri"); + } + } + + const savedRequestObjectSigningAlg = readMetadataString( + metadata, + "request_object_signing_alg", + ); + if (savedRequestObjectSigningAlg) { + setRequestObjectSigningAlg(savedRequestObjectSigningAlg); + } else if (savedAuthMethod === "private_key_jwt") { + setRequestObjectSigningAlg("RS256"); + } + const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined; if (savedScopes && Array.isArray(savedScopes)) { setScopes(savedScopes); @@ -116,6 +220,30 @@ function ClientGeneralPage() { } }, [data]); + const securityProfile: SecurityProfile = + clientType === "pkce" ? "pkce" : "private"; + + const handleSecurityProfileChange = (profile: SecurityProfile) => { + setClientType(profile); + if (profile === "pkce") { + setTokenEndpointAuthMethod( + headlessLoginEnabled ? "private_key_jwt" : "none", + ); + } else { + setTokenEndpointAuthMethod("client_secret_basic"); + } + }; + + const handleHeadlessToggle = (enabled: boolean) => { + setHeadlessLoginEnabled(enabled); + if (clientType === "pkce") { + setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none"); + if (enabled && requestObjectSigningAlg.trim() === "") { + setRequestObjectSigningAlg("RS256"); + } + } + }; + const addScope = () => { const newId = String(Date.now()); setScopes([ @@ -155,21 +283,97 @@ function ClientGeneralPage() { ); }; + // Convert on blur or change if desired, here we try to convert before validation + const finalJwksText = tryConvertToJwks(jwksText); + const validationErrors: string[] = []; + const trimmedJwksUri = jwksUri.trim(); + const trimmedJwksText = finalJwksText.trim(); + const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim(); + + if (headlessLoginEnabled) { + if (jwksSource === "uri") { + if (!trimmedJwksUri) { + validationErrors.push( + t( + "msg.dev.clients.general.public_key.validation.missing_jwks_uri", + "JWKS URI를 입력해야 합니다.", + ), + ); + } else if (!isValidUrl(trimmedJwksUri)) { + validationErrors.push( + t( + "msg.dev.clients.general.public_key.validation.invalid_jwks_uri", + "JWKS URI 형식이 올바르지 않습니다.", + ), + ); + } + } else if (jwksSource === "inline") { + if (!trimmedJwksText) { + validationErrors.push( + t( + "msg.dev.clients.general.public_key.validation.missing_jwks_inline", + "공개키(JWKS 또는 SSH-RSA)를 입력해야 합니다.", + ), + ); + } else if (!isValidJson(trimmedJwksText)) { + validationErrors.push( + t( + "msg.dev.clients.general.public_key.validation.invalid_jwks_inline", + "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다.", + ), + ); + } + } + + if (trimmedRequestObjectSigningAlg === "") { + validationErrors.push( + t( + "msg.dev.clients.general.public_key.validation.headless_requires_alg", + "Request Object Signing Algorithm (예: RS256)을 입력해야 합니다.", + ), + ); + } + } + + const hasValidationErrors = validationErrors.length > 0; + const mutation = useMutation({ mutationFn: async () => { const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); + + let finalJwks: ClientUpsertRequest["jwks"]; + if ( + tokenEndpointAuthMethod === "private_key_jwt" && + jwksSource === "inline" && + trimmedJwksText + ) { + try { + finalJwks = JSON.parse(trimmedJwksText); + } catch (e) { + throw new Error("Invalid Public Key Format"); + } + } + const payload: ClientUpsertRequest = { name, type: clientType, scopes: scopeNames, + tokenEndpointAuthMethod, + jwksUri: + tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri" + ? trimmedJwksUri + : undefined, + jwks: finalJwks, metadata: { description, logo_url: logoUrl, - structured_scopes: scopes, // 향후 보존을 위해 metadata에 저장 + structured_scopes: scopes, + token_endpoint_auth_method: tokenEndpointAuthMethod, + request_object_signing_alg: trimmedRequestObjectSigningAlg, + headless_login_enabled: headlessLoginEnabled, }, }; - // 생성 시에는 Redirect URIs를 포함해서 전송 if (isCreate) { payload.status = status; payload.redirectUris = redirectUris @@ -179,8 +383,6 @@ function ClientGeneralPage() { return createClient(payload); } - // 수정 시에는 Redirect URIs는 별도 탭에서 관리하고, - // status는 전용 PATCH API로 처리해서 감사로그 액션을 분리한다. const updated = await updateClient(clientId as string, payload); if (status !== initialStatus) { await updateClientStatus(clientId as string, status); @@ -271,6 +473,12 @@ function ClientGeneralPage() { ); } + const publicKeyStatusTone = headlessLoginEnabled + ? hasValidationErrors + ? "border-destructive/40 bg-destructive/5" + : "border-primary/30 bg-primary/5" + : "border-border bg-muted/20"; + const displayName = isCreate ? t("ui.dev.clients.general.display_new", "새 클라이언트") : data?.client?.name || data?.client?.id; @@ -472,7 +680,7 @@ function ClientGeneralPage() { - {/* 2. Scopes (Moved up and upgraded) */} + {/* 2. Scopes */}
@@ -497,7 +705,6 @@ function ClientGeneralPage() { - {/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */} {isCreate && (
+ {/* 4. Public Key Registration (Trusted RP) */} + {clientType === "pkce" && headlessLoginEnabled && ( + + +
+
+ + {t( + "ui.dev.clients.general.public_key.title", + "Public Key Registration", + )} + + + {t( + "msg.dev.clients.general.public_key.subtitle", + "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.", + )} + +
+
+
+ +
+
+
+ +

+ {t( + "msg.dev.clients.general.public_key.headless_help", + "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.", + )} +

+
+ + {t("ui.common.enabled", "Enabled")} + +
+
+ +
+
+ + setRequestObjectSigningAlg(e.target.value)} + placeholder={t( + "ui.dev.clients.general.public_key.request_object_alg_placeholder", + "예: RS256", + )} + /> +

+ {t( + "msg.dev.clients.general.public_key.request_object_alg_help", + "Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.", + )} +

+
+
+ +
+
+ +

+ {t( + "msg.dev.clients.general.public_key.source_help", + "OIDC 검증을 위한 공개키 제공 방식을 선택합니다. (운영 환경에서는 JWKS URI 사용을 권장합니다)", + )} +

+
+ +
+ + +
+ + {jwksSource === "uri" && ( +
+ + setJwksUri(e.target.value)} + placeholder={t( + "ui.dev.clients.general.public_key.jwks_uri_placeholder", + "https://rp.example.com/.well-known/jwks.json", + )} + /> +

+ {t( + "msg.dev.clients.general.public_key.jwks_uri_help", + "RP backend가 제공하는 공개키 endpoint URL을 입력하세요.", + )} +

+
+ )} + + {jwksSource === "inline" && ( +
+ +