Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions internal/disable_password_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
18 changes: 17 additions & 1 deletion internal/module_credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions internal/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions internal/step_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 == "" {
Expand Down
Loading