feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
// 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 (
|
2026-03-13 02:18:36 +01:00
|
|
|
"context"
|
|
|
|
|
"crypto/rsa"
|
|
|
|
|
"crypto/x509"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"encoding/pem"
|
|
|
|
|
"flag"
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
"fmt"
|
Give the runtime real readiness, graceful shutdown and stated limits
Closes gap G08. /healthz returned a constant without probing anything, the server
called ListenAndServe with no signal handling, and the operational limits of
in-memory state, startup-loaded keys and local-only logout lived in code comments
rather than anywhere an operator would look.
/readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and
probes nothing. Keeping them distinct matters: wiring liveness to dependency
health means an orchestrator restarts KeyCape when a dependency blinks, and a
restart also discards every in-flight login, so the reaction is worse than the
condition it reacts to.
LLDAP is probed with a bind rather than a dial, since a rotated or revoked
service password leaves the port open and every lookup failing -- exactly what
readiness should catch and exactly what a dial would miss. The response names the
failing check but never the reason: the endpoint is unauthenticated and upstream
error text carries hostnames and sometimes credentials-in-URLs. Results are
cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded
upstream traffic, and probes run concurrently under a 3s bound so a hung
dependency makes the endpoint answer rather than hang with it.
SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write
timeouts so a stuck request cannot outlive the window before SIGKILL.
docs/operations.md states the single-replica topology and why, and three limits
easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for
consumers caching JWKS, removing a client does not revoke its issued tokens, and
/logout is local only. No throughput figures are given, since nothing here
benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08
allows.
Verified in the running executable: 503 naming all three checks failed while
/healthz returned 200, the LLDAP check flipping to ok once started, and 40/40
requests succeeding across a SIGTERM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:43:49 +02:00
|
|
|
"keycape/internal/server/readiness"
|
2026-03-13 02:18:36 +01:00
|
|
|
"net/http"
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
"os"
|
Give the runtime real readiness, graceful shutdown and stated limits
Closes gap G08. /healthz returned a constant without probing anything, the server
called ListenAndServe with no signal handling, and the operational limits of
in-memory state, startup-loaded keys and local-only logout lived in code comments
rather than anywhere an operator would look.
/readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and
probes nothing. Keeping them distinct matters: wiring liveness to dependency
health means an orchestrator restarts KeyCape when a dependency blinks, and a
restart also discards every in-flight login, so the reaction is worse than the
condition it reacts to.
LLDAP is probed with a bind rather than a dial, since a rotated or revoked
service password leaves the port open and every lookup failing -- exactly what
readiness should catch and exactly what a dial would miss. The response names the
failing check but never the reason: the endpoint is unauthenticated and upstream
error text carries hostnames and sometimes credentials-in-URLs. Results are
cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded
upstream traffic, and probes run concurrently under a 3s bound so a hung
dependency makes the endpoint answer rather than hang with it.
SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write
timeouts so a stuck request cannot outlive the window before SIGKILL.
docs/operations.md states the single-replica topology and why, and three limits
easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for
consumers caching JWKS, removing a client does not revoke its issued tokens, and
/logout is local only. No throughput figures are given, since nothing here
benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08
allows.
Verified in the running executable: 503 naming all three checks failed while
/healthz returned 200, the LLDAP check flipping to ok once started, and 40/40
requests succeeding across a SIGTERM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:43:49 +02:00
|
|
|
"os/signal"
|
2026-03-13 02:18:36 +01:00
|
|
|
"strings"
|
Give the runtime real readiness, graceful shutdown and stated limits
Closes gap G08. /healthz returned a constant without probing anything, the server
called ListenAndServe with no signal handling, and the operational limits of
in-memory state, startup-loaded keys and local-only logout lived in code comments
rather than anywhere an operator would look.
/readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and
probes nothing. Keeping them distinct matters: wiring liveness to dependency
health means an orchestrator restarts KeyCape when a dependency blinks, and a
restart also discards every in-flight login, so the reaction is worse than the
condition it reacts to.
LLDAP is probed with a bind rather than a dial, since a rotated or revoked
service password leaves the port open and every lookup failing -- exactly what
readiness should catch and exactly what a dial would miss. The response names the
failing check but never the reason: the endpoint is unauthenticated and upstream
error text carries hostnames and sometimes credentials-in-URLs. Results are
cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded
upstream traffic, and probes run concurrently under a 3s bound so a hung
dependency makes the endpoint answer rather than hang with it.
SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write
timeouts so a stuck request cannot outlive the window before SIGKILL.
docs/operations.md states the single-replica topology and why, and three limits
easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for
consumers caching JWKS, removing a client does not revoke its issued tokens, and
/logout is local only. No throughput figures are given, since nothing here
benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08
allows.
Verified in the running executable: 503 naming all three checks failed while
/healthz returned 200, the LLDAP check flipping to ok once started, and 40/40
requests succeeding across a SIGTERM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:43:49 +02:00
|
|
|
"syscall"
|
2026-03-13 02:18:36 +01:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/rs/zerolog"
|
|
|
|
|
|
|
|
|
|
"keycape/internal/adapters/authelia"
|
|
|
|
|
"keycape/internal/adapters/lldap"
|
|
|
|
|
"keycape/internal/adapters/privacyidea"
|
2026-09-08 08:59:40 +02:00
|
|
|
"keycape/internal/adapters/tenantengine"
|
2026-09-05 01:08:58 +02:00
|
|
|
"keycape/internal/authclient"
|
2026-03-13 02:18:36 +01:00
|
|
|
"keycape/internal/config"
|
|
|
|
|
"keycape/internal/domain"
|
|
|
|
|
servererrors "keycape/internal/server/errors"
|
|
|
|
|
"keycape/internal/server/oidc"
|
|
|
|
|
"keycape/internal/server/telemetry"
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-13 02:18:36 +01:00
|
|
|
const version = "0.1.0"
|
|
|
|
|
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
func main() {
|
2026-09-05 01:08:58 +02:00
|
|
|
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token") {
|
|
|
|
|
if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil {
|
|
|
|
|
fmt.Fprintln(os.Stderr, err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 02:18:36 +01:00
|
|
|
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.
|
|
|
|
|
// -----------------------------------------------------------------
|
2026-07-27 20:03:07 +02:00
|
|
|
clients, err := buildClientRegistry(cfg.Clients)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Msg("failed to build client registry")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
2026-03-13 02:18:36 +01:00
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 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",
|
2026-08-16 01:05:27 +02:00
|
|
|
EndSessionEndpoint: issuer + "/logout",
|
Reconcile the canonical model and discovery with the runtime
Closes gap G02 of the scope assessment for the client-registration and discovery
surface. spec/canonical-model.yaml and domain/model.go both claimed to be the
source of truth and disagreed: the spec restricted grants to authorization_code,
required redirect URIs of every client, and omitted the audience, service
subject, tenant, role, MFA and handoff fields the runtime reads.
The durable part is the link, not the edit. A two-way conformance test compares
the spec against the Go model by reflection and fails when a runtime field has
no spec entry or a spec entry is not read by the runtime, the latter unless
marked runtime: false. It found drift beyond the assessment's list on its first
run -- User.tenant was undeclared -- which is the argument for the check over a
one-time reconciliation.
Discovery now advertises the core profile claims that appear on every token and
derives scopes_supported from the registered clients rather than a fixed list.
The Go model is stated as the runtime authority and the YAML as the reviewed
contract, in both files. This covers client registration and discovery, not
schema enforcement in general, which remains G06.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-07 00:22:49 +02:00
|
|
|
Clients: clients,
|
2026-03-13 02:18:36 +01:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// JWKS.
|
|
|
|
|
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
|
|
|
|
|
|
|
|
|
|
// Authorize handler (with enforcement middleware).
|
2026-08-16 01:05:27 +02:00
|
|
|
logins := oidc.NewLoginSessionStore()
|
2026-03-13 02:18:36 +01:00
|
|
|
authorizeHandler := &oidc.AuthorizeHandler{
|
|
|
|
|
ClientConfig: clients,
|
|
|
|
|
Auth: autheliaAdapter,
|
|
|
|
|
MFA: privacyIDEAAdapter,
|
|
|
|
|
Sessions: sessions,
|
2026-08-16 01:05:27 +02:00
|
|
|
Logins: logins,
|
|
|
|
|
Handoffs: oidc.NewHandoffStore(),
|
|
|
|
|
Issuer: issuer,
|
2026-03-13 02:18:36 +01:00
|
|
|
Emitter: emitter,
|
|
|
|
|
}
|
|
|
|
|
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
|
|
|
|
|
mux.Handle("/authorize/callback", authorizeHandler)
|
2026-08-16 01:05:27 +02:00
|
|
|
mux.Handle("/authorize/return", authorizeHandler)
|
|
|
|
|
mux.Handle("/authorize/register", authorizeHandler)
|
|
|
|
|
mux.Handle("/logout", &oidc.LogoutHandler{
|
|
|
|
|
ClientConfig: clients,
|
|
|
|
|
Logins: logins,
|
|
|
|
|
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"),
|
|
|
|
|
})
|
2026-03-13 02:18:36 +01:00
|
|
|
|
|
|
|
|
// Token handler (with enforcement middleware).
|
|
|
|
|
tokenHandler := &oidc.TokenHandler{
|
|
|
|
|
ClientConfig: clients,
|
|
|
|
|
Sessions: sessions,
|
|
|
|
|
Users: lldapAdapter,
|
2026-07-27 20:03:07 +02:00
|
|
|
SigningKey: privateKey,
|
2026-03-13 02:18:36 +01:00
|
|
|
Issuer: issuer,
|
|
|
|
|
TokenLifetime: tokenLifetime,
|
|
|
|
|
Emitter: emitter,
|
2026-09-08 08:59:40 +02:00
|
|
|
// Opt-in: nil unless tenantEngine.baseURL is configured, which is what
|
|
|
|
|
// leaves tenant_roles off the stock server (KEY-WP-0024). The adapter
|
|
|
|
|
// fails open, so a configured-but-unreachable cache omits the claim
|
|
|
|
|
// rather than failing issuance.
|
|
|
|
|
TenantEngine: buildTenantEngine(cfg.TenantEngine),
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|
|
|
|
|
mux.Handle("/token", enforcement.Middleware(tokenHandler))
|
|
|
|
|
|
|
|
|
|
// Userinfo handler.
|
|
|
|
|
mux.Handle("/userinfo", &oidc.UserinfoHandler{
|
2026-07-27 20:03:07 +02:00
|
|
|
Users: lldapAdapter,
|
2026-03-13 02:18:36 +01:00
|
|
|
SigningKey: &privateKey.PublicKey,
|
2026-07-27 20:03:07 +02:00
|
|
|
Issuer: issuer,
|
|
|
|
|
Emitter: emitter,
|
2026-03-13 02:18:36 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Healthz.
|
Give the runtime real readiness, graceful shutdown and stated limits
Closes gap G08. /healthz returned a constant without probing anything, the server
called ListenAndServe with no signal handling, and the operational limits of
in-memory state, startup-loaded keys and local-only logout lived in code comments
rather than anywhere an operator would look.
/readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and
probes nothing. Keeping them distinct matters: wiring liveness to dependency
health means an orchestrator restarts KeyCape when a dependency blinks, and a
restart also discards every in-flight login, so the reaction is worse than the
condition it reacts to.
LLDAP is probed with a bind rather than a dial, since a rotated or revoked
service password leaves the port open and every lookup failing -- exactly what
readiness should catch and exactly what a dial would miss. The response names the
failing check but never the reason: the endpoint is unauthenticated and upstream
error text carries hostnames and sometimes credentials-in-URLs. Results are
cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded
upstream traffic, and probes run concurrently under a 3s bound so a hung
dependency makes the endpoint answer rather than hang with it.
SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write
timeouts so a stuck request cannot outlive the window before SIGKILL.
docs/operations.md states the single-replica topology and why, and three limits
easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for
consumers caching JWKS, removing a client does not revoke its issued tokens, and
/logout is local only. No throughput figures are given, since nothing here
benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08
allows.
Verified in the running executable: 503 naming all three checks failed while
/healthz returned 200, the LLDAP check flipping to ok once started, and 40/40
requests succeeding across a SIGTERM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:43:49 +02:00
|
|
|
// /readyz gates traffic on the dependencies a login needs; /healthz stays a
|
|
|
|
|
// liveness signal (KEY-WP-0025). Restarting KeyCape because LLDAP blinked
|
|
|
|
|
// would convert a dependency blip into an outage of our own, so the two must
|
|
|
|
|
// not be the same endpoint.
|
|
|
|
|
mux.Handle("/readyz", readiness.New(2*time.Second, 3*time.Second,
|
|
|
|
|
readiness.Check{Name: "lldap", Probe: func(ctx context.Context) error {
|
|
|
|
|
return lldapAdapter.Ping(ctx)
|
|
|
|
|
}},
|
|
|
|
|
readiness.Check{Name: "authelia", Probe: httpReachable(cfg.Authelia.TokenBaseURL, cfg.Authelia.BaseURL)},
|
|
|
|
|
readiness.Check{Name: "privacyidea", Probe: httpReachable(cfg.PrivacyIDEA.BaseURL)},
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
// Liveness only: this says the process is up, and deliberately probes
|
|
|
|
|
// nothing. See /readyz.
|
2026-03-13 02:18:36 +01:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
Give the runtime real readiness, graceful shutdown and stated limits
Closes gap G08. /healthz returned a constant without probing anything, the server
called ListenAndServe with no signal handling, and the operational limits of
in-memory state, startup-loaded keys and local-only logout lived in code comments
rather than anywhere an operator would look.
/readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and
probes nothing. Keeping them distinct matters: wiring liveness to dependency
health means an orchestrator restarts KeyCape when a dependency blinks, and a
restart also discards every in-flight login, so the reaction is worse than the
condition it reacts to.
LLDAP is probed with a bind rather than a dial, since a rotated or revoked
service password leaves the port open and every lookup failing -- exactly what
readiness should catch and exactly what a dial would miss. The response names the
failing check but never the reason: the endpoint is unauthenticated and upstream
error text carries hostnames and sometimes credentials-in-URLs. Results are
cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded
upstream traffic, and probes run concurrently under a 3s bound so a hung
dependency makes the endpoint answer rather than hang with it.
SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write
timeouts so a stuck request cannot outlive the window before SIGKILL.
docs/operations.md states the single-replica topology and why, and three limits
easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for
consumers caching JWKS, removing a client does not revoke its issued tokens, and
/logout is local only. No throughput figures are given, since nothing here
benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08
allows.
Verified in the running executable: 503 naming all three checks failed while
/healthz returned 200, the LLDAP check flipping to ok once started, and 40/40
requests succeeding across a SIGTERM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:43:49 +02:00
|
|
|
// Serve in the background so shutdown can be driven by a signal. Without
|
|
|
|
|
// this the process died on SIGTERM mid-request, so every rolling restart
|
|
|
|
|
// returned errors to whoever was logging in at that moment.
|
|
|
|
|
serveErr := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
|
|
|
serveErr <- err
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
serveErr <- nil
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
signals := make(chan os.Signal, 1)
|
|
|
|
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case err := <-serveErr:
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Msg("server error")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
case sig := <-signals:
|
|
|
|
|
log.Info().Str("signal", sig.String()).Msg("shutting down")
|
|
|
|
|
// In-flight requests get a bounded window to finish. In-memory login and
|
|
|
|
|
// authorization state does not survive this and is not meant to: a
|
|
|
|
|
// browser mid-login must start again after a restart. That is a
|
|
|
|
|
// documented limit of the single-replica topology, not a bug to paper
|
|
|
|
|
// over here -- see docs/operations.md.
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
|
|
|
log.Error().Err(err).Msg("graceful shutdown failed; exiting anyway")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
log.Info().Msg("shutdown complete")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// shutdownGrace bounds how long in-flight requests may finish after a signal.
|
|
|
|
|
// Kept under the 30s read/write timeouts so a stuck request cannot outlive the
|
|
|
|
|
// window an orchestrator typically allows before sending SIGKILL.
|
|
|
|
|
const shutdownGrace = 15 * time.Second
|
|
|
|
|
|
|
|
|
|
// httpReachable probes the first non-empty URL with a GET, reporting whether the
|
|
|
|
|
// dependency answered at all. It is a reachability check, not a functional one:
|
|
|
|
|
// any HTTP response means something is listening and speaking HTTP there.
|
|
|
|
|
func httpReachable(candidates ...string) func(context.Context) error {
|
|
|
|
|
target := ""
|
|
|
|
|
for _, candidate := range candidates {
|
|
|
|
|
if candidate != "" {
|
|
|
|
|
target = candidate
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return func(ctx context.Context) error {
|
|
|
|
|
if target == "" {
|
|
|
|
|
return fmt.Errorf("no endpoint configured")
|
|
|
|
|
}
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
return nil
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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.
|
2026-07-27 20:03:07 +02:00
|
|
|
func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.Client, error) {
|
2026-03-13 02:18:36 +01:00
|
|
|
m := make(map[string]*domain.Client, len(cfgClients))
|
|
|
|
|
for i := range cfgClients {
|
|
|
|
|
c := &cfgClients[i]
|
2026-07-27 20:03:07 +02:00
|
|
|
clientSecret := ""
|
2026-08-23 13:10:13 +02:00
|
|
|
var clientTokenLifetime time.Duration
|
2026-07-27 20:03:07 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-23 13:10:13 +02:00
|
|
|
if c.TokenLifetime != "" {
|
|
|
|
|
parsedLifetime, parseErr := time.ParseDuration(c.TokenLifetime)
|
|
|
|
|
if parseErr != nil {
|
|
|
|
|
return nil, fmt.Errorf("client %q tokenLifetime is invalid: %w", c.ClientID, parseErr)
|
|
|
|
|
}
|
|
|
|
|
clientTokenLifetime = parsedLifetime
|
|
|
|
|
}
|
2026-03-13 02:18:36 +01:00
|
|
|
m[c.ClientID] = &domain.Client{
|
2026-08-23 13:10:13 +02:00
|
|
|
ClientID: c.ClientID,
|
|
|
|
|
DisplayName: c.DisplayName,
|
|
|
|
|
RedirectURIs: c.RedirectURIs,
|
|
|
|
|
AllowedScopes: c.AllowedScopes,
|
|
|
|
|
GrantTypes: c.GrantTypes,
|
|
|
|
|
ClientType: c.ClientType,
|
|
|
|
|
SecretRef: c.SecretRef,
|
|
|
|
|
ClientSecret: clientSecret,
|
2026-09-05 00:41:17 +02:00
|
|
|
Audience: c.Audience,
|
2026-08-23 13:10:13 +02:00
|
|
|
ServiceSubject: c.ServiceSubject,
|
|
|
|
|
Tenant: c.Tenant,
|
2026-08-16 01:05:27 +02:00
|
|
|
Roles: c.Roles,
|
2026-08-23 13:10:13 +02:00
|
|
|
TokenLifetime: clientTokenLifetime,
|
2026-08-16 01:05:27 +02:00
|
|
|
MFARequired: c.MFARequired,
|
|
|
|
|
RegistrationURL: c.RegistrationURL,
|
|
|
|
|
EnrollmentURL: c.EnrollmentURL,
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 20:03:07 +02:00
|
|
|
return m, nil
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
})
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
}
|
2026-09-08 08:59:40 +02:00
|
|
|
|
|
|
|
|
// buildTenantEngine returns the tenant_roles cache client, or nil when the
|
|
|
|
|
// claim is not configured. Validation has already accepted the URL and timeout.
|
|
|
|
|
func buildTenantEngine(cfg config.TenantEngineConfig) *tenantengine.Client {
|
|
|
|
|
if cfg.BaseURL == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
var httpClient *http.Client
|
|
|
|
|
if cfg.Timeout != "" {
|
|
|
|
|
if timeout, err := time.ParseDuration(cfg.Timeout); err == nil {
|
|
|
|
|
httpClient = &http.Client{Timeout: timeout}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return tenantengine.New(cfg.BaseURL, httpClient)
|
|
|
|
|
}
|