diff --git a/cmd/server/main.go b/cmd/server/main.go index 40cc387..ae8b040 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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{ diff --git a/internal/config/auth.go b/internal/config/auth.go index e85b8d1..64c1e27 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 04af06f..d7ec66c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } +}