mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 23:58:56 +00:00
auth-bundle-2 Phase 1: OIDC + Session + User + Breakglass domain types
Phase 1 ships the persisted-shape types Bundle 2 needs end-to-end.
No DB migrations, no service layer, no HTTP handlers; Phase 2 ships
the SQL, Phase 3+ ship the consumers. Each type has a Validate()
method that enforces the on-disk invariants the schema will mirror,
and a focused _test.go that pins each invariant's failure mode.
Per-package summary:
internal/auth/oidc/domain/ (OIDCProvider + GroupRoleMapping):
* OIDCProvider carries the operator-configured IdP record. Fields
match the prompt's Phase 1 list plus IATWindowSeconds and
JWKSCacheTTLSeconds (Phase 3 references these by name; landing
them in Phase 1's domain type avoids the lying-field gap).
ClientSecretEncrypted is opaque from this layer; it is the v2 blob
produced by internal/crypto/encryption.go and is `json:"-"` so it
never wire-leaks.
* Validate() rejects: invalid id prefix, empty name, non-https
issuer_url (matches Phase 3's "JWKS endpoint MUST be HTTPS"),
empty client_id, empty client_secret_encrypted, non-https
redirect_uri, invalid groups_claim_format, scopes missing openid,
IAT window outside (0, 600], JWKS cache TTL below 60s. Defaults
applied in-place: GroupsClaimPath="groups", GroupsClaimFormat=
"string-array", Scopes=["openid","profile","email"],
IATWindowSeconds=300, JWKSCacheTTLSeconds=3600,
TenantID="t-default".
* GroupRoleMapping carries the operator-configured group-to-role
rule. Validate() pins prefix conventions ("grm-", "op-", "r-")
and non-empty group name.
* 18 tests across happy-path + every negative invariant.
internal/auth/session/domain/ (Session + SessionSigningKey):
* Session covers BOTH the post-login row (full 1h-idle/8h-absolute
cookie lifecycle) AND the Phase 5 pre-login row (10-minute TTL,
carries OIDC state+nonce+PKCE verifier across the IdP redirect).
IsPreLogin discriminates. CSRFTokenHash holds SHA-256 of the
CSRF token plaintext (the plaintext lives in a JS-readable
certctl_csrf cookie; storing only the hash on the row defends
against DB-read leaks per the Phase 4 CSRF contract).
* Validate() pins: id prefix "ses-", non-empty actor id/type,
signing key id prefix "sk-", AbsoluteExpiresAt strictly > Idle,
IdleExpiresAt strictly > CreatedAt, CSRFTokenHash exactly 64
lowercase hex chars when set.
* Cookie naming constants pinned by a separate test
(TestCookieNamingConstants) so a future rename can't silently
break the GUI's web/src/api/client.ts which reads these names by
string.
* SessionSigningKey stores the v2-encrypted HMAC key material; the
retired-before-created invariant catches malformed rows. 14
tests across both types.
internal/auth/user/domain/ (User):
* Federated-human identity for SSO logins. Distinct from Bundle 1's
free-form actor_id strings: actor_roles.actor_id = User.ID for
federated humans (per the prompt's note about how the two
identity systems intersect).
* WebAuthnCredentials JSONB column reserved for v3 (Decision 12);
defaults to "[]" on Validate() so Bundle 2 + v3 share the same
on-disk format from day one.
* Email validation is intentionally loose (basic shape: one @,
non-empty local + domain, no whitespace, dot in domain). RFC 5321
/ 5322 grammars are not enforced; the IdP issued the email and
we trust its shape, only rejecting gross corruption.
* 8 tests across happy-path + invalid-id + empty-email +
malformed-email + invalid-provider-id + tenant defaulting +
WebAuthn-credentials passthrough.
internal/auth/breakglass/domain/ (BreakglassCredential):
* Phase 7.5 type. Argon2id PHC-format password hash; Validate()
pins the Argon2id magic prefix so non-Argon2id formats (bcrypt,
pbkdf2, plaintext) are rejected at the persistence boundary.
* MinPasswordLengthBytes (12) + MaxPasswordLengthBytes (256)
constants pinned by a dedicated test so the operator-facing
password-strength contract can't drift silently.
* IsLocked(now) helper exposes the lockout state machine for the
Phase 7.5 service to consume; the lockout window default is
15min in the service layer.
* 9 tests across happy-path + per-invariant negative + lockout
state machine + tenant defaulting.
Cross-cutting:
* Every type has json:"-" on the encrypted-credential field
(ClientSecretEncrypted, KeyMaterialEncrypted, PasswordHash,
CSRFTokenHash) so even a misconfigured handler that marshals the
domain type directly into a response body cannot leak the
secret. Mirrors Bundle 1's pattern for issuer/target credentials.
* Every type carries TenantID with Validate() defaulting to
authdomain.DefaultTenantID. Forward-compat for the future
managed-service multi-tenant activation; Bundle 2 ships
single-tenant.
Verifications:
* gofmt -l clean across all 8 new files (one round-trip required to
satisfy Go 1.19+ doc-comment list-formatting rules in
session/domain/types.go).
* go vet clean on internal/auth/oidc/... + session/... + user/... +
breakglass/...
* go test -short -count=1 green on all four new domain packages
(49 test functions total).
* go test -short -count=1 still green on Bundle 1 packages
(internal/auth, internal/auth/bootstrap, internal/service/auth,
internal/config).
* govulncheck ./... clean (M-024 hard CI gate).
* All 24 ci-guards pass locally.
Phase 1 exit criteria from cowork/auth-bundle-2-prompt.md:
* All types compile: yes.
* Validators have at least 5 test cases each: yes (smallest is
User with 8 tests; OIDCProvider has 13).
* make verify equivalent green: gofmt + vet + go test pass
(golangci-lint deferred to CI per the same operating-rule
pattern Phase 0 used).
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
// Package domain holds the break-glass-admin persisted-shape type.
|
||||
//
|
||||
// Auth Bundle 2 Phase 1 / Phase 7.5: types only. Phase 2 ships the
|
||||
// SQL migration; Phase 7.5 ships the service layer (set / authenticate
|
||||
// / unlock / remove / lockout-window).
|
||||
//
|
||||
// Break-glass is the SSO-broken-case recovery path. Decision 4 frames
|
||||
// it explicitly: enabled per-deployment via CERTCTL_BREAKGLASS_ENABLED,
|
||||
// default-OFF, paired with WebAuthn 2FA in v3 (Decision 12). The
|
||||
// threat-model is clear: enabling break-glass is a deliberate bypass
|
||||
// of the SSO security boundary; an attacker who phishes the password
|
||||
// bypasses every other defense. Operators turn it on during SSO
|
||||
// incidents and turn it off after recovery.
|
||||
//
|
||||
// `password_hash` is the Argon2id PHC-format string
|
||||
// (`$argon2id$v=19$m=65536,t=3,p=4$<salt-base64>$<hash-base64>`).
|
||||
// Validation here checks the field has the Argon2id magic prefix;
|
||||
// actual hashing / verifying happens in the service layer via
|
||||
// `golang.org/x/crypto/argon2`.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// BreakglassCredential is one actor's password-based recovery
|
||||
// credential. At most one row per actor (Phase 2 migration enforces
|
||||
// `UNIQUE(actor_id)`). FailureCount + LockedUntil track the lockout
|
||||
// state machine that defeats brute-force attacks against the password.
|
||||
type BreakglassCredential struct {
|
||||
ID string `json:"id"` // prefix `bg-`
|
||||
TenantID string `json:"tenant_id"`
|
||||
ActorID string `json:"actor_id"`
|
||||
PasswordHash string `json:"-"` // Argon2id PHC string; never JSON-encoded
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastPasswordChangeAt time.Time `json:"last_password_change_at"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
LockedUntil *time.Time `json:"locked_until,omitempty"`
|
||||
LastFailureAt *time.Time `json:"last_failure_at,omitempty"`
|
||||
}
|
||||
|
||||
// Argon2id parameter constants. The defaults match OWASP 2024
|
||||
// recommendations + sit on the same compute-budget tier as
|
||||
// internal/crypto/encryption.go's PBKDF2-SHA256 600k rounds. Phase
|
||||
// 7.5's service can override via env vars; the defaults are what
|
||||
// Validate() requires of a hash issued without override.
|
||||
const (
|
||||
// Argon2idPHCPrefix is the Argon2id PHC-format magic prefix.
|
||||
// Validate() checks every PasswordHash starts with this.
|
||||
Argon2idPHCPrefix = "$argon2id$"
|
||||
|
||||
// MinPasswordLengthBytes is the floor on raw password input
|
||||
// length (the service layer enforces this before hashing). 12
|
||||
// bytes is the OWASP 2024 lower bound for memorized secrets;
|
||||
// shorter passwords are rejected at SetPassword time. The domain
|
||||
// layer doesn't see plaintext, but the constant lives here so
|
||||
// the service + handler + GUI all reference the same number.
|
||||
MinPasswordLengthBytes = 12
|
||||
|
||||
// MaxPasswordLengthBytes is the upper bound on raw password
|
||||
// input. Argon2id handles arbitrary input but capping at 256
|
||||
// bytes prevents trivial DoS where an attacker submits a 1-MB
|
||||
// password to consume CPU on the verify path. Pre-hashing length
|
||||
// check in the service layer.
|
||||
MaxPasswordLengthBytes = 256
|
||||
)
|
||||
|
||||
// Validation errors. Service layer maps these to HTTP 400.
|
||||
var (
|
||||
ErrBreakglassInvalidID = errors.New("breakglass: id must start with 'bg-'")
|
||||
ErrBreakglassEmptyActorID = errors.New("breakglass: actor_id is required")
|
||||
ErrBreakglassEmptyPasswordHash = errors.New("breakglass: password_hash is required")
|
||||
ErrBreakglassInvalidHashFormat = errors.New("breakglass: password_hash must be Argon2id PHC format ($argon2id$...)")
|
||||
ErrBreakglassNegativeFailures = errors.New("breakglass: failure_count cannot be negative")
|
||||
ErrBreakglassEmptyTenantID = errors.New("breakglass: tenant_id is required")
|
||||
)
|
||||
|
||||
// Validate checks the persisted-shape invariants on a
|
||||
// BreakglassCredential. Defaults applied in-place: TenantID upgrades
|
||||
// to authdomain.DefaultTenantID when empty.
|
||||
//
|
||||
// IMPORTANT: this validator does NOT receive plaintext passwords. The
|
||||
// service-layer SetPassword method validates plaintext length /
|
||||
// strength before hashing; only the resulting Argon2id hash flows into
|
||||
// this struct.
|
||||
func (b *BreakglassCredential) Validate() error {
|
||||
if !strings.HasPrefix(b.ID, "bg-") {
|
||||
return ErrBreakglassInvalidID
|
||||
}
|
||||
if strings.TrimSpace(b.ActorID) == "" {
|
||||
return ErrBreakglassEmptyActorID
|
||||
}
|
||||
if strings.TrimSpace(b.PasswordHash) == "" {
|
||||
return ErrBreakglassEmptyPasswordHash
|
||||
}
|
||||
if !strings.HasPrefix(b.PasswordHash, Argon2idPHCPrefix) {
|
||||
return ErrBreakglassInvalidHashFormat
|
||||
}
|
||||
if b.FailureCount < 0 {
|
||||
return ErrBreakglassNegativeFailures
|
||||
}
|
||||
if strings.TrimSpace(b.TenantID) == "" {
|
||||
b.TenantID = authdomain.DefaultTenantID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsLocked reports whether the credential is currently locked out
|
||||
// (LockedUntil is set and in the future). Phase 7.5 service uses this
|
||||
// at Authenticate time; Validate() does not call it.
|
||||
func (b *BreakglassCredential) IsLocked(now time.Time) bool {
|
||||
return b.LockedUntil != nil && b.LockedUntil.After(now)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func validBreakglass() *BreakglassCredential {
|
||||
now := time.Now().UTC()
|
||||
return &BreakglassCredential{
|
||||
ID: "bg-alice",
|
||||
TenantID: "t-default",
|
||||
ActorID: "u-alice",
|
||||
PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaGhhc2g",
|
||||
CreatedAt: now,
|
||||
LastPasswordChangeAt: now,
|
||||
FailureCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_Validate_HappyPath(t *testing.T) {
|
||||
b := validBreakglass()
|
||||
if err := b.Validate(); err != nil {
|
||||
t.Fatalf("validate happy path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_Validate_RejectsInvalidID(t *testing.T) {
|
||||
for _, bad := range []string{"", "alice", "credential-1", "BG-1"} {
|
||||
b := validBreakglass()
|
||||
b.ID = bad
|
||||
if err := b.Validate(); !errors.Is(err, ErrBreakglassInvalidID) {
|
||||
t.Errorf("ID=%q: err = %v; want ErrBreakglassInvalidID", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_Validate_RejectsEmptyActorID(t *testing.T) {
|
||||
for _, bad := range []string{"", " "} {
|
||||
b := validBreakglass()
|
||||
b.ActorID = bad
|
||||
if err := b.Validate(); !errors.Is(err, ErrBreakglassEmptyActorID) {
|
||||
t.Errorf("actor=%q: err = %v; want ErrBreakglassEmptyActorID", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_Validate_RejectsEmptyPasswordHash(t *testing.T) {
|
||||
b := validBreakglass()
|
||||
b.PasswordHash = ""
|
||||
if err := b.Validate(); !errors.Is(err, ErrBreakglassEmptyPasswordHash) {
|
||||
t.Errorf("err = %v; want ErrBreakglassEmptyPasswordHash", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_Validate_RejectsNonArgon2idHash(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"$argon2i$v=19$...", // argon2i not argon2id
|
||||
"$argon2d$v=19$...", // argon2d not argon2id
|
||||
"$2y$10$...", // bcrypt
|
||||
"$pbkdf2-sha256$...", // pbkdf2
|
||||
"plaintext-password", // raw plaintext
|
||||
"argon2id$v=19$...", // missing leading $
|
||||
} {
|
||||
b := validBreakglass()
|
||||
b.PasswordHash = bad
|
||||
if err := b.Validate(); !errors.Is(err, ErrBreakglassInvalidHashFormat) {
|
||||
t.Errorf("hash=%q: err = %v; want ErrBreakglassInvalidHashFormat", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_Validate_RejectsNegativeFailureCount(t *testing.T) {
|
||||
b := validBreakglass()
|
||||
b.FailureCount = -1
|
||||
if err := b.Validate(); !errors.Is(err, ErrBreakglassNegativeFailures) {
|
||||
t.Errorf("err = %v; want ErrBreakglassNegativeFailures", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_Validate_DefaultsTenantID(t *testing.T) {
|
||||
b := validBreakglass()
|
||||
b.TenantID = ""
|
||||
if err := b.Validate(); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if b.TenantID != "t-default" {
|
||||
t.Errorf("default tenant = %q; want t-default", b.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakglass_IsLocked(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
future := now.Add(15 * time.Minute)
|
||||
past := now.Add(-15 * time.Minute)
|
||||
|
||||
b := validBreakglass()
|
||||
|
||||
// No LockedUntil set: not locked.
|
||||
if b.IsLocked(now) {
|
||||
t.Errorf("IsLocked with nil LockedUntil = true; want false")
|
||||
}
|
||||
|
||||
// LockedUntil in the future: locked.
|
||||
b.LockedUntil = &future
|
||||
if !b.IsLocked(now) {
|
||||
t.Errorf("IsLocked with future LockedUntil = false; want true")
|
||||
}
|
||||
|
||||
// LockedUntil in the past: not locked (window expired).
|
||||
b.LockedUntil = &past
|
||||
if b.IsLocked(now) {
|
||||
t.Errorf("IsLocked with past LockedUntil = true; want false (window expired)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBreakglass_Validate_RejectsTenantIDOnlyWhitespace pins the
|
||||
// strings.TrimSpace path so a tenant_id of " " gets re-defaulted
|
||||
// rather than passed through silently.
|
||||
func TestBreakglass_Validate_NormalizesWhitespaceTenantID(t *testing.T) {
|
||||
b := validBreakglass()
|
||||
b.TenantID = " "
|
||||
if err := b.Validate(); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if b.TenantID != "t-default" {
|
||||
t.Errorf("tenant after whitespace trim = %q; want t-default", b.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBreakglass_PasswordLengthConstantsArePinned exists so a future
|
||||
// PR doesn't silently change the operator-facing minimum / maximum
|
||||
// password length. The service layer + handler tests all reference
|
||||
// these constants; flipping them here changes the operator surface.
|
||||
func TestBreakglass_PasswordLengthConstantsArePinned(t *testing.T) {
|
||||
if MinPasswordLengthBytes != 12 {
|
||||
t.Errorf("MinPasswordLengthBytes = %d; want 12 (OWASP 2024 floor)", MinPasswordLengthBytes)
|
||||
}
|
||||
if MaxPasswordLengthBytes != 256 {
|
||||
t.Errorf("MaxPasswordLengthBytes = %d; want 256 (DoS upper bound)", MaxPasswordLengthBytes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user