forked from baron/baron-sso
headless login SSA 백엔드 작업
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user