diff --git a/internal/disable_password_test.go b/internal/disable_password_test.go new file mode 100644 index 0000000..e98725c --- /dev/null +++ b/internal/disable_password_test.go @@ -0,0 +1,107 @@ +package internal + +import ( + "context" + "testing" +) + +// TestPasswordSteps_DisableViaCredentialModule verifies that a +// credentialModule with `disable_password_auth: true` short-circuits +// both password steps. Mirrors gocodealone-multisite SPEC V17 / T-AUTH-1. +func TestPasswordSteps_DisableViaCredentialModule(t *testing.T) { + // Register a credential module with disable_password_auth = true. + // Note: bypassing newCredentialModule's Init() (which requires + // rpID/origin) — we only need disable flag set + module in registry. + m := &credentialModule{name: "test", disablePasswordAuth: true} + registerModule(m.name, m) + t.Cleanup(func() { unregisterModule(m.name) }) + + if !passwordAuthDisabled() { + t.Fatal("passwordAuthDisabled should return true after register") + } + + // hash step short-circuits. + hash := newPasswordHashStep("hash", nil) + res, err := hash.Execute(context.Background(), nil, nil, map[string]any{"password": "secret"}, nil, nil) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if disabled, _ := res.Output["disabled"].(bool); !disabled { + t.Errorf("expected disabled=true, got %v", res.Output) + } + if _, ok := res.Output["hash"]; ok { + t.Errorf("hash output should NOT include `hash` when disabled") + } + + // verify step short-circuits. + verify := newPasswordVerifyStep("verify", nil) + res, err = verify.Execute(context.Background(), nil, nil, map[string]any{"password": "x", "hash": "y"}, nil, nil) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if disabled, _ := res.Output["disabled"].(bool); !disabled { + t.Errorf("verify: expected disabled=true, got %v", res.Output) + } + if valid, _ := res.Output["valid"].(bool); valid { + t.Error("verify: should not report valid=true when disabled") + } +} + +func TestPasswordSteps_EnabledByDefault(t *testing.T) { + // No modules registered with disable → password steps work normally. + // Ensure clean registry state. + if passwordAuthDisabled() { + t.Skip("registry has a disabled module from prior test ordering") + } + + hash := newPasswordHashStep("hash", nil) + res, err := hash.Execute(context.Background(), nil, nil, map[string]any{"password": "secret"}, nil, nil) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if _, ok := res.Output["hash"]; !ok { + t.Errorf("expected hash output, got %v", res.Output) + } +} + +func TestNewCredentialModule_DisableFlagParsed(t *testing.T) { + cases := []struct { + name string + cfg map[string]any + want bool + }{ + {"absent", map[string]any{}, false}, + {"bool true", map[string]any{"disable_password_auth": true}, true}, + {"bool false", map[string]any{"disable_password_auth": false}, false}, + {"string true", map[string]any{"disable_password_auth": "true"}, true}, + {"string false", map[string]any{"disable_password_auth": "false"}, false}, + {"string TRUE mixed case", map[string]any{"disable_password_auth": "TRUE"}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m, err := newCredentialModule("test", tc.cfg) + if err != nil { + t.Fatalf("newCredentialModule: %v", err) + } + if m.disablePasswordAuth != tc.want { + t.Errorf("disablePasswordAuth: got %v want %v", m.disablePasswordAuth, tc.want) + } + }) + } +} + +func TestPasswordAuthDisabled_AnyModuleSetsFlag(t *testing.T) { + // Two modules, one with disable, one without → disabled=true overall. + m1 := &credentialModule{name: "a", disablePasswordAuth: false} + m2 := &credentialModule{name: "b", disablePasswordAuth: true} + registerModule(m1.name, m1) + registerModule(m2.name, m2) + t.Cleanup(func() { + unregisterModule(m1.name) + unregisterModule(m2.name) + }) + + if !passwordAuthDisabled() { + t.Error("expected disabled=true when ANY module has flag set") + } +} diff --git a/internal/module_credential.go b/internal/module_credential.go index 2a04932..97fd418 100644 --- a/internal/module_credential.go +++ b/internal/module_credential.go @@ -13,10 +13,26 @@ type credentialModule struct { name string config map[string]any webauthn *webauthn.WebAuthn + + // disablePasswordAuth, when true, causes step.auth_password_hash and + // step.auth_password_verify invocations to short-circuit with an + // "auth: password authentication disabled" error. + // + // Set via module config `disable_password_auth: true`. Default false + // keeps backwards-compatible behaviour for existing consumers. + // + // Used by hosts that want a passwordless-only posture + // (e.g. gocodealone-multisite SPEC.md V17). The plugin still ships + // the password steps; this flag is the host's opt-out switch. + disablePasswordAuth bool } func newCredentialModule(name string, config map[string]any) (*credentialModule, error) { - return &credentialModule{name: name, config: config}, nil + return &credentialModule{ + name: name, + config: config, + disablePasswordAuth: configBool(config, "disable_password_auth"), + }, nil } func (m *credentialModule) Init() error { diff --git a/internal/registry.go b/internal/registry.go index 41f4db7..0938d3f 100644 --- a/internal/registry.go +++ b/internal/registry.go @@ -7,6 +7,25 @@ var ( providers = make(map[string]*credentialModule) ) +// passwordAuthDisabled returns true iff any registered credentialModule +// has `disable_password_auth: true`. Any single tenant/host opting out +// is enough — password steps refuse for the whole process. This is the +// fail-safe default for hosts that want passwordless guarantees (V17). +// +// Hosts that need a mixed posture (some pipelines password-OK, others +// not) should run separate plugin processes — the disable knob is a +// per-process invariant, not per-pipeline. +func passwordAuthDisabled() bool { + mu.RLock() + defer mu.RUnlock() + for _, m := range providers { + if m != nil && m.disablePasswordAuth { + return true + } + } + return false +} + func registerModule(name string, m *credentialModule) { mu.Lock() defer mu.Unlock() diff --git a/internal/step_password.go b/internal/step_password.go index f66678f..6da4c7b 100644 --- a/internal/step_password.go +++ b/internal/step_password.go @@ -15,6 +15,13 @@ func newPasswordHashStep(name string, _ map[string]any) *passwordHashStep { } func (s *passwordHashStep) Execute(_ context.Context, _ map[string]any, _ map[string]map[string]any, current, _, _ map[string]any) (*sdk.StepResult, error) { + if passwordAuthDisabled() { + return &sdk.StepResult{Output: map[string]any{ + "error": "auth: password authentication disabled by host config", + "disabled": true, + }}, nil + } + password, _ := current["password"].(string) if password == "" { return &sdk.StepResult{Output: map[string]any{"error": "missing password"}}, nil @@ -35,6 +42,14 @@ func newPasswordVerifyStep(name string, _ map[string]any) *passwordVerifyStep { } func (s *passwordVerifyStep) Execute(_ context.Context, _ map[string]any, _ map[string]map[string]any, current, _, _ map[string]any) (*sdk.StepResult, error) { + if passwordAuthDisabled() { + return &sdk.StepResult{Output: map[string]any{ + "valid": false, + "error": "auth: password authentication disabled by host config", + "disabled": true, + }}, nil + } + password, _ := current["password"].(string) hash, _ := current["hash"].(string) if password == "" || hash == "" {