key-cape/src/cmd/keycape/main.go
tegwick 3bef507cb8
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 30s
KEY-WP-0008: honor per-client mfaRequired and acr_values step-up
Allow coulomb-social ordinary login at AAL1 via mfaRequired: false while
keeping provider requireForAll for clients without an override. Preserve
explicit acr_values=aal2 for step-up.
2026-08-09 22:42:51 +02:00

288 lines
8.9 KiB
Go

// keycape is the main server binary for the KeyCape IAM profile service.
// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
package main
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/rs/zerolog"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
"keycape/internal/config"
"keycape/internal/domain"
servererrors "keycape/internal/server/errors"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
const version = "0.1.0"
func main() {
log := zerolog.New(os.Stdout).With().Timestamp().Logger()
// -----------------------------------------------------------------
// 1. Parse flags and load config.
// -----------------------------------------------------------------
var cfgPath string
flag.StringVar(&cfgPath, "config", "", "path to YAML config file (env: KEYCAPE_CONFIG)")
flag.Parse()
cfg, err := config.Load(cfgPath)
if err != nil {
log.Error().Err(err).Msg("failed to load config")
os.Exit(1)
}
// -----------------------------------------------------------------
// 2. Validate config.
// -----------------------------------------------------------------
errs := config.ValidateConfig(cfg)
if len(errs) > 0 {
log.Error().Strs("errors", errs).Msg("config validation failed")
os.Exit(1)
}
// -----------------------------------------------------------------
// 3. Load RSA private key.
// -----------------------------------------------------------------
privateKey, err := loadPrivateKey(cfg.PrivateKeyPEM)
if err != nil {
log.Error().Err(err).Str("path", cfg.PrivateKeyPEM).Msg("failed to load private key")
os.Exit(1)
}
// -----------------------------------------------------------------
// 4. Build JWKS from public key.
// -----------------------------------------------------------------
ks := oidc.NewKeySet()
ks.AddKey("key-1", &privateKey.PublicKey)
// -----------------------------------------------------------------
// 5. Build client registry.
// -----------------------------------------------------------------
clients, err := buildClientRegistry(cfg.Clients)
if err != nil {
log.Error().Err(err).Msg("failed to build client registry")
os.Exit(1)
}
// -----------------------------------------------------------------
// 6. Create adapters.
// -----------------------------------------------------------------
lldapAdapter := lldap.New(cfg.LLDAP)
autheliaAdapter := authelia.New(cfg.Authelia, nil)
privacyIDEAAdapter := privacyidea.New(cfg.PrivacyIDEA, nil)
// -----------------------------------------------------------------
// 7. Create telemetry emitter.
// -----------------------------------------------------------------
emitter := telemetry.NewLogEmitter(log)
// -----------------------------------------------------------------
// 8. Create enforcement registry.
// -----------------------------------------------------------------
enforcement := servererrors.DefaultRegistry()
// -----------------------------------------------------------------
// 9. Create session store.
// -----------------------------------------------------------------
sessions := oidc.NewSessionStore()
// -----------------------------------------------------------------
// 10. Parse token lifetime.
// -----------------------------------------------------------------
tokenLifetime := 15 * time.Minute
if cfg.TokenLifetime != "" {
d, err := time.ParseDuration(cfg.TokenLifetime)
if err != nil {
log.Error().Err(err).Str("tokenLifetime", cfg.TokenLifetime).Msg("invalid tokenLifetime")
os.Exit(1)
}
tokenLifetime = d
}
// -----------------------------------------------------------------
// 11. Build issuer base URL.
// -----------------------------------------------------------------
issuer := strings.TrimRight(cfg.Issuer, "/")
// -----------------------------------------------------------------
// 12. Register HTTP handlers.
// -----------------------------------------------------------------
mux := http.NewServeMux()
// Discovery.
mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
JWKSUri: issuer + "/jwks",
UserinfoEndpoint: issuer + "/userinfo",
}))
// JWKS.
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
// Authorize handler (with enforcement middleware).
authorizeHandler := &oidc.AuthorizeHandler{
ClientConfig: clients,
Auth: autheliaAdapter,
MFA: privacyIDEAAdapter,
Sessions: sessions,
Emitter: emitter,
}
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler)
// Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{
ClientConfig: clients,
Sessions: sessions,
Users: lldapAdapter,
SigningKey: privateKey,
Issuer: issuer,
TokenLifetime: tokenLifetime,
Emitter: emitter,
}
mux.Handle("/token", enforcement.Middleware(tokenHandler))
// Userinfo handler.
mux.Handle("/userinfo", &oidc.UserinfoHandler{
Users: lldapAdapter,
SigningKey: &privateKey.PublicKey,
Issuer: issuer,
Emitter: emitter,
})
// Healthz.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": version,
})
})
// Inject emitter into request context.
handler := withEmitter(mux, emitter)
// -----------------------------------------------------------------
// 13. Start HTTP server.
// -----------------------------------------------------------------
addr := fmt.Sprintf(":%d", cfg.Port)
if cfg.Port == 0 {
addr = ":8080"
}
log.Info().
Str("issuer", issuer).
Str("addr", addr).
Str("environment", cfg.Environment).
Str("version", version).
Msg("starting keycape server")
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error().Err(err).Msg("server error")
os.Exit(1)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// loadPrivateKey reads a PEM file and parses the RSA private key.
// Supports both PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM blocks.
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block found in %q", path)
}
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS1 private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS8 private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not an RSA key")
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unexpected PEM block type %q; expected RSA PRIVATE KEY or PRIVATE KEY", block.Type)
}
}
// buildClientRegistry converts []ClientConfig into the map used by handlers.
func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.Client, error) {
m := make(map[string]*domain.Client, len(cfgClients))
for i := range cfgClients {
c := &cfgClients[i]
clientSecret := ""
if strings.HasPrefix(c.SecretRef, "env:") {
envName := strings.TrimPrefix(c.SecretRef, "env:")
clientSecret = os.Getenv(envName)
if clientSecret == "" {
return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName)
}
}
m[c.ClientID] = &domain.Client{
ClientID: c.ClientID,
DisplayName: c.DisplayName,
RedirectURIs: c.RedirectURIs,
AllowedScopes: c.AllowedScopes,
GrantTypes: c.GrantTypes,
ClientType: c.ClientType,
SecretRef: c.SecretRef,
ClientSecret: clientSecret,
ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant,
Roles: c.Roles,
MFARequired: c.MFARequired,
}
}
return m, nil
}
// withEmitter wraps a handler to inject the telemetry emitter into every request context.
func withEmitter(next http.Handler, e telemetry.Emitter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := telemetry.WithEmitter(context.Background(), e)
next.ServeHTTP(w, r.WithContext(ctx))
})
}