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 2d9110b0c4
commit b0ac24fbf8
8 changed files with 1344 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
// Package domain holds the session-management persisted-shape types.
//
// Auth Bundle 2 Phase 1: types only. Phase 2 ships the SQL migration;
// Phase 4 ships the service layer (cookie minting, validation,
// revocation, idle / absolute expiry, signing-key rotation, GC).
//
// Two cookie shapes share this Session table. Post-login sessions are
// minted by SessionService.Create after a successful OIDC callback (or
// break-glass authenticate); they carry the cookie HMAC-signed via the
// active SessionSigningKey, idle timeout 1h default, absolute timeout
// 8h default. Pre-login sessions are minted at /auth/oidc/login to
// hold the state, nonce, and PKCE verifier across the IdP redirect;
// same row shape, `is_pre_login = true`, 10-minute absolute TTL, GC'd
// by the same scheduler sweep as expired post-login sessions.
//
// CSRFTokenHash holds the SHA-256 of the operator-facing CSRF token
// (the plaintext lives in a separate `certctl_csrf` cookie that is
// JS-readable by design so the GUI can echo it into the X-CSRF-Token
// header). The hash on the session row defends against DB-read leaks:
// a compromised read-only DB user cannot replay live tokens.
package domain
import (
"errors"
"strings"
"time"
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
)
// Session is one cookie's worth of authenticated state. Created on
// login (post-login row) or on /auth/oidc/login (pre-login row);
// destroyed by Revoke / GarbageCollect.
type Session struct {
ID string `json:"id"` // prefix `ses-`
ActorID string `json:"actor_id"`
ActorType string `json:"actor_type"` // matches domain.ActorType strings
SigningKeyID string `json:"signing_key_id"`
IsPreLogin bool `json:"is_pre_login"`
CSRFTokenHash string `json:"-"` // hex-encoded SHA-256; never wire-exposed
IdleExpiresAt time.Time `json:"idle_expires_at"`
AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
TenantID string `json:"tenant_id"`
}
// SessionSigningKey holds the HMAC key material used to sign session
// cookies. Phase 4's `Service.RotateSigningKey` mints new keys and
// retires old ones; retired keys stay valid for verification during
// the configurable retention window so existing cookies don't
// immediately fail. KeyMaterialEncrypted is the v2 blob produced by
// `internal/crypto/encryption.go`; the plaintext is the 32-byte HMAC
// key the session cookie is signed with.
type SessionSigningKey struct {
ID string `json:"id"` // prefix `sk-`
TenantID string `json:"tenant_id"`
KeyMaterialEncrypted []byte `json:"-"` // v2 blob; never JSON-encoded
CreatedAt time.Time `json:"created_at"`
RetiredAt *time.Time `json:"retired_at,omitempty"`
}
// Cookie naming constants (referenced by Phase 4's service + Phase 5's
// handler).
const (
// PostLoginCookieName is the post-authentication session cookie.
// Set HttpOnly + Secure + SameSite=Lax (or Strict via env var).
PostLoginCookieName = "certctl_session"
// PreLoginCookieName is the pre-authentication session cookie that
// holds the OIDC state + nonce + PKCE verifier across the IdP
// redirect. 10-minute lifetime, separate from the post-login
// cookie, Path=/auth/oidc/.
PreLoginCookieName = "certctl_oidc_pending"
// CSRFCookieName is the JS-readable cookie holding the CSRF token
// plaintext. Mirrors the SHA-256 hash on the session row. The GUI
// reads this and echoes the value into the X-CSRF-Token header on
// every state-changing request.
CSRFCookieName = "certctl_csrf"
// CookieFormatVersion is the prefix on every session cookie value.
// Format: `v1.<session_id>.<signing_key_id>.<base64url-no-pad
// HMAC>`. Reserved so a future incompatible format upgrade ships
// as `v2.` without overlapping the validator.
CookieFormatVersion = "v1"
// PreLoginAbsoluteTTL is the maximum lifetime of a pre-login
// session row. The IdP redirect handshake should complete inside
// 10 minutes; rows older than this are GC'd.
PreLoginAbsoluteTTL = 10 * time.Minute
)
// Validation errors. Service layer maps these to HTTP 400 / 500.
var (
ErrSessionInvalidID = errors.New("session: id must start with 'ses-'")
ErrSessionEmptyActorID = errors.New("session: actor_id is required")
ErrSessionEmptyActorType = errors.New("session: actor_type is required")
ErrSessionInvalidSigningKeyID = errors.New("session: signing_key_id must start with 'sk-'")
ErrSessionExpiryOrder = errors.New("session: absolute_expires_at must be > idle_expires_at")
ErrSessionExpiryNotInFuture = errors.New("session: idle_expires_at must be after created_at")
ErrSessionEmptyTenantID = errors.New("session: tenant_id is required")
ErrSessionInvalidCSRFHash = errors.New("session: csrf_token_hash must be 64 hex characters (sha256) when set")
ErrSessionSigningKeyInvalidID = errors.New("session: signing key id must start with 'sk-'")
ErrSessionSigningKeyEmptyMaterial = errors.New("session: signing key material is required")
ErrSessionSigningKeyRetiredBeforeNow = errors.New("session: retired_at cannot be before created_at")
ErrSessionSigningKeyEmptyTenantID = errors.New("session: signing key tenant_id is required")
)
// Validate checks the persisted-shape invariants on a Session.
// Defaults applied in-place: TenantID upgrades to authdomain.DefaultTenantID
// when empty.
func (s *Session) Validate() error {
if !strings.HasPrefix(s.ID, "ses-") {
return ErrSessionInvalidID
}
if strings.TrimSpace(s.ActorID) == "" {
return ErrSessionEmptyActorID
}
if strings.TrimSpace(s.ActorType) == "" {
return ErrSessionEmptyActorType
}
if !strings.HasPrefix(s.SigningKeyID, "sk-") {
return ErrSessionInvalidSigningKeyID
}
if !s.AbsoluteExpiresAt.After(s.IdleExpiresAt) {
return ErrSessionExpiryOrder
}
if !s.CreatedAt.IsZero() && !s.IdleExpiresAt.After(s.CreatedAt) {
return ErrSessionExpiryNotInFuture
}
if s.CSRFTokenHash != "" {
// SHA-256 is 32 bytes => 64 lowercase hex chars.
if len(s.CSRFTokenHash) != 64 || !isHex(s.CSRFTokenHash) {
return ErrSessionInvalidCSRFHash
}
}
if strings.TrimSpace(s.TenantID) == "" {
s.TenantID = authdomain.DefaultTenantID
}
return nil
}
// Validate checks the persisted-shape invariants on a SessionSigningKey.
func (k *SessionSigningKey) Validate() error {
if !strings.HasPrefix(k.ID, "sk-") {
return ErrSessionSigningKeyInvalidID
}
if len(k.KeyMaterialEncrypted) == 0 {
return ErrSessionSigningKeyEmptyMaterial
}
if k.RetiredAt != nil && !k.CreatedAt.IsZero() && k.RetiredAt.Before(k.CreatedAt) {
return ErrSessionSigningKeyRetiredBeforeNow
}
if strings.TrimSpace(k.TenantID) == "" {
k.TenantID = authdomain.DefaultTenantID
}
return nil
}
// isHex reports whether s contains only lowercase hex characters.
// Used by Session.Validate to pin CSRFTokenHash format.
func isHex(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
+211
View File
@@ -0,0 +1,211 @@
package domain
import (
"errors"
"strings"
"testing"
"time"
)
func validSession() *Session {
now := time.Now().UTC()
return &Session{
ID: "ses-abc123",
ActorID: "alice",
ActorType: "User",
SigningKeyID: "sk-1",
IdleExpiresAt: now.Add(time.Hour),
AbsoluteExpiresAt: now.Add(8 * time.Hour),
CreatedAt: now,
LastSeenAt: now,
IPAddress: "10.0.0.1",
UserAgent: "Mozilla/5.0",
TenantID: "t-default",
}
}
func TestSession_Validate_HappyPath(t *testing.T) {
s := validSession()
if err := s.Validate(); err != nil {
t.Fatalf("validate happy path: %v", err)
}
}
func TestSession_Validate_RejectsInvalidID(t *testing.T) {
for _, bad := range []string{"", "abc", "session-abc", "SES-abc"} {
s := validSession()
s.ID = bad
if err := s.Validate(); !errors.Is(err, ErrSessionInvalidID) {
t.Errorf("ID=%q: err = %v; want ErrSessionInvalidID", bad, err)
}
}
}
func TestSession_Validate_RejectsEmptyActorID(t *testing.T) {
s := validSession()
s.ActorID = ""
if err := s.Validate(); !errors.Is(err, ErrSessionEmptyActorID) {
t.Errorf("err = %v; want ErrSessionEmptyActorID", err)
}
}
func TestSession_Validate_RejectsEmptyActorType(t *testing.T) {
s := validSession()
s.ActorType = ""
if err := s.Validate(); !errors.Is(err, ErrSessionEmptyActorType) {
t.Errorf("err = %v; want ErrSessionEmptyActorType", err)
}
}
func TestSession_Validate_RejectsInvalidSigningKeyID(t *testing.T) {
s := validSession()
s.SigningKeyID = "key-1"
if err := s.Validate(); !errors.Is(err, ErrSessionInvalidSigningKeyID) {
t.Errorf("err = %v; want ErrSessionInvalidSigningKeyID", err)
}
}
func TestSession_Validate_RejectsBadExpiryOrder(t *testing.T) {
now := time.Now().UTC()
s := validSession()
// idle == absolute: not strictly greater
s.IdleExpiresAt = now.Add(time.Hour)
s.AbsoluteExpiresAt = now.Add(time.Hour)
if err := s.Validate(); !errors.Is(err, ErrSessionExpiryOrder) {
t.Errorf("equal expiry: err = %v; want ErrSessionExpiryOrder", err)
}
// idle > absolute: strictly worse
s.IdleExpiresAt = now.Add(2 * time.Hour)
s.AbsoluteExpiresAt = now.Add(time.Hour)
if err := s.Validate(); !errors.Is(err, ErrSessionExpiryOrder) {
t.Errorf("idle>abs: err = %v; want ErrSessionExpiryOrder", err)
}
}
func TestSession_Validate_RejectsExpiryBeforeCreated(t *testing.T) {
now := time.Now().UTC()
s := validSession()
s.CreatedAt = now
s.IdleExpiresAt = now.Add(-time.Hour) // before created
s.AbsoluteExpiresAt = now.Add(-30 * time.Minute) // also before created, but greater than idle
if err := s.Validate(); !errors.Is(err, ErrSessionExpiryNotInFuture) {
t.Errorf("err = %v; want ErrSessionExpiryNotInFuture", err)
}
}
func TestSession_Validate_DefaultsTenantID(t *testing.T) {
s := validSession()
s.TenantID = ""
if err := s.Validate(); err != nil {
t.Fatalf("err: %v", err)
}
if s.TenantID != "t-default" {
t.Errorf("default tenant = %q; want t-default", s.TenantID)
}
}
func TestSession_Validate_AcceptsValidCSRFHash(t *testing.T) {
s := validSession()
s.CSRFTokenHash = strings.Repeat("a", 64)
if err := s.Validate(); err != nil {
t.Errorf("64-char lowercase hex: err = %v; want nil", err)
}
}
func TestSession_Validate_RejectsInvalidCSRFHash(t *testing.T) {
for _, bad := range []string{
strings.Repeat("a", 63), // too short
strings.Repeat("a", 65), // too long
strings.Repeat("Z", 64), // not lowercase hex
strings.Repeat("a", 60) + "1234", // OK length but the prior is bad mixed
"!@#$" + strings.Repeat("a", 60), // non-hex chars
} {
s := validSession()
s.CSRFTokenHash = bad
err := s.Validate()
// At least one of these should fail; lengths 64 with bad chars hit ErrSessionInvalidCSRFHash.
if len(bad) == 64 && bad != strings.Repeat("a", 60)+"1234" {
if !errors.Is(err, ErrSessionInvalidCSRFHash) {
t.Errorf("bad=%q: err = %v; want ErrSessionInvalidCSRFHash", bad, err)
}
}
}
}
// =============================================================================
// SessionSigningKey
// =============================================================================
func TestSessionSigningKey_Validate_HappyPath(t *testing.T) {
k := &SessionSigningKey{
ID: "sk-1",
TenantID: "t-default",
KeyMaterialEncrypted: []byte{0x02, 0x00},
CreatedAt: time.Now().UTC(),
}
if err := k.Validate(); err != nil {
t.Fatalf("err: %v", err)
}
}
func TestSessionSigningKey_Validate_RejectsInvalidID(t *testing.T) {
k := &SessionSigningKey{ID: "key-1", KeyMaterialEncrypted: []byte{0x01}}
if err := k.Validate(); !errors.Is(err, ErrSessionSigningKeyInvalidID) {
t.Errorf("err = %v; want ErrSessionSigningKeyInvalidID", err)
}
}
func TestSessionSigningKey_Validate_RejectsEmptyMaterial(t *testing.T) {
k := &SessionSigningKey{ID: "sk-1"}
if err := k.Validate(); !errors.Is(err, ErrSessionSigningKeyEmptyMaterial) {
t.Errorf("err = %v; want ErrSessionSigningKeyEmptyMaterial", err)
}
}
func TestSessionSigningKey_Validate_RejectsRetiredBeforeCreated(t *testing.T) {
now := time.Now().UTC()
earlier := now.Add(-time.Hour)
k := &SessionSigningKey{
ID: "sk-1",
KeyMaterialEncrypted: []byte{0x01},
CreatedAt: now,
RetiredAt: &earlier,
}
if err := k.Validate(); !errors.Is(err, ErrSessionSigningKeyRetiredBeforeNow) {
t.Errorf("err = %v; want ErrSessionSigningKeyRetiredBeforeNow", err)
}
}
func TestSessionSigningKey_Validate_DefaultsTenantID(t *testing.T) {
k := &SessionSigningKey{ID: "sk-1", KeyMaterialEncrypted: []byte{0x01}}
if err := k.Validate(); err != nil {
t.Fatalf("err: %v", err)
}
if k.TenantID != "t-default" {
t.Errorf("default tenant = %q; want t-default", k.TenantID)
}
}
// =============================================================================
// Cookie naming constants pin
// =============================================================================
func TestCookieNamingConstants(t *testing.T) {
// Pin the cookie names in case a future refactor accidentally
// renames them; the GUI's `web/src/api/client.ts` reads
// `certctl_csrf` by name and the back-channel handlers reference
// `certctl_session` directly. A rename without coordinated GUI
// updates would silently break login.
if PostLoginCookieName != "certctl_session" {
t.Errorf("PostLoginCookieName = %q; want certctl_session", PostLoginCookieName)
}
if PreLoginCookieName != "certctl_oidc_pending" {
t.Errorf("PreLoginCookieName = %q; want certctl_oidc_pending", PreLoginCookieName)
}
if CSRFCookieName != "certctl_csrf" {
t.Errorf("CSRFCookieName = %q; want certctl_csrf", CSRFCookieName)
}
if CookieFormatVersion != "v1" {
t.Errorf("CookieFormatVersion = %q; want v1", CookieFormatVersion)
}
}