feat: implement T22, T18, T23 — dev stack, profile tests, server binary
- T22: docker-compose.dev.yml dev stack, Dockerfile, root Makefile - T18: Profile test suite (Scenario A) — 8 integration tests with real handlers - T23: Server binary wiring all components, config validation, /healthz - Config: ValidateConfig with startup validation 14 test packages pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fa27adbc77
commit
c18adb6441
9 changed files with 1345 additions and 2 deletions
|
|
@ -4,11 +4,268 @@
|
|||
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() {
|
||||
fmt.Fprintln(os.Stderr, "keycape server: not yet implemented (T05+)")
|
||||
os.Exit(1)
|
||||
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 := buildClientRegistry(cfg.Clients)
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 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 {
|
||||
m := make(map[string]*domain.Client, len(cfgClients))
|
||||
for i := range cfgClients {
|
||||
c := &cfgClients[i]
|
||||
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,
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue