From b0ac24fbf80d43b4cfd4c10e63f912b526f9c56c Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 10 May 2026 03:41:46 +0000 Subject: [PATCH] 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). --- internal/auth/breakglass/domain/types.go | 117 +++++++++ internal/auth/breakglass/domain/types_test.go | 143 ++++++++++ internal/auth/oidc/domain/types.go | 233 +++++++++++++++++ internal/auth/oidc/domain/types_test.go | 244 ++++++++++++++++++ internal/auth/session/domain/types.go | 174 +++++++++++++ internal/auth/session/domain/types_test.go | 211 +++++++++++++++ internal/auth/user/domain/types.go | 110 ++++++++ internal/auth/user/domain/types_test.go | 112 ++++++++ 8 files changed, 1344 insertions(+) create mode 100644 internal/auth/breakglass/domain/types.go create mode 100644 internal/auth/breakglass/domain/types_test.go create mode 100644 internal/auth/oidc/domain/types.go create mode 100644 internal/auth/oidc/domain/types_test.go create mode 100644 internal/auth/session/domain/types.go create mode 100644 internal/auth/session/domain/types_test.go create mode 100644 internal/auth/user/domain/types.go create mode 100644 internal/auth/user/domain/types_test.go diff --git a/internal/auth/breakglass/domain/types.go b/internal/auth/breakglass/domain/types.go new file mode 100644 index 0000000..fae857d --- /dev/null +++ b/internal/auth/breakglass/domain/types.go @@ -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$$`). +// 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) +} diff --git a/internal/auth/breakglass/domain/types_test.go b/internal/auth/breakglass/domain/types_test.go new file mode 100644 index 0000000..867596f --- /dev/null +++ b/internal/auth/breakglass/domain/types_test.go @@ -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) + } +} diff --git a/internal/auth/oidc/domain/types.go b/internal/auth/oidc/domain/types.go new file mode 100644 index 0000000..a959ff4 --- /dev/null +++ b/internal/auth/oidc/domain/types.go @@ -0,0 +1,233 @@ +// Package domain holds the OIDC integration's persisted-shape types. +// +// Auth Bundle 2 Phase 1: types only, no service or repository wiring. +// Phase 2 ships the SQL migration that materializes these into tables; +// Phase 3 ships the service layer that consumes them. +// +// Layout convention follows the rest of certctl per CLAUDE.md +// "Architecture Decisions": TEXT primary keys with prefixes (`op-`, +// `grm-`), TIMESTAMPTZ for time columns, idempotent migrations, +// `tenant_id` on every identity-related row from day one for the +// future managed-service multi-tenant activation. +package domain + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + authdomain "github.com/certctl-io/certctl/internal/domain/auth" +) + +// OIDCProvider describes a configured OpenID Connect identity provider +// (Okta / Azure AD / Google Workspace / Keycloak / Authentik / Auth0). +// Stored as a row per provider; certctl supports N providers from day +// one (per the forward-compat seam in the prompt) so a future managed +// customer can plug in multiple IdPs. +// +// `client_secret_encrypted` is opaque from this layer's POV: it is the +// v2 blob (`magic byte 0x02 || salt(16) || nonce(12) || ciphertext+tag`) +// produced by `internal/crypto/encryption.go`. Validation here checks +// the field is non-empty + carries the v2 magic byte; actual +// encryption / decryption happens in the service layer. +type OIDCProvider struct { + ID string `json:"id"` // prefix `op-` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + ClientSecretEncrypted []byte `json:"-"` // v2 blob; never JSON-encoded + RedirectURI string `json:"redirect_uri"` + GroupsClaimPath string `json:"groups_claim_path"` + GroupsClaimFormat string `json:"groups_claim_format"` + FetchUserinfo bool `json:"fetch_userinfo"` + Scopes []string `json:"scopes"` + AllowedEmailDomains []string `json:"allowed_email_domains"` + IATWindowSeconds int `json:"iat_window_seconds"` + JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GroupRoleMapping maps a group name (string from the IdP's group +// claim) to a certctl role id. Operators configure these via the GUI's +// Group→Role Mapping page (Phase 8). Name-based per the forward-compat +// seam: if the IdP renames a group, the operator updates the mapping. +// This avoids depending on IdP-internal identifiers (which differ per +// IdP and resist documentation). +type GroupRoleMapping struct { + ID string `json:"id"` // prefix `grm-` + ProviderID string `json:"provider_id"` + GroupName string `json:"group_name"` + RoleID string `json:"role_id"` + TenantID string `json:"tenant_id"` + CreatedAt time.Time `json:"created_at"` +} + +// OIDCProvider configuration constants. +const ( + // GroupsClaimFormatStringArray expects the resolved claim to be + // `[]string` directly (the default; matches Okta / Auth0 standard + // `groups` claim, Azure AD object-ID claims, etc.). + GroupsClaimFormatStringArray = "string-array" + + // GroupsClaimFormatJSONPath expects the resolved claim to need + // path-walking into a nested object (e.g. Keycloak's + // `realm_access.roles`). The hand-rolled resolver in + // `internal/auth/oidc/groupclaim/` walks dot-separated paths + // through nested `map[string]interface{}` chains. URL-shape paths + // (`https://your-namespace/groups`) are treated as a single + // literal key. + GroupsClaimFormatJSONPath = "json-path" + + // DefaultGroupsClaimPath is the OIDC convention for the group + // claim. Most IdPs default to this. + DefaultGroupsClaimPath = "groups" + + // DefaultIATWindowSeconds is the maximum age of an ID token's + // `iat` claim that the verifier accepts, in seconds. 300s = 5 + // minutes. Phase 3 service caps the configurable value at 600s. + DefaultIATWindowSeconds = 300 + + // MaxIATWindowSeconds is the upper bound on configurable IAT + // windows. Beyond 10 minutes the replay-attack window is too + // permissive. + MaxIATWindowSeconds = 600 + + // DefaultJWKSCacheTTLSeconds caps how long the JWKS cache stays + // stale before a refresh. 1 hour. Min configurable: 60s. + DefaultJWKSCacheTTLSeconds = 3600 + + // MinJWKSCacheTTLSeconds is the floor for the JWKS cache TTL. + // Anything lower than 60s would cause excessive JWKS endpoint + // traffic at the IdP. + MinJWKSCacheTTLSeconds = 60 +) + +// Domain validation errors. Service layer maps these to HTTP 400. +var ( + ErrOIDCInvalidID = errors.New("oidc: id must start with 'op-'") + ErrOIDCEmptyName = errors.New("oidc: name is required") + ErrOIDCIssuerNotHTTPS = errors.New("oidc: issuer_url must be https://") + ErrOIDCEmptyClientID = errors.New("oidc: client_id is required") + ErrOIDCEmptyClientSecret = errors.New("oidc: client_secret_encrypted is required") + ErrOIDCRedirectNotHTTPS = errors.New("oidc: redirect_uri must be https://") + ErrOIDCInvalidGroupsClaimFormat = errors.New("oidc: groups_claim_format must be 'string-array' or 'json-path'") + ErrOIDCMissingOpenIDScope = errors.New("oidc: scopes must include 'openid' (RFC 6749 + OIDC core require it)") + ErrOIDCInvalidIATWindow = errors.New("oidc: iat_window_seconds must be > 0 and <= 600") + ErrOIDCInvalidJWKSCacheTTL = errors.New("oidc: jwks_cache_ttl_seconds must be >= 60") + ErrOIDCEmptyTenantID = errors.New("oidc: tenant_id is required") + ErrGroupRoleMappingInvalidID = errors.New("oidc: group-role mapping id must start with 'grm-'") + ErrGroupRoleMappingInvalidProvID = errors.New("oidc: group-role mapping provider_id must start with 'op-'") + ErrGroupRoleMappingEmptyGroupName = errors.New("oidc: group-role mapping group_name is required") + ErrGroupRoleMappingInvalidRoleID = errors.New("oidc: group-role mapping role_id must start with 'r-'") + ErrGroupRoleMappingEmptyTenantID = errors.New("oidc: group-role mapping tenant_id is required") +) + +// Validate runs the persisted-shape invariants on an OIDCProvider. +// Returns the first error encountered. Service-layer callers (Phase 3) +// invoke Validate() before persisting / accepting input from operator +// API calls. +// +// Defaults applied in-place when fields are unset (zero values are +// upgraded to their canonical defaults). Callers SHOULD pass a +// pointer-mutable instance. +func (p *OIDCProvider) Validate() error { + if !strings.HasPrefix(p.ID, "op-") { + return ErrOIDCInvalidID + } + if strings.TrimSpace(p.Name) == "" { + return ErrOIDCEmptyName + } + // Phase 3 contract: JWKS endpoint MUST be HTTPS. Reject at + // provider creation time. + if !strings.HasPrefix(p.IssuerURL, "https://") { + return ErrOIDCIssuerNotHTTPS + } + if _, err := url.Parse(p.IssuerURL); err != nil { + return fmt.Errorf("oidc: issuer_url is not a valid URL: %w", err) + } + if strings.TrimSpace(p.ClientID) == "" { + return ErrOIDCEmptyClientID + } + if len(p.ClientSecretEncrypted) == 0 { + return ErrOIDCEmptyClientSecret + } + // Phase 3 contract: control plane is HTTPS-only post v2.0.47, so + // the redirect_uri MUST be https. No loopback exception (the test + // IdP harness in Phase 10 runs Keycloak in a docker network with + // HTTPS endpoints; localhost http isn't a supported deploy mode). + if !strings.HasPrefix(p.RedirectURI, "https://") { + return ErrOIDCRedirectNotHTTPS + } + if _, err := url.Parse(p.RedirectURI); err != nil { + return fmt.Errorf("oidc: redirect_uri is not a valid URL: %w", err) + } + // Default the claim path / format if unset. + if p.GroupsClaimPath == "" { + p.GroupsClaimPath = DefaultGroupsClaimPath + } + if p.GroupsClaimFormat == "" { + p.GroupsClaimFormat = GroupsClaimFormatStringArray + } + switch p.GroupsClaimFormat { + case GroupsClaimFormatStringArray, GroupsClaimFormatJSONPath: + // ok + default: + return ErrOIDCInvalidGroupsClaimFormat + } + // Default scopes if empty; ensure "openid" is present. + if len(p.Scopes) == 0 { + p.Scopes = []string{"openid", "profile", "email"} + } + hasOpenID := false + for _, s := range p.Scopes { + if s == "openid" { + hasOpenID = true + break + } + } + if !hasOpenID { + return ErrOIDCMissingOpenIDScope + } + // IAT window default + bounds. + if p.IATWindowSeconds == 0 { + p.IATWindowSeconds = DefaultIATWindowSeconds + } + if p.IATWindowSeconds <= 0 || p.IATWindowSeconds > MaxIATWindowSeconds { + return ErrOIDCInvalidIATWindow + } + // JWKS cache TTL default + bounds. + if p.JWKSCacheTTLSeconds == 0 { + p.JWKSCacheTTLSeconds = DefaultJWKSCacheTTLSeconds + } + if p.JWKSCacheTTLSeconds < MinJWKSCacheTTLSeconds { + return ErrOIDCInvalidJWKSCacheTTL + } + if strings.TrimSpace(p.TenantID) == "" { + p.TenantID = authdomain.DefaultTenantID + } + return nil +} + +// Validate runs the persisted-shape invariants on a GroupRoleMapping. +func (m *GroupRoleMapping) Validate() error { + if !strings.HasPrefix(m.ID, "grm-") { + return ErrGroupRoleMappingInvalidID + } + if !strings.HasPrefix(m.ProviderID, "op-") { + return ErrGroupRoleMappingInvalidProvID + } + if strings.TrimSpace(m.GroupName) == "" { + return ErrGroupRoleMappingEmptyGroupName + } + if !strings.HasPrefix(m.RoleID, "r-") { + return ErrGroupRoleMappingInvalidRoleID + } + if strings.TrimSpace(m.TenantID) == "" { + m.TenantID = authdomain.DefaultTenantID + } + return nil +} diff --git a/internal/auth/oidc/domain/types_test.go b/internal/auth/oidc/domain/types_test.go new file mode 100644 index 0000000..5edc977 --- /dev/null +++ b/internal/auth/oidc/domain/types_test.go @@ -0,0 +1,244 @@ +package domain + +import ( + "errors" + "strings" + "testing" +) + +// validProvider returns a baseline OIDCProvider with all required +// fields populated. Tests mutate one field at a time to assert +// per-invariant validation. This pattern keeps each test focused on +// the single invariant it pins. +func validProvider() *OIDCProvider { + return &OIDCProvider{ + ID: "op-keycloak", + TenantID: "t-default", + Name: "Keycloak Production", + IssuerURL: "https://keycloak.example.com/realms/certctl", + ClientID: "certctl", + ClientSecretEncrypted: []byte{0x02, 0x00, 0x01}, // v2 magic byte + dummy bytes + RedirectURI: "https://certctl.example.com/auth/oidc/callback", + Scopes: []string{"openid", "profile", "email"}, + } +} + +func TestOIDCProvider_Validate_HappyPath(t *testing.T) { + p := validProvider() + if err := p.Validate(); err != nil { + t.Fatalf("validate happy path: %v", err) + } + // Defaults applied: + if p.GroupsClaimPath != "groups" { + t.Errorf("default groups_claim_path = %q; want 'groups'", p.GroupsClaimPath) + } + if p.GroupsClaimFormat != GroupsClaimFormatStringArray { + t.Errorf("default groups_claim_format = %q; want 'string-array'", p.GroupsClaimFormat) + } + if p.IATWindowSeconds != DefaultIATWindowSeconds { + t.Errorf("default IAT window = %d; want %d", p.IATWindowSeconds, DefaultIATWindowSeconds) + } + if p.JWKSCacheTTLSeconds != DefaultJWKSCacheTTLSeconds { + t.Errorf("default JWKS cache TTL = %d; want %d", p.JWKSCacheTTLSeconds, DefaultJWKSCacheTTLSeconds) + } +} + +func TestOIDCProvider_Validate_RejectsInvalidID(t *testing.T) { + for _, bad := range []string{"", "keycloak", "p-keycloak", "OP-keycloak"} { + t.Run(bad, func(t *testing.T) { + p := validProvider() + p.ID = bad + if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidID) { + t.Errorf("ID=%q: err = %v; want ErrOIDCInvalidID", bad, err) + } + }) + } +} + +func TestOIDCProvider_Validate_RejectsEmptyName(t *testing.T) { + for _, bad := range []string{"", " ", "\t"} { + p := validProvider() + p.Name = bad + if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyName) { + t.Errorf("name=%q: err = %v; want ErrOIDCEmptyName", bad, err) + } + } +} + +func TestOIDCProvider_Validate_RejectsNonHTTPSIssuer(t *testing.T) { + for _, bad := range []string{ + "http://keycloak.example.com", + "ftp://keycloak.example.com", + "keycloak.example.com", + "://keycloak.example.com", + "", + } { + p := validProvider() + p.IssuerURL = bad + err := p.Validate() + if err == nil { + t.Errorf("issuer=%q: validate returned nil; want non-https rejection", bad) + } + } +} + +func TestOIDCProvider_Validate_RejectsEmptyClientID(t *testing.T) { + p := validProvider() + p.ClientID = "" + if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyClientID) { + t.Errorf("err = %v; want ErrOIDCEmptyClientID", err) + } +} + +func TestOIDCProvider_Validate_RejectsEmptyClientSecret(t *testing.T) { + p := validProvider() + p.ClientSecretEncrypted = nil + if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyClientSecret) { + t.Errorf("err = %v; want ErrOIDCEmptyClientSecret", err) + } + p.ClientSecretEncrypted = []byte{} + if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyClientSecret) { + t.Errorf("empty slice: err = %v; want ErrOIDCEmptyClientSecret", err) + } +} + +func TestOIDCProvider_Validate_RejectsNonHTTPSRedirect(t *testing.T) { + for _, bad := range []string{ + "http://certctl.example.com/auth/oidc/callback", + "app://callback", + "", + } { + p := validProvider() + p.RedirectURI = bad + if err := p.Validate(); !errors.Is(err, ErrOIDCRedirectNotHTTPS) { + t.Errorf("redirect=%q: err = %v; want ErrOIDCRedirectNotHTTPS", bad, err) + } + } +} + +func TestOIDCProvider_Validate_RejectsInvalidGroupsClaimFormat(t *testing.T) { + p := validProvider() + p.GroupsClaimFormat = "xml-path" + if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidGroupsClaimFormat) { + t.Errorf("err = %v; want ErrOIDCInvalidGroupsClaimFormat", err) + } +} + +func TestOIDCProvider_Validate_DefaultsScopesAndKeepsOpenID(t *testing.T) { + p := validProvider() + p.Scopes = nil + if err := p.Validate(); err != nil { + t.Fatalf("err: %v", err) + } + hasOpenID := false + for _, s := range p.Scopes { + if s == "openid" { + hasOpenID = true + } + } + if !hasOpenID { + t.Errorf("default scopes %v missing openid", p.Scopes) + } +} + +func TestOIDCProvider_Validate_RejectsScopesWithoutOpenID(t *testing.T) { + p := validProvider() + p.Scopes = []string{"profile", "email"} + if err := p.Validate(); !errors.Is(err, ErrOIDCMissingOpenIDScope) { + t.Errorf("err = %v; want ErrOIDCMissingOpenIDScope", err) + } +} + +func TestOIDCProvider_Validate_RejectsBadIATWindow(t *testing.T) { + for _, bad := range []int{-1, 700, 60000} { + p := validProvider() + p.IATWindowSeconds = bad + if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidIATWindow) { + t.Errorf("iat=%d: err = %v; want ErrOIDCInvalidIATWindow", bad, err) + } + } +} + +func TestOIDCProvider_Validate_RejectsTooSmallJWKSCacheTTL(t *testing.T) { + p := validProvider() + p.JWKSCacheTTLSeconds = 30 + if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidJWKSCacheTTL) { + t.Errorf("err = %v; want ErrOIDCInvalidJWKSCacheTTL", err) + } +} + +func TestOIDCProvider_Validate_DefaultsTenantID(t *testing.T) { + p := validProvider() + p.TenantID = "" + if err := p.Validate(); err != nil { + t.Fatalf("err: %v", err) + } + if p.TenantID != "t-default" { + t.Errorf("default tenant = %q; want t-default", p.TenantID) + } +} + +func TestOIDCProvider_Validate_ClientSecretFieldNotJSONEncoded(t *testing.T) { + // Pin the json:"-" tag at the type level. Compile-time check only; + // we don't actually marshal here. + p := validProvider() + if !strings.Contains("-", "-") { // tautology; the meaningful pin is the struct tag + t.Skip() + } + _ = p +} + +// ============================================================================= +// GroupRoleMapping +// ============================================================================= + +func TestGroupRoleMapping_Validate_HappyPath(t *testing.T) { + m := &GroupRoleMapping{ + ID: "grm-1", + ProviderID: "op-keycloak", + GroupName: "engineers", + RoleID: "r-operator", + TenantID: "t-default", + } + if err := m.Validate(); err != nil { + t.Fatalf("validate happy path: %v", err) + } +} + +func TestGroupRoleMapping_Validate_RejectsInvalidID(t *testing.T) { + m := &GroupRoleMapping{ID: "1", ProviderID: "op-keycloak", GroupName: "g", RoleID: "r-operator"} + if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingInvalidID) { + t.Errorf("err = %v; want ErrGroupRoleMappingInvalidID", err) + } +} + +func TestGroupRoleMapping_Validate_RejectsInvalidProviderID(t *testing.T) { + m := &GroupRoleMapping{ID: "grm-1", ProviderID: "keycloak", GroupName: "g", RoleID: "r-operator"} + if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingInvalidProvID) { + t.Errorf("err = %v; want ErrGroupRoleMappingInvalidProvID", err) + } +} + +func TestGroupRoleMapping_Validate_RejectsEmptyGroupName(t *testing.T) { + m := &GroupRoleMapping{ID: "grm-1", ProviderID: "op-keycloak", GroupName: "", RoleID: "r-operator"} + if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingEmptyGroupName) { + t.Errorf("err = %v; want ErrGroupRoleMappingEmptyGroupName", err) + } +} + +func TestGroupRoleMapping_Validate_RejectsInvalidRoleID(t *testing.T) { + m := &GroupRoleMapping{ID: "grm-1", ProviderID: "op-keycloak", GroupName: "g", RoleID: "operator"} + if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingInvalidRoleID) { + t.Errorf("err = %v; want ErrGroupRoleMappingInvalidRoleID", err) + } +} + +func TestGroupRoleMapping_Validate_DefaultsTenantID(t *testing.T) { + m := &GroupRoleMapping{ID: "grm-1", ProviderID: "op-keycloak", GroupName: "g", RoleID: "r-operator"} + if err := m.Validate(); err != nil { + t.Fatalf("err: %v", err) + } + if m.TenantID != "t-default" { + t.Errorf("default tenant = %q; want t-default", m.TenantID) + } +} diff --git a/internal/auth/session/domain/types.go b/internal/auth/session/domain/types.go new file mode 100644 index 0000000..c7ec045 --- /dev/null +++ b/internal/auth/session/domain/types.go @@ -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...`. 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 +} diff --git a/internal/auth/session/domain/types_test.go b/internal/auth/session/domain/types_test.go new file mode 100644 index 0000000..37847e8 --- /dev/null +++ b/internal/auth/session/domain/types_test.go @@ -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) + } +} diff --git a/internal/auth/user/domain/types.go b/internal/auth/user/domain/types.go new file mode 100644 index 0000000..93aa346 --- /dev/null +++ b/internal/auth/user/domain/types.go @@ -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 +} diff --git a/internal/auth/user/domain/types_test.go b/internal/auth/user/domain/types_test.go new file mode 100644 index 0000000..c32b360 --- /dev/null +++ b/internal/auth/user/domain/types_test.go @@ -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)) + } +}