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,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",
}
}