mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
fix(auth): ARCH-002 — relax OIDC runtime guard, full Bundle-2 stack ships
Sprint 4 unified-master-audit closure. The README has advertised OIDC
SSO as a v2.1 feature (L18, L74) but cmd/server/main.go retained a
Bundle-2-Phase-0 runtime guard that os.Exit(1)'d the moment any
operator set CERTCTL_AUTH_TYPE=oidc:
CERTCTL_AUTH_TYPE=oidc: the OIDC auth chain is not yet wired in
this build (Auth Bundle 2 Phase 6 ships the session middleware
that consumes this auth-type literal).
That message was true when Phase 0 landed (the literal got reserved
in ValidAuthTypes ahead of the handler chain). It's been stale since
Phase 6 shipped. As of 2026-05-16 the full stack is live:
- session.NewService at cmd/server/main.go:394
- oidcsvc.NewService at cmd/server/main.go:436
- ChainAuthSessionThenBearer at cmd/server/main.go:2012
- csrfMiddleware at cmd/server/main.go:2017
- /auth/oidc/{login,callback,back-channel-logout} routes at router.go
- 6 OIDC handler files in internal/api/handler/
- 2,852 LOC in internal/auth/oidc/ + 1,632 LOC in internal/auth/session/
Fix:
- Introduce config.IsRuntimeSupportedAuthType(AuthType) as the
single source of truth for which auth-type literals the cmd/server
runtime guard accepts. The set is {api-key, none, oidc} —
every entry in ValidAuthTypes(). The helper exists so the test
suite can pin the invariant 'ValidAuthTypes ⊆ runtime-supported'
without grepping cmd/server source.
- cmd/server/main.go's switch collapses to a single
IsRuntimeSupportedAuthType check; the dedicated AuthTypeOIDC
fail-loud case is gone. The G-1 silent-auth-downgrade invariant
stays intact — 'jwt' is still rejected at config.Validate()
time (never made it into ValidAuthTypes()).
- internal/config/auth.go AuthTypeOIDC comment updated to reflect
the post-Phase-6 reality (it was prescriptive pre-fix:
'Once Bundle 2's session middleware + OIDC service ship, the
runtime guard relaxes' — that condition is met).
Regression coverage:
- TestIsRuntimeSupportedAuthType_AcceptsAllValidEntries — every
valid type is runtime-supported (catches future drift).
- TestIsRuntimeSupportedAuthType_AcceptsOIDC — explicit pin on
the ARCH-002 invariant.
- TestIsRuntimeSupportedAuthType_RejectsUnknown — 'jwt', empty,
'saml', 'mtls', 'API-KEY' all rejected.
(Also lands the ARCH-003 keygen-mode tests in the same file —
contiguous hunk in config_test.go.)
Closes ARCH-002.
This commit is contained in:
+19
-16
@@ -76,27 +76,30 @@ func main() {
|
||||
// the slog logger is constructed from cfg below this point; we want
|
||||
// the failure to be visible regardless of log-level configuration.
|
||||
//
|
||||
// Auth Bundle 2 Phase 0: AuthTypeOIDC is in ValidAuthTypes() but the
|
||||
// session middleware + OIDC handler chain ship in later phases. An
|
||||
// operator who sets CERTCTL_AUTH_TYPE=oidc on a Bundle-2-incomplete
|
||||
// deployment must NOT silently fall back to api-key (the silent
|
||||
// auth-downgrade failure mode that drove G-1 in the first place).
|
||||
// The OIDC case below refuses-to-start with an actionable message.
|
||||
// Phase 6 of Bundle 2 (session middleware wiring) relaxes this case
|
||||
// to fall through alongside the api-key + none cases.
|
||||
switch config.AuthType(cfg.Auth.Type) {
|
||||
case config.AuthTypeAPIKey, config.AuthTypeNone:
|
||||
// ok — fall through
|
||||
case config.AuthTypeOIDC:
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"CERTCTL_AUTH_TYPE=oidc: the OIDC auth chain is not yet wired in this build (Auth Bundle 2 Phase 6 ships the session middleware that consumes this auth-type literal). Set CERTCTL_AUTH_TYPE=api-key or run an authenticating gateway with CERTCTL_AUTH_TYPE=none until Bundle 2 lands. See cowork/auth-bundle-2-prompt.md.\n")
|
||||
os.Exit(1)
|
||||
default:
|
||||
// ARCH-002 closure (Sprint 4, 2026-05-16). Auth Bundle 2 is now
|
||||
// fully wired: session.NewService at L394 + oidcsvc.NewService at
|
||||
// L436 + ChainAuthSessionThenBearer at L2012 + the OIDC handler
|
||||
// routes (`/auth/oidc/login`, `/auth/oidc/callback`,
|
||||
// `/auth/oidc/back-channel-logout`) registered in
|
||||
// internal/api/router/router.go. The pre-ARCH-002 Phase-0 guard
|
||||
// that exited on AuthTypeOIDC made sense when the handler chain
|
||||
// was a stub; it became a stale fail-loud after Phase 6 shipped
|
||||
// and is the only thing that stopped CERTCTL_AUTH_TYPE=oidc from
|
||||
// being a viable production auth mode.
|
||||
//
|
||||
// Post-fix: oidc falls through alongside api-key + none. The
|
||||
// G-1 silent-auth-downgrade invariant stays intact — "jwt" is
|
||||
// still rejected at config.Validate() time (it never made it
|
||||
// into ValidAuthTypes()) and the default branch below still
|
||||
// refuses any other unrecognised value at runtime.
|
||||
if !config.IsRuntimeSupportedAuthType(config.AuthType(cfg.Auth.Type)) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"unsupported auth type at runtime: %q (valid: %v) — config validation should have caught this; refusing to start\n",
|
||||
cfg.Auth.Type, config.ValidAuthTypes())
|
||||
os.Exit(1)
|
||||
}
|
||||
// ok — all three modes (api-key / none / oidc) route through the
|
||||
// chained session-then-Bearer auth middleware constructed at L2011.
|
||||
|
||||
// Set up structured logging
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
|
||||
+25
-9
@@ -138,15 +138,13 @@ const (
|
||||
// docs/architecture.md "Authenticating-gateway pattern".
|
||||
AuthTypeNone AuthType = "none"
|
||||
|
||||
// AuthTypeOIDC (Auth Bundle 2 Phase 0) reserves the literal that the
|
||||
// OIDC handler chain (Bundle 2 Phase 5+6) consumes. Pre-Bundle-2
|
||||
// behavior: the literal is allowed by the validator but the handler
|
||||
// chain is not yet wired, so the runtime guard in cmd/server/main.go
|
||||
// surfaces a clear "oidc auth-type configured but Bundle 2 handlers
|
||||
// not registered" error rather than silently falling back to api-key
|
||||
// (the failure mode that drove G-1's jwt-literal removal). Once
|
||||
// Bundle 2's session middleware + OIDC service ship, the runtime
|
||||
// guard relaxes and CERTCTL_AUTH_TYPE=oidc routes through them.
|
||||
// AuthTypeOIDC drives the OIDC SSO handler chain (Bundle 2 Phase 5+6).
|
||||
// ARCH-002 closure (Sprint 4, 2026-05-16): the Phase-0 runtime guard
|
||||
// at cmd/server/main.go that refused to boot on this literal has
|
||||
// been relaxed — every prerequisite (session.NewService,
|
||||
// oidcsvc.NewService, ChainAuthSessionThenBearer, the OIDC handler
|
||||
// routes) ships, so CERTCTL_AUTH_TYPE=oidc is now a fully-supported
|
||||
// production auth mode alongside api-key + none.
|
||||
//
|
||||
// Note: this is the AUTH-TYPE literal value, NOT the JWT alg literal.
|
||||
// ID tokens are JWTs internally but the auth-type config string is
|
||||
@@ -171,6 +169,24 @@ func ValidAuthTypes() []AuthType {
|
||||
return []AuthType{AuthTypeAPIKey, AuthTypeNone, AuthTypeOIDC}
|
||||
}
|
||||
|
||||
// IsRuntimeSupportedAuthType reports whether the cmd/server/main.go
|
||||
// runtime guard accepts this auth-type literal at boot. ARCH-002
|
||||
// closure (Sprint 4, 2026-05-16): post-fix this returns true for
|
||||
// every entry in ValidAuthTypes() — the Bundle-2-Phase-0 stale guard
|
||||
// that exited on AuthTypeOIDC has been relaxed, since the full
|
||||
// session middleware + OIDC handler chain ships. The helper exists
|
||||
// as a single source of truth so the test suite can pin the
|
||||
// invariant `ValidAuthTypes ⊆ runtime-supported` (which protects
|
||||
// against future drift in either direction).
|
||||
func IsRuntimeSupportedAuthType(t AuthType) bool {
|
||||
switch t {
|
||||
case AuthTypeAPIKey, AuthTypeNone, AuthTypeOIDC:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication configuration.
|
||||
type AuthConfig struct {
|
||||
// Type sets the authentication mechanism for the REST API.
|
||||
|
||||
@@ -1969,3 +1969,140 @@ func TestExpandDatabaseURL_MultipleOccurrences(t *testing.T) {
|
||||
t.Errorf("expandDatabaseURL = %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ARCH-002 closure (Sprint 4, 2026-05-16). Auth Bundle 2 Phase 6
|
||||
// shipped the OIDC session middleware + handler chain in code, but
|
||||
// cmd/server/main.go retained a Phase-0 runtime guard that exited
|
||||
// the process when CERTCTL_AUTH_TYPE=oidc. The guard was supposed
|
||||
// to relax once the prerequisites landed; it didn't, and the
|
||||
// README's "Sign in with OIDC SSO" claim was effectively a lie
|
||||
// because the server refused to start with auth=oidc.
|
||||
//
|
||||
// Post-fix the runtime gate is centralised at
|
||||
// config.IsRuntimeSupportedAuthType and accepts every entry in
|
||||
// ValidAuthTypes(). These tests pin the new invariant — the
|
||||
// runtime support set MUST equal the validator's allowed set.
|
||||
// A future regression that flips back to "OIDC not supported"
|
||||
// surfaces here.
|
||||
// =============================================================================
|
||||
|
||||
func TestIsRuntimeSupportedAuthType_AcceptsAllValidEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, at := range ValidAuthTypes() {
|
||||
if !IsRuntimeSupportedAuthType(at) {
|
||||
t.Errorf("IsRuntimeSupportedAuthType(%q) = false; want true (every valid auth type must be runtime-supported)", at)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRuntimeSupportedAuthType_AcceptsOIDC(t *testing.T) {
|
||||
// Explicit ARCH-002 invariant — OIDC must boot cleanly.
|
||||
t.Parallel()
|
||||
if !IsRuntimeSupportedAuthType(AuthTypeOIDC) {
|
||||
t.Fatalf("IsRuntimeSupportedAuthType(oidc) = false; the Bundle-2 stale runtime guard regressed (ARCH-002)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRuntimeSupportedAuthType_RejectsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, bad := range []AuthType{"", "jwt", "saml", "mtls", "API-KEY"} {
|
||||
if IsRuntimeSupportedAuthType(bad) {
|
||||
t.Errorf("IsRuntimeSupportedAuthType(%q) = true; want false (unknown auth types must be rejected)", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ARCH-003 closure (Sprint 4, 2026-05-16). README claimed "private
|
||||
// keys stay on your infrastructure" / "never touch the control plane"
|
||||
// as a blanket promise. CERTCTL_KEYGEN_MODE=server breaks both — keys
|
||||
// are minted in the server process and shipped to the renewal job.
|
||||
// Pre-fix the server printed a boot WARN and started anyway, so the
|
||||
// blanket claim was silently false in any deploy that flipped the flag
|
||||
// without reading logs.
|
||||
//
|
||||
// Post-fix Validate() refuses to accept Mode=server unless
|
||||
// CERTCTL_DEMO_MODE_ACK=true is also set (mirroring the SEC-H3
|
||||
// 24-hour ACK pattern). Production deploys must use Mode=agent.
|
||||
// =============================================================================
|
||||
|
||||
func TestValidate_RejectsServerKeygenWithoutDemoAck(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{
|
||||
Server: validServerConfig(t),
|
||||
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Auth: AuthConfig{Type: "api-key", Secret: "x", DemoModeAck: false},
|
||||
Keygen: KeygenConfig{Mode: "server"},
|
||||
Scheduler: SchedulerConfig{
|
||||
RenewalCheckInterval: 1 * time.Hour,
|
||||
JobProcessorInterval: 30 * time.Second,
|
||||
AgentHealthCheckInterval: 2 * time.Minute,
|
||||
NotificationProcessInterval: 1 * time.Minute,
|
||||
NotificationRetryInterval: 2 * time.Minute,
|
||||
RetryInterval: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("Validate(KeygenMode=server, DemoAck=false) returned nil; want fail-closed rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CERTCTL_KEYGEN_MODE=server") {
|
||||
t.Errorf("Validate err = %v; want error citing CERTCTL_KEYGEN_MODE=server", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AcceptsServerKeygenWithDemoAck(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Operators who explicitly acknowledge the demo posture get to boot
|
||||
// in server-keygen mode. Same pattern SEC-H3 uses for AUTH_TYPE=none.
|
||||
tsRecent := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
cfg := &Config{
|
||||
Server: validServerConfig(t),
|
||||
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Auth: AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "x",
|
||||
DemoModeAck: true,
|
||||
DemoModeAckTS: tsRecent,
|
||||
},
|
||||
Keygen: KeygenConfig{Mode: "server"},
|
||||
Scheduler: SchedulerConfig{
|
||||
RenewalCheckInterval: 1 * time.Hour,
|
||||
JobProcessorInterval: 30 * time.Second,
|
||||
AgentHealthCheckInterval: 2 * time.Minute,
|
||||
NotificationProcessInterval: 1 * time.Minute,
|
||||
NotificationRetryInterval: 2 * time.Minute,
|
||||
RetryInterval: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("Validate(KeygenMode=server, DemoAck=true, fresh TS) = %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AgentKeygenIgnoresDemoAck(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The new gate must NOT regress production deploys — agent mode
|
||||
// (the default) boots cleanly without any demo ACK.
|
||||
cfg := &Config{
|
||||
Server: validServerConfig(t),
|
||||
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Auth: AuthConfig{Type: "api-key", Secret: "x", DemoModeAck: false},
|
||||
Keygen: KeygenConfig{Mode: "agent"},
|
||||
Scheduler: SchedulerConfig{
|
||||
RenewalCheckInterval: 1 * time.Hour,
|
||||
JobProcessorInterval: 30 * time.Second,
|
||||
AgentHealthCheckInterval: 2 * time.Minute,
|
||||
NotificationProcessInterval: 1 * time.Minute,
|
||||
NotificationRetryInterval: 2 * time.Minute,
|
||||
RetryInterval: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("Validate(KeygenMode=agent, DemoAck=false) = %v; want nil (production default must boot)", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user