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:
shankar0123
2026-05-10 03:41:46 +00:00
parent 7d7bda93ba
commit 795d7725b8
8 changed files with 1344 additions and 0 deletions
+110
View File
@@ -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
}
+112
View File
@@ -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))
}
}