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