~bigbes/lethe

a4e6ca5c5d5520261f0646c934559cb7914eaca6 — Eugene Blikh a month ago c832d32
auth: fix OIDCVerifier injection wiring + OIDCDevStub root attachment

Surfaced by the first end-to-end smoke of `auth.oidc.enabled=true`:

- `OIDCVerifier.Cfg` was typed `config.OIDCConfig`, but only `AuthConfig`
  is registered as a `config-section:""` (config.go:31-37). Steward's
  type-keyed injection threw `failed to find dependency` at `mgr.Inject`,
  panicking the daemon. Latent since 80b1c09 — never reached because no
  prior verified path enabled OIDC. Retyped to `config.AuthConfig`,
  Init now reads `Cfg.OIDC.{Issuer,Audience,UsernameClaim}`. Sibling-
  consistent with `Authenticator` and `OIDCDevStub`.

- `OIDCDevStub` had no dependents and no `steward.Root()` modifier, so
  steward logged `ERR empty dependents asset without root option` and
  skipped it in lifecycle bookkeeping (Destroy never called on shutdown).
  Added `steward.Root()` to its registration; shutdown log now shows
  `destroying component component=auth.OIDCDevStub`.
M cmd/lethe/main.go => cmd/lethe/main.go +4 -1
@@ 134,7 134,10 @@ func run() int {
	if cfg.Auth.OIDC.Enabled && cfg.Auth.OIDC.DevStub.Enabled {
		devStubSvc := &authpkg.OIDCDevStub{}
		registered = append(registered, devStubSvc)
		mgr.AddComponent(ctx, steward.MustServiceAsset(devStubSvc))
		// Root: OIDCDevStub has no in-process dependents (it's a side listener),
		// so without explicit Root attachment steward treats it as orphan and
		// logs an ERR/WRN about graph quality.
		mgr.AddComponent(ctx, steward.MustServiceAsset(devStubSvc, steward.Root()))
	}
	if cfg.Auth.OIDC.Enabled {
		oidcSvc := &authpkg.OIDCVerifier{}

M internal/server/auth/devstub_test.go => internal/server/auth/devstub_test.go +7 -5
@@ 83,11 83,13 @@ func TestOIDCDevStub_InitStartsListener_TokenVerifies(t *testing.T) {

	// Prove the listener answers OIDC discovery by constructing a real verifier.
	verifier := &auth.OIDCVerifier{
		Cfg: config.OIDCConfig{
			Enabled:       true,
			Issuer:        issuer,
			Audience:      "lethe",
			UsernameClaim: "preferred_username",
		Cfg: config.AuthConfig{
			OIDC: config.OIDCConfig{
				Enabled:       true,
				Issuer:        issuer,
				Audience:      "lethe",
				UsernameClaim: "preferred_username",
			},
		},
	}
	if err := verifier.Init(ctx); err != nil {

M internal/server/auth/middleware_test.go => internal/server/auth/middleware_test.go +7 -5
@@ 98,11 98,13 @@ func signToken(t *testing.T, stub *oidcstub.Stub, claims map[string]any) string 
func newOIDCVerifier(t *testing.T, stub *oidcstub.Stub, aud string) *auth.OIDCVerifier {
	t.Helper()
	v := &auth.OIDCVerifier{
		Cfg: config.OIDCConfig{
			Enabled:       true,
			Issuer:        stub.Issuer(),
			Audience:      aud,
			UsernameClaim: "preferred_username",
		Cfg: config.AuthConfig{
			OIDC: config.OIDCConfig{
				Enabled:       true,
				Issuer:        stub.Issuer(),
				Audience:      aud,
				UsernameClaim: "preferred_username",
			},
		},
	}
	if err := v.Init(context.Background()); err != nil {

M internal/server/auth/oidc.go => internal/server/auth/oidc.go +9 -7
@@ 28,25 28,27 @@ const subClaim = "sub"

// OIDCVerifier is the steward-managed bearer-token verifier. It is a
// zero-value type: construct with `&OIDCVerifier{Cfg: ...}` and call Init
// before Verify.
// before Verify. Cfg holds the full AuthConfig so steward injection by type
// matches the registered `auth` config section (sibling-consistent with
// Authenticator and OIDCDevStub); the verifier reads `Cfg.OIDC.*` fields.
type OIDCVerifier struct {
	Cfg config.OIDCConfig `config:""`
	Cfg config.AuthConfig `config:""`

	verifier      *oidc.IDTokenVerifier
	usernameClaim string
}

// Init performs OIDC discovery against Cfg.Issuer and constructs the JWKS-
// backed verifier. Failures are wrapped with codes that distinguish the
// Init performs OIDC discovery against Cfg.OIDC.Issuer and constructs the
// JWKS-backed verifier. Failures are wrapped with codes that distinguish the
// discovery handshake (likely a network/issuer problem) from any other
// initialization fault.
func (v *OIDCVerifier) Init(ctx context.Context) error {
	provider, err := oidc.NewProvider(ctx, v.Cfg.Issuer)
	provider, err := oidc.NewProvider(ctx, v.Cfg.OIDC.Issuer)
	if err != nil {
		return culpa.WithCode(culpa.Wrap(err, "oidc discovery"), "OIDC_DISCOVERY")
	}
	v.verifier = provider.Verifier(&oidc.Config{ClientID: v.Cfg.Audience})
	v.usernameClaim = v.Cfg.UsernameClaim
	v.verifier = provider.Verifier(&oidc.Config{ClientID: v.Cfg.OIDC.Audience})
	v.usernameClaim = v.Cfg.OIDC.UsernameClaim
	if v.usernameClaim == "" {
		// Defense in depth: the validator already requires a non-empty value
		// when OIDC is enabled, but a zero-value here would silently fall