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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ printf '%s\n' 'a-local-secret' | ./hoocloak hash
| `realms[].clients[]` | no | Optional realm SPA and service clients; absent or empty is valid. |
| `users[].id` | yes | Stable subject identifier; shares a per-realm namespace with client IDs. |
| `users[].username` | yes | Password-login name; unique per realm after Unicode case folding. |
| `users[].password_hash` | yes | Valid bcrypt hash, including when process-wide select mode is used. |
| `users[].password_hash` | no | Valid bcrypt hash when set. Users without it sign in with the default password `hoo`. A configured hash takes precedence. |
| `users[].name`, `email`, `email_verified` | no | Optional profile values. With the corresponding scope, empty or omitted `name`/`email` and false or omitted `email_verified` are left out of serialized UserInfo and ID-token claims; only non-zero configured values are emitted. |
| `users[].roles`, `permissions` | no | Optional unique authorization values; absent or empty is valid. Permissions are custom OAuth scope tokens, never reserved OIDC scopes. |
| `clients[].id`, `type` | yes | Stable client ID and either `spa` or `service`; ID shares the realm namespace with user IDs. |
Expand Down
6 changes: 4 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,10 @@ func (c Config) Validate() error {
return fmt.Errorf("realms[%d] has duplicate username %q", realmIndex, user.Username)
}
usernames[username] = struct{}{}
if err := validBcrypt(user.PasswordHash); err != nil {
return fmt.Errorf("%s.password_hash: %w", where, err)
if user.PasswordHash != "" {
if err := validBcrypt(user.PasswordHash); err != nil {
return fmt.Errorf("%s.password_hash: %w", where, err)
}
}
if err := validatePermissions(user.Permissions, where+".permissions"); err != nil {
return err
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,26 @@ func TestLoadAcceptsSupportedBcryptMinorVersions(t *testing.T) {
}
}

func TestLoadAcceptsUsersWithoutPasswordHash(t *testing.T) {
t.Parallel()
for _, passwordHashLine := range []string{"", " password_hash: \"\"\n"} {
configYAML := strings.Replace(validConfigYAML,
" password_hash: \"$2a$10$vWq8DjfdBvihgDARWb4jaOyhhRpU6Vgygi49GnwKTTVP45M8nPylW\"\n",
passwordHashLine, 1)
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Realms[0].Users[0].PasswordHash != "" {
t.Fatal("user unexpectedly has a password hash")
}
}
}

func TestValidateOriginAcceptsCanonicalForms(t *testing.T) {
t.Parallel()
for _, origin := range []string{
Expand Down
6 changes: 5 additions & 1 deletion internal/idp/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,14 +329,18 @@ func (s *Store) Authenticate(requestID, username, password string) error {
userID, userOK := s.usernames[config.CanonicalUsername(username)]
user := s.users[userID]
hash := dummyPasswordHash
if userOK {
defaultPassword := userOK && user.PasswordHash == ""
if userOK && !defaultPassword {
hash = user.PasswordHash
}
s.mu.Unlock()
passwordOK, err := compareCredential(hash, password)
if err != nil {
return err
}
if defaultPassword {
passwordOK = subtle.ConstantTimeCompare([]byte(password), []byte("hoo")) == 1
}
if !requestOK {
return errors.New("authorization request is missing or expired")
}
Expand Down
36 changes: 36 additions & 0 deletions internal/idp/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/openhoo/hoocloak/internal/config"
"github.com/zitadel/oidc/v3/pkg/oidc"
)

Expand Down Expand Up @@ -373,6 +374,41 @@ func TestAuthenticationIntersectsClientAndUserScopes(t *testing.T) {
t.Fatalf("additional ID-token scopes = %v", idScopes)
}
}

func TestAuthenticationUsesDefaultPasswordOnlyWithoutHash(t *testing.T) {
clock := &fakeClock{current: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
cfg := testConfig(t)
cfg.Realms[0].Users = append(cfg.Realms[0].Users, config.User{ID: "bob", Username: "bob"})
if err := cfg.Validate(); err != nil {
t.Fatalf("config with a user without a hash: %v", err)
}
store := NewStore(cfg.Realms[0], cfg.Tokens, "/realms/development", nil, "test-kid", clock)
for _, tt := range []struct {
name, username, password string
wantSuccess bool
}{
{"default password", "bob", "hoo", true},
{"wrong default password", "bob", "other", false},
{"default password does not override hash", "alice", "hoo", false},
{"configured hash still works", "alice", "alice-password", true},
{"unknown user", "unknown", "hoo", false},
} {
t.Run(tt.name, func(t *testing.T) {
request := &AuthRequest{id: tt.name, clientID: "react-spa", expires: clock.Now().Add(5 * time.Minute)}
store.authRequests[request.id] = request
err := store.Authenticate(request.id, tt.username, tt.password)
if tt.wantSuccess && err != nil {
t.Fatalf("Authenticate() error = %v", err)
}
if !tt.wantSuccess && !errors.Is(err, errInvalidCredentials) {
t.Fatalf("Authenticate() error = %v, want invalid credentials", err)
}
if request.done != tt.wantSuccess {
t.Fatalf("request.done = %v, want %v", request.done, tt.wantSuccess)
}
})
}
}
func TestSelectIdentityCompletesAuthorizationWithoutPassword(t *testing.T) {
clock := &fakeClock{current: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
store := newTestStore(t, clock)
Expand Down
Loading