feat: authsession service

This commit is contained in:
Ilia Denisov
2026-04-08 16:23:07 +02:00
committed by GitHub
parent 28f04916af
commit 86a68ed9d0
174 changed files with 31732 additions and 112 deletions
@@ -0,0 +1,342 @@
// Package challenge defines the source-of-truth domain model for one e-mail
// confirmation challenge.
package challenge
import (
"errors"
"fmt"
"time"
"galaxy/authsession/internal/domain/common"
)
// Status identifies the coarse lifecycle state of one challenge.
type Status string
const (
// StatusPendingSend reports that the challenge has been created but its
// delivery outcome has not been recorded yet.
StatusPendingSend Status = "pending_send"
// StatusSent reports that the confirmation code was delivered successfully.
StatusSent Status = "sent"
// StatusDeliverySuppressed reports that outward send succeeded but actual
// delivery was intentionally suppressed by policy.
StatusDeliverySuppressed Status = "delivery_suppressed"
// StatusDeliveryThrottled reports that a fresh challenge was created but
// delivery was skipped because the auth-side resend cooldown is still
// active.
StatusDeliveryThrottled Status = "delivery_throttled"
// StatusConfirmedPendingExpire reports that the challenge was confirmed
// successfully and is temporarily retained for idempotent retry handling.
StatusConfirmedPendingExpire Status = "confirmed_pending_expire"
// StatusExpired reports that the challenge can no longer be confirmed.
StatusExpired Status = "expired"
// StatusFailed reports that the challenge reached a terminal failure state.
StatusFailed Status = "failed"
// StatusCancelled reports that the challenge was cancelled explicitly.
StatusCancelled Status = "cancelled"
)
// IsKnown reports whether Status is one of the challenge states supported by
// the current domain model.
func (s Status) IsKnown() bool {
switch s {
case StatusPendingSend,
StatusSent,
StatusDeliverySuppressed,
StatusDeliveryThrottled,
StatusConfirmedPendingExpire,
StatusExpired,
StatusFailed,
StatusCancelled:
return true
default:
return false
}
}
// IsTerminal reports whether Status can no longer accept any lifecycle
// transition in the v1 challenge state machine.
func (s Status) IsTerminal() bool {
switch s {
case StatusExpired, StatusFailed, StatusCancelled:
return true
default:
return false
}
}
// AcceptsFreshConfirm reports whether Status may still consume a first
// successful confirmation attempt.
func (s Status) AcceptsFreshConfirm() bool {
switch s {
case StatusSent, StatusDeliverySuppressed:
return true
default:
return false
}
}
// IsConfirmedRetryState reports whether Status should use the idempotent retry
// path for a previously successful confirmation.
func (s Status) IsConfirmedRetryState() bool {
return s == StatusConfirmedPendingExpire
}
// CanTransitionTo reports whether the current challenge Status may move to
// next under the coarse lifecycle rules fixed by Stage 2.
func (s Status) CanTransitionTo(next Status) bool {
switch s {
case StatusPendingSend:
switch next {
case StatusSent, StatusDeliverySuppressed, StatusDeliveryThrottled, StatusFailed, StatusCancelled, StatusExpired:
return true
}
case StatusSent, StatusDeliverySuppressed:
switch next {
case StatusConfirmedPendingExpire, StatusFailed, StatusCancelled, StatusExpired:
return true
}
case StatusConfirmedPendingExpire:
return next == StatusExpired
}
return false
}
// DeliveryState identifies the recorded delivery result of one challenge.
type DeliveryState string
const (
// DeliveryPending reports that no delivery outcome has been recorded yet.
DeliveryPending DeliveryState = "pending"
// DeliverySent reports that the challenge code was sent successfully.
DeliverySent DeliveryState = "sent"
// DeliverySuppressed reports that the outward flow stays success-shaped
// while actual delivery is intentionally skipped.
DeliverySuppressed DeliveryState = "suppressed"
// DeliveryThrottled reports that the outward flow stays success-shaped
// while actual delivery is skipped because the resend cooldown is active.
DeliveryThrottled DeliveryState = "throttled"
// DeliveryFailed reports that delivery was attempted and failed explicitly.
DeliveryFailed DeliveryState = "failed"
)
// IsKnown reports whether DeliveryState is one of the delivery states
// supported by the current domain model.
func (s DeliveryState) IsKnown() bool {
switch s {
case DeliveryPending, DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed:
return true
default:
return false
}
}
// CanTransitionTo reports whether the current DeliveryState may move to next
// under the coarse delivery rules fixed by Stage 2.
func (s DeliveryState) CanTransitionTo(next DeliveryState) bool {
if s != DeliveryPending {
return false
}
switch next {
case DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed:
return true
default:
return false
}
}
// AttemptCounters groups the mutable send and confirm counters tracked by one
// challenge aggregate.
type AttemptCounters struct {
// Send counts delivery attempts initiated for the challenge.
Send int
// Confirm counts confirmation attempts evaluated against the challenge.
Confirm int
}
// Validate reports whether AttemptCounters contains only non-negative values.
func (c AttemptCounters) Validate() error {
if c.Send < 0 {
return errors.New("challenge send attempt count must not be negative")
}
if c.Confirm < 0 {
return errors.New("challenge confirm attempt count must not be negative")
}
return nil
}
// AbuseMetadata stores minimal abuse-related timestamps without fixing later
// anti-abuse policy details too early.
type AbuseMetadata struct {
// LastAttemptAt optionally records the last send or confirm attempt time
// associated with the challenge.
LastAttemptAt *time.Time
}
// Validate reports whether AbuseMetadata contains structurally valid values.
func (m AbuseMetadata) Validate() error {
if m.LastAttemptAt != nil && m.LastAttemptAt.IsZero() {
return errors.New("challenge abuse metadata last attempt time must not be zero")
}
return nil
}
// Confirmation stores the idempotency metadata recorded after a successful
// challenge confirmation.
type Confirmation struct {
// SessionID is the created device session returned by the successful
// confirmation.
SessionID common.DeviceSessionID
// ClientPublicKey is the validated client key bound to SessionID.
ClientPublicKey common.ClientPublicKey
// ConfirmedAt records when the successful confirmation happened.
ConfirmedAt time.Time
}
// Validate reports whether Confirmation contains all metadata required for a
// confirmed challenge.
func (c Confirmation) Validate() error {
if err := c.SessionID.Validate(); err != nil {
return fmt.Errorf("challenge confirmation session id: %w", err)
}
if err := c.ClientPublicKey.Validate(); err != nil {
return fmt.Errorf("challenge confirmation client public key: %w", err)
}
if c.ConfirmedAt.IsZero() {
return errors.New("challenge confirmation time must not be zero")
}
return nil
}
// Challenge is the minimal source-of-truth aggregate shape fixed by Stage 2.
type Challenge struct {
// ID identifies the challenge.
ID common.ChallengeID
// Email stores the normalized target e-mail address.
Email common.Email
// CodeHash stores only the hashed confirmation code.
CodeHash []byte
// Status reports the coarse challenge lifecycle state.
Status Status
// DeliveryState reports the recorded delivery outcome.
DeliveryState DeliveryState
// CreatedAt reports when the challenge was created.
CreatedAt time.Time
// ExpiresAt reports when the challenge becomes unusable.
ExpiresAt time.Time
// Attempts groups the send and confirm counters.
Attempts AttemptCounters
// Abuse stores minimal abuse-related timestamps.
Abuse AbuseMetadata
// Confirmation is present only after a successful confirm transition.
Confirmation *Confirmation
}
// IsExpiredAt reports whether the challenge is unusable at now either because
// it is already marked expired or because its expiration timestamp has passed.
func (c Challenge) IsExpiredAt(now time.Time) bool {
return c.Status == StatusExpired || !c.ExpiresAt.After(now)
}
// Validate reports whether Challenge satisfies the Stage-2 structural and
// lifecycle invariants.
func (c Challenge) Validate() error {
if err := c.ID.Validate(); err != nil {
return fmt.Errorf("challenge id: %w", err)
}
if err := c.Email.Validate(); err != nil {
return fmt.Errorf("challenge email: %w", err)
}
if len(c.CodeHash) == 0 {
return errors.New("challenge code hash must not be empty")
}
if !c.Status.IsKnown() {
return fmt.Errorf("challenge status %q is unsupported", c.Status)
}
if !c.DeliveryState.IsKnown() {
return fmt.Errorf("challenge delivery state %q is unsupported", c.DeliveryState)
}
if c.CreatedAt.IsZero() {
return errors.New("challenge creation time must not be zero")
}
if c.ExpiresAt.IsZero() {
return errors.New("challenge expiration time must not be zero")
}
if c.ExpiresAt.Before(c.CreatedAt) {
return errors.New("challenge expiration time must not be before creation time")
}
if err := c.Attempts.Validate(); err != nil {
return err
}
if err := c.Abuse.Validate(); err != nil {
return err
}
switch c.Status {
case StatusPendingSend:
if c.DeliveryState != DeliveryPending {
return errors.New("pending_send challenge must keep pending delivery state")
}
case StatusSent:
if c.DeliveryState != DeliverySent {
return errors.New("sent challenge must keep sent delivery state")
}
case StatusDeliverySuppressed:
if c.DeliveryState != DeliverySuppressed {
return errors.New("delivery_suppressed challenge must keep suppressed delivery state")
}
case StatusDeliveryThrottled:
if c.DeliveryState != DeliveryThrottled {
return errors.New("delivery_throttled challenge must keep throttled delivery state")
}
case StatusConfirmedPendingExpire:
if c.DeliveryState != DeliverySent && c.DeliveryState != DeliverySuppressed {
return errors.New("confirmed_pending_expire challenge must come from sent or suppressed delivery state")
}
}
if c.Status == StatusConfirmedPendingExpire {
if c.Confirmation == nil {
return errors.New("confirmed_pending_expire challenge must contain confirmation metadata")
}
if err := c.Confirmation.Validate(); err != nil {
return fmt.Errorf("challenge confirmation: %w", err)
}
return nil
}
if c.Confirmation != nil {
return errors.New("only confirmed_pending_expire challenge may contain confirmation metadata")
}
return nil
}
@@ -0,0 +1,439 @@
package challenge
import (
"crypto/ed25519"
"github.com/stretchr/testify/require"
"testing"
"time"
"galaxy/authsession/internal/domain/common"
)
func TestPolicyConstants(t *testing.T) {
t.Parallel()
if InitialTTL != 5*time.Minute {
require.Failf(t, "test failed", "InitialTTL = %s, want %s", InitialTTL, 5*time.Minute)
}
if ResendThrottleCooldown != time.Minute {
require.Failf(t, "test failed", "ResendThrottleCooldown = %s, want %s", ResendThrottleCooldown, time.Minute)
}
if ConfirmedRetention != 5*time.Minute {
require.Failf(t, "test failed", "ConfirmedRetention = %s, want %s", ConfirmedRetention, 5*time.Minute)
}
if MaxInvalidConfirmAttempts != 5 {
require.Failf(t, "test failed", "MaxInvalidConfirmAttempts = %d, want %d", MaxInvalidConfirmAttempts, 5)
}
}
func TestStatusIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Status
want bool
}{
{name: "pending send", value: StatusPendingSend, want: true},
{name: "sent", value: StatusSent, want: true},
{name: "suppressed", value: StatusDeliverySuppressed, want: true},
{name: "throttled", value: StatusDeliveryThrottled, want: true},
{name: "confirmed", value: StatusConfirmedPendingExpire, want: true},
{name: "expired", value: StatusExpired, want: true},
{name: "failed", value: StatusFailed, want: true},
{name: "cancelled", value: StatusCancelled, want: true},
{name: "unknown", value: Status("unknown"), want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsKnown(); got != tt.want {
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
}
})
}
}
func TestStatusIsTerminal(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Status
want bool
}{
{name: "pending send", value: StatusPendingSend, want: false},
{name: "sent", value: StatusSent, want: false},
{name: "delivery suppressed", value: StatusDeliverySuppressed, want: false},
{name: "delivery throttled", value: StatusDeliveryThrottled, want: false},
{name: "confirmed pending expire", value: StatusConfirmedPendingExpire, want: false},
{name: "expired", value: StatusExpired, want: true},
{name: "failed", value: StatusFailed, want: true},
{name: "cancelled", value: StatusCancelled, want: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsTerminal(); got != tt.want {
require.Failf(t, "test failed", "IsTerminal() = %v, want %v", got, tt.want)
}
})
}
}
func TestStatusAcceptsFreshConfirm(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Status
want bool
}{
{name: "pending send", value: StatusPendingSend, want: false},
{name: "sent", value: StatusSent, want: true},
{name: "delivery suppressed", value: StatusDeliverySuppressed, want: true},
{name: "delivery throttled", value: StatusDeliveryThrottled, want: false},
{name: "confirmed", value: StatusConfirmedPendingExpire, want: false},
{name: "expired", value: StatusExpired, want: false},
{name: "failed", value: StatusFailed, want: false},
{name: "cancelled", value: StatusCancelled, want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.AcceptsFreshConfirm(); got != tt.want {
require.Failf(t, "test failed", "AcceptsFreshConfirm() = %v, want %v", got, tt.want)
}
})
}
}
func TestStatusIsConfirmedRetryState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Status
want bool
}{
{name: "sent", value: StatusSent, want: false},
{name: "delivery suppressed", value: StatusDeliverySuppressed, want: false},
{name: "delivery throttled", value: StatusDeliveryThrottled, want: false},
{name: "confirmed", value: StatusConfirmedPendingExpire, want: true},
{name: "expired", value: StatusExpired, want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsConfirmedRetryState(); got != tt.want {
require.Failf(t, "test failed", "IsConfirmedRetryState() = %v, want %v", got, tt.want)
}
})
}
}
func TestStatusCanTransitionTo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from Status
to Status
want bool
}{
{name: "pending to sent", from: StatusPendingSend, to: StatusSent, want: true},
{name: "pending to suppressed", from: StatusPendingSend, to: StatusDeliverySuppressed, want: true},
{name: "pending to throttled", from: StatusPendingSend, to: StatusDeliveryThrottled, want: true},
{name: "pending to failed", from: StatusPendingSend, to: StatusFailed, want: true},
{name: "pending to cancelled", from: StatusPendingSend, to: StatusCancelled, want: true},
{name: "pending to expired", from: StatusPendingSend, to: StatusExpired, want: true},
{name: "pending to confirmed", from: StatusPendingSend, to: StatusConfirmedPendingExpire, want: false},
{name: "sent to confirmed", from: StatusSent, to: StatusConfirmedPendingExpire, want: true},
{name: "sent to failed", from: StatusSent, to: StatusFailed, want: true},
{name: "suppressed to confirmed", from: StatusDeliverySuppressed, to: StatusConfirmedPendingExpire, want: true},
{name: "throttled to confirmed", from: StatusDeliveryThrottled, to: StatusConfirmedPendingExpire, want: false},
{name: "confirmed to expired", from: StatusConfirmedPendingExpire, to: StatusExpired, want: true},
{name: "confirmed to failed", from: StatusConfirmedPendingExpire, to: StatusFailed, want: false},
{name: "expired terminal", from: StatusExpired, to: StatusCancelled, want: false},
{name: "failed terminal", from: StatusFailed, to: StatusExpired, want: false},
{name: "cancelled terminal", from: StatusCancelled, to: StatusExpired, want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.from.CanTransitionTo(tt.to); got != tt.want {
require.Failf(t, "test failed", "CanTransitionTo() = %v, want %v", got, tt.want)
}
})
}
}
func TestDeliveryStateIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value DeliveryState
want bool
}{
{name: "pending", value: DeliveryPending, want: true},
{name: "sent", value: DeliverySent, want: true},
{name: "suppressed", value: DeliverySuppressed, want: true},
{name: "throttled", value: DeliveryThrottled, want: true},
{name: "failed", value: DeliveryFailed, want: true},
{name: "unknown", value: DeliveryState("unknown"), want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsKnown(); got != tt.want {
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
}
})
}
}
func TestDeliveryStateCanTransitionTo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from DeliveryState
to DeliveryState
want bool
}{
{name: "pending to sent", from: DeliveryPending, to: DeliverySent, want: true},
{name: "pending to suppressed", from: DeliveryPending, to: DeliverySuppressed, want: true},
{name: "pending to throttled", from: DeliveryPending, to: DeliveryThrottled, want: true},
{name: "pending to failed", from: DeliveryPending, to: DeliveryFailed, want: true},
{name: "sent terminal", from: DeliverySent, to: DeliveryFailed, want: false},
{name: "suppressed terminal", from: DeliverySuppressed, to: DeliverySent, want: false},
{name: "failed terminal", from: DeliveryFailed, to: DeliverySent, want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.from.CanTransitionTo(tt.to); got != tt.want {
require.Failf(t, "test failed", "CanTransitionTo() = %v, want %v", got, tt.want)
}
})
}
}
func TestChallengeIsExpiredAt(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_121_700, 0).UTC()
tests := []struct {
name string
mutate func(*Challenge)
want bool
}{
{name: "active before expiration", want: false},
{
name: "expired status",
mutate: func(c *Challenge) {
c.Status = StatusExpired
},
want: true,
},
{
name: "expiration timestamp passed",
mutate: func(c *Challenge) {
c.ExpiresAt = now
},
want: true,
},
{
name: "confirmed retained before expiration",
mutate: func(c *Challenge) {
c.Status = StatusConfirmedPendingExpire
c.DeliveryState = DeliverySent
c.Confirmation = validConfirmation(t)
c.ExpiresAt = now.Add(time.Second)
},
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
challenge := validChallenge(t)
challenge.CreatedAt = now.Add(-time.Minute)
challenge.ExpiresAt = now.Add(time.Minute)
if tt.mutate != nil {
tt.mutate(&challenge)
}
if err := challenge.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
if got := challenge.IsExpiredAt(now); got != tt.want {
require.Failf(t, "test failed", "IsExpiredAt() = %v, want %v", got, tt.want)
}
})
}
}
func TestChallengeValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*Challenge)
wantErr bool
}{
{name: "valid pending"},
{
name: "valid confirmed",
mutate: func(c *Challenge) {
c.Status = StatusConfirmedPendingExpire
c.DeliveryState = DeliverySent
c.Confirmation = validConfirmation(t)
},
},
{
name: "confirmed requires metadata",
mutate: func(c *Challenge) {
c.Status = StatusConfirmedPendingExpire
c.DeliveryState = DeliverySent
},
wantErr: true,
},
{
name: "unconfirmed rejects metadata",
mutate: func(c *Challenge) {
c.Confirmation = validConfirmation(t)
},
wantErr: true,
},
{
name: "pending requires pending delivery",
mutate: func(c *Challenge) {
c.DeliveryState = DeliverySent
},
wantErr: true,
},
{
name: "sent requires sent delivery",
mutate: func(c *Challenge) {
c.Status = StatusSent
c.DeliveryState = DeliverySuppressed
},
wantErr: true,
},
{
name: "throttled requires throttled delivery",
mutate: func(c *Challenge) {
c.Status = StatusDeliveryThrottled
c.DeliveryState = DeliverySent
},
wantErr: true,
},
{
name: "expiration before creation",
mutate: func(c *Challenge) {
c.ExpiresAt = c.CreatedAt.Add(-time.Second)
},
wantErr: true,
},
{
name: "negative confirm attempts",
mutate: func(c *Challenge) {
c.Attempts.Confirm = -1
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
challenge := validChallenge(t)
if tt.mutate != nil {
tt.mutate(&challenge)
}
err := challenge.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}
func validChallenge(t *testing.T) Challenge {
t.Helper()
return Challenge{
ID: common.ChallengeID("challenge-123"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash-123"),
Status: StatusPendingSend,
DeliveryState: DeliveryPending,
CreatedAt: time.Unix(1_775_121_600, 0).UTC(),
ExpiresAt: time.Unix(1_775_121_900, 0).UTC(),
Attempts: AttemptCounters{
Send: 0,
Confirm: 0,
},
}
}
func validConfirmation(t *testing.T) *Confirmation {
t.Helper()
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
for index := range raw {
raw[index] = byte(index + 1)
}
key, err := common.NewClientPublicKey(raw)
if err != nil {
require.Failf(t, "test failed", "NewClientPublicKey() returned error: %v", err)
}
return &Confirmation{
SessionID: common.DeviceSessionID("device-session-123"),
ClientPublicKey: key,
ConfirmedAt: time.Unix(1_775_121_700, 0).UTC(),
}
}
@@ -0,0 +1,26 @@
package challenge
import "time"
const (
// InitialTTL is the v1 lifetime of a newly created challenge before it
// becomes expired.
InitialTTL = 5 * time.Minute
// ResendThrottleCooldown is the fixed Stage-17 cooldown applied to repeated
// public send-email-code requests for the same normalized e-mail address.
ResendThrottleCooldown = time.Minute
// ConfirmedRetention is the v1 idempotency window kept after a successful
// challenge confirmation.
ConfirmedRetention = 5 * time.Minute
// MaxInvalidConfirmAttempts is the v1 threshold after which repeated invalid
// confirmation codes move a challenge into the failed state.
MaxInvalidConfirmAttempts = 5
)
// V1 resend policy keeps every public send-email-code request independent:
// each call creates a fresh challenge, existing challenges are not reused or
// deduplicated, and Stage 17 adds a fixed auth-side resend cooldown that may
// record the fresh challenge as delivery_throttled.
+201
View File
@@ -0,0 +1,201 @@
// Package common defines small shared domain primitives used by auth/session
// aggregates and integration models.
package common
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
"net/mail"
"strings"
)
// ChallengeID identifies one auth confirmation challenge owned by the service.
type ChallengeID string
// String returns ChallengeID as a plain string identifier.
func (id ChallengeID) String() string {
return string(id)
}
// IsZero reports whether ChallengeID does not contain a usable identifier.
func (id ChallengeID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether ChallengeID is non-empty and already normalized for
// domain use.
func (id ChallengeID) Validate() error {
return validateToken("challenge id", string(id))
}
// DeviceSessionID identifies one persisted device session.
type DeviceSessionID string
// String returns DeviceSessionID as a plain string identifier.
func (id DeviceSessionID) String() string {
return string(id)
}
// IsZero reports whether DeviceSessionID does not contain a usable identifier.
func (id DeviceSessionID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether DeviceSessionID is non-empty and already
// normalized for domain use.
func (id DeviceSessionID) Validate() error {
return validateToken("device session id", string(id))
}
// UserID identifies one user resolved through the user-service boundary.
type UserID string
// String returns UserID as a plain string identifier.
func (id UserID) String() string {
return string(id)
}
// IsZero reports whether UserID does not contain a usable identifier.
func (id UserID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether UserID is non-empty and already normalized for
// domain use.
func (id UserID) Validate() error {
return validateToken("user id", string(id))
}
// Email stores one already-normalized e-mail address used by the auth domain.
type Email string
// String returns Email as the stored canonical e-mail string.
func (e Email) String() string {
return string(e)
}
// IsZero reports whether Email does not contain a usable e-mail value.
func (e Email) IsZero() bool {
return strings.TrimSpace(string(e)) == ""
}
// Validate reports whether Email is non-empty, does not contain surrounding
// whitespace, and matches the same single-address syntax expected by the
// public gateway contract.
func (e Email) Validate() error {
raw := string(e)
if err := validateToken("email", raw); err != nil {
return err
}
parsedAddress, err := mail.ParseAddress(raw)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
return fmt.Errorf("email %q must be a single valid email address", raw)
}
return nil
}
// RevokeReasonCode stores one machine-readable revoke reason code.
type RevokeReasonCode string
// String returns RevokeReasonCode as its stored code value.
func (code RevokeReasonCode) String() string {
return string(code)
}
// IsZero reports whether RevokeReasonCode is empty.
func (code RevokeReasonCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether RevokeReasonCode is non-empty and normalized for
// domain use.
func (code RevokeReasonCode) Validate() error {
return validateToken("revoke reason code", string(code))
}
// RevokeActorType stores one machine-readable actor type for revoke audit.
type RevokeActorType string
// String returns RevokeActorType as its stored type value.
func (actorType RevokeActorType) String() string {
return string(actorType)
}
// IsZero reports whether RevokeActorType is empty.
func (actorType RevokeActorType) IsZero() bool {
return strings.TrimSpace(string(actorType)) == ""
}
// Validate reports whether RevokeActorType is non-empty and normalized for
// domain use.
func (actorType RevokeActorType) Validate() error {
return validateToken("revoke actor type", string(actorType))
}
// ClientPublicKey stores one validated Ed25519 public key in parsed binary
// form inside the domain model.
type ClientPublicKey struct {
value ed25519.PublicKey
}
// NewClientPublicKey validates value and returns a defensive copy suitable for
// storing inside domain aggregates.
func NewClientPublicKey(value ed25519.PublicKey) (ClientPublicKey, error) {
key := ClientPublicKey{
value: bytes.Clone(value),
}
if err := key.Validate(); err != nil {
return ClientPublicKey{}, err
}
return key, nil
}
// String returns ClientPublicKey as the standard base64-encoded raw 32-byte
// Ed25519 public key string.
func (key ClientPublicKey) String() string {
if key.IsZero() {
return ""
}
return base64.StdEncoding.EncodeToString(key.value)
}
// IsZero reports whether ClientPublicKey does not contain key material.
func (key ClientPublicKey) IsZero() bool {
return len(key.value) == 0
}
// Validate reports whether ClientPublicKey contains exactly one Ed25519 public
// key.
func (key ClientPublicKey) Validate() error {
switch len(key.value) {
case 0:
return errors.New("client public key must not be empty")
case ed25519.PublicKeySize:
return nil
default:
return fmt.Errorf("client public key must contain exactly %d bytes", ed25519.PublicKeySize)
}
}
// PublicKey returns a defensive copy of the parsed Ed25519 public key.
func (key ClientPublicKey) PublicKey() ed25519.PublicKey {
return bytes.Clone(key.value)
}
func validateToken(name string, value string) error {
switch {
case strings.TrimSpace(value) == "":
return fmt.Errorf("%s must not be empty", name)
case strings.TrimSpace(value) != value:
return fmt.Errorf("%s must not contain surrounding whitespace", name)
default:
return nil
}
}
@@ -0,0 +1,133 @@
package common
import (
"crypto/ed25519"
"github.com/stretchr/testify/require"
"testing"
)
func TestChallengeIDValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value ChallengeID
wantErr bool
}{
{name: "valid", value: ChallengeID("challenge-123")},
{name: "empty", value: ChallengeID(""), wantErr: true},
{name: "whitespace", value: ChallengeID(" challenge-123 "), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}
func TestEmailValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Email
wantErr bool
}{
{name: "valid", value: Email("pilot@example.com")},
{name: "invalid", value: Email("pilot"), wantErr: true},
{name: "surrounding whitespace", value: Email(" pilot@example.com "), wantErr: true},
{name: "display name", value: Email("Pilot <pilot@example.com>"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}
func TestNewClientPublicKey(t *testing.T) {
t.Parallel()
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
for i := range raw {
raw[i] = byte(i)
}
key, err := NewClientPublicKey(raw)
if err != nil {
require.Failf(t, "test failed", "NewClientPublicKey() returned error: %v", err)
}
if key.IsZero() {
require.FailNow(t, "IsZero() = true, want false")
}
cloned := key.PublicKey()
if len(cloned) != ed25519.PublicKeySize {
require.Failf(t, "test failed", "PublicKey() length = %d, want %d", len(cloned), ed25519.PublicKeySize)
}
raw[0] = 99
if key.PublicKey()[0] == 99 {
require.FailNow(t, "PublicKey() was mutated through constructor input")
}
}
func TestClientPublicKeyValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value ClientPublicKey
wantErr bool
}{
{name: "empty", value: ClientPublicKey{}, wantErr: true},
{
name: "short",
value: ClientPublicKey{value: make(ed25519.PublicKey, ed25519.PublicKeySize-1)},
wantErr: true,
},
{
name: "valid",
value: ClientPublicKey{value: make(ed25519.PublicKey, ed25519.PublicKeySize)},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}
@@ -0,0 +1,162 @@
// Package devicesession defines the source-of-truth domain model for one
// authenticated device session.
package devicesession
import (
"errors"
"fmt"
"strings"
"time"
"galaxy/authsession/internal/domain/common"
)
// Status identifies the coarse lifecycle state of one device session.
type Status string
const (
// StatusActive reports that the session may be used for authenticated
// request verification.
StatusActive Status = "active"
// StatusRevoked reports that the session has been revoked and must no
// longer authenticate requests.
StatusRevoked Status = "revoked"
)
// RevokeReasonDeviceLogout reports that one device logged itself out.
const RevokeReasonDeviceLogout common.RevokeReasonCode = "device_logout"
// RevokeReasonLogoutAll reports that the session was revoked by a
// user-scoped logout-all action.
const RevokeReasonLogoutAll common.RevokeReasonCode = "logout_all"
// RevokeReasonAdminRevoke reports that the session was revoked
// administratively.
const RevokeReasonAdminRevoke common.RevokeReasonCode = "admin_revoke"
// RevokeReasonUserBlocked reports that the session was revoked because future
// auth flow for the user or e-mail was blocked.
const RevokeReasonUserBlocked common.RevokeReasonCode = "user_blocked"
// IsKnown reports whether Status is one of the device-session states
// supported by the current domain model.
func (s Status) IsKnown() bool {
switch s {
case StatusActive, StatusRevoked:
return true
default:
return false
}
}
// CanTransitionTo reports whether the current device-session Status may move
// to next under the Stage-2 lifecycle rules.
func (s Status) CanTransitionTo(next Status) bool {
return s == StatusActive && next == StatusRevoked
}
// IsKnownRevokeReasonCode reports whether code is one of the built-in revoke
// reasons fixed by the Stage-2 domain model.
func IsKnownRevokeReasonCode(code common.RevokeReasonCode) bool {
switch code {
case RevokeReasonDeviceLogout,
RevokeReasonLogoutAll,
RevokeReasonAdminRevoke,
RevokeReasonUserBlocked:
return true
default:
return false
}
}
// Revocation stores the audit metadata recorded when a session is revoked.
type Revocation struct {
// At reports when the revoke took effect.
At time.Time
// ReasonCode stores one machine-readable revoke reason code.
ReasonCode common.RevokeReasonCode
// ActorType stores one machine-readable initiator type.
ActorType common.RevokeActorType
// ActorID optionally stores a stable initiator identifier.
ActorID string
}
// Validate reports whether Revocation contains all metadata required for a
// revoked session.
func (r Revocation) Validate() error {
if r.At.IsZero() {
return errors.New("session revocation time must not be zero")
}
if err := r.ReasonCode.Validate(); err != nil {
return fmt.Errorf("session revocation reason code: %w", err)
}
if err := r.ActorType.Validate(); err != nil {
return fmt.Errorf("session revocation actor type: %w", err)
}
if strings.TrimSpace(r.ActorID) != r.ActorID {
return errors.New("session revocation actor id must not contain surrounding whitespace")
}
return nil
}
// Session is the minimal source-of-truth aggregate shape fixed by Stage 2.
type Session struct {
// ID identifies the device session.
ID common.DeviceSessionID
// UserID identifies the durable user linkage for the session.
UserID common.UserID
// ClientPublicKey stores the validated device public key in parsed form.
ClientPublicKey common.ClientPublicKey
// Status reports the coarse lifecycle state of the session.
Status Status
// CreatedAt reports when the session was created.
CreatedAt time.Time
// Revocation is present only when Status is StatusRevoked.
Revocation *Revocation
}
// Validate reports whether Session satisfies the Stage-2 structural and
// lifecycle invariants.
func (s Session) Validate() error {
if err := s.ID.Validate(); err != nil {
return fmt.Errorf("session id: %w", err)
}
if err := s.UserID.Validate(); err != nil {
return fmt.Errorf("session user id: %w", err)
}
if err := s.ClientPublicKey.Validate(); err != nil {
return fmt.Errorf("session client public key: %w", err)
}
if !s.Status.IsKnown() {
return fmt.Errorf("session status %q is unsupported", s.Status)
}
if s.CreatedAt.IsZero() {
return errors.New("session creation time must not be zero")
}
switch s.Status {
case StatusActive:
if s.Revocation != nil {
return errors.New("active session must not contain revocation metadata")
}
case StatusRevoked:
if s.Revocation == nil {
return errors.New("revoked session must contain revocation metadata")
}
if err := s.Revocation.Validate(); err != nil {
return fmt.Errorf("session revocation: %w", err)
}
}
return nil
}
@@ -0,0 +1,186 @@
package devicesession
import (
"crypto/ed25519"
"github.com/stretchr/testify/require"
"testing"
"time"
"galaxy/authsession/internal/domain/common"
)
func TestStatusIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Status
want bool
}{
{name: "active", value: StatusActive, want: true},
{name: "revoked", value: StatusRevoked, want: true},
{name: "unknown", value: Status("unknown"), want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsKnown(); got != tt.want {
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
}
})
}
}
func TestStatusCanTransitionTo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from Status
to Status
want bool
}{
{name: "active to revoked", from: StatusActive, to: StatusRevoked, want: true},
{name: "active to active", from: StatusActive, to: StatusActive, want: false},
{name: "revoked terminal", from: StatusRevoked, to: StatusActive, want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.from.CanTransitionTo(tt.to); got != tt.want {
require.Failf(t, "test failed", "CanTransitionTo() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsKnownRevokeReasonCode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value common.RevokeReasonCode
want bool
}{
{name: "device logout", value: RevokeReasonDeviceLogout, want: true},
{name: "logout all", value: RevokeReasonLogoutAll, want: true},
{name: "admin revoke", value: RevokeReasonAdminRevoke, want: true},
{name: "user blocked", value: RevokeReasonUserBlocked, want: true},
{name: "custom code", value: common.RevokeReasonCode("custom_policy"), want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := IsKnownRevokeReasonCode(tt.value); got != tt.want {
require.Failf(t, "test failed", "IsKnownRevokeReasonCode() = %v, want %v", got, tt.want)
}
})
}
}
func TestSessionValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*Session)
wantErr bool
}{
{name: "active valid"},
{
name: "revoked valid",
mutate: func(s *Session) {
s.Status = StatusRevoked
s.Revocation = validRevocation()
},
},
{
name: "active rejects revocation",
mutate: func(s *Session) {
s.Revocation = validRevocation()
},
wantErr: true,
},
{
name: "revoked requires revocation",
mutate: func(s *Session) {
s.Status = StatusRevoked
},
wantErr: true,
},
{
name: "revoked requires complete metadata",
mutate: func(s *Session) {
s.Status = StatusRevoked
revocation := validRevocation()
revocation.ReasonCode = ""
s.Revocation = revocation
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
session := validSession(t)
if tt.mutate != nil {
tt.mutate(&session)
}
err := session.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}
func validSession(t *testing.T) Session {
t.Helper()
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
for index := range raw {
raw[index] = byte(index + 7)
}
key, err := common.NewClientPublicKey(raw)
if err != nil {
require.Failf(t, "test failed", "NewClientPublicKey() returned error: %v", err)
}
return Session{
ID: common.DeviceSessionID("device-session-123"),
UserID: common.UserID("user-123"),
ClientPublicKey: key,
Status: StatusActive,
CreatedAt: time.Unix(1_775_121_600, 0).UTC(),
}
}
func validRevocation() *Revocation {
return &Revocation{
At: time.Unix(1_775_121_800, 0).UTC(),
ReasonCode: RevokeReasonAdminRevoke,
ActorType: common.RevokeActorType("admin"),
ActorID: "admin-123",
}
}
@@ -0,0 +1,141 @@
// Package gatewayprojection defines the gateway-facing integration snapshot
// model that stays separate from source-of-truth session entities.
package gatewayprojection
import (
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"galaxy/authsession/internal/domain/common"
)
// Status identifies the coarse lifecycle state projected to the gateway.
type Status string
const (
// StatusActive reports that the projected session may authenticate
// requests on the gateway hot path.
StatusActive Status = "active"
// StatusRevoked reports that the projected session must be rejected on the
// gateway hot path.
StatusRevoked Status = "revoked"
)
// IsKnown reports whether Status is one of the projection states supported by
// the current integration model.
func (s Status) IsKnown() bool {
switch s {
case StatusActive, StatusRevoked:
return true
default:
return false
}
}
// Snapshot stores the gateway-facing session projection without exposing any
// Redis-specific field naming or storage encoding.
type Snapshot struct {
// DeviceSessionID identifies the projected device session.
DeviceSessionID common.DeviceSessionID
// UserID identifies the projected user.
UserID common.UserID
// ClientPublicKey stores the standard base64-encoded raw 32-byte Ed25519
// public key string expected by the gateway.
ClientPublicKey string
// Status reports whether the projected session is active or revoked.
Status Status
// RevokedAt optionally reports when the revoke took effect.
RevokedAt *time.Time
// RevokeReasonCode optionally stores the machine-readable revoke reason.
RevokeReasonCode common.RevokeReasonCode
// RevokeActorType optionally stores the machine-readable revoke actor type.
RevokeActorType common.RevokeActorType
// RevokeActorID optionally stores a stable revoke actor identifier.
RevokeActorID string
}
// Validate reports whether Snapshot satisfies the Stage-2 structural
// invariants.
func (s Snapshot) Validate() error {
if err := s.DeviceSessionID.Validate(); err != nil {
return fmt.Errorf("gateway projection device session id: %w", err)
}
if err := s.UserID.Validate(); err != nil {
return fmt.Errorf("gateway projection user id: %w", err)
}
if err := validateClientPublicKey(s.ClientPublicKey); err != nil {
return fmt.Errorf("gateway projection client public key: %w", err)
}
if !s.Status.IsKnown() {
return fmt.Errorf("gateway projection status %q is unsupported", s.Status)
}
if s.Status == StatusActive {
if s.RevokedAt != nil {
return errors.New("active gateway projection must not contain revoked time")
}
if !s.RevokeReasonCode.IsZero() {
return errors.New("active gateway projection must not contain revoke reason code")
}
if !s.RevokeActorType.IsZero() {
return errors.New("active gateway projection must not contain revoke actor type")
}
if s.RevokeActorID != "" {
return errors.New("active gateway projection must not contain revoke actor id")
}
return nil
}
if s.RevokedAt != nil && s.RevokedAt.IsZero() {
return errors.New("gateway projection revoked time must not be zero")
}
if !s.RevokeReasonCode.IsZero() {
if err := s.RevokeReasonCode.Validate(); err != nil {
return fmt.Errorf("gateway projection revoke reason code: %w", err)
}
}
if !s.RevokeActorType.IsZero() {
if err := s.RevokeActorType.Validate(); err != nil {
return fmt.Errorf("gateway projection revoke actor type: %w", err)
}
}
if s.RevokeActorType.IsZero() && s.RevokeActorID != "" {
return errors.New("gateway projection revoke actor id requires revoke actor type")
}
if strings.TrimSpace(s.RevokeActorID) != s.RevokeActorID {
return errors.New("gateway projection revoke actor id must not contain surrounding whitespace")
}
return nil
}
func validateClientPublicKey(value string) error {
switch {
case strings.TrimSpace(value) == "":
return errors.New("client public key must not be empty")
case strings.TrimSpace(value) != value:
return errors.New("client public key must not contain surrounding whitespace")
}
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return fmt.Errorf("client public key must be valid base64: %w", err)
}
if len(decoded) != ed25519.PublicKeySize {
return fmt.Errorf("client public key must contain exactly %d bytes", ed25519.PublicKeySize)
}
return nil
}
@@ -0,0 +1,146 @@
package gatewayprojection
import (
"crypto/ed25519"
"encoding/base64"
"github.com/stretchr/testify/require"
"reflect"
"testing"
"time"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
)
func TestStatusIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Status
want bool
}{
{name: "active", value: StatusActive, want: true},
{name: "revoked", value: StatusRevoked, want: true},
{name: "unknown", value: Status("unknown"), want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsKnown(); got != tt.want {
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
}
})
}
}
func TestSnapshotValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*Snapshot)
wantErr bool
}{
{name: "active valid"},
{
name: "revoked valid",
mutate: func(snapshot *Snapshot) {
snapshot.Status = StatusRevoked
revokedAt := time.Unix(1_775_121_900, 0).UTC()
snapshot.RevokedAt = &revokedAt
snapshot.RevokeReasonCode = common.RevokeReasonCode("admin_revoke")
snapshot.RevokeActorType = common.RevokeActorType("admin")
snapshot.RevokeActorID = "admin-123"
},
},
{
name: "active rejects revoke metadata",
mutate: func(snapshot *Snapshot) {
snapshot.RevokeReasonCode = common.RevokeReasonCode("admin_revoke")
},
wantErr: true,
},
{
name: "invalid key encoding",
mutate: func(snapshot *Snapshot) {
snapshot.ClientPublicKey = "not-base64"
},
wantErr: true,
},
{
name: "actor id requires actor type",
mutate: func(snapshot *Snapshot) {
snapshot.Status = StatusRevoked
snapshot.RevokeActorID = "admin-123"
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
snapshot := validSnapshot()
if tt.mutate != nil {
tt.mutate(&snapshot)
}
err := snapshot.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}
func TestSnapshotStaysSeparateFromSessionDomainShape(t *testing.T) {
t.Parallel()
snapshotType := reflect.TypeOf(Snapshot{})
sessionType := reflect.TypeOf(devicesession.Session{})
clientPublicKeyField, ok := snapshotType.FieldByName("ClientPublicKey")
if !ok {
require.FailNow(t, "Snapshot is missing ClientPublicKey field")
}
if clientPublicKeyField.Type.Kind() != reflect.String {
require.Failf(t, "test failed", "Snapshot.ClientPublicKey kind = %s, want string", clientPublicKeyField.Type.Kind())
}
sessionClientPublicKeyField, ok := sessionType.FieldByName("ClientPublicKey")
if !ok {
require.FailNow(t, "devicesession.Session is missing ClientPublicKey field")
}
if clientPublicKeyField.Type == sessionClientPublicKeyField.Type {
require.FailNow(t, "Snapshot.ClientPublicKey must stay separate from devicesession.Session.ClientPublicKey type")
}
if _, ok := snapshotType.FieldByName("RevokedAtMS"); ok {
require.FailNow(t, "Snapshot must not expose Redis-specific RevokedAtMS field")
}
}
func validSnapshot() Snapshot {
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
for index := range raw {
raw[index] = byte(index + 17)
}
return Snapshot{
DeviceSessionID: common.DeviceSessionID("device-session-123"),
UserID: common.UserID("user-123"),
ClientPublicKey: base64.StdEncoding.EncodeToString(raw),
Status: StatusActive,
}
}
@@ -0,0 +1,89 @@
// Package sessionlimit defines the domain decision shape used for active
// device-session limit evaluation.
package sessionlimit
import (
"errors"
"fmt"
)
// Kind identifies the coarse outcome of evaluating the active-session limit.
type Kind string
const (
// KindDisabled reports that no configured limit is currently active.
KindDisabled Kind = "disabled"
// KindAllowed reports that creating the next session is allowed.
KindAllowed Kind = "allowed"
// KindExceeded reports that creating the next session would exceed the
// configured limit.
KindExceeded Kind = "exceeded"
)
// IsKnown reports whether Kind is one of the session-limit outcomes supported
// by the current domain model.
func (k Kind) IsKnown() bool {
switch k {
case KindDisabled, KindAllowed, KindExceeded:
return true
default:
return false
}
}
// Decision stores the result of evaluating one possible next session creation.
type Decision struct {
// Kind reports the coarse decision outcome.
Kind Kind
// ConfiguredLimit stores the active configured limit when one exists.
ConfiguredLimit *int
// ActiveSessionCount stores the current active-session count before create.
ActiveSessionCount int
// NextSessionCount stores the count that would exist after creating the next
// session.
NextSessionCount int
}
// Validate reports whether Decision satisfies the Stage-2 structural
// invariants.
func (d Decision) Validate() error {
if !d.Kind.IsKnown() {
return fmt.Errorf("session-limit decision kind %q is unsupported", d.Kind)
}
if d.ActiveSessionCount < 0 {
return errors.New("session-limit active session count must not be negative")
}
if d.NextSessionCount < 0 {
return errors.New("session-limit next session count must not be negative")
}
if d.NextSessionCount != d.ActiveSessionCount+1 {
return errors.New("session-limit next session count must equal active session count plus one")
}
switch d.Kind {
case KindDisabled:
if d.ConfiguredLimit != nil {
return errors.New("disabled session-limit decision must not contain configured limit")
}
case KindAllowed, KindExceeded:
if d.ConfiguredLimit == nil {
return errors.New("limited session-limit decision must contain configured limit")
}
if *d.ConfiguredLimit <= 0 {
return errors.New("session-limit configured limit must be positive")
}
if d.Kind == KindAllowed && d.NextSessionCount > *d.ConfiguredLimit {
return errors.New("allowed session-limit decision must not exceed configured limit")
}
if d.Kind == KindExceeded && d.NextSessionCount <= *d.ConfiguredLimit {
return errors.New("exceeded session-limit decision must be above configured limit")
}
}
return nil
}
@@ -0,0 +1,128 @@
package sessionlimit
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestKindIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Kind
want bool
}{
{name: "disabled", value: KindDisabled, want: true},
{name: "allowed", value: KindAllowed, want: true},
{name: "exceeded", value: KindExceeded, want: true},
{name: "unknown", value: Kind("unknown"), want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsKnown(); got != tt.want {
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
}
})
}
}
func TestDecisionValidate(t *testing.T) {
t.Parallel()
limitTwo := 2
limitThree := 3
tests := []struct {
name string
value Decision
wantErr bool
}{
{
name: "disabled valid",
value: Decision{
Kind: KindDisabled,
ActiveSessionCount: 0,
NextSessionCount: 1,
},
},
{
name: "allowed valid",
value: Decision{
Kind: KindAllowed,
ConfiguredLimit: &limitThree,
ActiveSessionCount: 1,
NextSessionCount: 2,
},
},
{
name: "exceeded valid",
value: Decision{
Kind: KindExceeded,
ConfiguredLimit: &limitTwo,
ActiveSessionCount: 2,
NextSessionCount: 3,
},
},
{
name: "disabled rejects limit",
value: Decision{
Kind: KindDisabled,
ConfiguredLimit: &limitTwo,
ActiveSessionCount: 0,
NextSessionCount: 1,
},
wantErr: true,
},
{
name: "allowed requires limit",
value: Decision{
Kind: KindAllowed,
ActiveSessionCount: 0,
NextSessionCount: 1,
},
wantErr: true,
},
{
name: "allowed rejects overflow",
value: Decision{
Kind: KindAllowed,
ConfiguredLimit: &limitTwo,
ActiveSessionCount: 2,
NextSessionCount: 3,
},
wantErr: true,
},
{
name: "next count must be active plus one",
value: Decision{
Kind: KindDisabled,
ActiveSessionCount: 2,
NextSessionCount: 2,
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}
@@ -0,0 +1,110 @@
// Package userresolution defines the domain result returned by the user
// resolution boundary before session creation.
package userresolution
import (
"errors"
"fmt"
"strings"
"galaxy/authsession/internal/domain/common"
)
// Kind identifies the coarse user-resolution result for one normalized e-mail.
type Kind string
const (
// KindExisting reports that the e-mail belongs to an existing user.
KindExisting Kind = "existing"
// KindCreatable reports that the e-mail is free and user creation is
// allowed.
KindCreatable Kind = "creatable"
// KindBlocked reports that the e-mail or subject is blocked from login or
// registration.
KindBlocked Kind = "blocked"
)
// IsKnown reports whether Kind is one of the user-resolution kinds supported
// by the current domain model.
func (k Kind) IsKnown() bool {
switch k {
case KindExisting, KindCreatable, KindBlocked:
return true
default:
return false
}
}
// BlockReasonCode stores one machine-readable user-block reason.
type BlockReasonCode string
// String returns BlockReasonCode as its stored code value.
func (code BlockReasonCode) String() string {
return string(code)
}
// IsZero reports whether BlockReasonCode is empty.
func (code BlockReasonCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether BlockReasonCode is non-empty and normalized for
// domain use.
func (code BlockReasonCode) Validate() error {
switch {
case code.IsZero():
return errors.New("block reason code must not be empty")
case strings.TrimSpace(string(code)) != string(code):
return errors.New("block reason code must not contain surrounding whitespace")
default:
return nil
}
}
// Result stores the coarse user-resolution outcome consumed by later auth
// workflow stages.
type Result struct {
// Kind reports the coarse resolution outcome.
Kind Kind
// UserID is set only when Kind is KindExisting.
UserID common.UserID
// BlockReasonCode is set only when Kind is KindBlocked.
BlockReasonCode BlockReasonCode
}
// Validate reports whether Result satisfies the Stage-2 structural invariants.
func (r Result) Validate() error {
if !r.Kind.IsKnown() {
return fmt.Errorf("user resolution kind %q is unsupported", r.Kind)
}
switch r.Kind {
case KindExisting:
if err := r.UserID.Validate(); err != nil {
return fmt.Errorf("user resolution user id: %w", err)
}
if !r.BlockReasonCode.IsZero() {
return errors.New("existing user resolution must not contain block reason code")
}
case KindCreatable:
if !r.UserID.IsZero() {
return errors.New("creatable user resolution must not contain user id")
}
if !r.BlockReasonCode.IsZero() {
return errors.New("creatable user resolution must not contain block reason code")
}
case KindBlocked:
if !r.UserID.IsZero() {
return errors.New("blocked user resolution must not contain user id")
}
if err := r.BlockReasonCode.Validate(); err != nil {
return fmt.Errorf("user resolution block reason code: %w", err)
}
}
return nil
}
@@ -0,0 +1,113 @@
package userresolution
import (
"github.com/stretchr/testify/require"
"testing"
"galaxy/authsession/internal/domain/common"
)
func TestKindIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Kind
want bool
}{
{name: "existing", value: KindExisting, want: true},
{name: "creatable", value: KindCreatable, want: true},
{name: "blocked", value: KindBlocked, want: true},
{name: "unknown", value: Kind("unknown"), want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.value.IsKnown(); got != tt.want {
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
}
})
}
}
func TestResultValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Result
wantErr bool
}{
{
name: "existing valid",
value: Result{
Kind: KindExisting,
UserID: common.UserID("user-123"),
},
},
{
name: "creatable valid",
value: Result{
Kind: KindCreatable,
},
},
{
name: "blocked valid",
value: Result{
Kind: KindBlocked,
BlockReasonCode: BlockReasonCode("policy_blocked"),
},
},
{
name: "existing requires user id",
value: Result{
Kind: KindExisting,
},
wantErr: true,
},
{
name: "creatable rejects user id",
value: Result{
Kind: KindCreatable,
UserID: common.UserID("user-123"),
},
wantErr: true,
},
{
name: "blocked requires reason",
value: Result{
Kind: KindBlocked,
},
wantErr: true,
},
{
name: "blocked rejects user id",
value: Result{
Kind: KindBlocked,
UserID: common.UserID("user-123"),
BlockReasonCode: BlockReasonCode("policy_blocked"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr && err == nil {
require.FailNow(t, "Validate() returned nil error")
}
if !tt.wantErr && err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
})
}
}