forked from baron/baron-sso
fix(headless-login): simplify jwks policy checks
This commit is contained in:
@@ -10,6 +10,7 @@ const (
|
|||||||
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||||
MetadataHeadlessJWKS = "headless_jwks"
|
MetadataHeadlessJWKS = "headless_jwks"
|
||||||
|
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HydraClient struct {
|
type HydraClient struct {
|
||||||
|
|||||||
@@ -1105,8 +1105,12 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
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) {
|
||||||
|
resolvedJWKS = nil
|
||||||
|
} else {
|
||||||
resolvedJWKS = current.JWKS
|
resolvedJWKS = current.JWKS
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if err := validateHeadlessClientInput(resolvedClientType, resolvedJWKSURI, resolvedJWKS, metadata); err != nil {
|
if err := validateHeadlessClientInput(resolvedClientType, resolvedJWKSURI, resolvedJWKS, metadata); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
@@ -1909,6 +1913,7 @@ func normalizeHeadlessClientConfig(
|
|||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
delete(metadata, domain.MetadataRequestObjectSigningAlg)
|
||||||
|
|
||||||
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
|
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
|
||||||
if clientType == "pkce" && headlessEnabled {
|
if clientType == "pkce" && headlessEnabled {
|
||||||
|
|||||||
@@ -693,7 +693,8 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
|||||||
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"])
|
||||||
assert.True(t, captured.IsHeadlessLoginEnabled())
|
assert.True(t, captured.IsHeadlessLoginEnabled())
|
||||||
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
||||||
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"])
|
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
|
||||||
|
assert.False(t, hasRequestObjectAlg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
|
func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
|
||||||
@@ -777,6 +778,7 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
|||||||
"headless_jwks": map[string]any{"keys": []map[string]any{}},
|
"headless_jwks": map[string]any{"keys": []map[string]any{}},
|
||||||
"headless_jwks_uri": "https://stale.example.com/old.json",
|
"headless_jwks_uri": "https://stale.example.com/old.json",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
|
"request_object_signing_alg": "RS256",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -838,10 +840,100 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
|||||||
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"]
|
||||||
assert.False(t, hasInlineJWKS)
|
assert.False(t, hasInlineJWKS)
|
||||||
|
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
|
||||||
|
assert.False(t, hasRequestObjectAlg)
|
||||||
assert.True(t, captured.IsHeadlessLoginEnabled())
|
assert.True(t, captured.IsHeadlessLoginEnabled())
|
||||||
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(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-headless-login" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
|
"client_id": "client-headless-login",
|
||||||
|
"client_name": "Headless Login 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",
|
||||||
|
"jwks": map[string]any{
|
||||||
|
"keys": []map[string]any{{
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"n": "AQIDBAUGBw",
|
||||||
|
"e": "AQAB",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"status": "active",
|
||||||
|
"headless_login_enabled": true,
|
||||||
|
"headless_jwks_uri": "https://stale.example.com/old.json",
|
||||||
|
"request_object_signing_alg": "RS256",
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
||||||
|
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": "Headless Login 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-headless-login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Nil(t, captured.JWKS)
|
||||||
|
assert.Equal(t, "", captured.JWKSUri)
|
||||||
|
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
|
||||||
|
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
|
||||||
|
assert.False(t, hasRequestObjectAlg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) {
|
func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) {
|
||||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||||
_ = privateKey
|
_ = privateKey
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Info,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -68,6 +69,18 @@ const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
|
|||||||
"EdDSA",
|
"EdDSA",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET = new Set<string>(
|
||||||
|
HEADLESS_LOGIN_ALLOWED_ALGORITHMS,
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatHeadlessParsedKeyLabel(kid: string | undefined, index: number): string {
|
||||||
|
const trimmedKid = kid?.trim();
|
||||||
|
if (trimmedKid) {
|
||||||
|
return trimmedKid;
|
||||||
|
}
|
||||||
|
return `key #${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isTokenEndpointAuthMethod(
|
function isTokenEndpointAuthMethod(
|
||||||
value: string,
|
value: string,
|
||||||
): value is TokenEndpointAuthMethod {
|
): value is TokenEndpointAuthMethod {
|
||||||
@@ -126,8 +139,6 @@ function ClientGeneralPage() {
|
|||||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||||
useState<TokenEndpointAuthMethod>("client_secret_basic");
|
useState<TokenEndpointAuthMethod>("client_secret_basic");
|
||||||
const [jwksUri, setJwksUri] = useState("");
|
const [jwksUri, setJwksUri] = useState("");
|
||||||
const [requestObjectSigningAlg, setRequestObjectSigningAlg] =
|
|
||||||
useState("RS256");
|
|
||||||
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
||||||
|
|
||||||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||||||
@@ -209,16 +220,6 @@ function ClientGeneralPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
||||||
if (savedScopes && Array.isArray(savedScopes)) {
|
if (savedScopes && Array.isArray(savedScopes)) {
|
||||||
setScopes(savedScopes);
|
setScopes(savedScopes);
|
||||||
@@ -252,9 +253,6 @@ function ClientGeneralPage() {
|
|||||||
setHeadlessLoginEnabled(enabled);
|
setHeadlessLoginEnabled(enabled);
|
||||||
if (clientType === "pkce") {
|
if (clientType === "pkce") {
|
||||||
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none");
|
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none");
|
||||||
if (enabled && requestObjectSigningAlg.trim() === "") {
|
|
||||||
setRequestObjectSigningAlg("RS256");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -299,7 +297,40 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const validationErrors: string[] = [];
|
const validationErrors: string[] = [];
|
||||||
const trimmedJwksUri = jwksUri.trim();
|
const trimmedJwksUri = jwksUri.trim();
|
||||||
const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim();
|
const currentHeadlessJwksCache = data?.headlessJwksCache;
|
||||||
|
const parsedKeysForCurrentJwksUri =
|
||||||
|
headlessLoginEnabled &&
|
||||||
|
trimmedJwksUri !== "" &&
|
||||||
|
currentHeadlessJwksCache?.jwksUri === trimmedJwksUri
|
||||||
|
? currentHeadlessJwksCache.parsedKeys ?? []
|
||||||
|
: [];
|
||||||
|
const unsupportedParsedAlgorithms = parsedKeysForCurrentJwksUri
|
||||||
|
.map((key, index) => ({
|
||||||
|
alg: key.alg?.trim() ?? "",
|
||||||
|
label: formatHeadlessParsedKeyLabel(key.kid, index),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.alg !== "" &&
|
||||||
|
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(entry.alg),
|
||||||
|
);
|
||||||
|
const missingParsedAlgorithms = parsedKeysForCurrentJwksUri
|
||||||
|
.map((key, index) => ({
|
||||||
|
alg: key.alg?.trim() ?? "",
|
||||||
|
label: formatHeadlessParsedKeyLabel(key.kid, index),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.alg === "");
|
||||||
|
const unsupportedParsedAlgorithmSummary = unsupportedParsedAlgorithms
|
||||||
|
.map((entry) => `${entry.label}: ${entry.alg}`)
|
||||||
|
.join(", ");
|
||||||
|
const missingParsedAlgorithmSummary = missingParsedAlgorithms
|
||||||
|
.map((entry) => entry.label)
|
||||||
|
.join(", ");
|
||||||
|
const allowedHeadlessAlgorithmsTooltip = t(
|
||||||
|
"msg.dev.clients.general.public_key.allowed_algorithms_tooltip",
|
||||||
|
"허용 알고리즘: {{algorithms}}",
|
||||||
|
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
|
||||||
|
);
|
||||||
|
|
||||||
if (headlessLoginEnabled) {
|
if (headlessLoginEnabled) {
|
||||||
if (!trimmedJwksUri) {
|
if (!trimmedJwksUri) {
|
||||||
@@ -317,19 +348,27 @@ function ClientGeneralPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (unsupportedParsedAlgorithms.length > 0) {
|
||||||
if (trimmedRequestObjectSigningAlg === "") {
|
|
||||||
validationErrors.push(
|
validationErrors.push(
|
||||||
t(
|
t(
|
||||||
"msg.dev.clients.general.public_key.validation.headless_requires_alg",
|
"msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
|
||||||
"Request Object Signing Algorithm (예: RS256)을 입력해야 합니다.",
|
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
|
||||||
|
{ details: unsupportedParsedAlgorithmSummary },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (missingParsedAlgorithms.length > 0) {
|
||||||
|
validationErrors.push(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.general.public_key.validation.missing_parsed_algorithms",
|
||||||
|
"JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}",
|
||||||
|
{ details: missingParsedAlgorithmSummary },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasValidationErrors = validationErrors.length > 0;
|
const hasValidationErrors = validationErrors.length > 0;
|
||||||
const currentHeadlessJwksCache = data?.headlessJwksCache;
|
|
||||||
|
|
||||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -418,7 +457,6 @@ function ClientGeneralPage() {
|
|||||||
logo_url: logoUrl,
|
logo_url: logoUrl,
|
||||||
structured_scopes: scopes,
|
structured_scopes: scopes,
|
||||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||||
request_object_signing_alg: trimmedRequestObjectSigningAlg,
|
|
||||||
headless_login_enabled: headlessLoginEnabled,
|
headless_login_enabled: headlessLoginEnabled,
|
||||||
headless_token_endpoint_auth_method:
|
headless_token_endpoint_auth_method:
|
||||||
clientType === "pkce" && headlessLoginEnabled
|
clientType === "pkce" && headlessLoginEnabled
|
||||||
@@ -1046,59 +1084,10 @@ function ClientGeneralPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="space-y-4">
|
||||||
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
|
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold" htmlFor="request-object-signing-alg">
|
<div className="flex items-center gap-2">
|
||||||
{t(
|
|
||||||
"ui.dev.clients.general.public_key.request_object_alg",
|
|
||||||
"Request Object Signing Algorithm",
|
|
||||||
)}
|
|
||||||
<span className="text-destructive ml-1">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="request-object-signing-alg"
|
|
||||||
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 className="space-y-2 rounded-lg border border-border bg-background/60 p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"ui.dev.clients.general.public_key.allowed_algorithms",
|
|
||||||
"Allowed Algorithms",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{HEADLESS_LOGIN_ALLOWED_ALGORITHMS.map((algorithm) => (
|
|
||||||
<Badge
|
|
||||||
key={algorithm}
|
|
||||||
variant="outline"
|
|
||||||
className="font-mono text-[11px]"
|
|
||||||
>
|
|
||||||
{algorithm}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"msg.dev.clients.general.public_key.allowed_algorithms_help",
|
|
||||||
"Headless Login JAR 검증은 이 목록에 있는 서명 알고리즘만 허용합니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
|
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
|
||||||
{t(
|
{t(
|
||||||
"ui.dev.clients.general.public_key.jwks_uri",
|
"ui.dev.clients.general.public_key.jwks_uri",
|
||||||
@@ -1106,6 +1095,20 @@ function ClientGeneralPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-destructive ml-1">*</span>
|
<span className="text-destructive ml-1">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground"
|
||||||
|
title={allowedHeadlessAlgorithmsTooltip}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.dev.clients.general.public_key.allowed_algorithms_info",
|
||||||
|
"Headless Login 허용 알고리즘 정보",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="jwks-uri"
|
id="jwks-uri"
|
||||||
value={jwksUri}
|
value={jwksUri}
|
||||||
@@ -1297,6 +1300,35 @@ function ClientGeneralPage() {
|
|||||||
{currentHeadlessJwksCache.lastError || "-"}
|
{currentHeadlessJwksCache.lastError || "-"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{(unsupportedParsedAlgorithms.length > 0 ||
|
||||||
|
missingParsedAlgorithms.length > 0) && (
|
||||||
|
<div className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 md:col-span-2">
|
||||||
|
<p className="text-sm font-semibold text-destructive">
|
||||||
|
{unsupportedParsedAlgorithms.length > 0
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_title",
|
||||||
|
"지원하지 않는 알고리즘이 감지되었습니다.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.dev.clients.general.public_key.cache.missing_algorithms_title",
|
||||||
|
"알고리즘이 선언되지 않았습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{unsupportedParsedAlgorithms.length > 0
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_help",
|
||||||
|
"저장 전 JWKS를 수정해 주세요: {{details}}",
|
||||||
|
{ details: unsupportedParsedAlgorithmSummary },
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.dev.clients.general.public_key.cache.missing_algorithms_help",
|
||||||
|
"저장 전 JWKS 각 키에 `alg`를 명시해 주세요: {{details}}",
|
||||||
|
{ details: missingParsedAlgorithmSummary },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-3 md:col-span-2">
|
<div className="space-y-3 md:col-span-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||||
@@ -1314,10 +1346,25 @@ function ClientGeneralPage() {
|
|||||||
</div>
|
</div>
|
||||||
{currentHeadlessJwksCache.parsedKeys?.length ? (
|
{currentHeadlessJwksCache.parsedKeys?.length ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{currentHeadlessJwksCache.parsedKeys.map((key, index) => (
|
{currentHeadlessJwksCache.parsedKeys.map((key, index) => {
|
||||||
|
const normalizedAlgorithm = key.alg?.trim() ?? "";
|
||||||
|
const isMissingAlgorithm =
|
||||||
|
normalizedAlgorithm === "";
|
||||||
|
const isUnsupportedAlgorithm =
|
||||||
|
!isMissingAlgorithm &&
|
||||||
|
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
|
||||||
|
normalizedAlgorithm,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${key.kid || "key"}-${index}`}
|
key={`${key.kid || "key"}-${index}`}
|
||||||
className="rounded-xl border border-border bg-muted/30 p-3"
|
className={cn(
|
||||||
|
"rounded-xl border bg-muted/30 p-3",
|
||||||
|
isUnsupportedAlgorithm || isMissingAlgorithm
|
||||||
|
? "border-destructive/50 bg-destructive/5"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1348,9 +1395,36 @@ function ClientGeneralPage() {
|
|||||||
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||||
ALG
|
ALG
|
||||||
</p>
|
</p>
|
||||||
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
|
<p
|
||||||
{key.alg || "-"}
|
className={cn(
|
||||||
|
"break-all rounded-lg border bg-background px-3 py-2 font-mono text-[11px]",
|
||||||
|
isUnsupportedAlgorithm || isMissingAlgorithm
|
||||||
|
? "border-destructive/50 text-destructive"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{key.alg ||
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.general.public_key.cache.missing_algorithm_badge",
|
||||||
|
"알고리즘 미선언",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{isMissingAlgorithm && (
|
||||||
|
<p className="text-[11px] text-destructive">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.public_key.cache.missing_algorithm_reason",
|
||||||
|
"이 키는 `alg`가 비어 있어서 저장할 수 없습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isUnsupportedAlgorithm && (
|
||||||
|
<p className="text-[11px] text-destructive">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason",
|
||||||
|
"이 알고리즘은 Headless Login에서 지원되지 않습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1">
|
<div className="mt-3 space-y-1">
|
||||||
@@ -1365,7 +1439,8 @@ function ClientGeneralPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
makeClient("client-headless-login", {
|
makeClient("client-headless-login", {
|
||||||
name: "Headless Login App",
|
name: "Headless Login App",
|
||||||
type: "pkce",
|
type: "pkce",
|
||||||
|
metadata: {
|
||||||
|
request_object_signing_alg: "RS256",
|
||||||
|
},
|
||||||
headlessJwksCache: {
|
headlessJwksCache: {
|
||||||
clientId: "client-headless-login",
|
clientId: "client-headless-login",
|
||||||
jwksUri,
|
jwksUri,
|
||||||
@@ -185,8 +188,9 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("radio", { name: /Inline Public Key|Inline/i }),
|
page.getByText(/Request Object Signing Algorithm/i),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
|
await expect(page.getByText(/Allowed algorithms|허용 알고리즘/i)).toHaveCount(0);
|
||||||
await page
|
await page
|
||||||
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
||||||
.fill(jwksUri);
|
.fill(jwksUri);
|
||||||
@@ -206,6 +210,9 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
|
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
|
||||||
.toBe(jwksUri);
|
.toBe(jwksUri);
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.metadata?.request_object_signing_alg)
|
||||||
|
.toBeUndefined();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
|
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
|
||||||
@@ -213,23 +220,6 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
|
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
|
||||||
await expect(page.getByText(/^KID$/i)).toBeVisible();
|
await expect(page.getByText(/^KID$/i)).toBeVisible();
|
||||||
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
|
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
|
||||||
await expect(
|
|
||||||
page.getByText(/Allowed algorithms|허용 알고리즘/i),
|
|
||||||
).toBeVisible();
|
|
||||||
for (const algorithm of [
|
|
||||||
"RS256",
|
|
||||||
"RS384",
|
|
||||||
"RS512",
|
|
||||||
"PS256",
|
|
||||||
"PS384",
|
|
||||||
"PS512",
|
|
||||||
"ES256",
|
|
||||||
"ES384",
|
|
||||||
"ES512",
|
|
||||||
"EdDSA",
|
|
||||||
]) {
|
|
||||||
await expect(page.getByText(algorithm, { exact: true }).last()).toBeVisible();
|
|
||||||
}
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(
|
page.getByText(
|
||||||
"voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
|
"voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
|
||||||
@@ -268,4 +258,104 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i })).toHaveValue(jwksUri);
|
await expect(page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i })).toHaveValue(jwksUri);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = {
|
||||||
|
clients: [
|
||||||
|
makeClient("client-headless-unsupported", {
|
||||||
|
name: "Unsupported Headless Login App",
|
||||||
|
type: "pkce",
|
||||||
|
metadata: {
|
||||||
|
headless_login_enabled: true,
|
||||||
|
request_object_signing_alg: "RS256",
|
||||||
|
},
|
||||||
|
headlessJwksCache: {
|
||||||
|
clientId: "client-headless-unsupported",
|
||||||
|
jwksUri,
|
||||||
|
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||||
|
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastRefreshStatus: "success",
|
||||||
|
lastError: "",
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
cachedKids: ["kid-unsupported"],
|
||||||
|
parsedKeys: [
|
||||||
|
{
|
||||||
|
kid: "kid-unsupported",
|
||||||
|
kty: "RSA",
|
||||||
|
use: "sig",
|
||||||
|
alg: "HS256",
|
||||||
|
n: "unsupported-n-value",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-headless-unsupported/settings");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
||||||
|
.fill(jwksUri);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^저장$|^Save$/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = {
|
||||||
|
clients: [
|
||||||
|
makeClient("client-headless-missing-alg", {
|
||||||
|
name: "Missing Alg Headless Login App",
|
||||||
|
type: "pkce",
|
||||||
|
metadata: {
|
||||||
|
headless_login_enabled: true,
|
||||||
|
headless_jwks_uri: jwksUri,
|
||||||
|
},
|
||||||
|
headlessJwksCache: {
|
||||||
|
clientId: "client-headless-missing-alg",
|
||||||
|
jwksUri,
|
||||||
|
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||||
|
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastRefreshStatus: "success",
|
||||||
|
lastError: "",
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
cachedKids: ["kid-missing-alg"],
|
||||||
|
parsedKeys: [
|
||||||
|
{
|
||||||
|
kid: "kid-missing-alg",
|
||||||
|
kty: "RSA",
|
||||||
|
use: "sig",
|
||||||
|
alg: "",
|
||||||
|
n: "missing-alg-n-value",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-headless-missing-alg/settings");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^저장$|^Save$/i })).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user