2026-03-13 01:45:21 +01:00
|
|
|
// Package errors implements the unsupported feature enforcement layer for KeyCape.
|
|
|
|
|
// Every request passes through the Registry middleware before reaching any handler.
|
|
|
|
|
// If a registered feature is detected the middleware writes a ProfileError JSON
|
|
|
|
|
// response, emits an EventUnsupportedFeature telemetry event, and short-circuits
|
|
|
|
|
// the handler chain. Adding a new unsupported feature requires only a call to
|
|
|
|
|
// Register — no handler changes are needed.
|
|
|
|
|
package errors
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
profileerrors "keycape/internal/errors"
|
|
|
|
|
"keycape/internal/server/telemetry"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// UnsupportedFeature describes a profile boundary that KeyCape enforces.
|
|
|
|
|
type UnsupportedFeature struct {
|
|
|
|
|
// Name is a stable string identifier used in telemetry and error payloads.
|
|
|
|
|
Name string
|
|
|
|
|
// ErrorType is the profile error category emitted when this feature is triggered.
|
|
|
|
|
ErrorType profileerrors.ErrorType
|
|
|
|
|
// Description is a human-readable explanation of why the feature is blocked.
|
|
|
|
|
Description string
|
|
|
|
|
// Detector reports whether the given request triggers this feature.
|
|
|
|
|
Detector func(r *http.Request) bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Registry holds all known unsupported features and exposes middleware that
|
|
|
|
|
// enforces them on every incoming request.
|
|
|
|
|
type Registry struct {
|
|
|
|
|
features []UnsupportedFeature
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewRegistry returns an empty Registry. Use Register to add features and
|
|
|
|
|
// DefaultRegistry to obtain one pre-populated with the spec-mandated set.
|
|
|
|
|
func NewRegistry() *Registry {
|
|
|
|
|
return &Registry{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register appends a feature to the registry. Registered features are checked
|
|
|
|
|
// in insertion order; the first match wins.
|
|
|
|
|
func (reg *Registry) Register(f UnsupportedFeature) {
|
|
|
|
|
reg.features = append(reg.features, f)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Middleware returns an http.Handler that evaluates all registered features
|
|
|
|
|
// for every request before delegating to next.
|
|
|
|
|
//
|
|
|
|
|
// If a feature is triggered:
|
|
|
|
|
// - A ProfileError JSON response is written with an appropriate HTTP status.
|
|
|
|
|
// - An EventUnsupportedFeature telemetry event is emitted via the Emitter
|
|
|
|
|
// stored in the request context (a NoopEmitter is used when none is set).
|
|
|
|
|
// - next is NOT called.
|
|
|
|
|
//
|
|
|
|
|
// If no feature matches, next is called normally.
|
|
|
|
|
func (reg *Registry) Middleware(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
for _, f := range reg.features {
|
|
|
|
|
if f.Detector(r) {
|
|
|
|
|
pe := &profileerrors.ProfileError{
|
|
|
|
|
Error: f.ErrorType,
|
|
|
|
|
Description: f.Description,
|
|
|
|
|
Feature: f.Name,
|
|
|
|
|
}
|
|
|
|
|
pe.Write(w, httpStatusFor(f.ErrorType))
|
|
|
|
|
|
|
|
|
|
em := telemetry.EmitterFromContext(r.Context())
|
|
|
|
|
em.Emit(r.Context(), telemetry.Event{
|
|
|
|
|
Timestamp: time.Now().UTC(),
|
|
|
|
|
EventType: telemetry.EventUnsupportedFeature,
|
|
|
|
|
Feature: f.Name,
|
|
|
|
|
ErrorType: string(f.ErrorType),
|
|
|
|
|
Endpoint: r.URL.Path,
|
|
|
|
|
Result: "failure",
|
|
|
|
|
Environment: "",
|
|
|
|
|
TraceID: "",
|
|
|
|
|
ClientID: r.URL.Query().Get("client_id"),
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// httpStatusFor maps an ErrorType to its canonical HTTP status code.
|
|
|
|
|
func httpStatusFor(et profileerrors.ErrorType) int {
|
|
|
|
|
switch et {
|
|
|
|
|
case profileerrors.ErrInvalidProfileUsage:
|
|
|
|
|
return http.StatusBadRequest
|
|
|
|
|
case profileerrors.ErrRejectedForSafety:
|
|
|
|
|
return http.StatusForbidden
|
|
|
|
|
case profileerrors.ErrKeycloakModeOnly:
|
|
|
|
|
return http.StatusNotImplemented
|
|
|
|
|
default: // ErrFeatureNotSupported
|
|
|
|
|
return http.StatusNotImplemented
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Default feature set (spec §4 — normative).
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// DefaultRegistry returns a Registry pre-populated with all spec-mandated
|
|
|
|
|
// unsupported features. No handler changes are required to enforce new entries.
|
|
|
|
|
func DefaultRegistry() *Registry {
|
|
|
|
|
reg := NewRegistry()
|
|
|
|
|
|
|
|
|
|
// 1. Dynamic client registration (RFC 7591) — not in the profile.
|
|
|
|
|
reg.Register(UnsupportedFeature{
|
|
|
|
|
Name: "dynamic_client_registration",
|
|
|
|
|
ErrorType: profileerrors.ErrFeatureNotSupported,
|
|
|
|
|
Description: "Dynamic client registration is not part of the NetKingdom IAM Profile. Register clients statically in KeyCape configuration.",
|
|
|
|
|
Detector: func(r *http.Request) bool {
|
|
|
|
|
return (r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/connect/register")) ||
|
|
|
|
|
strings.Contains(r.URL.Path, "registration")
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 2. Implicit flow — blocked for security.
|
|
|
|
|
reg.Register(UnsupportedFeature{
|
|
|
|
|
Name: "implicit_flow",
|
|
|
|
|
ErrorType: profileerrors.ErrRejectedForSafety,
|
|
|
|
|
Description: "The implicit flow (response_type=token or id_token) is rejected. Use the authorization code flow with PKCE.",
|
|
|
|
|
Detector: func(r *http.Request) bool {
|
|
|
|
|
rt := r.URL.Query().Get("response_type")
|
|
|
|
|
if rt == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
// Blocked when response_type contains "token" or "id_token" but NOT when it is exactly "code".
|
|
|
|
|
// "code token" (hybrid) is also blocked.
|
|
|
|
|
return rt == "token" || rt == "id_token" || strings.Contains(rt, "token") && rt != "code"
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 3. Wildcard redirect_uri — blocked for security.
|
|
|
|
|
reg.Register(UnsupportedFeature{
|
|
|
|
|
Name: "wildcard_redirect_uri",
|
|
|
|
|
ErrorType: profileerrors.ErrRejectedForSafety,
|
|
|
|
|
Description: "Wildcard redirect URIs are not permitted. Register exact redirect URIs in the client configuration.",
|
|
|
|
|
Detector: func(r *http.Request) bool {
|
|
|
|
|
return strings.Contains(r.URL.Query().Get("redirect_uri"), "*")
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 4. Identity brokering — available only in Keycloak mode.
|
|
|
|
|
reg.Register(UnsupportedFeature{
|
|
|
|
|
Name: "identity_broker",
|
|
|
|
|
ErrorType: profileerrors.ErrKeycloakModeOnly,
|
|
|
|
|
Description: "Identity brokering is available only in expanded (Keycloak) mode.",
|
|
|
|
|
Detector: func(r *http.Request) bool {
|
|
|
|
|
return strings.Contains(r.URL.Path, "/broker/")
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 5. PKCE plain method — blocked for security (must use S256).
|
|
|
|
|
// Registered BEFORE missing_pkce so a plain-method request is reported
|
|
|
|
|
// as pkce_plain_method, not missing_pkce.
|
|
|
|
|
reg.Register(UnsupportedFeature{
|
|
|
|
|
Name: "pkce_plain_method",
|
|
|
|
|
ErrorType: profileerrors.ErrRejectedForSafety,
|
|
|
|
|
Description: "PKCE plain code challenge method is not allowed. Use S256.",
|
|
|
|
|
Detector: func(r *http.Request) bool {
|
|
|
|
|
return r.URL.Query().Get("code_challenge_method") == "plain"
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 6. Missing PKCE on /authorize — invalid profile usage.
|
|
|
|
|
reg.Register(UnsupportedFeature{
|
|
|
|
|
Name: "missing_pkce",
|
|
|
|
|
ErrorType: profileerrors.ErrInvalidProfileUsage,
|
|
|
|
|
Description: "Requests to /authorize must include a code_challenge (PKCE S256 required).",
|
|
|
|
|
Detector: func(r *http.Request) bool {
|
|
|
|
|
return strings.HasSuffix(r.URL.Path, "/authorize") &&
|
|
|
|
|
r.URL.Query().Get("code_challenge") == ""
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 7. Unknown grant type on /token.
|
|
|
|
|
reg.Register(UnsupportedFeature{
|
|
|
|
|
Name: "unknown_grant_type",
|
|
|
|
|
ErrorType: profileerrors.ErrFeatureNotSupported,
|
2026-07-27 20:17:55 +02:00
|
|
|
Description: "Only authorization_code and client_credentials grant types are supported.",
|
2026-03-13 01:45:21 +01:00
|
|
|
Detector: func(r *http.Request) bool {
|
|
|
|
|
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/token") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
gt := r.URL.Query().Get("grant_type")
|
|
|
|
|
if gt == "" {
|
|
|
|
|
// Also check form body if already parsed — callers may pre-parse.
|
|
|
|
|
gt = r.FormValue("grant_type")
|
|
|
|
|
}
|
|
|
|
|
if gt == "" {
|
|
|
|
|
return false // no grant_type present; let the handler decide
|
|
|
|
|
}
|
2026-07-27 20:17:55 +02:00
|
|
|
return gt != "authorization_code" && gt != "client_credentials"
|
2026-03-13 01:45:21 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return reg
|
|
|
|
|
}
|