1
0
forked from baron/baron-sso

Merge pull request 'feature/df-healess-alter' (#695) from feature/df-healess-alter into dev

Reviewed-on: baron/baron-sso#695
This commit is contained in:
2026-05-04 16:00:35 +09:00
9 changed files with 243 additions and 129 deletions

View File

@@ -1648,17 +1648,6 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private")
} }
// [Security] Check permission for private clients
if clientType == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
}
if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
}
}
status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active"))) status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
if status != "active" && status != "inactive" { if status != "active" && status != "inactive" {
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
@@ -1700,6 +1689,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error()) return errorJSON(c, fiber.StatusBadRequest, err.Error())
} }
clientType = normalizeClientTypeForHeadless(clientType, metadata)
// [Security] Check permission for private clients
if clientType == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
}
if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
}
}
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" { if tokenAuthMethod == "" {
@@ -1709,11 +1710,10 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
tokenAuthMethod = "client_secret_basic" tokenAuthMethod = "client_secret_basic"
} }
} }
if err := validateHeadlessClientInput(clientType, valueOr(req.JwksUri, ""), req.Jwks, metadata); err != nil { if err := validateHeadlessClientInput(valueOr(req.JwksUri, ""), req.Jwks, metadata); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error()) return errorJSON(c, fiber.StatusBadRequest, err.Error())
} }
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig( tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
clientType,
tokenAuthMethod, tokenAuthMethod,
valueOr(req.JwksUri, ""), valueOr(req.JwksUri, ""),
req.Jwks, req.Jwks,
@@ -1900,21 +1900,21 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
if clientType != "" { if clientType != "" {
resolvedClientType = clientType resolvedClientType = clientType
} }
resolvedClientType = normalizeClientTypeForHeadless(resolvedClientType, metadata)
resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod) resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod)
resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri) resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri)
resolvedJWKS := req.Jwks resolvedJWKS := req.Jwks
if req.Jwks == nil { if req.Jwks == nil {
if resolvedClientType == "pkce" && readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
resolvedJWKS = nil resolvedJWKS = nil
} else { } else {
resolvedJWKS = current.JWKS resolvedJWKS = current.JWKS
} }
} }
if err := validateHeadlessClientInput(resolvedClientType, resolvedJWKSURI, resolvedJWKS, metadata); err != nil { if err := validateHeadlessClientInput(resolvedJWKSURI, resolvedJWKS, metadata); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error()) return errorJSON(c, fiber.StatusBadRequest, err.Error())
} }
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig( resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
resolvedClientType,
resolvedTokenAuthMethod, resolvedTokenAuthMethod,
resolvedJWKSURI, resolvedJWKSURI,
resolvedJWKS, resolvedJWKS,
@@ -2633,12 +2633,10 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
} }
clientType := "private" clientType := "private"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { if client.IsHeadlessLoginEnabled() {
clientType = "private"
} else if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
clientType = "pkce" clientType = "pkce"
} else if strings.EqualFold(client.TokenEndpointAuthMethod, "private_key_jwt") && client.Metadata != nil {
if val, ok := client.Metadata["headless_login_enabled"].(bool); ok && val {
clientType = "pkce"
}
} }
name := strings.TrimSpace(client.ClientName) name := strings.TrimSpace(client.ClientName)
@@ -2786,7 +2784,6 @@ func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[stri
} }
func normalizeHeadlessClientConfig( func normalizeHeadlessClientConfig(
clientType string,
tokenAuthMethod string, tokenAuthMethod string,
jwksURI string, jwksURI string,
jwks interface{}, jwks interface{},
@@ -2798,12 +2795,12 @@ func normalizeHeadlessClientConfig(
delete(metadata, domain.MetadataRequestObjectSigningAlg) delete(metadata, domain.MetadataRequestObjectSigningAlg)
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
if clientType == "pkce" && headlessEnabled { if headlessEnabled {
headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") { if headlessTokenAuthMethod == "" && strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "private_key_jwt") {
headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod) headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod)
} }
if headlessTokenAuthMethod == "" { if headlessTokenAuthMethod == "" || strings.EqualFold(headlessTokenAuthMethod, "none") {
headlessTokenAuthMethod = "private_key_jwt" headlessTokenAuthMethod = "private_key_jwt"
} }
metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod
@@ -2820,7 +2817,7 @@ func normalizeHeadlessClientConfig(
delete(metadata, domain.MetadataHeadlessJWKS) delete(metadata, domain.MetadataHeadlessJWKS)
return "none", "", nil, metadata return headlessTokenAuthMethod, headlessJWKSURI, nil, metadata
} }
delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
@@ -2829,8 +2826,8 @@ func normalizeHeadlessClientConfig(
return tokenAuthMethod, jwksURI, jwks, metadata return tokenAuthMethod, jwksURI, jwks, metadata
} }
func validateHeadlessClientInput(clientType string, jwksURI string, jwks interface{}, metadata map[string]interface{}) error { func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[string]interface{}) error {
if clientType != "pkce" || !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
return nil return nil
} }
@@ -2848,6 +2845,13 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa
return nil return nil
} }
func normalizeClientTypeForHeadless(clientType string, metadata map[string]interface{}) string {
if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
return "private"
}
return clientType
}
func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
if metadata == nil { if metadata == nil {
return nil, nil return nil, nil

View File

@@ -1467,7 +1467,7 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"name": "Headless Login App", "name": "Headless Login App",
"type": "pkce", "type": "private",
"redirectUris": []string{"https://rp.example.com/callback"}, "redirectUris": []string{"https://rp.example.com/callback"},
"scopes": []string{"openid", "profile"}, "scopes": []string{"openid", "profile"},
"tokenEndpointAuthMethod": "private_key_jwt", "tokenEndpointAuthMethod": "private_key_jwt",
@@ -1482,7 +1482,8 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "none", captured.TokenEndpointAuthMethod) assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod)
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri)
assert.Nil(t, captured.JWKS) assert.Nil(t, captured.JWKS)
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
@@ -1633,6 +1634,87 @@ func TestCreateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
assert.False(t, *captured.SkipConsent) assert.False(t, *captured.SkipConsent)
} }
func TestCreateClient_LegacyPKCEHeadlessInputIsNormalizedToPrivate(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_uri": captured.JWKSUri,
"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": "Legacy Headless Login App",
"type": "pkce",
"redirectUris": []string{"https://rp.example.com/callback"},
"metadata": map[string]any{
"headless_login_enabled": true,
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
},
})
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.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri)
assert.Nil(t, captured.JWKS)
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
assert.True(t, captured.IsHeadlessLoginEnabled())
}
func TestMapClientSummary_ClassifiesHeadlessLoginAsPrivate(t *testing.T) {
h := &DevHandler{}
summary := h.mapClientSummary(domain.HydraClient{
ClientID: "client-headless-login",
ClientName: "Headless Login App",
TokenEndpointAuthMethod: "none",
Metadata: map[string]any{
"status": "active",
"headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt",
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
},
})
assert.Equal(t, "private", summary.Type)
}
func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) { func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
var hydraCalled bool var hydraCalled bool
h := &DevHandler{ h := &DevHandler{
@@ -1895,7 +1977,7 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"name": "Headless Login After", "name": "Headless Login After",
"type": "pkce", "type": "private",
"tokenEndpointAuthMethod": "private_key_jwt", "tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://rp.example.com/.well-known/jwks.json", "jwksUri": "https://rp.example.com/.well-known/jwks.json",
"metadata": map[string]any{ "metadata": map[string]any{
@@ -1908,8 +1990,8 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "none", captured.TokenEndpointAuthMethod) assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod)
assert.Equal(t, "", captured.JWKSUri) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri)
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
_, hasInlineJWKS := captured.Metadata["headless_jwks"] _, hasInlineJWKS := captured.Metadata["headless_jwks"]
@@ -2071,7 +2153,8 @@ func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Nil(t, captured.JWKS) assert.Nil(t, captured.JWKS)
assert.Equal(t, "", captured.JWKSUri) assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod)
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri)
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"] _, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
assert.False(t, hasRequestObjectAlg) assert.False(t, hasRequestObjectAlg)

View File

@@ -175,6 +175,7 @@ function ClientDetailsPage() {
} }
const client = data?.client; const client = data?.client;
const isHeadlessLogin = client?.metadata?.headless_login_enabled === true;
if (!client) { if (!client) {
return null; return null;
} }
@@ -213,16 +214,21 @@ function ClientDetailsPage() {
}, },
]; ];
const hasClientSecret = client.type === "private"; const hasClientSecret = client.type === "private" && !isHeadlessLogin;
const secretPlaceholder = "SECRET_NOT_AVAILABLE"; const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = hasClientSecret const clientSecret = hasClientSecret
? client?.clientSecret || secretPlaceholder ? client?.clientSecret || secretPlaceholder
: t("ui.common.na", "N/A"); : t("ui.common.na", "N/A");
const displaySecret = !hasClientSecret const displaySecret = !hasClientSecret
? t( ? isHeadlessLogin
"msg.dev.clients.details.secret_not_applicable", ? t(
"PKCE 앱에는 Client Secret이 없습니다.", "msg.dev.clients.details.secret_not_applicable_headless",
) "이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.",
)
: t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)
: clientSecret === secretPlaceholder : clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE") ? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret; : clientSecret;
@@ -394,10 +400,15 @@ function ClientDetailsPage() {
</div> </div>
{!hasClientSecret ? ( {!hasClientSecret ? (
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{t( {isHeadlessLogin
"msg.dev.clients.details.secret_not_applicable", ? t(
"PKCE 앱에는 Client Secret이 없습니다.", "msg.dev.clients.details.secret_not_applicable_headless",
)} "이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.",
)
: t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)}
</p> </p>
) : null} ) : null}
</div> </div>

View File

@@ -391,12 +391,13 @@ function ClientGeneralPage() {
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
const { client } = data; const { client } = data;
const metadata = client.metadata ?? {};
const headlessEnabled = !!metadata.headless_login_enabled;
setName(client.name || client.id); setName(client.name || client.id);
setClientType(client.type); setClientType(headlessEnabled ? "private" : client.type);
setStatus(client.status); setStatus(client.status);
setInitialStatus(client.status); setInitialStatus(client.status);
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string") if (typeof metadata.description === "string")
setDescription(metadata.description); setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
@@ -412,7 +413,6 @@ function ClientGeneralPage() {
if (typeof metadata.auto_login_url === "string") if (typeof metadata.auto_login_url === "string")
setAutoLoginUrl(metadata.auto_login_url); setAutoLoginUrl(metadata.auto_login_url);
const headlessEnabled = !!metadata.headless_login_enabled;
setHeadlessLoginEnabled(headlessEnabled); setHeadlessLoginEnabled(headlessEnabled);
const restrictedTenants = Array.isArray(metadata.allowed_tenants) const restrictedTenants = Array.isArray(metadata.allowed_tenants)
? metadata.allowed_tenants ? metadata.allowed_tenants
@@ -532,18 +532,25 @@ function ClientGeneralPage() {
const handleSecurityProfileChange = (profile: SecurityProfile) => { const handleSecurityProfileChange = (profile: SecurityProfile) => {
setClientType(profile); setClientType(profile);
if (profile === "pkce") { if (profile === "pkce") {
setTokenEndpointAuthMethod( setHeadlessLoginEnabled(false);
headlessLoginEnabled ? "private_key_jwt" : "none", setTokenEndpointAuthMethod("none");
);
} else { } else {
setTokenEndpointAuthMethod("client_secret_basic"); setTokenEndpointAuthMethod(
headlessLoginEnabled ? "private_key_jwt" : "client_secret_basic",
);
} }
}; };
const handleHeadlessToggle = (enabled: boolean) => { const handleHeadlessToggle = (enabled: boolean) => {
setHeadlessLoginEnabled(enabled); setHeadlessLoginEnabled(enabled);
if (clientType === "pkce") { if (enabled) {
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none"); setClientType("private");
setTokenEndpointAuthMethod("private_key_jwt");
return;
}
if (clientType === "private") {
setTokenEndpointAuthMethod("client_secret_basic");
} }
}; };
@@ -974,18 +981,17 @@ function ClientGeneralPage() {
.map((scope) => scope.name.trim()) .map((scope) => scope.name.trim())
.filter(Boolean); .filter(Boolean);
const effectiveTokenEndpointAuthMethod = const persistedClientType = headlessLoginEnabled ? "private" : clientType;
clientType === "pkce" && headlessLoginEnabled const effectiveTokenEndpointAuthMethod = tokenEndpointAuthMethod;
? "none"
: tokenEndpointAuthMethod;
const payload: ClientUpsertRequest = { const payload: ClientUpsertRequest = {
name, name,
type: clientType, type: persistedClientType,
scopes: scopeNames, scopes: scopeNames,
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod, tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri: jwksUri:
effectiveTokenEndpointAuthMethod === "private_key_jwt" && (headlessLoginEnabled ||
effectiveTokenEndpointAuthMethod === "private_key_jwt") &&
trimmedJwksUri trimmedJwksUri
? trimmedJwksUri ? trimmedJwksUri
: undefined, : undefined,
@@ -1003,14 +1009,10 @@ function ClientGeneralPage() {
id_token_claims: normalizedIdTokenClaims, id_token_claims: normalizedIdTokenClaims,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled, headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method: headless_token_endpoint_auth_method: headlessLoginEnabled
clientType === "pkce" && headlessLoginEnabled ? tokenEndpointAuthMethod
? tokenEndpointAuthMethod : undefined,
: undefined, headless_jwks_uri: headlessLoginEnabled ? trimmedJwksUri : undefined,
headless_jwks_uri:
clientType === "pkce" && headlessLoginEnabled
? trimmedJwksUri
: undefined,
tenant_access_restricted: tenantAccessRestricted, tenant_access_restricted: tenantAccessRestricted,
allowed_tenants: tenantAccessRestricted allowed_tenants: tenantAccessRestricted
? normalizedAllowedTenantIds ? normalizedAllowedTenantIds
@@ -2291,6 +2293,38 @@ function ClientGeneralPage() {
<span className="absolute right-4 top-4 text-primary"> <span className="absolute right-4 top-4 text-primary">
{securityProfile === "private" ? "✓" : ""} {securityProfile === "private" ? "✓" : ""}
</span> </span>
{securityProfile === "private" && (
<div
className="mt-4 flex items-center justify-between border-t border-primary/20 pt-4"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="space-y-0.5">
<Label
className="cursor-pointer text-xs font-bold"
htmlFor="headless-login-toggle"
>
{t(
"ui.dev.clients.general.security.headless_login_enable",
"Headless Login (자체 로그인 UI 사용)",
)}
</Label>
<p className="text-[10px] text-muted-foreground">
{t(
"msg.dev.clients.general.security.headless_login_enable_help",
"Baron SSO 로그인 창 대신 RP 자체 로그인 UI를 사용하고, RP backend의 서명 키로 클라이언트를 검증하려는 경우 활성화합니다.",
)}
</p>
</div>
<Switch
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
disabled={isGeneralSettingsReadOnly}
/>
</div>
)}
</label> </label>
<label <label
@@ -2321,45 +2355,13 @@ function ClientGeneralPage() {
<span className="absolute right-4 top-4 text-primary"> <span className="absolute right-4 top-4 text-primary">
{securityProfile === "pkce" ? "✓" : ""} {securityProfile === "pkce" ? "✓" : ""}
</span> </span>
{securityProfile === "pkce" && (
<div
className="mt-4 pt-4 border-t border-primary/20 flex items-center justify-between"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="space-y-0.5">
<Label
className="text-xs font-bold cursor-pointer"
htmlFor="headless-login-toggle"
>
{t(
"ui.dev.clients.general.security.headless_login_enable",
"Headless Login (자체 로그인 UI 사용)",
)}
</Label>
<p className="text-[10px] text-muted-foreground">
{t(
"ui.dev.clients.general.security.headless_login_enable_help",
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
)}
</p>
</div>
<Switch
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
disabled={isGeneralSettingsReadOnly}
/>
</div>
)}
</label> </label>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* 4. Public Key Registration (Headless Login) */} {/* 4. Public Key Registration (Headless Login) */}
{clientType === "pkce" && headlessLoginEnabled && ( {headlessLoginEnabled && (
<Card className="glass-panel border-primary/20"> <Card className="glass-panel border-primary/20">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
@@ -2373,7 +2375,7 @@ function ClientGeneralPage() {
<CardDescription> <CardDescription>
{t( {t(
"msg.dev.clients.general.public_key.subtitle", "msg.dev.clients.general.public_key.subtitle",
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.", "Server side App의 Headless Login capability에 필요한 공개키와 검증 정보를 관리합니다.",
)} )}
</CardDescription> </CardDescription>
</div> </div>
@@ -2392,7 +2394,7 @@ function ClientGeneralPage() {
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{t( {t(
"msg.dev.clients.general.public_key.headless_help", "msg.dev.clients.general.public_key.headless_help",
"애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.", "RP가 자체 로그인 UI를 제공하더라도 실제 인증 흐름은 Baron API와 RP backend의 signed key 검증을 통해 이어집니다.",
)} )}
</p> </p>
</div> </div>

View File

@@ -524,18 +524,28 @@ function ClientsPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <div className="flex flex-wrap items-center gap-2">
variant={client.type === "private" ? "success" : "muted"} <Badge
> variant={
{client.type === "private" client.type === "private" ||
? t("ui.dev.clients.type.private", "Server side App") client.metadata?.headless_login_enabled
: client.metadata?.headless_login_enabled ? "success"
: "muted"
}
>
{client.metadata?.headless_login_enabled
? t( ? t(
"ui.dev.clients.type.pkce_headless", "ui.dev.clients.type.private_headless",
"PKCE (Headless Login)", "Server side App (Headless Login)",
) )
: t("ui.dev.clients.type.pkce", "PKCE")} : client.type === "private"
</Badge> ? t(
"ui.dev.clients.type.private",
"Server side App",
)
: t("ui.dev.clients.type.pkce", "PKCE")}
</Badge>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge

View File

@@ -376,6 +376,7 @@ save_error = "Save Error"
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship." save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
secret_rotated = "Secret Rotated" secret_rotated = "Secret Rotated"
secret_not_applicable = "PKCE apps do not have a client secret." secret_not_applicable = "PKCE apps do not have a client secret."
secret_not_applicable_headless = "This app uses signed key authentication for Headless Login, so it does not use a client secret."
secret_unavailable = "SECRET_NOT_AVAILABLE" secret_unavailable = "SECRET_NOT_AVAILABLE"
subtitle = "Manage OIDC credentials and endpoints." subtitle = "Manage OIDC credentials and endpoints."
@@ -1545,7 +1546,7 @@ pkce = "PKCE"
headless_login = "Headless Login" headless_login = "Headless Login"
title = "Security Settings" title = "Security Settings"
headless_login_enable = "Headless Login (Custom Login UI)" headless_login_enable = "Headless Login (Custom Login UI)"
headless_login_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page." headless_login_enable_help = "Enable this when the RP uses its own login UI and the RP backend proves the client with signed keys instead of the Baron SSO login page."
[ui.dev.clients.general.public_key] [ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method" auth_method = "Token Endpoint Auth Method"
@@ -1688,6 +1689,7 @@ type = "Type"
pkce = "PKCE" pkce = "PKCE"
private = "Server side App" private = "Server side App"
pkce_headless = "PKCE (Headless Login)" pkce_headless = "PKCE (Headless Login)"
private_headless = "Server side App (Headless Login)"
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "devfront ready" ready_badge = "devfront ready"

View File

@@ -376,6 +376,7 @@ save_error = "저장 실패: {{error}}"
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요." save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
secret_rotated = "Client Secret이 재발급되었습니다." secret_rotated = "Client Secret이 재발급되었습니다."
secret_not_applicable = "PKCE 앱에는 Client Secret이 없습니다." secret_not_applicable = "PKCE 앱에는 Client Secret이 없습니다."
secret_not_applicable_headless = "이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다."
secret_unavailable = "SECRET_NOT_AVAILABLE" secret_unavailable = "SECRET_NOT_AVAILABLE"
subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다." subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다."
@@ -1543,7 +1544,7 @@ private = "Server side App"
pkce = "PKCE" pkce = "PKCE"
title = "보안 설정" title = "보안 설정"
headless_login_enable = "Headless Login (자체 로그인 UI 사용)" headless_login_enable = "Headless Login (자체 로그인 UI 사용)"
headless_login_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다." headless_login_enable_help = "Baron SSO 로그인 창 대신 RP 자체 로그인 UI를 사용하고, RP backend의 서명 키로 클라이언트를 검증하려는 경우 활성화합니다."
[ui.dev.clients.general.public_key] [ui.dev.clients.general.public_key]
@@ -1687,6 +1688,7 @@ type = "유형"
private = "Server side App" private = "Server side App"
pkce = "PKCE" pkce = "PKCE"
pkce_headless = "PKCE (Headless Login)" pkce_headless = "PKCE (Headless Login)"
private_headless = "Server side App (Headless Login)"
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "devfront ready" ready_badge = "devfront ready"

View File

@@ -414,6 +414,7 @@ save_error = ""
save_forbidden = "" save_forbidden = ""
secret_rotated = "" secret_rotated = ""
secret_not_applicable = "" secret_not_applicable = ""
secret_not_applicable_headless = ""
secret_unavailable = "" secret_unavailable = ""
subtitle = "" subtitle = ""
@@ -1744,6 +1745,7 @@ type = ""
pkce = "" pkce = ""
private = "" private = ""
pkce_headless = "" pkce_headless = ""
private_headless = ""
[ui.dev.dashboard] [ui.dev.dashboard]
ready_badge = "" ready_badge = ""

View File

@@ -282,16 +282,18 @@ test.describe("DevFront clients lifecycle", () => {
).toHaveValue("2"); ).toHaveValue("2");
}); });
test("pkce headless login uses jwks uri only and shows cache actions", async ({ test("headless login uses jwks uri only and shows cache actions", async ({
page, page,
}) => { }) => {
const state = { const state = {
clients: [ clients: [
makeClient("client-headless-login", { makeClient("client-headless-login", {
name: "Headless Login App", name: "Headless Login App",
type: "pkce", type: "private",
metadata: { metadata: {
request_object_signing_alg: "RS256", headless_login_enabled: true,
headless_token_endpoint_auth_method: "private_key_jwt",
headless_jwks_uri: jwksUri,
}, },
headlessJwksCache: { headlessJwksCache: {
clientId: "client-headless-login", clientId: "client-headless-login",
@@ -338,12 +340,6 @@ test.describe("DevFront clients lifecycle", () => {
await page.goto("/clients/client-headless-login/settings"); await page.goto("/clients/client-headless-login/settings");
await page
.getByRole("switch", {
name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
})
.click();
await expect( await expect(
page.getByRole("heading", { page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i, name: /공개키 등록|Public Key Registration/i,
@@ -363,7 +359,7 @@ test.describe("DevFront clients lifecycle", () => {
await expect await expect
.poll(() => state.clients[0]?.tokenEndpointAuthMethod) .poll(() => state.clients[0]?.tokenEndpointAuthMethod)
.toBe("none"); .toBe("private_key_jwt");
await expect await expect
.poll(() => state.clients[0]?.metadata?.headless_login_enabled) .poll(() => state.clients[0]?.metadata?.headless_login_enabled)
.toBe(true); .toBe(true);
@@ -455,17 +451,18 @@ test.describe("DevFront clients lifecycle", () => {
.toBe(autoLoginUrl); .toBe(autoLoginUrl);
}); });
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({ test("headless login blocks save when parsed jwks algorithm is unsupported", async ({
page, page,
}) => { }) => {
const state = { const state = {
clients: [ clients: [
makeClient("client-headless-unsupported", { makeClient("client-headless-unsupported", {
name: "Unsupported Headless Login App", name: "Unsupported Headless Login App",
type: "pkce", type: "private",
metadata: { metadata: {
headless_login_enabled: true, headless_login_enabled: true,
request_object_signing_alg: "RS256", headless_token_endpoint_auth_method: "private_key_jwt",
headless_jwks_uri: jwksUri,
}, },
headlessJwksCache: { headlessJwksCache: {
clientId: "client-headless-unsupported", clientId: "client-headless-unsupported",
@@ -511,16 +508,17 @@ test.describe("DevFront clients lifecycle", () => {
).toBeDisabled(); ).toBeDisabled();
}); });
test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({ test("headless login blocks save when parsed jwks algorithm is missing", async ({
page, page,
}) => { }) => {
const state = { const state = {
clients: [ clients: [
makeClient("client-headless-missing-alg", { makeClient("client-headless-missing-alg", {
name: "Missing Alg Headless Login App", name: "Missing Alg Headless Login App",
type: "pkce", type: "private",
metadata: { metadata: {
headless_login_enabled: true, headless_login_enabled: true,
headless_token_endpoint_auth_method: "private_key_jwt",
headless_jwks_uri: jwksUri, headless_jwks_uri: jwksUri,
}, },
headlessJwksCache: { headlessJwksCache: {