1
0
forked from baron/baron-sso

headless login SSA 백엔드 작업

This commit is contained in:
2026-05-04 14:58:31 +09:00
parent b888b33cde
commit 9f78698f54
3 changed files with 125 additions and 39 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")
}
// [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")))
if status != "active" && status != "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 {
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, ""))
if tokenAuthMethod == "" {
@@ -1709,11 +1710,10 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
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())
}
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
clientType,
tokenAuthMethod,
valueOr(req.JwksUri, ""),
req.Jwks,
@@ -1900,21 +1900,21 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
if clientType != "" {
resolvedClientType = clientType
}
resolvedClientType = normalizeClientTypeForHeadless(resolvedClientType, metadata)
resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod)
resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri)
resolvedJWKS := req.Jwks
if req.Jwks == nil {
if resolvedClientType == "pkce" && readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
resolvedJWKS = nil
} else {
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())
}
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
resolvedClientType,
resolvedTokenAuthMethod,
resolvedJWKSURI,
resolvedJWKS,
@@ -2633,12 +2633,10 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
}
clientType := "private"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
if client.IsHeadlessLoginEnabled() {
clientType = "private"
} else if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
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)
@@ -2786,7 +2784,6 @@ func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[stri
}
func normalizeHeadlessClientConfig(
clientType string,
tokenAuthMethod string,
jwksURI string,
jwks interface{},
@@ -2798,12 +2795,12 @@ func normalizeHeadlessClientConfig(
delete(metadata, domain.MetadataRequestObjectSigningAlg)
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
if clientType == "pkce" && headlessEnabled {
if headlessEnabled {
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)
}
if headlessTokenAuthMethod == "" {
if headlessTokenAuthMethod == "" || strings.EqualFold(headlessTokenAuthMethod, "none") {
headlessTokenAuthMethod = "private_key_jwt"
}
metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod
@@ -2820,7 +2817,7 @@ func normalizeHeadlessClientConfig(
delete(metadata, domain.MetadataHeadlessJWKS)
return "none", "", nil, metadata
return headlessTokenAuthMethod, headlessJWKSURI, nil, metadata
}
delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
@@ -2829,8 +2826,8 @@ func normalizeHeadlessClientConfig(
return tokenAuthMethod, jwksURI, jwks, metadata
}
func validateHeadlessClientInput(clientType string, jwksURI string, jwks interface{}, metadata map[string]interface{}) error {
if clientType != "pkce" || !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[string]interface{}) error {
if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
return nil
}
@@ -2848,6 +2845,13 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa
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) {
if metadata == nil {
return nil, nil

View File

@@ -1467,7 +1467,7 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"name": "Headless Login App",
"type": "pkce",
"type": "private",
"redirectUris": []string{"https://rp.example.com/callback"},
"scopes": []string{"openid", "profile"},
"tokenEndpointAuthMethod": "private_key_jwt",
@@ -1482,7 +1482,8 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
resp, _ := app.Test(req, -1)
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.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"])
@@ -1633,6 +1634,87 @@ func TestCreateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
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) {
var hydraCalled bool
h := &DevHandler{
@@ -1895,7 +1977,7 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"name": "Headless Login After",
"type": "pkce",
"type": "private",
"tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
"metadata": map[string]any{
@@ -1908,8 +1990,8 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
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, "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"])
_, hasInlineJWKS := captured.Metadata["headless_jwks"]
@@ -2071,7 +2153,8 @@ func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) {
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, "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"])
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
assert.False(t, hasRequestObjectAlg)

View File

@@ -981,10 +981,8 @@ function ClientGeneralPage() {
.map((scope) => scope.name.trim())
.filter(Boolean);
const persistedClientType = headlessLoginEnabled ? "pkce" : clientType;
const effectiveTokenEndpointAuthMethod = headlessLoginEnabled
? "none"
: tokenEndpointAuthMethod;
const persistedClientType = headlessLoginEnabled ? "private" : clientType;
const effectiveTokenEndpointAuthMethod = tokenEndpointAuthMethod;
const payload: ClientUpsertRequest = {
name,
@@ -992,7 +990,8 @@ function ClientGeneralPage() {
scopes: scopeNames,
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri:
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
(headlessLoginEnabled ||
effectiveTokenEndpointAuthMethod === "private_key_jwt") &&
trimmedJwksUri
? trimmedJwksUri
: undefined,