feat(auth): instance-owner gate — owner store, JWT sessions, setup/login UI (onboarding M1+M2)

lib/owner: argon2id credential store, HS256 access/refresh pair with kid,
hashed-jti session store, throttler (5 free failures then 4^n backoff).
handlers/auth: /api/auth/* + RequireSession/CSRF/GateWebSocket middleware;
every /api/* route is now gated (428 in setup mode). --reset-owner recovery.
Frontend: AuthScreen (setup/login), useAuth gate hook, gated data hooks +
single-flight silent refresh, Owner account section in settings.
spec.yaml documents the full surface.
This commit is contained in:
2026-08-22 22:58:18 +01:00
parent cf51a6be94
commit 2ffdf77905
24 changed files with 2850 additions and 37 deletions
+1 -1
View File
@@ -81,4 +81,4 @@ func TestSyncAuthorizationIntegration(t *testing.T) {
assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t")
// Armed database: PUBLIC grant removed.
assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f")
}
}
+274
View File
@@ -0,0 +1,274 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package owner is Maestro's self-contained instance-owner identity store
// (onboarding theme M1, D1/D2): argon2id password hashes in a JSON file at the
// data root, deliberately independent of the fleet database and auth service —
// logging into the orchestrator must work exactly when everything else is
// down. The Store interface keeps the middleware swappable for a DB- or
// LDAP-backed implementation later.
package owner
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
)
// Owner is one dashboard user. The setup wizard creates the first admin;
// further owners seed through the same store/API path (no management UI yet).
type Owner struct {
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Role renders the JWT role claim: admins gatekeep trust decisions.
func (o *Owner) Role() string {
if o.IsAdmin {
return "admin"
}
return "member"
}
// Store persists owners. FileOwnerStore is the on-disk implementation; a
// DB-backed store can slot in later without touching the middleware.
type Store interface {
Count(ctx context.Context) (int, error)
Verify(ctx context.Context, username, password string) (*Owner, error)
Create(ctx context.Context, username, password string, isAdmin bool) (*Owner, error)
// Get looks a user up without credentials (role refresh during token
// rotation).
Get(ctx context.Context, username string) (*Owner, error)
}
var (
usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`)
// Mirrors the dcntrlized-auth conventions.
MinPasswordLen = 10
ErrInvalidUsername = fmt.Errorf("username must match %s", usernameRe.String())
ErrWeakPassword = fmt.Errorf("password must be at least %d characters", MinPasswordLen)
)
// ValidateCredentials enforces the shared-auth conventions: lowercase
// usernames of 3–64 chars, passwords of at least MinPasswordLen chars.
func ValidateCredentials(username, password string) error {
if !usernameRe.MatchString(username) {
return ErrInvalidUsername
}
if len(password) < MinPasswordLen {
return ErrWeakPassword
}
return nil
}
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
)
// HashPassword derives an argon2id PHC-style hash string.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("read salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key)), nil
}
// VerifyPassword compares a password against a PHC-style argon2id hash in
// constant time.
func VerifyPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
// "", "argon2id", "v=19", "m=…,t=…,p=…", salt, key
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// ChangePassword rehashes the owner's password and bumps updated_at.
func (s *FileOwnerStore) ChangePassword(ctx context.Context, username, currentPassword, newPassword string) (*Owner, error) {
s.mu.Lock()
defer s.mu.Unlock()
file, owners, err := s.read()
if err != nil {
return nil, err
}
for i := range owners.Owners {
o := &owners.Owners[i]
if o.Username != username {
continue
}
if !VerifyPassword(o.PasswordHash, currentPassword) {
return nil, os.ErrPermission
}
hash, err := HashPassword(newPassword)
if err != nil {
return nil, err
}
o.PasswordHash = hash
o.UpdatedAt = time.Now().UTC()
return o, s.write(file, owners)
}
return nil, os.ErrNotExist
}
type ownerFile struct {
Version int `json:"version"`
Owners []Owner `json:"owners"`
}
// FileOwnerStore persists owners to <dataRoot>/maestro/owners.json with
// atomic writes and chmod 600.
type FileOwnerStore struct {
path string
mu sync.Mutex
}
// NewFileOwnerStore targets dir/owners.json (dir = <dataRoot>/maestro).
func NewFileOwnerStore(dir string) (*FileOwnerStore, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create owner store directory: %w", err)
}
return &FileOwnerStore{path: filepath.Join(dir, "owners.json")}, nil
}
func (s *FileOwnerStore) read() (string, *ownerFile, error) {
file := ownerFile{Version: 1}
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return s.path, &file, nil
}
if err != nil {
return s.path, nil, fmt.Errorf("read owners file: %w", err)
}
if err := json.Unmarshal(data, &file); err != nil {
return s.path, nil, fmt.Errorf("parse owners file: %w", err)
}
return s.path, &file, nil
}
// write atomically replaces the owners file; the mutex serializes writers and
// concurrent setups end up idempotent through Count's check in the caller.
func (s *FileOwnerStore) write(path string, owners *ownerFile) error {
data, err := json.MarshalIndent(owners, "", " ")
if err != nil {
return fmt.Errorf("encode owners file: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write owners file: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("replace owners file: %w", err)
}
return nil
}
func (s *FileOwnerStore) Count(_ context.Context) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, owners, err := s.read()
if err != nil {
return 0, err
}
return len(owners.Owners), nil
}
func (s *FileOwnerStore) Create(_ context.Context, username, password string, isAdmin bool) (*Owner, error) {
if err := ValidateCredentials(username, password); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
file, owners, err := s.read()
if err != nil {
return nil, err
}
for _, o := range owners.Owners {
if o.Username == username {
return nil, os.ErrExist
}
}
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
now := time.Now().UTC()
owner := Owner{Username: username, PasswordHash: hash, IsAdmin: isAdmin, CreatedAt: now, UpdatedAt: now}
owners.Owners = append(owners.Owners, owner)
if err := s.write(file, owners); err != nil {
return nil, err
}
return &owner, nil
}
func (s *FileOwnerStore) Verify(_ context.Context, username, password string) (*Owner, error) {
o, err := s.Get(context.Background(), username)
if err != nil || o == nil {
return nil, os.ErrNotExist
}
if VerifyPassword(o.PasswordHash, password) {
return o, nil
}
return nil, os.ErrNotExist
}
func (s *FileOwnerStore) Get(_ context.Context, username string) (*Owner, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, owners, err := s.read()
if err != nil {
return nil, err
}
for _, o := range owners.Owners {
if o.Username == username {
return &o, nil
}
}
return nil, os.ErrNotExist
}
+168
View File
@@ -0,0 +1,168 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package owner
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func newTestStore(t *testing.T) *FileOwnerStore {
t.Helper()
s, err := NewFileOwnerStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
}
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$") {
t.Fatalf("hash missing argon2id PHC prefix: %s", hash)
}
if !VerifyPassword(hash, "correct horse battery") {
t.Fatal("correct password rejected")
}
if VerifyPassword(hash, "wrong password!") {
t.Fatal("wrong password accepted")
}
if VerifyPassword("garbage", "x") {
t.Fatal("garbage hash accepted")
}
}
func TestValidateCredentials(t *testing.T) {
good := [][2]string{{"admin", "longenough123"}, {"a.b-c_d1", "0123456789"}}
for _, c := range good {
if err := ValidateCredentials(c[0], c[1]); err != nil {
t.Errorf("%q/%q rejected: %v", c[0], c[1], err)
}
}
bad := [][2]string{
{"Admin", "0123456789"}, // uppercase
{"-lead", "0123456789"}, // bad first char
{"ab", "0123456789"}, // too short
{"okname", "short"}, // weak password
}
for _, c := range bad {
if err := ValidateCredentials(c[0], c[1]); err == nil {
t.Errorf("%q/%q accepted", c[0], c[1])
}
}
}
func TestStoreRoundtripAndMultiUser(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if n, _ := s.Count(ctx); n != 0 {
t.Fatalf("fresh store Count = %d", n)
}
first, err := s.Create(ctx, "admin", "first-owner-pass", true)
if err != nil {
t.Fatal(err)
}
if !first.IsAdmin || first.Role() != "admin" {
t.Fatalf("first owner should be admin, got %+v", first)
}
second, err := s.Create(ctx, "member", "second-owner-pass", false)
if err != nil {
t.Fatal(err)
}
if second.Role() != "member" {
t.Fatalf("second owner role = %q", second.Role())
}
if n, _ := s.Count(ctx); n != 2 {
t.Fatalf("Count = %d, want 2", n)
}
if got, err := s.Verify(ctx, "admin", "first-owner-pass"); err != nil || got.Username != "admin" {
t.Fatalf("Verify admin: %v %+v", err, got)
}
if _, err := s.Verify(ctx, "admin", "wrong"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("wrong password: %v", err)
}
if _, err := s.Create(ctx, "admin", "whatever-123", false); !errors.Is(err, os.ErrExist) {
t.Fatalf("duplicate create: %v", err)
}
changed, err := s.ChangePassword(ctx, "admin", "first-owner-pass", "rotated-password")
if err != nil {
t.Fatal(err)
}
if changed.UpdatedAt.Equal(changed.CreatedAt) {
t.Fatal("updated_at not bumped")
}
if _, err := s.Verify(ctx, "admin", "first-owner-pass"); err == nil {
t.Fatal("old password still valid")
}
if _, err := s.Verify(ctx, "admin", "rotated-password"); err != nil {
t.Fatalf("new password rejected: %v", err)
}
}
func TestConcurrentCreateIsIdempotent(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
var wg sync.WaitGroup
errs := make(chan error, 16)
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := s.Create(ctx, "wizard", "setup-password", true); err != nil && !errors.Is(err, os.ErrExist) {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("unexpected error: %v", err)
}
if n, _ := s.Count(ctx); n != 1 {
t.Fatalf("Count after concurrent setup = %d, want 1", n)
}
}
func TestOwnerFilePermissions(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
s, err := NewFileOwnerStore(dir)
if err != nil {
t.Fatal(err)
}
if _, err := s.Create(ctx, "admin", "perm-check-pass", true); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(dir, "owners.json"))
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("owners.json mode = %o, want 600", perm)
}
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package owner
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Session is one persisted refresh-token record. Only the SHA-256 hash of the
// jti is stored — never the raw value, mirroring the auth service's
// token_hash convention.
type Session struct {
Username string `json:"username"`
JTIHash string `json:"jti_hash"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
type sessionFile struct {
Version int `json:"version"`
Sessions []Session `json:"sessions"`
}
// HashJTI renders the stored digest of a raw refresh jti.
func HashJTI(jti string) string {
sum := sha256.Sum256([]byte(jti))
return hex.EncodeToString(sum[:])
}
// FileSessionStore persists refresh-token state to
// <dataRoot>/maestro/sessions.json so sessions survive restarts, can be
// revoked on logout, and support rotation + reuse detection.
type FileSessionStore struct {
path string
mu sync.Mutex
}
// NewFileSessionStore targets dir/sessions.json.
func NewFileSessionStore(dir string) (*FileSessionStore, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create session store directory: %w", err)
}
return &FileSessionStore{path: filepath.Join(dir, "sessions.json")}, nil
}
func (s *FileSessionStore) load() (*sessionFile, error) {
file := sessionFile{Version: 1}
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return &file, nil
}
if err != nil {
return nil, fmt.Errorf("read sessions file: %w", err)
}
if err := json.Unmarshal(data, &file); err != nil {
return nil, fmt.Errorf("parse sessions file: %w", err)
}
return &file, nil
}
// save atomically replaces the file after pruning expired/revoked rows.
func (s *FileSessionStore) save(file *sessionFile) error {
now := time.Now()
live := file.Sessions[:0]
for _, s := range file.Sessions {
if s.RevokedAt == nil && now.Before(s.ExpiresAt) {
live = append(live, s)
}
}
file.Sessions = live
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("encode sessions file: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write sessions file: %w", err)
}
return os.Rename(tmp, s.path)
}
// Store records a freshly minted refresh token.
func (s *FileSessionStore) Store(username, jti string, expiresAt time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
file, err := s.load()
if err != nil {
return err
}
file.Sessions = append(file.Sessions, Session{
Username: username,
JTIHash: HashJTI(jti),
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt.UTC(),
})
return s.save(file)
}
// Valid reports whether the jti maps to a live (stored, unrevoked, unexpired)
// session row.
func (s *FileSessionStore) Valid(jti string) bool {
s.mu.Lock()
defer s.mu.Unlock()
file, err := s.load()
if err != nil {
return false
}
hash := HashJTI(jti)
now := time.Now()
for _, sess := range file.Sessions {
if sess.JTIHash == hash {
return sess.RevokedAt == nil && now.Before(sess.ExpiresAt)
}
}
return false
}
// Revoke marks the jti's row revoked (logout / rotation).
func (s *FileSessionStore) Revoke(jti string) error {
return s.revokeWhere(func(sess Session) bool { return sess.JTIHash == HashJTI(jti) })
}
// RevokeAllForUser kills every live session row of the user — the response to
// refresh-token reuse (theft signal).
func (s *FileSessionStore) RevokeAllForUser(username string) error {
return s.revokeWhere(func(sess Session) bool { return sess.Username == username })
}
func (s *FileSessionStore) revokeWhere(match func(Session) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
file, err := s.load()
if err != nil {
return err
}
now := time.Now().UTC()
changed := false
for i := range file.Sessions {
if match(file.Sessions[i]) && file.Sessions[i].RevokedAt == nil {
t := now
file.Sessions[i].RevokedAt = &t
changed = true
}
}
if !changed {
return nil
}
return s.save(file)
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package owner
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
)
func TestSessionsStoreValidRevoke(t *testing.T) {
s, err := NewFileSessionStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
jti := "jti-1"
if err := s.Store("admin", jti, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if !s.Valid(jti) {
t.Fatal("fresh session invalid")
}
if s.Valid("missing") {
t.Fatal("unknown session valid")
}
if err := s.Revoke(jti); err != nil {
t.Fatal(err)
}
if s.Valid(jti) {
t.Fatal("revoked session still valid")
}
// Re-revoke must not fail (logout idempotence).
if err := s.Revoke(jti); err != nil {
t.Fatalf("double revoke: %v", err)
}
}
func TestRevokeAllForUser(t *testing.T) {
s, _ := NewFileSessionStore(t.TempDir())
for _, j := range []string{"a", "b", "c"} {
if err := s.Store("admin", j, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
}
if err := s.Store("other", "d", time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := s.RevokeAllForUser("admin"); err != nil {
t.Fatal(err)
}
for _, j := range []string{"a", "b", "c"} {
if s.Valid(j) {
t.Fatalf("session %q survived revoke-all", j)
}
}
if !s.Valid("d") {
t.Fatal("other user's session revoked")
}
}
func TestExpiredSessionsPrunedOnSave(t *testing.T) {
dir := t.TempDir()
s, _ := NewFileSessionStore(dir)
// Expired session: stored but never valid.
expired := sha256.Sum256([]byte("expired"))
jti := hex.EncodeToString(expired[:])
if err := s.Store("admin", jti, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
if s.Valid(jti) {
t.Fatal("expired session valid")
}
// Prune happens on save: force a write and confirm the expired entry
// disappears from disk.
if err := s.Store("admin", "fresh", time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "sessions.json"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte(HashJTI(jti))) {
t.Fatal("expired session not pruned from disk")
}
if !bytes.Contains(data, []byte(HashJTI("fresh"))) {
t.Fatal("live session missing from disk")
}
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package owner
import (
"sync"
"time"
)
// In-process throttling for login, setup and password change, keyed by
// username — mirrors dcntrlized-auth's handlers/auth/throttle.go: the first
// throttleMaxAttempts failures inside the window pass freely (humans mistype),
// beyond that each further failure multiplies the wait (5s×4^n, capped at
// 24h). Maestro is single-instance, so process-local state suffices.
const (
throttleMaxAttempts = 5
throttleWindow = 15 * time.Minute
throttleBackoffBase = 5 * time.Second
throttleMultiplier = 4
throttleMaxWait = 24 * time.Hour
)
type throttleAttempt struct {
count int
last time.Time
}
type Throttler struct {
mu sync.Mutex
state map[string]*throttleAttempt
nowFn func() time.Time
}
// NewThrottler builds an empty throttler.
func NewThrottler() *Throttler {
return &Throttler{state: map[string]*throttleAttempt{}, nowFn: time.Now}
}
func backoffFor(count int) time.Duration {
delay := throttleBackoffBase
for i := 1; i < count-throttleMaxAttempts && delay < throttleMaxWait; i++ {
delay *= throttleMultiplier
}
if delay > throttleMaxWait {
delay = throttleMaxWait
}
return delay
}
// Allow reports whether an attempt for identifier may proceed right now.
func (t *Throttler) Allow(identifier string) bool {
t.mu.Lock()
defer t.mu.Unlock()
a, ok := t.state[identifier]
if !ok || a.count <= throttleMaxAttempts {
return true
}
if t.nowFn().Sub(a.last) > throttleWindow {
delete(t.state, identifier)
return true
}
return t.nowFn().Sub(a.last) >= backoffFor(a.count)
}
// Fail records a failed attempt.
func (t *Throttler) Fail(identifier string) {
t.mu.Lock()
defer t.mu.Unlock()
a, ok := t.state[identifier]
now := t.nowFn()
if !ok || now.Sub(a.last) > throttleWindow {
t.state[identifier] = &throttleAttempt{count: 1, last: now}
return
}
a.count++
a.last = now
}
// Reset clears the identifier after a success.
func (t *Throttler) Reset(identifier string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.state, identifier)
}
// RetryAfter reports how long the identifier must wait before the next
// attempt is permitted; 0 when attempts are currently allowed. Used for the
// 429 Retry-After header.
func (t *Throttler) RetryAfter(identifier string) time.Duration {
t.mu.Lock()
defer t.mu.Unlock()
a, ok := t.state[identifier]
if !ok || a.count <= throttleMaxAttempts {
return 0
}
now := t.nowFn()
if now.Sub(a.last) > throttleWindow {
return 0
}
if wait := backoffFor(a.count) - now.Sub(a.last); wait > 0 {
return wait
}
return 0
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package owner
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
// AccessTokenTTL is the stateless access-token lifetime.
AccessTokenTTL = 15 * time.Minute
// RefreshTokenTTL is the persisted refresh-token lifetime.
RefreshTokenTTL = 7 * 24 * time.Hour
// Issuer tags every minted token; verification rejects others.
Issuer = "maestro"
claimType = "type"
claimKid = "kid"
claimJti = "jti"
typeAccess = "access"
typeRefresh = "refresh"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrWrongType = errors.New("token has the wrong type")
)
// TokenPair is one login/rotation result: the cookie payloads plus the refresh
// jti (its raw value lives only in the cookies and the caller's response).
type TokenPair struct {
Access string
Refresh string
RefreshJTI string
AccessTTL time.Duration
RefreshTTL time.Duration
Username string
Role string
}
// TokenService mints and verifies the HS256 access/refresh session pair. The
// signing secret is a per-instance random key on disk (`session.key`, chmod
// 600); kid = sha256(secret)[:16] so a rotated secret keeps old access tokens
// valid through their remaining TTL (grace window).
type TokenService struct {
dir string
key []byte
kid string
}
// NewTokenService loads or creates dir/session.key.
func NewTokenService(dir string) (*TokenService, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create token directory: %w", err)
}
path := filepath.Join(dir, "session.key")
key, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
key = make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate session key: %w", err)
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, fmt.Errorf("persist session key: %w", err)
}
} else if err != nil {
return nil, fmt.Errorf("read session key: %w", err)
} else if len(key) < 32 {
return nil, fmt.Errorf("session key at %s is too short", path)
}
sum := sha256.Sum256(key)
return &TokenService{dir: dir, key: key, kid: hex.EncodeToString(sum[:])[:16]}, nil
}
// Kid returns the active signing-key identifier.
func (t *TokenService) Kid() string { return t.kid }
func (t *TokenService) claims(sub, typ, role string, ttl time.Duration, jti string) jwt.MapClaims {
now := time.Now()
c := jwt.MapClaims{
"sub": sub,
"iss": Issuer,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
claimType: typ,
claimKid: t.kid,
}
if role != "" {
c["role"] = role
}
if jti != "" {
c[claimJti] = jti
}
return c
}
func (t *TokenService) sign(c jwt.MapClaims) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(t.key)
}
// MintPair issues a fresh access/refresh pair for the owner. The refresh jti
// must be recorded by the session store before the cookie is served.
func (t *TokenService) MintPair(username, role string) (*TokenPair, error) {
jti := make([]byte, 16)
if _, err := rand.Read(jti); err != nil {
return nil, fmt.Errorf("generate jti: %w", err)
}
raw := hex.EncodeToString(jti)
access, err := t.sign(t.claims(username, typeAccess, role, AccessTokenTTL, ""))
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
refresh, err := t.sign(t.claims(username, typeRefresh, "", RefreshTokenTTL, raw))
if err != nil {
return nil, fmt.Errorf("sign refresh token: %w", err)
}
return &TokenPair{
Access: access, Refresh: refresh, RefreshJTI: raw,
AccessTTL: AccessTokenTTL, RefreshTTL: RefreshTokenTTL,
Username: username, Role: role,
}, nil
}
// parse validates signature (constant-time HMAC compare inside the library),
// issuer and expiry, returning the claims.
func (t *TokenService) parse(token string) (jwt.MapClaims, error) {
parsed, err := jwt.Parse(token, func(tk *jwt.Token) (interface{}, error) {
if _, ok := tk.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return t.key, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(Issuer), jwt.WithExpirationRequired())
if err != nil || !parsed.Valid {
return nil, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrInvalidToken
}
if kid, _ := claims[claimKid].(string); kid != "" && kid != t.kid && !hmac.Equal([]byte(kid), []byte(t.kid)) {
// A different kid means an unknown secret generation — refuse. The
// same-kid path is the normal case; empty kid predates kid tagging.
return nil, ErrInvalidToken
}
return claims, nil
}
// VerifyAccess validates an access token and returns the username + role.
func (t *TokenService) VerifyAccess(token string) (username, role string, err error) {
claims, err := t.parse(token)
if err != nil {
return "", "", err
}
if typ, _ := claims[claimType].(string); typ != typeAccess {
return "", "", ErrWrongType
}
sub, _ := claims["sub"].(string)
role, _ = claims["role"].(string)
if sub == "" {
return "", "", ErrInvalidToken
}
return sub, role, nil
}
// VerifyRefresh validates a refresh token and returns username + jti.
func (t *TokenService) VerifyRefresh(token string) (username, jti string, err error) {
claims, err := t.parse(token)
if err != nil {
return "", "", err
}
if typ, _ := claims[claimType].(string); typ != typeRefresh {
return "", "", ErrWrongType
}
sub, _ := claims["sub"].(string)
jti, _ = claims[claimJti].(string)
if sub == "" || jti == "" {
return "", "", ErrInvalidToken
}
return sub, jti, nil
}
+224
View File
@@ -0,0 +1,224 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package owner
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func newTestTokenService(t *testing.T) *TokenService {
t.Helper()
s, err := NewTokenService(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
}
func TestMintAndVerifyPair(t *testing.T) {
s := newTestTokenService(t)
pair, err := s.MintPair("admin", "admin")
if err != nil {
t.Fatal(err)
}
if pair.Access == "" || pair.Refresh == "" || pair.RefreshJTI == "" {
t.Fatal("empty field in pair")
}
if pair.Username != "admin" || pair.Role != "admin" {
t.Fatalf("pair identity wrong: %+v", pair)
}
if pair.AccessTTL != AccessTokenTTL || pair.RefreshTTL != RefreshTokenTTL {
t.Fatalf("pair TTLs wrong: %+v", pair)
}
for _, tok := range []string{pair.Access, pair.Refresh} {
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("token not JWS compact: %q", tok)
}
}
username, role, err := s.VerifyAccess(pair.Access)
if err != nil {
t.Fatalf("VerifyAccess: %v", err)
}
if username != "admin" || role != "admin" {
t.Fatalf("access claims: %q/%q", username, role)
}
rUser, rJTI, err := s.VerifyRefresh(pair.Refresh)
if err != nil {
t.Fatalf("VerifyRefresh: %v", err)
}
if rUser != "admin" || rJTI != pair.RefreshJTI {
t.Fatalf("refresh claims: %q/%q", rUser, rJTI)
}
}
func TestTokenTypeIsEnforced(t *testing.T) {
s := newTestTokenService(t)
pair, _ := s.MintPair("admin", "admin")
if _, _, err := s.VerifyAccess(pair.Refresh); err == nil {
t.Fatal("refresh token accepted as access")
}
if _, _, err := s.VerifyRefresh(pair.Access); err == nil {
t.Fatal("access token accepted as refresh")
}
}
func craftToken(t *testing.T, s *TokenService, claims jwt.MapClaims) string {
t.Helper()
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.key)
if err != nil {
t.Fatal(err)
}
return tok
}
func TestExpiredTokensRejected(t *testing.T) {
s := newTestTokenService(t)
mk := func(typ string, ttl time.Duration) string {
now := time.Now()
return craftToken(t, s, jwt.MapClaims{
"iss": Issuer, "sub": "admin", "type": typ, "kid": s.kid,
"iat": now.Add(-2 * ttl).Unix(), "exp": now.Add(-ttl).Unix(),
"jti": "expired-" + typ,
})
}
if _, _, err := s.VerifyAccess(mk(typeAccess, AccessTokenTTL)); err == nil {
t.Fatal("expired access accepted")
}
if _, _, err := s.VerifyRefresh(mk(typeRefresh, RefreshTokenTTL)); err == nil {
t.Fatal("expired refresh accepted")
}
}
func TestForgedTokensRejected(t *testing.T) {
s := newTestTokenService(t)
pair, _ := s.MintPair("admin", "admin")
// Signed by a different key.
other := newTestTokenService(t)
forged, _ := other.MintPair("admin", "admin")
if _, _, err := s.VerifyAccess(forged.Access); err == nil {
t.Fatal("foreign-signed access accepted")
}
if _, _, err := s.VerifyRefresh(forged.Refresh); err == nil {
t.Fatal("foreign-signed refresh accepted")
}
// Tampered payload.
if _, _, err := s.VerifyAccess(strings.TrimSuffix(pair.Access, "x") + "y"); err == nil {
t.Fatal("tampered token accepted")
}
// Wrong issuer but right key.
now := time.Now()
badIss := craftToken(t, s, jwt.MapClaims{
"iss": "someone-else", "sub": "admin", "type": typeAccess, "kid": s.kid,
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
})
if _, _, err := s.VerifyAccess(badIss); err == nil {
t.Fatal("wrong-issuer token accepted")
}
// Unknown kid (foreign key generation).
unknownKid := craftToken(t, s, jwt.MapClaims{
"iss": Issuer, "sub": "admin", "type": typeAccess, "kid": "deadbeefdeadbeef",
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
})
if _, _, err := s.VerifyAccess(unknownKid); err == nil {
t.Fatal("unknown-kid token accepted")
}
}
func TestKeyPersistsAcrossRestarts(t *testing.T) {
dir := t.TempDir()
s1, err := NewTokenService(dir)
if err != nil {
t.Fatal(err)
}
pair, err := s1.MintPair("admin", "admin")
if err != nil {
t.Fatal(err)
}
s2, err := NewTokenService(dir)
if err != nil {
t.Fatal(err)
}
if s1.Kid() != s2.Kid() {
t.Fatalf("kid changed across restarts: %s vs %s", s1.Kid(), s2.Kid())
}
if _, _, err := s2.VerifyAccess(pair.Access); err != nil {
t.Fatalf("token invalid after restart: %v", err)
}
}
func TestThrottlerBackoffAndReset(t *testing.T) {
th := NewThrottler()
id := "admin"
if !th.Allow(id) {
t.Fatal("fresh identifier blocked")
}
if th.RetryAfter(id) != 0 {
t.Fatal("RetryAfter nonzero without failures")
}
// The first five failures stay within the free allowance.
for i := 0; i < throttleMaxAttempts; i++ {
th.Fail(id)
}
if !th.Allow(id) {
t.Fatal("attempt inside allowance blocked")
}
if th.RetryAfter(id) != 0 {
t.Fatal("RetryAfter nonzero inside allowance")
}
// The next failure crosses the threshold: backoff starts at base…
th.Fail(id)
if th.Allow(id) {
t.Fatal("locked-out identifier allowed immediately")
}
if wait := th.RetryAfter(id); wait <= 0 || wait > throttleBackoffBase {
t.Fatalf("RetryAfter = %v, want (0,%v]", wait, throttleBackoffBase)
}
// …and escalates multiplicatively with further failures.
th.Fail(id)
if wait := th.RetryAfter(id); wait <= throttleBackoffBase || wait > throttleBackoffBase*throttleMultiplier {
t.Fatalf("RetryAfter = %v, want escalated", wait)
}
// Success clears state.
th.Reset(id)
if !th.Allow(id) || th.RetryAfter(id) != 0 {
t.Fatal("reset did not clear throttler")
}
// Window expiry also clears state.
th.Fail(id)
th.nowFn = func() time.Time { return time.Now().Add(2 * throttleWindow) }
if !th.Allow(id) {
t.Fatal("identifier not forgiven after window")
}
}