1
0
forked from baron/baron-sso

Merge pull request 'feature/df-trusted-rp' (#467) from feature/df-trusted-rp into dev

Reviewed-on: baron/baron-sso#467
This commit is contained in:
2026-03-30 14:09:48 +09:00
18 changed files with 1245 additions and 39 deletions

View File

@@ -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"`

View File

@@ -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")
}
})
}

View File

@@ -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,
}
}

View File

@@ -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"},

View File

@@ -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<string, unknown>,
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<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
useState<TokenEndpointAuthMethod>("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<ScopeItem[]>(() => [
{
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() {
</div>
</div>
{/* 2. Scopes (Moved up and upgraded) */}
{/* 2. Scopes */}
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div>
@@ -497,7 +705,6 @@ function ClientGeneralPage() {
</Button>
</CardHeader>
<CardContent className="space-y-6">
{/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */}
{isCreate && (
<div className="space-y-2 border-b border-border pb-6 mb-6">
<Label className="text-sm font-semibold">
@@ -622,7 +829,7 @@ function ClientGeneralPage() {
</CardContent>
</Card>
{/* 3. Security Settings (Moved down) */}
{/* 3. Security Settings */}
<Card className="glass-panel">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold">
@@ -640,7 +847,7 @@ function ClientGeneralPage() {
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "private"
securityProfile === "private"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
@@ -648,9 +855,9 @@ function ClientGeneralPage() {
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "private"}
onChange={() => setClientType("private")}
name="security-profile"
checked={securityProfile === "private"}
onChange={() => handleSecurityProfileChange("private")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" />
@@ -666,14 +873,14 @@ function ClientGeneralPage() {
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "private" ? "✓" : ""}
{securityProfile === "private" ? "✓" : ""}
</span>
</label>
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "pkce"
securityProfile === "pkce"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
@@ -681,9 +888,9 @@ function ClientGeneralPage() {
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "pkce"}
onChange={() => setClientType("pkce")}
name="security-profile"
checked={securityProfile === "pkce"}
onChange={() => handleSecurityProfileChange("pkce")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" />
@@ -696,13 +903,231 @@ function ClientGeneralPage() {
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "pkce" ? "✓" : ""}
{securityProfile === "pkce" ? "✓" : ""}
</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="trusted-rp-toggle"
>
{t(
"ui.dev.clients.general.security.trusted_rp_enable",
"Trusted RP (자체 로그인 UI 사용)",
)}
</Label>
<p className="text-[10px] text-muted-foreground">
{t(
"ui.dev.clients.general.security.trusted_rp_enable_help",
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
)}
</p>
</div>
<Switch
id="trusted-rp-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
/>
</div>
)}
</label>
</div>
</CardContent>
</Card>
{/* 4. Public Key Registration (Trusted RP) */}
{clientType === "pkce" && headlessLoginEnabled && (
<Card className="glass-panel border-primary/20">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
{t(
"ui.dev.clients.general.public_key.title",
"Public Key Registration",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.public_key.subtitle",
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className={cn("rounded-xl border p-4", publicKeyStatusTone)}>
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-bold text-foreground">
{t(
"ui.dev.clients.general.public_key.headless_toggle",
"Headless Login 허용 여부",
)}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.headless_help",
"애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.",
)}
</p>
</div>
<Badge
variant="default"
className="bg-primary/20 text-primary border-primary/30"
>
{t("ui.common.enabled", "Enabled")}
</Badge>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.public_key.request_object_alg",
"Request Object Signing Algorithm",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
value={requestObjectSigningAlg}
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
"예: RS256",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.request_object_alg_help",
"Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.",
)}
</p>
</div>
</div>
<div className="space-y-4 rounded-xl border border-border bg-muted/5 p-4">
<div className="space-y-1 pb-2 border-b border-border/50">
<Label className="text-sm font-bold">
{t(
"ui.dev.clients.general.public_key.source",
"Public Key Source",
)}
</Label>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.source_help",
"OIDC 검증을 위한 공개키 제공 방식을 선택합니다. (운영 환경에서는 JWKS URI 사용을 권장합니다)",
)}
</p>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="jwksSource"
checked={jwksSource === "inline"}
onChange={() => setJwksSource("inline")}
className="accent-primary"
/>
<span>Inline Public Key</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="jwksSource"
checked={jwksSource === "uri"}
onChange={() => setJwksSource("uri")}
className="accent-primary"
/>
<span>JWKS URI</span>
</label>
</div>
{jwksSource === "uri" && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.public_key.jwks_uri",
"JWKS URI",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
value={jwksUri}
onChange={(e) => setJwksUri(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.jwks_uri_placeholder",
"https://rp.example.com/.well-known/jwks.json",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.jwks_uri_help",
"RP backend가 제공하는 공개키 endpoint URL을 입력하세요.",
)}
</p>
</div>
)}
{jwksSource === "inline" && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.public_key.jwks_inline",
"JWKS 또는 OpenSSH 공개키",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
rows={8}
value={jwksText}
onChange={(e) => setJwksText(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.jwks_inline_placeholder",
"JWKS (JSON) 또는 'ssh-rsa AAA...' 형식의 공개키를 붙여넣으세요.",
)}
className="font-mono text-xs leading-tight"
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.jwks_inline_help",
"OIDC 표준인 JWKS(JSON) 형식을 권장하지만, SSH-RSA 공개키를 입력하면 자동으로 변환하여 저장합니다.",
)}
</p>
</div>
)}
</div>
{hasValidationErrors && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4 animate-in fade-in">
<p className="text-sm font-semibold text-destructive flex items-center gap-2">
<span></span>
{t(
"ui.dev.clients.general.public_key.validation_title",
"저장 전 확인 필요",
)}
</p>
<ul className="mt-2 list-disc space-y-1 pl-6 text-sm text-destructive">
{validationErrors.map((errorMessage) => (
<li key={errorMessage}>{errorMessage}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<div>
{!isCreate && (
@@ -729,7 +1154,8 @@ function ClientGeneralPage() {
mutation.isPending ||
isLoading ||
name.trim() === "" ||
(isCreate && redirectUris.trim() === "")
(isCreate && redirectUris.trim() === "") ||
hasValidationErrors
}
className="shadow-lg shadow-primary/20"
>

View File

@@ -122,10 +122,11 @@ function formatTemplate(
template: string,
vars?: Record<string, string | number>,
): string {
const normalizedTemplate = template.replace(/\\n/g, "\n");
if (!vars) {
return template;
return normalizedTemplate;
}
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;

View File

@@ -0,0 +1,139 @@
/**
* Key Utilities for converting various public key formats (PEM, OpenSSH) to JWKS.
*/
interface JWK {
kty: string;
n: string;
e: string;
kid?: string;
use?: string;
alg?: string;
}
/**
* Converts a Base64 string to a URL-safe Base64 string (RFC 7515).
*/
function toBase64Url(base64: string): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
/**
* Converts a hex string to a URL-safe Base64 string.
*/
function hexToBase64Url(hex: string): string {
const binary = hex
.match(/.{1,2}/g)
?.map((byte) => String.fromCharCode(Number.parseInt(byte, 16)))
.join("");
if (!binary) return "";
return toBase64Url(btoa(binary));
}
/**
* Extracts RSA Modulus (n) and Exponent (e) from a SubjectPublicKeyInfo (PEM).
* This is a simplified parser for common RSA keys.
*/
export function parsePemToJwk(pem: string): JWK | null {
try {
// Remove headers, footers and whitespace
const base64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/, "")
.replace(/-----END PUBLIC KEY-----/, "")
.replace(/\s/g, "");
// In a real browser environment without heavy libraries,
// we would need a full ASN.1 parser.
// For now, we recommend using JWKS or OpenSSH formats for reliability,
// or we can hint the user that complex PEMs might fail.
// However, we'll try to support a basic one.
return null; // Placeholder: PEM parsing is complex without libs.
} catch (e) {
console.error("Failed to parse PEM", e);
return null;
}
}
/**
* Parses an OpenSSH Public Key (ssh-rsa AAAA...) into a JWK.
*/
export function parseSshRsaToJwk(sshKey: string): JWK | null {
try {
const parts = sshKey.trim().split(" ");
if (parts.length < 2 || parts[0] !== "ssh-rsa") return null;
const keyData = atob(parts[1]);
let offset = 0;
const readBlob = () => {
const len =
(keyData.charCodeAt(offset) << 24) |
(keyData.charCodeAt(offset + 1) << 16) |
(keyData.charCodeAt(offset + 2) << 8) |
keyData.charCodeAt(offset + 3);
offset += 4;
const blob = keyData.slice(offset, offset + len);
offset += len;
return blob;
};
const type = readBlob(); // "ssh-rsa"
if (type !== "ssh-rsa") return null;
const eBlob = readBlob();
const nBlob = readBlob();
const toB64Url = (blob: string) => toBase64Url(btoa(blob));
return {
kty: "RSA",
n: semanticsBase64Url(nBlob),
e: semanticsBase64Url(eBlob),
alg: "RS256",
use: "sig",
};
} catch (e) {
console.error("Failed to parse SSH key", e);
return null;
}
}
function semanticsBase64Url(blob: string): string {
// Ensure leading zero removal for BigInt representations if necessary
let start = 0;
while (start < blob.length && blob.charCodeAt(start) === 0) {
start++;
}
return toBase64Url(btoa(blob.slice(start)));
}
/**
* Tries to auto-detect and convert input to JWKS JSON string.
* Returns the original string if it's already JSON or conversion fails.
*/
export function tryConvertToJwks(input: string): string {
const trimmed = input.trim();
// 1. If it looks like JSON, return as is (validation happens in component)
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return trimmed;
}
// 2. Try SSH RSA
if (trimmed.startsWith("ssh-rsa")) {
const jwk = parseSshRsaToJwk(trimmed);
if (jwk) {
return JSON.stringify({ keys: [jwk] }, null, 2);
}
}
// 3. PEM (Simplified check)
if (trimmed.includes("BEGIN PUBLIC KEY")) {
// For PEM, we suggest the user uses JWKS or SSH-RSA for now
// as JS doesn't have a built-in ASN1 parser and we want to avoid heavy deps.
return trimmed;
}
return trimmed;
}

View File

@@ -390,6 +390,30 @@ subtitle = "Define the permission scopes this application can request."
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
subtitle = "Select application type. Security level determines authentication method."
trusted_help = "Operate as a trusted RP using private_key_jwt and public key registration. Headless login is only available for this profile."
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
auth_method_none_help = "Use this for PKCE-based public clients."
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification."
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
guide_step_2 = "Expose the public key from the RP backend through a JWKS (JSON Web Key Set) endpoint."
guide_step_3 = "Enter a URL such as https://rp.example.com/.well-known/jwks.json in DevFront."
headless_help = "You can design your own login UI within the application. While the UI is yours, the actual identity verification and security checks are handled in the background via Baron's API."
jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an 'ssh-rsa AAA...' key, Baron converts it to OIDC-standard JWKS (JSON) before saving."
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
subtitle = "Manage the public key and headless login settings required for trusted RP evaluation."
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
headless_requires_private_key_jwt = "Headless login requires token endpoint auth method to be private_key_jwt."
headless_requires_public_key = "Headless login requires a JWKS URI."
invalid_jwks_uri = "JWKS URI format is invalid."
private_key_jwt_requires_public_key = "Signed key-based authentication requires a JWKS URI."
[msg.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
@@ -1368,7 +1392,30 @@ delete = "Delete"
[ui.dev.clients.general.security]
private = "Server Side App"
pkce = "PKCE"
trusted = "Trusted RP"
title = "Security Settings"
trusted_rp_enable = "Trusted RP (Custom Login UI)"
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
[ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method"
auth_method_client_secret_basic = "client_secret_basic"
auth_method_none = "none"
auth_method_private_key_jwt = "Signed Key Authentication"
guide_toggle = "JWKS URI Setup Guide"
headless_disabled = "Headless Disabled"
headless_enabled = "Headless Enabled"
headless_toggle = "Headless Login"
jwks_inline = "SSH-RSA or JWKS Public Key"
jwks_inline_placeholder = "Paste an 'ssh-rsa AAA...' public key first. JWKS (JSON) is also accepted if needed."
jwks_uri = "JWKS URI"
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
request_object_alg = "Request Object Signing Algorithm"
request_object_alg_placeholder = "RS256"
source = "Public Key Source"
source_uri = "JWKS URI"
title = "Public Key Registration"
validation_title = "Check before saving"
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."

View File

@@ -390,6 +390,31 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
trusted_help = "private_key_jwt와 공개키 등록을 사용해 trusted RP로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다."
guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다."
headless_help = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다."
jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다."
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다."
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다."
headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다."
invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다."
missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다."
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
[msg.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
@@ -1368,6 +1393,29 @@ delete = "삭제"
private = "Server side App"
pkce = "PKCE"
title = "보안 설정"
trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)"
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
[ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method"
auth_method_client_secret_basic = "client_secret_basic"
auth_method_none = "none"
auth_method_private_key_jwt = "서명 키 기반 인증"
guide_toggle = "JWKS URI 준비 가이드"
headless_disabled = "Headless Disabled"
headless_enabled = "Headless Enabled"
headless_toggle = "Headless Login"
jwks_inline = "SSH-RSA 또는 JWKS 공개키"
jwks_inline_placeholder = "'ssh-rsa AAA...' 형식의 공개키를 먼저 붙여넣으세요. 필요하면 JWKS (JSON)도 입력할 수 있습니다."
jwks_uri = "JWKS URI"
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
request_object_alg = "Request Object Signing Algorithm"
request_object_alg_placeholder = "RS256"
source = "Public Key Source"
source_uri = "JWKS URI"
title = "공개키 등록"
validation_title = "저장 전 확인 필요"
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."

View File

@@ -390,6 +390,30 @@ subtitle = ""
private_help = ""
pkce_help = ""
subtitle = ""
trusted_help = ""
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = ""
auth_method_none_help = ""
auth_method_private_key_jwt_help = ""
guide_example = ""
guide_intro = ""
guide_step_1 = ""
guide_step_2 = ""
guide_step_3 = ""
headless_help = ""
jwks_inline_help = ""
jwks_uri_help = ""
request_object_alg_help = ""
source_help = ""
subtitle = ""
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = ""
headless_requires_private_key_jwt = ""
headless_requires_public_key = ""
invalid_jwks_uri = ""
private_key_jwt_requires_public_key = ""
[msg.dev.clients.help]
docs_body = ""
@@ -1368,6 +1392,28 @@ delete = ""
private = ""
pkce = ""
title = ""
trusted_rp_enable = ""
trusted_rp_enable_help = ""
[ui.dev.clients.general.public_key]
auth_method = ""
auth_method_client_secret_basic = ""
auth_method_none = ""
auth_method_private_key_jwt = ""
guide_toggle = ""
headless_disabled = ""
headless_enabled = ""
headless_toggle = ""
jwks_inline = ""
jwks_inline_placeholder = ""
jwks_uri = ""
jwks_uri_placeholder = ""
request_object_alg = ""
request_object_alg_placeholder = ""
source = ""
source_uri = ""
title = ""
validation_title = ""
[ui.dev.clients.help]
docs_body = ""

View File

@@ -8,6 +8,8 @@ import {
} from "./helpers/devfront-fixtures";
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
const sshRsaPublicKey =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAABwECAwQFBgc= test@example";
test.describe("DevFront clients lifecycle", () => {
test.beforeEach(async ({ page }) => {
@@ -120,4 +122,77 @@ test.describe("DevFront clients lifecycle", () => {
page.getByRole("textbox", { name: /인증 콜백 URL|Callback/i }),
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
});
test("pkce trusted rp with inline ssh-rsa key should persist mapped payload", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-trusted", { name: "Trusted App", type: "pkce" }),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-trusted/settings");
await page
.getByRole("switch", {
name: /Trusted RP \(자체 로그인 UI 사용\)|Trusted RP \(Custom Login UI\)/i,
})
.click();
await expect(
page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i,
}),
).toBeVisible();
await page
.getByPlaceholder(
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
)
.fill(sshRsaPublicKey);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
.toBe("private_key_jwt");
await expect
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
.toBe(true);
await expect
.poll(
() =>
(
state.clients[0]?.jwks as {
keys?: Array<{ kty?: string; alg?: string }>;
}
)?.keys?.[0]?.kty,
)
.toBe("RSA");
await expect
.poll(
() =>
(
state.clients[0]?.jwks as {
keys?: Array<{ kty?: string; alg?: string }>;
}
)?.keys?.[0]?.alg,
)
.toBe("RS256");
await page.reload();
await expect(
page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i,
}),
).toBeVisible();
await expect(
page.getByPlaceholder(
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
),
).toHaveValue(/"kty": "RSA"/);
});
});

View File

@@ -12,6 +12,9 @@ export type Client = {
scopes: string[];
createdAt: string;
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: Record<string, unknown> | string;
metadata?: Record<string, unknown>;
};
@@ -214,6 +217,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
status?: ClientStatus;
redirectUris?: string[];
scopes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: Record<string, unknown> | string;
metadata?: Record<string, unknown>;
}) || { name: "created app" };
@@ -223,6 +229,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
status: payload.status ?? "active",
redirectUris: payload.redirectUris ?? [],
scopes: payload.scopes ?? ["openid"],
tokenEndpointAuthMethod: payload.tokenEndpointAuthMethod,
jwksUri: payload.jwksUri,
jwks: payload.jwks,
metadata: payload.metadata ?? {},
});
@@ -294,6 +303,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
type?: ClientType;
scopes?: string[];
redirectUris?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: Record<string, unknown> | string;
metadata?: Record<string, unknown>;
}) || { name: "updated app" };
const found = state.clients.find((client) => client.id === clientId);
@@ -302,6 +314,15 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
if (payload.type) found.type = payload.type;
if (payload.scopes) found.scopes = payload.scopes;
if (payload.redirectUris) found.redirectUris = payload.redirectUris;
if (payload.tokenEndpointAuthMethod !== undefined) {
found.tokenEndpointAuthMethod = payload.tokenEndpointAuthMethod;
}
if (payload.jwksUri !== undefined) {
found.jwksUri = payload.jwksUri;
}
if (payload.jwks !== undefined) {
found.jwks = payload.jwks;
}
if (payload.metadata) found.metadata = payload.metadata;
appendAuditLog("CLIENT_UPDATE", "UPDATE_CLIENT", clientId);
return json(route, {

View File

@@ -234,6 +234,32 @@ limit_notice = "Showing members from the first 10 descendant organizations due t
[msg.admin.tenants.registry]
count = "{{count}} tenants loaded."
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
auth_method_none_help = "Use this for PKCE-based public clients."
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification."
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
guide_step_2 = "Expose the public key from the RP backend through a JWKS (JSON Web Key Set) endpoint."
guide_step_3 = "Enter a URL such as https://rp.example.com/.well-known/jwks.json in DevFront."
headless_help = "You can design your own login UI within the application. While the UI is yours, the actual identity verification and security checks are handled in the background via Baron's API."
jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an 'ssh-rsa AAA...' key, Baron converts it to OIDC-standard JWKS (JSON) before saving."
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
subtitle = "Manage the public key and headless login settings required for trusted RP evaluation."
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
headless_requires_private_key_jwt = "Headless login requires token endpoint auth method to be private_key_jwt."
headless_requires_public_key = "Headless login requires a JWKS URI."
invalid_jwks_inline = "The input must be valid JSON (JWKS). For SSH-RSA input, it must start with 'ssh-rsa'."
invalid_jwks_uri = "JWKS URI format is invalid."
missing_jwks_inline = "Enter a public key in SSH-RSA or JWKS format."
missing_jwks_uri = "JWKS URI is required."
private_key_jwt_requires_public_key = "Signed key-based authentication requires a JWKS URI."
[msg.admin.tenants.schema]
empty = "No custom fields defined. Click \"Add Field\" to begin."
missing_id = "Tenant ID missing"
@@ -1215,6 +1241,7 @@ create = "Create"
delete = "Delete"
details = "Details"
edit = "Edit"
enabled = "Enabled"
export = "Export"
fail = "Fail"
go_home = "Go Home"
@@ -1459,6 +1486,28 @@ delete = "Delete"
private = "Server Side App"
pkce = "PKCE"
title = "Security Settings"
trusted_rp_enable = "Trusted RP (Custom Login UI)"
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
[ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method"
auth_method_client_secret_basic = "client_secret_basic"
auth_method_none = "none"
auth_method_private_key_jwt = "Signed Key Authentication"
guide_toggle = "JWKS URI Setup Guide"
headless_disabled = "Headless Disabled"
headless_enabled = "Headless Enabled"
headless_toggle = "Headless Login"
jwks_inline = "SSH-RSA or JWKS Public Key"
jwks_inline_placeholder = "Paste an 'ssh-rsa AAA...' public key first. JWKS (JSON) is also accepted if needed."
jwks_uri = "JWKS URI"
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
request_object_alg = "Request Object Signing Algorithm"
request_object_alg_placeholder = "RS256"
source = "Public Key Source"
source_uri = "JWKS URI"
title = "Public Key Registration"
validation_title = "Check before saving"
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."

View File

@@ -90,6 +90,7 @@ create = "생성"
delete = "삭제"
details = "상세정보"
edit = "편집"
enabled = "사용"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
@@ -229,6 +230,32 @@ showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
notice = "개발자 전용 콘솔입니다."
notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다."
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다."
guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다."
headless_help = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다."
jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다."
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다."
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다."
headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다."
invalid_jwks_inline = "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다."
invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다."
missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다."
missing_jwks_uri = "JWKS URI를 입력해야 합니다."
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
[msg.userfront.audit]
date = "접속일자: {{value}}"
device = "접속환경: {{value}}"
@@ -1712,6 +1739,28 @@ title = "스코프"
private = "Server side App"
pkce = "PKCE"
title = "보안 설정"
trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)"
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
[ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method"
auth_method_client_secret_basic = "client_secret_basic"
auth_method_none = "none"
auth_method_private_key_jwt = "서명 키 기반 인증"
guide_toggle = "JWKS URI 준비 가이드"
headless_disabled = "Headless Disabled"
headless_enabled = "Headless Enabled"
headless_toggle = "Headless Login"
jwks_inline = "SSH-RSA 또는 JWKS 공개키"
jwks_inline_placeholder = "'ssh-rsa AAA...' 형식의 공개키를 먼저 붙여넣으세요. 필요하면 JWKS (JSON)도 입력할 수 있습니다."
jwks_uri = "JWKS URI"
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
request_object_alg = "Request Object Signing Algorithm"
request_object_alg_placeholder = "RS256"
source = "Public Key Source"
source_uri = "JWKS URI"
title = "공개키 등록"
validation_title = "저장 전 확인 필요"
[ui.dev.dashboard.ops.card]
consent_revoked = "Consent 회수 건수"

View File

@@ -90,6 +90,7 @@ create = ""
delete = ""
details = ""
edit = ""
enabled = ""
export = ""
fail = ""
go_home = ""
@@ -229,6 +230,32 @@ delete_confirm = ""
notice = ""
notice_detail = ""
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = ""
auth_method_none_help = ""
auth_method_private_key_jwt_help = ""
guide_example = ""
guide_intro = ""
guide_step_1 = ""
guide_step_2 = ""
guide_step_3 = ""
headless_help = ""
jwks_inline_help = ""
jwks_uri_help = ""
request_object_alg_help = ""
source_help = ""
subtitle = ""
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = ""
headless_requires_private_key_jwt = ""
headless_requires_public_key = ""
invalid_jwks_inline = ""
invalid_jwks_uri = ""
missing_jwks_inline = ""
missing_jwks_uri = ""
private_key_jwt_requires_public_key = ""
[msg.userfront.audit]
date = ""
device = ""
@@ -1706,6 +1733,28 @@ title = ""
private = ""
pkce = ""
title = ""
trusted_rp_enable = ""
trusted_rp_enable_help = ""
[ui.dev.clients.general.public_key]
auth_method = ""
auth_method_client_secret_basic = ""
auth_method_none = ""
auth_method_private_key_jwt = ""
guide_toggle = ""
headless_disabled = ""
headless_enabled = ""
headless_toggle = ""
jwks_inline = ""
jwks_inline_placeholder = ""
jwks_uri = ""
jwks_uri_placeholder = ""
request_object_alg = ""
request_object_alg_placeholder = ""
source = ""
source_uri = ""
title = ""
validation_title = ""
[ui.dev.dashboard.ops.card]
consent_revoked = ""

View File

@@ -359,6 +359,7 @@ create = "Create"
delete = "Delete"
details = "Details"
edit = "Edit"
enabled = "Enabled"
export = "Export"
fail = "Fail"
go_home = "Go Home"

View File

@@ -37,6 +37,7 @@ create = "생성"
delete = "삭제"
details = "상세정보"
edit = "편집"
enabled = "사용"
export = "내보내기"
fail = "실패"
go_home = "홈으로"

View File

@@ -37,6 +37,7 @@ create = ""
delete = ""
details = ""
edit = ""
enabled = ""
export = ""
fail = ""
go_home = ""