mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 07:08:55 +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,110 @@
|
||||
// Package domain holds the federated-human user persisted-shape type.
|
||||
//
|
||||
// Auth Bundle 2 Phase 1: types only. Phase 2 ships the SQL migration;
|
||||
// Phase 3's OIDCService.HandleCallback creates / updates rows here on
|
||||
// successful login.
|
||||
//
|
||||
// Distinction from `internal/domain/auth.Tenant / Role / Permission`:
|
||||
// Bundle 1's RBAC indexes by `actor_id` strings (free-form names). For
|
||||
// federated humans, the user's actor_id IS the user's `User.ID` so
|
||||
// Bundle 1's `actor_roles.actor_id = User.ID` for SSO logins. API-key
|
||||
// actors continue to use the env-var-name as their actor_id; they are
|
||||
// not represented here.
|
||||
//
|
||||
// `webauthn_credentials` is reserved for v3 (Decision 12). Bundle 2
|
||||
// always stores `[]`; v3's WebAuthn enrollment populates it.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// User is a federated-human identity. One row per (oidc_subject,
|
||||
// oidc_provider_id) tuple per the Phase 2 unique index. A person who
|
||||
// authenticates against multiple providers gets multiple rows by
|
||||
// design: identity is per-provider, not global.
|
||||
type User struct {
|
||||
ID string `json:"id"` // prefix `u-`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
OIDCSubject string `json:"oidc_subject"`
|
||||
OIDCProviderID string `json:"oidc_provider_id"`
|
||||
LastLoginAt time.Time `json:"last_login_at"`
|
||||
WebAuthnCredentials []byte `json:"webauthn_credentials,omitempty"` // JSONB; reserved for v3, always `[]` in Bundle 2
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Validation errors. Service layer maps these to HTTP 400.
|
||||
var (
|
||||
ErrUserInvalidID = errors.New("user: id must start with 'u-'")
|
||||
ErrUserEmptyEmail = errors.New("user: email is required")
|
||||
ErrUserInvalidEmail = errors.New("user: email format is invalid")
|
||||
ErrUserEmptyOIDCSubject = errors.New("user: oidc_subject is required")
|
||||
ErrUserInvalidProviderID = errors.New("user: oidc_provider_id must start with 'op-'")
|
||||
ErrUserEmptyTenantID = errors.New("user: tenant_id is required")
|
||||
)
|
||||
|
||||
// Validate checks the persisted-shape invariants on a User.
|
||||
//
|
||||
// Email format is checked with a basic invariant (contains exactly one
|
||||
// `@`, has a non-empty local part, has a non-empty domain part). RFC
|
||||
// 5321 / RFC 5322 grammars are intentionally NOT enforced fully:
|
||||
// production deployments accept whatever the IdP issued + don't reject
|
||||
// based on email pickiness. The check below catches gross corruption
|
||||
// (empty / multiple `@` / leading-or-trailing whitespace).
|
||||
func (u *User) Validate() error {
|
||||
if !strings.HasPrefix(u.ID, "u-") {
|
||||
return ErrUserInvalidID
|
||||
}
|
||||
if strings.TrimSpace(u.Email) == "" {
|
||||
return ErrUserEmptyEmail
|
||||
}
|
||||
if !isPlausibleEmail(u.Email) {
|
||||
return ErrUserInvalidEmail
|
||||
}
|
||||
if strings.TrimSpace(u.OIDCSubject) == "" {
|
||||
return ErrUserEmptyOIDCSubject
|
||||
}
|
||||
if !strings.HasPrefix(u.OIDCProviderID, "op-") {
|
||||
return ErrUserInvalidProviderID
|
||||
}
|
||||
// WebAuthnCredentials default to empty array (`[]`) at the SQL layer
|
||||
// via DEFAULT '[]'. Bundle 2 doesn't populate; v3 does.
|
||||
if u.WebAuthnCredentials == nil {
|
||||
u.WebAuthnCredentials = []byte("[]")
|
||||
}
|
||||
if strings.TrimSpace(u.TenantID) == "" {
|
||||
u.TenantID = authdomain.DefaultTenantID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPlausibleEmail catches gross corruption without enforcing
|
||||
// RFC 5321 / 5322 grammars. The IdP issued the email; we trust it
|
||||
// shape-wise but reject obvious garbage.
|
||||
func isPlausibleEmail(s string) bool {
|
||||
if s != strings.TrimSpace(s) {
|
||||
return false
|
||||
}
|
||||
at := strings.Count(s, "@")
|
||||
if at != 1 {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(s, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(parts[1], ".") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func validUser() *User {
|
||||
now := time.Now().UTC()
|
||||
return &User{
|
||||
ID: "u-alice",
|
||||
TenantID: "t-default",
|
||||
Email: "alice@example.com",
|
||||
DisplayName: "Alice Smith",
|
||||
OIDCSubject: "okta-user-12345",
|
||||
OIDCProviderID: "op-okta-prod",
|
||||
LastLoginAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_HappyPath(t *testing.T) {
|
||||
u := validUser()
|
||||
if err := u.Validate(); err != nil {
|
||||
t.Fatalf("validate happy path: %v", err)
|
||||
}
|
||||
// WebAuthnCredentials defaulted to []
|
||||
if string(u.WebAuthnCredentials) != "[]" {
|
||||
t.Errorf("default webauthn_credentials = %q; want []", string(u.WebAuthnCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_RejectsInvalidID(t *testing.T) {
|
||||
for _, bad := range []string{"", "alice", "user-alice", "U-alice"} {
|
||||
u := validUser()
|
||||
u.ID = bad
|
||||
if err := u.Validate(); !errors.Is(err, ErrUserInvalidID) {
|
||||
t.Errorf("ID=%q: err = %v; want ErrUserInvalidID", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_RejectsEmptyEmail(t *testing.T) {
|
||||
for _, bad := range []string{"", " ", "\t"} {
|
||||
u := validUser()
|
||||
u.Email = bad
|
||||
if err := u.Validate(); !errors.Is(err, ErrUserEmptyEmail) {
|
||||
t.Errorf("email=%q: err = %v; want ErrUserEmptyEmail", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_RejectsMalformedEmail(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"alice", // no @
|
||||
"alice@@example.com", // double @
|
||||
"@example.com", // empty local
|
||||
"alice@", // empty domain
|
||||
"alice@example", // no dot in domain
|
||||
" alice@example.com", // leading whitespace
|
||||
"alice@example.com ", // trailing whitespace
|
||||
} {
|
||||
u := validUser()
|
||||
u.Email = bad
|
||||
if err := u.Validate(); !errors.Is(err, ErrUserInvalidEmail) {
|
||||
t.Errorf("email=%q: err = %v; want ErrUserInvalidEmail", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_RejectsEmptyOIDCSubject(t *testing.T) {
|
||||
u := validUser()
|
||||
u.OIDCSubject = ""
|
||||
if err := u.Validate(); !errors.Is(err, ErrUserEmptyOIDCSubject) {
|
||||
t.Errorf("err = %v; want ErrUserEmptyOIDCSubject", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_RejectsInvalidOIDCProviderID(t *testing.T) {
|
||||
for _, bad := range []string{"", "okta-prod", "OP-okta-prod", "provider-okta"} {
|
||||
u := validUser()
|
||||
u.OIDCProviderID = bad
|
||||
if err := u.Validate(); !errors.Is(err, ErrUserInvalidProviderID) {
|
||||
t.Errorf("provider=%q: err = %v; want ErrUserInvalidProviderID", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_DefaultsTenantID(t *testing.T) {
|
||||
u := validUser()
|
||||
u.TenantID = ""
|
||||
if err := u.Validate(); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if u.TenantID != "t-default" {
|
||||
t.Errorf("default tenant = %q; want t-default", u.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Validate_PreservesExistingWebAuthnCredentials(t *testing.T) {
|
||||
u := validUser()
|
||||
u.WebAuthnCredentials = []byte(`[{"id":"cred1"}]`)
|
||||
if err := u.Validate(); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(u.WebAuthnCredentials), "cred1") {
|
||||
t.Errorf("Validate clobbered existing webauthn_credentials: %q", string(u.WebAuthnCredentials))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user