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,43 @@
package ports
import (
"context"
"fmt"
"galaxy/authsession/internal/domain/challenge"
"galaxy/authsession/internal/domain/common"
)
// ChallengeStore provides source-of-truth persistence for auth confirmation
// challenges without exposing storage-specific primitives.
type ChallengeStore interface {
// Get returns the stored challenge for challengeID. Implementations must
// wrap ErrNotFound when challengeID does not exist.
Get(ctx context.Context, challengeID common.ChallengeID) (challenge.Challenge, error)
// Create persists record as a new challenge. Implementations must wrap
// ErrConflict when record.ID already exists.
Create(ctx context.Context, record challenge.Challenge) error
// CompareAndSwap replaces previous with next when the currently stored
// challenge matches previous exactly. Implementations must wrap ErrConflict
// when the stored challenge differs from previous and wrap ErrNotFound when
// previous.ID does not exist.
CompareAndSwap(ctx context.Context, previous challenge.Challenge, next challenge.Challenge) error
}
// ValidateComparableChallenges reports whether previous and next are suitable
// for one ChallengeStore compare-and-swap call.
func ValidateComparableChallenges(previous challenge.Challenge, next challenge.Challenge) error {
if err := previous.Validate(); err != nil {
return fmt.Errorf("previous challenge: %w", err)
}
if err := next.Validate(); err != nil {
return fmt.Errorf("next challenge: %w", err)
}
if previous.ID != next.ID {
return fmt.Errorf("challenge compare-and-swap ids must match: %q != %q", previous.ID, next.ID)
}
return nil
}
+9
View File
@@ -0,0 +1,9 @@
package ports
import "time"
// Clock returns current UTC time for the auth/session application layer.
type Clock interface {
// Now returns the current service time.
Now() time.Time
}
@@ -0,0 +1,8 @@
package ports
// CodeGenerator generates cleartext confirmation codes for new auth
// challenges.
type CodeGenerator interface {
// Generate returns one fresh cleartext confirmation code.
Generate() (string, error)
}
+11
View File
@@ -0,0 +1,11 @@
package ports
// CodeHasher hashes cleartext confirmation codes and compares later user input
// against stored hashes.
type CodeHasher interface {
// Hash returns the stored representation for code.
Hash(code string) ([]byte, error)
// Compare reports whether hash matches code.
Compare(hash []byte, code string) (bool, error)
}
@@ -0,0 +1,42 @@
package ports
import (
"context"
"errors"
"fmt"
)
// ConfigProvider returns dynamic auth/session configuration required by later
// service workflows.
type ConfigProvider interface {
// LoadSessionLimit returns the current active-session-limit configuration.
// A nil ActiveSessionLimit means that the limit is disabled.
LoadSessionLimit(ctx context.Context) (SessionLimitConfig, error)
}
// SessionLimitConfig stores the active-session-limit configuration in a form
// that preserves “limit absent” as a first-class state.
type SessionLimitConfig struct {
// ActiveSessionLimit stores the configured limit when one is present. Nil
// means that no active-session limit is configured.
ActiveSessionLimit *int
}
// Validate reports whether SessionLimitConfig contains a valid limit value
// when one is configured.
func (c SessionLimitConfig) Validate() error {
if c.ActiveSessionLimit != nil && *c.ActiveSessionLimit <= 0 {
return errors.New("session limit config active session limit must be positive when configured")
}
return nil
}
// String returns a debug-friendly representation of SessionLimitConfig.
func (c SessionLimitConfig) String() string {
if c.ActiveSessionLimit == nil {
return "session_limit=disabled"
}
return fmt.Sprintf("session_limit=%d", *c.ActiveSessionLimit)
}
+16
View File
@@ -0,0 +1,16 @@
// Package ports defines the storage-agnostic and transport-agnostic service
// boundaries used by the auth/session application layer.
package ports
import "errors"
var (
// ErrNotFound reports that a requested source-of-truth record or remote
// subject does not exist in the dependency behind the port.
ErrNotFound = errors.New("ports: record not found")
// ErrConflict reports that a create or compare-and-swap style mutation
// cannot be applied because the current dependency state no longer matches
// the caller expectation.
ErrConflict = errors.New("ports: conflict")
)
@@ -0,0 +1,13 @@
package ports
import "galaxy/authsession/internal/domain/common"
// IDGenerator generates stable domain identifiers for new challenges and
// device sessions.
type IDGenerator interface {
// NewChallengeID returns a fresh challenge identifier.
NewChallengeID() (common.ChallengeID, error)
// NewDeviceSessionID returns a fresh device-session identifier.
NewDeviceSessionID() (common.DeviceSessionID, error)
}
+86
View File
@@ -0,0 +1,86 @@
package ports
import (
"context"
"errors"
"fmt"
"strings"
"galaxy/authsession/internal/domain/common"
)
// MailSender delivers the public login code or intentionally suppresses
// outward delivery while keeping the auth flow success-shaped.
type MailSender interface {
// SendLoginCode attempts delivery for one generated login code. Explicit
// delivery failure is reported through error, while sent vs suppressed is
// returned in the result.
SendLoginCode(ctx context.Context, input SendLoginCodeInput) (SendLoginCodeResult, error)
}
// SendLoginCodeInput describes one mail-delivery request generated by the auth
// flow.
type SendLoginCodeInput struct {
// Email identifies the normalized target e-mail address.
Email common.Email
// Code stores the cleartext login code that should be delivered to Email.
Code string
}
// Validate reports whether SendLoginCodeInput contains a complete delivery
// request.
func (i SendLoginCodeInput) Validate() error {
if err := i.Email.Validate(); err != nil {
return fmt.Errorf("send login code input email: %w", err)
}
switch {
case strings.TrimSpace(i.Code) == "":
return errors.New("send login code input code must not be empty")
case strings.TrimSpace(i.Code) != i.Code:
return errors.New("send login code input code must not contain surrounding whitespace")
default:
return nil
}
}
// SendLoginCodeOutcome identifies the coarse mail-delivery outcome reported
// back to the auth flow.
type SendLoginCodeOutcome string
const (
// SendLoginCodeOutcomeSent reports that delivery was attempted and accepted.
SendLoginCodeOutcomeSent SendLoginCodeOutcome = "sent"
// SendLoginCodeOutcomeSuppressed reports that outward behavior remains
// success-shaped while actual delivery is intentionally skipped.
SendLoginCodeOutcomeSuppressed SendLoginCodeOutcome = "suppressed"
)
// IsKnown reports whether SendLoginCodeOutcome is supported by the current
// mail-sender contract.
func (o SendLoginCodeOutcome) IsKnown() bool {
switch o {
case SendLoginCodeOutcomeSent, SendLoginCodeOutcomeSuppressed:
return true
default:
return false
}
}
// SendLoginCodeResult describes the stable outcome returned by MailSender for
// one delivery request.
type SendLoginCodeResult struct {
// Outcome reports whether delivery was sent or intentionally suppressed.
Outcome SendLoginCodeOutcome
}
// Validate reports whether SendLoginCodeResult satisfies the mail-sender
// contract invariants.
func (r SendLoginCodeResult) Validate() error {
if !r.Outcome.IsKnown() {
return fmt.Errorf("send login code result outcome %q is unsupported", r.Outcome)
}
return nil
}
+371
View File
@@ -0,0 +1,371 @@
package ports
import (
"github.com/stretchr/testify/require"
"testing"
"time"
"galaxy/authsession/internal/domain/challenge"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/userresolution"
)
func TestRevokeSessionOutcomeIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value RevokeSessionOutcome
want bool
}{
{name: "revoked", value: RevokeSessionOutcomeRevoked, want: true},
{name: "already revoked", value: RevokeSessionOutcomeAlreadyRevoked, want: true},
{name: "unknown", value: RevokeSessionOutcome("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 TestRevokeUserSessionsOutcomeIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value RevokeUserSessionsOutcome
want bool
}{
{name: "revoked", value: RevokeUserSessionsOutcomeRevoked, want: true},
{name: "no active sessions", value: RevokeUserSessionsOutcomeNoActiveSessions, want: true},
{name: "unknown", value: RevokeUserSessionsOutcome("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 TestEnsureUserOutcomeIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value EnsureUserOutcome
want bool
}{
{name: "existing", value: EnsureUserOutcomeExisting, want: true},
{name: "created", value: EnsureUserOutcomeCreated, want: true},
{name: "blocked", value: EnsureUserOutcomeBlocked, want: true},
{name: "unknown", value: EnsureUserOutcome("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 TestBlockUserOutcomeIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value BlockUserOutcome
want bool
}{
{name: "blocked", value: BlockUserOutcomeBlocked, want: true},
{name: "already blocked", value: BlockUserOutcomeAlreadyBlocked, want: true},
{name: "unknown", value: BlockUserOutcome("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 TestSendLoginCodeOutcomeIsKnown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value SendLoginCodeOutcome
want bool
}{
{name: "sent", value: SendLoginCodeOutcomeSent, want: true},
{name: "suppressed", value: SendLoginCodeOutcomeSuppressed, want: true},
{name: "unknown", value: SendLoginCodeOutcome("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 TestSessionLimitConfigValidate(t *testing.T) {
t.Parallel()
positive := 3
zero := 0
tests := []struct {
name string
value SessionLimitConfig
wantErr bool
}{
{name: "absent", value: SessionLimitConfig{}},
{name: "positive", value: SessionLimitConfig{ActiveSessionLimit: &positive}},
{name: "zero", value: SessionLimitConfig{ActiveSessionLimit: &zero}, 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 TestRevokeSessionInputValidate(t *testing.T) {
t.Parallel()
input := RevokeSessionInput{
DeviceSessionID: common.DeviceSessionID("device-session-1"),
Revocation: devicesession.Revocation{
At: time.Unix(10, 0).UTC(),
ReasonCode: devicesession.RevokeReasonLogoutAll,
ActorType: common.RevokeActorType("system"),
},
}
if err := input.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
}
func TestRevokeSessionResultValidate(t *testing.T) {
t.Parallel()
result := RevokeSessionResult{
Outcome: RevokeSessionOutcomeRevoked,
Session: revokedSessionFixture(),
}
if err := result.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
}
func TestRevokeUserSessionsResultValidate(t *testing.T) {
t.Parallel()
result := RevokeUserSessionsResult{
Outcome: RevokeUserSessionsOutcomeRevoked,
UserID: common.UserID("user-1"),
Sessions: []devicesession.Session{
revokedSessionFixture(),
},
}
if err := result.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
}
func TestEnsureUserResultValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value EnsureUserResult
wantErr bool
}{
{
name: "existing",
value: EnsureUserResult{
Outcome: EnsureUserOutcomeExisting,
UserID: common.UserID("user-1"),
},
},
{
name: "created",
value: EnsureUserResult{
Outcome: EnsureUserOutcomeCreated,
UserID: common.UserID("user-2"),
},
},
{
name: "blocked",
value: EnsureUserResult{
Outcome: EnsureUserOutcomeBlocked,
BlockReasonCode: userresolution.BlockReasonCode("policy_block"),
},
},
{
name: "blocked with user id",
value: EnsureUserResult{
Outcome: EnsureUserOutcomeBlocked,
UserID: common.UserID("user-1"),
BlockReasonCode: userresolution.BlockReasonCode("policy_block"),
},
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 TestBlockUserInputsAndResultValidate(t *testing.T) {
t.Parallel()
byID := BlockUserByIDInput{
UserID: common.UserID("user-1"),
ReasonCode: userresolution.BlockReasonCode("policy_block"),
}
if err := byID.Validate(); err != nil {
require.Failf(t, "test failed", "BlockUserByIDInput.Validate() returned error: %v", err)
}
byEmail := BlockUserByEmailInput{
Email: common.Email("pilot@example.com"),
ReasonCode: userresolution.BlockReasonCode("policy_block"),
}
if err := byEmail.Validate(); err != nil {
require.Failf(t, "test failed", "BlockUserByEmailInput.Validate() returned error: %v", err)
}
result := BlockUserResult{
Outcome: BlockUserOutcomeBlocked,
UserID: common.UserID("user-1"),
}
if err := result.Validate(); err != nil {
require.Failf(t, "test failed", "BlockUserResult.Validate() returned error: %v", err)
}
}
func TestSendLoginCodeInputAndResultValidate(t *testing.T) {
t.Parallel()
input := SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: "654321",
}
if err := input.Validate(); err != nil {
require.Failf(t, "test failed", "SendLoginCodeInput.Validate() returned error: %v", err)
}
result := SendLoginCodeResult{Outcome: SendLoginCodeOutcomeSent}
if err := result.Validate(); err != nil {
require.Failf(t, "test failed", "SendLoginCodeResult.Validate() returned error: %v", err)
}
}
func TestValidateComparableChallenges(t *testing.T) {
t.Parallel()
previous := challengeFixture()
next := challengeFixture()
next.Status = challenge.StatusSent
next.DeliveryState = challenge.DeliverySent
if err := ValidateComparableChallenges(previous, next); err != nil {
require.Failf(t, "test failed", "ValidateComparableChallenges() returned error: %v", err)
}
}
func challengeFixture() challenge.Challenge {
timestamp := time.Unix(10, 0).UTC()
return challenge.Challenge{
ID: common.ChallengeID("challenge-1"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash"),
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: timestamp,
ExpiresAt: timestamp.Add(5 * time.Minute),
}
}
func revokedSessionFixture() devicesession.Session {
timestamp := time.Unix(10, 0).UTC()
key, err := common.NewClientPublicKey(make([]byte, 32))
if err != nil {
panic(err)
}
return devicesession.Session{
ID: common.DeviceSessionID("device-session-1"),
UserID: common.UserID("user-1"),
ClientPublicKey: key,
Status: devicesession.StatusRevoked,
CreatedAt: timestamp.Add(-time.Minute),
Revocation: &devicesession.Revocation{
At: timestamp,
ReasonCode: devicesession.RevokeReasonLogoutAll,
ActorType: common.RevokeActorType("system"),
},
}
}
@@ -0,0 +1,15 @@
package ports
import (
"context"
"galaxy/authsession/internal/domain/gatewayprojection"
)
// GatewaySessionProjectionPublisher publishes gateway-facing session snapshots
// after source-of-truth session changes.
type GatewaySessionProjectionPublisher interface {
// PublishSession writes or propagates snapshot in the gateway-facing
// projection model.
PublishSession(ctx context.Context, snapshot gatewayprojection.Snapshot) error
}
@@ -0,0 +1,100 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/authsession/internal/domain/challenge"
"galaxy/authsession/internal/domain/common"
)
// SendEmailCodeAbuseProtector decides whether one public send-email-code
// attempt may proceed immediately or must be throttled by the auth-side resend
// cooldown.
type SendEmailCodeAbuseProtector interface {
// CheckAndReserve validates input, checks the current resend cooldown
// decision for input.Email, and reserves a new cooldown window immediately
// when the outcome is allowed.
CheckAndReserve(ctx context.Context, input SendEmailCodeAbuseInput) (SendEmailCodeAbuseResult, error)
}
// SendEmailCodeAbuseInput describes one resend-throttle decision request for
// a normalized public send-email-code attempt.
type SendEmailCodeAbuseInput struct {
// Email identifies the normalized e-mail address addressed by the public
// request.
Email common.Email
// Now records when the send attempt is being evaluated.
Now time.Time
}
// Validate reports whether SendEmailCodeAbuseInput contains a complete resend
// cooldown decision request.
func (i SendEmailCodeAbuseInput) Validate() error {
if err := i.Email.Validate(); err != nil {
return fmt.Errorf("send email code abuse input email: %w", err)
}
if i.Now.IsZero() {
return fmt.Errorf("send email code abuse input now must not be zero")
}
return nil
}
// SendEmailCodeAbuseOutcome identifies the coarse resend-throttle decision for
// one public send-email-code attempt.
type SendEmailCodeAbuseOutcome string
const (
// SendEmailCodeAbuseOutcomeAllowed reports that the attempt may proceed and
// that the cooldown window has been reserved immediately.
SendEmailCodeAbuseOutcomeAllowed SendEmailCodeAbuseOutcome = "allowed"
// SendEmailCodeAbuseOutcomeThrottled reports that the cooldown window is
// still active and that the caller must not extend it.
SendEmailCodeAbuseOutcomeThrottled SendEmailCodeAbuseOutcome = "throttled"
)
// IsKnown reports whether SendEmailCodeAbuseOutcome belongs to the stable
// Stage-17 resend-throttle contract.
func (o SendEmailCodeAbuseOutcome) IsKnown() bool {
switch o {
case SendEmailCodeAbuseOutcomeAllowed, SendEmailCodeAbuseOutcomeThrottled:
return true
default:
return false
}
}
// SendEmailCodeAbuseResult describes one resend-throttle decision returned by
// SendEmailCodeAbuseProtector.
type SendEmailCodeAbuseResult struct {
// Outcome reports whether the current send attempt may proceed or must be
// throttled.
Outcome SendEmailCodeAbuseOutcome
}
// Validate reports whether SendEmailCodeAbuseResult satisfies the resend
// cooldown contract.
func (r SendEmailCodeAbuseResult) Validate() error {
if !r.Outcome.IsKnown() {
return fmt.Errorf("send email code abuse result outcome %q is unsupported", r.Outcome)
}
return nil
}
// SendEmailCodeThrottleStatusToChallengeStatus maps one resend-throttle
// outcome to the challenge lifecycle state used by sendemailcode.
func SendEmailCodeThrottleStatusToChallengeStatus(outcome SendEmailCodeAbuseOutcome) (challenge.Status, challenge.DeliveryState, error) {
switch outcome {
case SendEmailCodeAbuseOutcomeAllowed:
return challenge.StatusPendingSend, challenge.DeliveryPending, nil
case SendEmailCodeAbuseOutcomeThrottled:
return challenge.StatusDeliveryThrottled, challenge.DeliveryThrottled, nil
default:
return "", "", fmt.Errorf("map send email code abuse outcome %q: unsupported outcome", outcome)
}
}
@@ -0,0 +1,47 @@
package ports
import (
"testing"
"time"
"galaxy/authsession/internal/domain/challenge"
"galaxy/authsession/internal/domain/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSendEmailCodeAbuseOutcomeIsKnown(t *testing.T) {
t.Parallel()
assert.True(t, SendEmailCodeAbuseOutcomeAllowed.IsKnown())
assert.True(t, SendEmailCodeAbuseOutcomeThrottled.IsKnown())
assert.False(t, SendEmailCodeAbuseOutcome("unknown").IsKnown())
}
func TestSendEmailCodeAbuseInputAndResultValidate(t *testing.T) {
t.Parallel()
input := SendEmailCodeAbuseInput{
Email: common.Email("pilot@example.com"),
Now: time.Unix(10, 0).UTC(),
}
require.NoError(t, input.Validate())
result := SendEmailCodeAbuseResult{Outcome: SendEmailCodeAbuseOutcomeThrottled}
require.NoError(t, result.Validate())
}
func TestSendEmailCodeThrottleStatusToChallengeStatus(t *testing.T) {
t.Parallel()
status, deliveryState, err := SendEmailCodeThrottleStatusToChallengeStatus(SendEmailCodeAbuseOutcomeAllowed)
require.NoError(t, err)
assert.Equal(t, challenge.StatusPendingSend, status)
assert.Equal(t, challenge.DeliveryPending, deliveryState)
status, deliveryState, err = SendEmailCodeThrottleStatusToChallengeStatus(SendEmailCodeAbuseOutcomeThrottled)
require.NoError(t, err)
assert.Equal(t, challenge.StatusDeliveryThrottled, status)
assert.Equal(t, challenge.DeliveryThrottled, deliveryState)
}
+214
View File
@@ -0,0 +1,214 @@
package ports
import (
"context"
"errors"
"fmt"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
)
// SessionStore provides source-of-truth persistence for device sessions
// without exposing storage-specific encoding or transaction primitives.
type SessionStore interface {
// Get returns the stored session for deviceSessionID. Implementations must
// wrap ErrNotFound when deviceSessionID does not exist.
Get(ctx context.Context, deviceSessionID common.DeviceSessionID) (devicesession.Session, error)
// ListByUserID returns every stored session for userID in newest-first
// order. Implementations must return an empty slice, not ErrNotFound, when
// userID has no stored sessions.
ListByUserID(ctx context.Context, userID common.UserID) ([]devicesession.Session, error)
// CountActiveByUserID returns the number of active sessions currently stored
// for userID.
CountActiveByUserID(ctx context.Context, userID common.UserID) (int, error)
// Create persists record as a new device session. Implementations must wrap
// ErrConflict when record.ID already exists.
Create(ctx context.Context, record devicesession.Session) error
// Revoke stores a revoked view of one target session. Implementations must
// wrap ErrNotFound when input.DeviceSessionID does not exist.
Revoke(ctx context.Context, input RevokeSessionInput) (RevokeSessionResult, error)
// RevokeAllByUserID stores revoked views for all currently active sessions
// owned by input.UserID.
RevokeAllByUserID(ctx context.Context, input RevokeUserSessionsInput) (RevokeUserSessionsResult, error)
}
// RevokeSessionInput describes one single-session revoke mutation requested
// from SessionStore.
type RevokeSessionInput struct {
// DeviceSessionID identifies the session that should be revoked.
DeviceSessionID common.DeviceSessionID
// Revocation stores the audit metadata that must be attached to the revoked
// session.
Revocation devicesession.Revocation
}
// Validate reports whether RevokeSessionInput contains a complete revoke
// request.
func (i RevokeSessionInput) Validate() error {
if err := i.DeviceSessionID.Validate(); err != nil {
return fmt.Errorf("revoke session input device session id: %w", err)
}
if err := i.Revocation.Validate(); err != nil {
return fmt.Errorf("revoke session input revocation: %w", err)
}
return nil
}
// RevokeSessionOutcome identifies the coarse outcome of revoking one device
// session.
type RevokeSessionOutcome string
const (
// RevokeSessionOutcomeRevoked reports that an active session was moved to
// the revoked state by the current mutation.
RevokeSessionOutcomeRevoked RevokeSessionOutcome = "revoked"
// RevokeSessionOutcomeAlreadyRevoked reports that the requested session had
// already been revoked before the current mutation.
RevokeSessionOutcomeAlreadyRevoked RevokeSessionOutcome = "already_revoked"
)
// IsKnown reports whether RevokeSessionOutcome is supported by the current
// session-store contract.
func (o RevokeSessionOutcome) IsKnown() bool {
switch o {
case RevokeSessionOutcomeRevoked, RevokeSessionOutcomeAlreadyRevoked:
return true
default:
return false
}
}
// RevokeSessionResult describes the stable outcome returned by SessionStore
// after a single-session revoke attempt.
type RevokeSessionResult struct {
// Outcome reports whether the session was revoked just now or had already
// been revoked.
Outcome RevokeSessionOutcome
// Session stores the current source-of-truth session state after the revoke
// attempt.
Session devicesession.Session
}
// Validate reports whether RevokeSessionResult satisfies the session-store
// contract invariants.
func (r RevokeSessionResult) Validate() error {
if !r.Outcome.IsKnown() {
return fmt.Errorf("revoke session result outcome %q is unsupported", r.Outcome)
}
if err := r.Session.Validate(); err != nil {
return fmt.Errorf("revoke session result session: %w", err)
}
if r.Session.Status != devicesession.StatusRevoked {
return errors.New("revoke session result session must be revoked")
}
return nil
}
// RevokeUserSessionsInput describes one bulk user-session revoke mutation
// requested from SessionStore.
type RevokeUserSessionsInput struct {
// UserID identifies the owner whose active sessions should be revoked.
UserID common.UserID
// Revocation stores the audit metadata that must be attached to every
// revoked session.
Revocation devicesession.Revocation
}
// Validate reports whether RevokeUserSessionsInput contains a complete bulk
// revoke request.
func (i RevokeUserSessionsInput) Validate() error {
if err := i.UserID.Validate(); err != nil {
return fmt.Errorf("revoke user sessions input user id: %w", err)
}
if err := i.Revocation.Validate(); err != nil {
return fmt.Errorf("revoke user sessions input revocation: %w", err)
}
return nil
}
// RevokeUserSessionsOutcome identifies the coarse outcome of revoking all
// active sessions of one user.
type RevokeUserSessionsOutcome string
const (
// RevokeUserSessionsOutcomeRevoked reports that one or more active sessions
// were revoked by the current mutation.
RevokeUserSessionsOutcomeRevoked RevokeUserSessionsOutcome = "revoked"
// RevokeUserSessionsOutcomeNoActiveSessions reports that the target user did
// not currently own any active sessions.
RevokeUserSessionsOutcomeNoActiveSessions RevokeUserSessionsOutcome = "no_active_sessions"
)
// IsKnown reports whether RevokeUserSessionsOutcome is supported by the
// current session-store contract.
func (o RevokeUserSessionsOutcome) IsKnown() bool {
switch o {
case RevokeUserSessionsOutcomeRevoked, RevokeUserSessionsOutcomeNoActiveSessions:
return true
default:
return false
}
}
// RevokeUserSessionsResult describes the stable outcome returned by
// SessionStore after one bulk revoke attempt.
type RevokeUserSessionsResult struct {
// Outcome reports whether at least one active session was revoked.
Outcome RevokeUserSessionsOutcome
// UserID identifies the owner whose sessions were evaluated.
UserID common.UserID
// Sessions stores the current source-of-truth session states for every
// session affected by the bulk revoke operation.
Sessions []devicesession.Session
}
// Validate reports whether RevokeUserSessionsResult satisfies the bulk
// session-store contract invariants.
func (r RevokeUserSessionsResult) Validate() error {
if !r.Outcome.IsKnown() {
return fmt.Errorf("revoke user sessions result outcome %q is unsupported", r.Outcome)
}
if err := r.UserID.Validate(); err != nil {
return fmt.Errorf("revoke user sessions result user id: %w", err)
}
for index, session := range r.Sessions {
if err := session.Validate(); err != nil {
return fmt.Errorf("revoke user sessions result session %d: %w", index, err)
}
if session.Status != devicesession.StatusRevoked {
return fmt.Errorf("revoke user sessions result session %d must be revoked", index)
}
if session.UserID != r.UserID {
return fmt.Errorf("revoke user sessions result session %d belongs to %q, want %q", index, session.UserID, r.UserID)
}
}
switch r.Outcome {
case RevokeUserSessionsOutcomeRevoked:
if len(r.Sessions) == 0 {
return errors.New("revoke user sessions result must include sessions when outcome is revoked")
}
case RevokeUserSessionsOutcomeNoActiveSessions:
if len(r.Sessions) != 0 {
return errors.New("revoke user sessions result must not include sessions when outcome is no_active_sessions")
}
}
return nil
}
@@ -0,0 +1,203 @@
package ports
import (
"context"
"errors"
"fmt"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/userresolution"
)
// UserDirectory provides the auth/session boundary to user ownership,
// registration, and block-policy decisions.
type UserDirectory interface {
// ResolveByEmail returns the current resolution state for email without
// creating any new user record.
ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error)
// ExistsByUserID reports whether userID currently identifies a stored user
// record.
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome when the
// address may not continue through confirm flow.
EnsureUserByEmail(ctx context.Context, email common.Email) (EnsureUserResult, error)
// BlockByUserID applies a block state to the user identified by
// input.UserID. Implementations must wrap ErrNotFound when input.UserID does
// not exist.
BlockByUserID(ctx context.Context, input BlockUserByIDInput) (BlockUserResult, error)
// BlockByEmail applies a block state to input.Email, even when no user
// record currently exists for that e-mail address.
BlockByEmail(ctx context.Context, input BlockUserByEmailInput) (BlockUserResult, error)
}
// EnsureUserOutcome identifies the coarse outcome of ensuring a user record
// for one normalized e-mail address.
type EnsureUserOutcome string
const (
// EnsureUserOutcomeExisting reports that the e-mail already belonged to a
// stored user.
EnsureUserOutcomeExisting EnsureUserOutcome = "existing"
// EnsureUserOutcomeCreated reports that a new user was created for the
// e-mail address.
EnsureUserOutcomeCreated EnsureUserOutcome = "created"
// EnsureUserOutcomeBlocked reports that the e-mail cannot be used for login
// or registration.
EnsureUserOutcomeBlocked EnsureUserOutcome = "blocked"
)
// IsKnown reports whether EnsureUserOutcome is supported by the current
// user-directory contract.
func (o EnsureUserOutcome) IsKnown() bool {
switch o {
case EnsureUserOutcomeExisting, EnsureUserOutcomeCreated, EnsureUserOutcomeBlocked:
return true
default:
return false
}
}
// EnsureUserResult describes the stable outcome returned by UserDirectory
// after one ensure-user attempt.
type EnsureUserResult struct {
// Outcome reports whether the user already existed, was created, or is
// blocked by policy.
Outcome EnsureUserOutcome
// UserID is present when Outcome is EnsureUserOutcomeExisting or
// EnsureUserOutcomeCreated.
UserID common.UserID
// BlockReasonCode is present only when Outcome is EnsureUserOutcomeBlocked.
BlockReasonCode userresolution.BlockReasonCode
}
// Validate reports whether EnsureUserResult satisfies the user-directory
// contract invariants.
func (r EnsureUserResult) Validate() error {
if !r.Outcome.IsKnown() {
return fmt.Errorf("ensure user result outcome %q is unsupported", r.Outcome)
}
switch r.Outcome {
case EnsureUserOutcomeExisting, EnsureUserOutcomeCreated:
if err := r.UserID.Validate(); err != nil {
return fmt.Errorf("ensure user result user id: %w", err)
}
if !r.BlockReasonCode.IsZero() {
return errors.New("ensure user result must not contain block reason code for existing or created outcomes")
}
case EnsureUserOutcomeBlocked:
if !r.UserID.IsZero() {
return errors.New("ensure user result must not contain user id for blocked outcome")
}
if err := r.BlockReasonCode.Validate(); err != nil {
return fmt.Errorf("ensure user result block reason code: %w", err)
}
}
return nil
}
// BlockUserByIDInput describes one block mutation targeted by stable user id.
type BlockUserByIDInput struct {
// UserID identifies the user that should be blocked.
UserID common.UserID
// ReasonCode stores the machine-readable block reason to apply.
ReasonCode userresolution.BlockReasonCode
}
// Validate reports whether BlockUserByIDInput contains a complete block
// request.
func (i BlockUserByIDInput) Validate() error {
if err := i.UserID.Validate(); err != nil {
return fmt.Errorf("block user by id input user id: %w", err)
}
if err := i.ReasonCode.Validate(); err != nil {
return fmt.Errorf("block user by id input reason code: %w", err)
}
return nil
}
// BlockUserByEmailInput describes one block mutation targeted by normalized
// e-mail address.
type BlockUserByEmailInput struct {
// Email identifies the e-mail address that should be blocked.
Email common.Email
// ReasonCode stores the machine-readable block reason to apply.
ReasonCode userresolution.BlockReasonCode
}
// Validate reports whether BlockUserByEmailInput contains a complete block
// request.
func (i BlockUserByEmailInput) Validate() error {
if err := i.Email.Validate(); err != nil {
return fmt.Errorf("block user by email input email: %w", err)
}
if err := i.ReasonCode.Validate(); err != nil {
return fmt.Errorf("block user by email input reason code: %w", err)
}
return nil
}
// BlockUserOutcome identifies the coarse outcome of blocking one user or
// e-mail subject.
type BlockUserOutcome string
const (
// BlockUserOutcomeBlocked reports that the current mutation applied a new
// block state.
BlockUserOutcomeBlocked BlockUserOutcome = "blocked"
// BlockUserOutcomeAlreadyBlocked reports that the target subject had already
// been blocked before the current mutation.
BlockUserOutcomeAlreadyBlocked BlockUserOutcome = "already_blocked"
)
// IsKnown reports whether BlockUserOutcome is supported by the current
// user-directory contract.
func (o BlockUserOutcome) IsKnown() bool {
switch o {
case BlockUserOutcomeBlocked, BlockUserOutcomeAlreadyBlocked:
return true
default:
return false
}
}
// BlockUserResult describes the stable outcome returned by UserDirectory after
// one block attempt.
type BlockUserResult struct {
// Outcome reports whether the current mutation applied a new block state.
Outcome BlockUserOutcome
// UserID optionally stores the stable user identifier resolved for the
// blocked subject when one exists.
UserID common.UserID
}
// Validate reports whether BlockUserResult satisfies the user-directory
// contract invariants.
func (r BlockUserResult) Validate() error {
if !r.Outcome.IsKnown() {
return fmt.Errorf("block user result outcome %q is unsupported", r.Outcome)
}
if !r.UserID.IsZero() {
if err := r.UserID.Validate(); err != nil {
return fmt.Errorf("block user result user id: %w", err)
}
}
return nil
}