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,4 @@
// Package shared provides cross-use-case application helpers for auth/session
// services, including typed service errors, input normalization, DTO mapping,
// and application-level retry helpers.
package shared
@@ -0,0 +1,407 @@
package shared
import (
"errors"
"net/http"
"strings"
)
const (
// ErrorCodeInvalidRequest reports malformed or semantically invalid service
// input.
ErrorCodeInvalidRequest = "invalid_request"
// ErrorCodeChallengeNotFound reports that the requested challenge does not
// exist.
ErrorCodeChallengeNotFound = "challenge_not_found"
// ErrorCodeChallengeExpired reports that the requested challenge may no
// longer be confirmed.
ErrorCodeChallengeExpired = "challenge_expired"
// ErrorCodeInvalidCode reports that the submitted confirmation code does not
// match the stored challenge.
ErrorCodeInvalidCode = "invalid_code"
// ErrorCodeInvalidClientPublicKey reports that the submitted client public
// key does not satisfy the Ed25519/base64 contract.
ErrorCodeInvalidClientPublicKey = "invalid_client_public_key"
// ErrorCodeBlockedByPolicy reports that the auth flow is denied by current
// user or registration policy.
ErrorCodeBlockedByPolicy = "blocked_by_policy"
// ErrorCodeSessionLimitExceeded reports that creating another active session
// would violate the configured limit.
ErrorCodeSessionLimitExceeded = "session_limit_exceeded"
// ErrorCodeSessionNotFound reports that the requested device session does
// not exist.
ErrorCodeSessionNotFound = "session_not_found"
// ErrorCodeSubjectNotFound reports that the requested trusted internal
// subject does not exist.
ErrorCodeSubjectNotFound = "subject_not_found"
// ErrorCodeServiceUnavailable reports that a required dependency or
// propagation step is temporarily unavailable.
ErrorCodeServiceUnavailable = "service_unavailable"
// ErrorCodeInternalError reports that local state is inconsistent or an
// invariant was broken unexpectedly.
ErrorCodeInternalError = "internal_error"
)
const genericInvalidRequestMessage = "request is invalid"
var publicErrorStatusCodes = map[string]int{
ErrorCodeInvalidRequest: http.StatusBadRequest,
ErrorCodeInvalidClientPublicKey: http.StatusBadRequest,
ErrorCodeInvalidCode: http.StatusBadRequest,
ErrorCodeChallengeNotFound: http.StatusNotFound,
ErrorCodeChallengeExpired: http.StatusGone,
ErrorCodeBlockedByPolicy: http.StatusForbidden,
ErrorCodeSessionLimitExceeded: http.StatusConflict,
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
}
var publicStableMessages = map[string]string{
ErrorCodeChallengeNotFound: "challenge not found",
ErrorCodeChallengeExpired: "challenge expired",
ErrorCodeInvalidCode: "confirmation code is invalid",
ErrorCodeInvalidClientPublicKey: "client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key",
ErrorCodeBlockedByPolicy: "authentication is blocked by policy",
ErrorCodeSessionLimitExceeded: "active session limit would be exceeded",
ErrorCodeServiceUnavailable: "service is unavailable",
}
var internalErrorStatusCodes = map[string]int{
ErrorCodeInvalidRequest: http.StatusBadRequest,
ErrorCodeSessionNotFound: http.StatusNotFound,
ErrorCodeSubjectNotFound: http.StatusNotFound,
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
ErrorCodeInternalError: http.StatusInternalServerError,
}
var internalStableMessages = map[string]string{
ErrorCodeSessionNotFound: "session not found",
ErrorCodeSubjectNotFound: "subject not found",
ErrorCodeServiceUnavailable: "service is unavailable",
ErrorCodeInternalError: "internal server error",
}
// PublicErrorProjection describes one transport-ready public auth error after
// internal service errors have been normalized to the frozen client-safe
// surface.
type PublicErrorProjection struct {
// StatusCode is the HTTP status that should be returned to the public auth
// caller.
StatusCode int
// Code is the stable client-safe error code written into the public JSON
// envelope.
Code string
// Message is the client-safe error description exposed to the public auth
// caller.
Message string
}
// InternalErrorProjection describes one transport-ready internal API error
// after service-layer failures have been normalized to the frozen trusted
// caller surface.
type InternalErrorProjection struct {
// StatusCode is the HTTP status that should be returned to the trusted
// caller.
StatusCode int
// Code is the stable error code written into the internal JSON envelope.
Code string
// Message is the trusted-caller-safe error description exposed by the
// internal HTTP API.
Message string
}
// ServiceError projects one stable application-layer failure with a service
// error code and a caller-safe message.
type ServiceError struct {
// Code is the stable error code expected by later transport mapping.
Code string
// Message is the caller-safe error description.
Message string
// Err optionally stores the wrapped underlying cause.
Err error
}
// Error returns the caller-safe error description.
func (e *ServiceError) Error() string {
if e == nil {
return ""
}
switch {
case strings.TrimSpace(e.Message) != "":
return e.Message
case strings.TrimSpace(e.Code) != "":
return e.Code
case e.Err != nil:
return e.Err.Error()
default:
return ErrorCodeInternalError
}
}
// Unwrap returns the wrapped cause, if any.
func (e *ServiceError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// NewServiceError returns a new typed application-layer error.
func NewServiceError(code string, message string, err error) *ServiceError {
return &ServiceError{
Code: strings.TrimSpace(code),
Message: strings.TrimSpace(message),
Err: err,
}
}
// IsPublicErrorCode reports whether code belongs to the frozen public auth
// error surface.
func IsPublicErrorCode(code string) bool {
_, ok := publicErrorStatusCodes[strings.TrimSpace(code)]
return ok
}
// IsInternalOnlyErrorCode reports whether code is intentionally excluded from
// the public auth transport surface.
func IsInternalOnlyErrorCode(code string) bool {
switch strings.TrimSpace(code) {
case ErrorCodeSessionNotFound, ErrorCodeSubjectNotFound, ErrorCodeInternalError:
return true
default:
return false
}
}
// IsSendEmailCodePublicErrorCode reports whether code may be exposed by the
// public send-email-code route after public projection.
func IsSendEmailCodePublicErrorCode(code string) bool {
switch strings.TrimSpace(code) {
case ErrorCodeInvalidRequest, ErrorCodeServiceUnavailable:
return true
default:
return false
}
}
// IsConfirmEmailCodePublicErrorCode reports whether code may be exposed by the
// public confirm-email-code route after public projection.
func IsConfirmEmailCodePublicErrorCode(code string) bool {
switch strings.TrimSpace(code) {
case ErrorCodeInvalidRequest,
ErrorCodeChallengeNotFound,
ErrorCodeChallengeExpired,
ErrorCodeInvalidCode,
ErrorCodeInvalidClientPublicKey,
ErrorCodeBlockedByPolicy,
ErrorCodeSessionLimitExceeded,
ErrorCodeServiceUnavailable:
return true
default:
return false
}
}
// PublicHTTPStatusCode reports the frozen public HTTP status for code. Unknown
// or internal-only codes are normalized to 503 service_unavailable.
func PublicHTTPStatusCode(code string) int {
if statusCode, ok := publicErrorStatusCodes[strings.TrimSpace(code)]; ok {
return statusCode
}
return http.StatusServiceUnavailable
}
// ProjectPublicError normalizes err to the frozen public-auth error surface.
// Unknown and internal-only service failures are intentionally projected as
// 503 service_unavailable so internal invariants do not leak to public callers.
func ProjectPublicError(err error) PublicErrorProjection {
serviceErr, ok := errors.AsType[*ServiceError](err)
code := CodeOf(err)
if !IsPublicErrorCode(code) {
return PublicErrorProjection{
StatusCode: http.StatusServiceUnavailable,
Code: ErrorCodeServiceUnavailable,
Message: publicMessageForCode(ErrorCodeServiceUnavailable, ""),
}
}
message := ""
if ok && serviceErr != nil {
message = serviceErr.Message
}
return PublicErrorProjection{
StatusCode: PublicHTTPStatusCode(code),
Code: code,
Message: publicMessageForCode(code, message),
}
}
// InternalHTTPStatusCode reports the frozen internal HTTP status for code.
// Unknown codes are normalized to 500 internal_error.
func InternalHTTPStatusCode(code string) int {
if statusCode, ok := internalErrorStatusCodes[strings.TrimSpace(code)]; ok {
return statusCode
}
return http.StatusInternalServerError
}
// ProjectInternalError normalizes err to the frozen internal trusted HTTP
// error surface. Unknown failures are intentionally projected as
// 500 internal_error so transport callers do not depend on unclassified local
// failures.
func ProjectInternalError(err error) InternalErrorProjection {
serviceErr, ok := errors.AsType[*ServiceError](err)
code := CodeOf(err)
if _, known := internalErrorStatusCodes[code]; !known {
return InternalErrorProjection{
StatusCode: http.StatusInternalServerError,
Code: ErrorCodeInternalError,
Message: internalMessageForCode(ErrorCodeInternalError, ""),
}
}
message := ""
if ok && serviceErr != nil {
message = serviceErr.Message
}
return InternalErrorProjection{
StatusCode: InternalHTTPStatusCode(code),
Code: code,
Message: internalMessageForCode(code, message),
}
}
// InvalidRequest reports one malformed or semantically invalid caller input.
func InvalidRequest(message string) *ServiceError {
return NewServiceError(ErrorCodeInvalidRequest, message, nil)
}
// ChallengeNotFound reports that the requested challenge does not exist.
func ChallengeNotFound() *ServiceError {
return NewServiceError(ErrorCodeChallengeNotFound, "challenge not found", nil)
}
// ChallengeExpired reports that the requested challenge is expired.
func ChallengeExpired() *ServiceError {
return NewServiceError(ErrorCodeChallengeExpired, "challenge expired", nil)
}
// InvalidCode reports that the submitted confirmation code is invalid.
func InvalidCode() *ServiceError {
return NewServiceError(ErrorCodeInvalidCode, "confirmation code is invalid", nil)
}
// InvalidClientPublicKey reports that the submitted client public key does not
// satisfy the frozen contract.
func InvalidClientPublicKey() *ServiceError {
return NewServiceError(
ErrorCodeInvalidClientPublicKey,
"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key",
nil,
)
}
// BlockedByPolicy reports that the current auth flow is denied by policy.
func BlockedByPolicy() *ServiceError {
return NewServiceError(ErrorCodeBlockedByPolicy, "authentication is blocked by policy", nil)
}
// SessionLimitExceeded reports that creating another active session would
// exceed the current configured limit.
func SessionLimitExceeded() *ServiceError {
return NewServiceError(ErrorCodeSessionLimitExceeded, "active session limit would be exceeded", nil)
}
// SessionNotFound reports that the requested session does not exist.
func SessionNotFound() *ServiceError {
return NewServiceError(ErrorCodeSessionNotFound, "session not found", nil)
}
// SubjectNotFound reports that the requested internal subject does not exist.
func SubjectNotFound() *ServiceError {
return NewServiceError(ErrorCodeSubjectNotFound, "subject not found", nil)
}
// ServiceUnavailable reports that a required dependency or propagation step is
// temporarily unavailable.
func ServiceUnavailable(err error) *ServiceError {
return NewServiceError(ErrorCodeServiceUnavailable, "service is unavailable", err)
}
// InternalError reports an invariant-breaking local failure.
func InternalError(err error) *ServiceError {
return NewServiceError(ErrorCodeInternalError, "internal error", err)
}
// CodeOf returns the stable service error code of err when err wraps a
// ServiceError. Otherwise it returns ErrorCodeInternalError.
func CodeOf(err error) string {
serviceErr, ok := errors.AsType[*ServiceError](err)
if !ok || serviceErr == nil || strings.TrimSpace(serviceErr.Code) == "" {
return ErrorCodeInternalError
}
return serviceErr.Code
}
func publicMessageForCode(code string, message string) string {
trimmedMessage := strings.TrimSpace(message)
switch strings.TrimSpace(code) {
case ErrorCodeInvalidRequest:
if trimmedMessage != "" {
return trimmedMessage
}
return genericInvalidRequestMessage
case ErrorCodeServiceUnavailable:
return publicStableMessages[ErrorCodeServiceUnavailable]
default:
if stableMessage, ok := publicStableMessages[strings.TrimSpace(code)]; ok {
return stableMessage
}
return publicStableMessages[ErrorCodeServiceUnavailable]
}
}
func internalMessageForCode(code string, message string) string {
trimmedMessage := strings.TrimSpace(message)
switch strings.TrimSpace(code) {
case ErrorCodeInvalidRequest:
if trimmedMessage != "" {
return trimmedMessage
}
return genericInvalidRequestMessage
case ErrorCodeSessionNotFound,
ErrorCodeSubjectNotFound,
ErrorCodeServiceUnavailable,
ErrorCodeInternalError:
if stableMessage, ok := internalStableMessages[strings.TrimSpace(code)]; ok {
return stableMessage
}
return internalStableMessages[ErrorCodeInternalError]
default:
return internalStableMessages[ErrorCodeInternalError]
}
}
@@ -0,0 +1,158 @@
package shared
import (
"crypto/ed25519"
"encoding/base64"
"fmt"
"strings"
"time"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
)
// NormalizeString trims surrounding Unicode whitespace from value.
func NormalizeString(value string) string {
return strings.TrimSpace(value)
}
// ParseEmail trims value and validates it against the frozen public e-mail
// contract.
func ParseEmail(value string) (common.Email, error) {
email := common.Email(NormalizeString(value))
if err := email.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return email, nil
}
// ParseChallengeID trims value and validates it as one challenge identifier.
func ParseChallengeID(value string) (common.ChallengeID, error) {
challengeID := common.ChallengeID(NormalizeString(value))
if err := challengeID.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return challengeID, nil
}
// ParseDeviceSessionID trims value and validates it as one device-session
// identifier.
func ParseDeviceSessionID(value string) (common.DeviceSessionID, error) {
deviceSessionID := common.DeviceSessionID(NormalizeString(value))
if err := deviceSessionID.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return deviceSessionID, nil
}
// ParseUserID trims value and validates it as one user identifier.
func ParseUserID(value string) (common.UserID, error) {
userID := common.UserID(NormalizeString(value))
if err := userID.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return userID, nil
}
// ParseRequiredCode trims value and validates it as a required non-empty
// confirmation code.
func ParseRequiredCode(value string) (string, error) {
code := NormalizeString(value)
if code == "" {
return "", InvalidRequest("code must not be empty")
}
return code, nil
}
// ParseClientPublicKey trims value and validates it as the standard
// base64-encoded raw 32-byte Ed25519 public key expected by the public auth
// contract.
func ParseClientPublicKey(value string) (common.ClientPublicKey, error) {
normalized := NormalizeString(value)
if normalized == "" {
return common.ClientPublicKey{}, InvalidClientPublicKey()
}
decoded, err := base64.StdEncoding.Strict().DecodeString(normalized)
if err != nil || len(decoded) != ed25519.PublicKeySize {
return common.ClientPublicKey{}, InvalidClientPublicKey()
}
key, err := common.NewClientPublicKey(ed25519.PublicKey(decoded))
if err != nil {
return common.ClientPublicKey{}, InvalidClientPublicKey()
}
return key, nil
}
// ParseRevokeReasonCode trims value and validates it as one machine-readable
// revoke reason code.
func ParseRevokeReasonCode(value string) (common.RevokeReasonCode, error) {
code := common.RevokeReasonCode(NormalizeString(value))
if err := code.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return code, nil
}
// ParseRevokeActorType trims value and validates it as one machine-readable
// revoke actor type.
func ParseRevokeActorType(value string) (common.RevokeActorType, error) {
actorType := common.RevokeActorType(NormalizeString(value))
if err := actorType.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return actorType, nil
}
// ParseOptionalActorID trims value and validates it as one optional stable
// actor identifier.
func ParseOptionalActorID(value string) (string, error) {
actorID := NormalizeString(value)
if actorID != value {
return "", InvalidRequest("actor_id must not contain surrounding whitespace")
}
return actorID, nil
}
// BuildRevocation validates one revoke request payload and returns the domain
// revocation metadata applied to a session mutation.
func BuildRevocation(reasonCode string, actorType string, actorID string, at time.Time) (devicesession.Revocation, error) {
if at.IsZero() {
return devicesession.Revocation{}, InternalError(fmt.Errorf("revocation time must not be zero"))
}
parsedReasonCode, err := ParseRevokeReasonCode(reasonCode)
if err != nil {
return devicesession.Revocation{}, err
}
parsedActorType, err := ParseRevokeActorType(actorType)
if err != nil {
return devicesession.Revocation{}, err
}
parsedActorID, err := ParseOptionalActorID(actorID)
if err != nil {
return devicesession.Revocation{}, err
}
revocation := devicesession.Revocation{
At: at.UTC(),
ReasonCode: parsedReasonCode,
ActorType: parsedActorType,
ActorID: parsedActorID,
}
if err := revocation.Validate(); err != nil {
return devicesession.Revocation{}, InternalError(fmt.Errorf("build revocation: %w", err))
}
return revocation, nil
}
@@ -0,0 +1,46 @@
package shared
import (
"context"
authlogging "galaxy/authsession/internal/logging"
"go.uber.org/zap"
)
// LogServiceOutcome writes one structured service-level outcome log with a
// stable severity derived from err and with trace fields attached when ctx
// carries an active span.
func LogServiceOutcome(logger *zap.Logger, ctx context.Context, message string, err error, fields ...zap.Field) {
if logger == nil {
logger = zap.NewNop()
}
fields = append(fields, authlogging.TraceFieldsFromContext(ctx)...)
switch {
case err == nil:
logger.Info(message, fields...)
case isExpectedServiceErrorCode(CodeOf(err)):
logger.Warn(message, append(fields, zap.Error(err))...)
default:
logger.Error(message, append(fields, zap.Error(err))...)
}
}
func isExpectedServiceErrorCode(code string) bool {
switch code {
case ErrorCodeInvalidRequest,
ErrorCodeChallengeNotFound,
ErrorCodeChallengeExpired,
ErrorCodeInvalidCode,
ErrorCodeInvalidClientPublicKey,
ErrorCodeBlockedByPolicy,
ErrorCodeSessionLimitExceeded,
ErrorCodeSessionNotFound,
ErrorCodeSubjectNotFound:
return true
default:
return false
}
}
@@ -0,0 +1,11 @@
package shared
const (
// MaxCompareAndSwapRetries bounds application-level retry loops around
// compare-and-swap challenge updates.
MaxCompareAndSwapRetries = 3
// MaxProjectionPublishAttempts bounds synchronous request-path retries
// around gateway session projection publication.
MaxProjectionPublishAttempts = 3
)
@@ -0,0 +1,86 @@
package shared
import (
"context"
"errors"
"fmt"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/gatewayprojection"
"galaxy/authsession/internal/ports"
"galaxy/authsession/internal/telemetry"
)
// PublishProjectionSnapshot publishes snapshot through publisher with a small
// bounded retry loop suitable for request-path consistency repair.
func PublishProjectionSnapshot(ctx context.Context, publisher ports.GatewaySessionProjectionPublisher, snapshot gatewayprojection.Snapshot) error {
return PublishProjectionSnapshotWithTelemetry(ctx, publisher, snapshot, nil, "")
}
// PublishProjectionSnapshotWithTelemetry publishes snapshot through publisher
// with the bounded request-path retry policy and optional publish-failure
// telemetry.
func PublishProjectionSnapshotWithTelemetry(
ctx context.Context,
publisher ports.GatewaySessionProjectionPublisher,
snapshot gatewayprojection.Snapshot,
telemetryRuntime *telemetry.Runtime,
operation string,
) error {
if publisher == nil {
return InternalError(errors.New("projection publisher must not be nil"))
}
if ctx == nil {
return ServiceUnavailable(errors.New("projection publish context must not be nil"))
}
if err := snapshot.Validate(); err != nil {
return InternalError(fmt.Errorf("publish projection snapshot: %w", err))
}
var lastErr error
for attempt := 0; attempt < MaxProjectionPublishAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return ServiceUnavailable(err)
}
if err := publisher.PublishSession(ctx, snapshot); err == nil {
return nil
} else {
lastErr = err
}
}
telemetryRuntime.RecordProjectionPublishFailure(ctx, operation)
return ServiceUnavailable(
fmt.Errorf(
"publish projection snapshot %q after %d attempts: %w",
snapshot.DeviceSessionID,
MaxProjectionPublishAttempts,
lastErr,
),
)
}
// PublishSessionProjection converts record into the gateway-facing snapshot and
// publishes it with the bounded request-path retry policy.
func PublishSessionProjection(ctx context.Context, publisher ports.GatewaySessionProjectionPublisher, record devicesession.Session) error {
return PublishSessionProjectionWithTelemetry(ctx, publisher, record, nil, "")
}
// PublishSessionProjectionWithTelemetry converts record into the
// gateway-facing snapshot and publishes it with the bounded request-path retry
// policy and optional publish-failure telemetry.
func PublishSessionProjectionWithTelemetry(
ctx context.Context,
publisher ports.GatewaySessionProjectionPublisher,
record devicesession.Session,
telemetryRuntime *telemetry.Runtime,
operation string,
) error {
snapshot, err := ToGatewayProjectionSnapshot(record)
if err != nil {
return InternalError(err)
}
return PublishProjectionSnapshotWithTelemetry(ctx, publisher, snapshot, telemetryRuntime, operation)
}
@@ -0,0 +1,119 @@
package shared
import (
"context"
"errors"
"testing"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/gatewayprojection"
"galaxy/authsession/internal/testkit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublishSessionProjectionRetriesUntilSuccess(t *testing.T) {
t.Parallel()
tests := []struct {
name string
errors []error
wantAttempts int
}{
{
name: "success on second attempt",
errors: []error{errors.New("transient publish failure"), nil},
wantAttempts: 2,
},
{
name: "success on third attempt",
errors: []error{errors.New("transient publish failure"), errors.New("transient publish failure"), nil},
wantAttempts: 3,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
publisher := &testkit.RecordingProjectionPublisher{Errors: tt.errors}
err := PublishSessionProjection(context.Background(), publisher, revokedSessionFixture())
require.NoError(t, err)
require.Len(t, publisher.PublishedSnapshots(), tt.wantAttempts)
})
}
}
func TestPublishSessionProjectionReturnsServiceUnavailableAfterExhaustedRetries(t *testing.T) {
t.Parallel()
publisher := &testkit.RecordingProjectionPublisher{Err: errors.New("publish failed")}
err := PublishSessionProjection(context.Background(), publisher, revokedSessionFixture())
require.Error(t, err)
assert.Equal(t, ErrorCodeServiceUnavailable, CodeOf(err))
require.Len(t, publisher.PublishedSnapshots(), MaxProjectionPublishAttempts)
}
func TestPublishProjectionSnapshotStopsRetriesWhenContextIsCanceled(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
publisher := &cancelingProjectionPublisher{
cancel: cancel,
err: errors.New("publish failed"),
}
err := PublishProjectionSnapshot(ctx, publisher, mustProjectionSnapshot(t))
require.Error(t, err)
assert.Equal(t, ErrorCodeServiceUnavailable, CodeOf(err))
assert.Equal(t, 1, publisher.attempts)
}
func TestPublishSessionProjectionReturnsInternalErrorForInvalidLocalRecord(t *testing.T) {
t.Parallel()
publisher := &testkit.RecordingProjectionPublisher{}
err := PublishSessionProjection(context.Background(), publisher, invalidSessionFixture())
require.Error(t, err)
assert.Equal(t, ErrorCodeInternalError, CodeOf(err))
assert.Empty(t, publisher.PublishedSnapshots())
}
type cancelingProjectionPublisher struct {
attempts int
cancel context.CancelFunc
err error
}
func (p *cancelingProjectionPublisher) PublishSession(_ context.Context, snapshot gatewayprojection.Snapshot) error {
if err := snapshot.Validate(); err != nil {
return err
}
p.attempts++
if p.cancel != nil {
p.cancel()
p.cancel = nil
}
return p.err
}
func mustProjectionSnapshot(t *testing.T) gatewayprojection.Snapshot {
t.Helper()
snapshot, err := ToGatewayProjectionSnapshot(revokedSessionFixture())
require.NoError(t, err)
return snapshot
}
func invalidSessionFixture() devicesession.Session {
return devicesession.Session{}
}
@@ -0,0 +1,134 @@
package shared
import (
"fmt"
"time"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/gatewayprojection"
)
// Session mirrors the frozen internal read-model DTO used by later trusted
// transport handlers.
type Session struct {
// DeviceSessionID is the stable identifier of one device session.
DeviceSessionID string
// UserID is the stable identifier of the session owner.
UserID string
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key of
// the device session.
ClientPublicKey string
// Status reports whether the session is active or revoked.
Status string
// CreatedAt is the RFC3339 UTC timestamp at which the session was created.
CreatedAt string
// RevokedAt is the RFC3339 UTC timestamp at which the session was revoked,
// when the session is revoked.
RevokedAt *string
// RevokeReasonCode is the machine-readable revoke reason code when the
// session is revoked.
RevokeReasonCode *string
// RevokeActorType is the machine-readable revoke actor type when the
// session is revoked.
RevokeActorType *string
// RevokeActorID is the optional stable revoke actor identifier when the
// session is revoked.
RevokeActorID *string
}
// ToSession converts source-of-truth session into the frozen internal read DTO
// shape.
func ToSession(record devicesession.Session) (Session, error) {
if err := record.Validate(); err != nil {
return Session{}, fmt.Errorf("map session: %w", err)
}
result := Session{
DeviceSessionID: record.ID.String(),
UserID: record.UserID.String(),
ClientPublicKey: record.ClientPublicKey.String(),
Status: string(record.Status),
CreatedAt: formatTime(record.CreatedAt),
}
if record.Revocation != nil {
revokedAt := formatTime(record.Revocation.At)
reasonCode := record.Revocation.ReasonCode.String()
actorType := record.Revocation.ActorType.String()
result.RevokedAt = &revokedAt
result.RevokeReasonCode = &reasonCode
result.RevokeActorType = &actorType
if record.Revocation.ActorID != "" {
actorID := record.Revocation.ActorID
result.RevokeActorID = &actorID
}
}
return result, nil
}
// ToSessions converts every source-of-truth session into the frozen internal
// read DTO shape.
func ToSessions(records []devicesession.Session) ([]Session, error) {
result := make([]Session, 0, len(records))
for index, record := range records {
mapped, err := ToSession(record)
if err != nil {
return nil, fmt.Errorf("map session %d: %w", index, err)
}
result = append(result, mapped)
}
return result, nil
}
// ToGatewayProjectionSnapshot converts source-of-truth session into the
// separate gateway-facing projection model.
func ToGatewayProjectionSnapshot(record devicesession.Session) (gatewayprojection.Snapshot, error) {
if err := record.Validate(); err != nil {
return gatewayprojection.Snapshot{}, fmt.Errorf("map gateway projection snapshot: %w", err)
}
snapshot := gatewayprojection.Snapshot{
DeviceSessionID: record.ID,
UserID: record.UserID,
ClientPublicKey: record.ClientPublicKey.String(),
Status: gatewayprojection.Status(record.Status),
}
if record.Revocation != nil {
snapshot.RevokedAt = cloneTimePointer(commonTimePointer(record.Revocation.At.UTC()))
snapshot.RevokeReasonCode = record.Revocation.ReasonCode
snapshot.RevokeActorType = record.Revocation.ActorType
snapshot.RevokeActorID = record.Revocation.ActorID
}
if err := snapshot.Validate(); err != nil {
return gatewayprojection.Snapshot{}, fmt.Errorf("map gateway projection snapshot: %w", err)
}
return snapshot, nil
}
func formatTime(value time.Time) string {
return value.UTC().Format(time.RFC3339)
}
func commonTimePointer(value time.Time) *time.Time {
return &value
}
func cloneTimePointer(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
@@ -0,0 +1,40 @@
package shared
import (
"fmt"
"galaxy/authsession/internal/domain/sessionlimit"
"galaxy/authsession/internal/ports"
)
// EvaluateSessionLimit evaluates the Stage-4 active-session creation decision
// from the loaded configuration and current active-session count.
func EvaluateSessionLimit(config ports.SessionLimitConfig, activeSessionCount int) (sessionlimit.Decision, error) {
if err := config.Validate(); err != nil {
return sessionlimit.Decision{}, InternalError(fmt.Errorf("evaluate session limit: %w", err))
}
if activeSessionCount < 0 {
return sessionlimit.Decision{}, InternalError(fmt.Errorf("evaluate session limit: active session count %d is negative", activeSessionCount))
}
decision := sessionlimit.Decision{
ActiveSessionCount: activeSessionCount,
NextSessionCount: activeSessionCount + 1,
}
if config.ActiveSessionLimit == nil {
decision.Kind = sessionlimit.KindDisabled
} else {
decision.ConfiguredLimit = config.ActiveSessionLimit
if decision.NextSessionCount <= *config.ActiveSessionLimit {
decision.Kind = sessionlimit.KindAllowed
} else {
decision.Kind = sessionlimit.KindExceeded
}
}
if err := decision.Validate(); err != nil {
return sessionlimit.Decision{}, InternalError(fmt.Errorf("evaluate session limit: %w", err))
}
return decision, nil
}
@@ -0,0 +1,380 @@
package shared
import (
"errors"
"net/http"
"testing"
"time"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/gatewayprojection"
"galaxy/authsession/internal/domain/sessionlimit"
"galaxy/authsession/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeString(t *testing.T) {
t.Parallel()
assert.Equal(t, "pilot@example.com", NormalizeString(" pilot@example.com \n"))
}
func TestParseClientPublicKey(t *testing.T) {
t.Parallel()
key, err := ParseClientPublicKey(" AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= ")
require.NoError(t, err)
assert.Equal(t, "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", key.String())
_, err = ParseClientPublicKey("invalid")
require.Error(t, err)
assert.Equal(t, ErrorCodeInvalidClientPublicKey, CodeOf(err))
}
func TestToSession(t *testing.T) {
t.Parallel()
record := revokedSessionFixture()
dto, err := ToSession(record)
require.NoError(t, err)
assert.Equal(t, record.ID.String(), dto.DeviceSessionID)
require.NotNil(t, dto.RevokedAt)
assert.Equal(t, record.Revocation.At.UTC().Format(time.RFC3339), *dto.RevokedAt)
}
func TestToGatewayProjectionSnapshot(t *testing.T) {
t.Parallel()
record := revokedSessionFixture()
snapshot, err := ToGatewayProjectionSnapshot(record)
require.NoError(t, err)
assert.Equal(t, gatewayprojection.StatusRevoked, snapshot.Status)
}
func TestEvaluateSessionLimit(t *testing.T) {
t.Parallel()
limit := 2
tests := []struct {
name string
config ports.SessionLimitConfig
active int
want sessionlimit.Kind
}{
{name: "disabled", config: ports.SessionLimitConfig{}, active: 3, want: sessionlimit.KindDisabled},
{name: "allowed", config: ports.SessionLimitConfig{ActiveSessionLimit: &limit}, active: 1, want: sessionlimit.KindAllowed},
{name: "exceeded", config: ports.SessionLimitConfig{ActiveSessionLimit: &limit}, active: 2, want: sessionlimit.KindExceeded},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
decision, err := EvaluateSessionLimit(tt.config, tt.active)
require.NoError(t, err)
assert.Equal(t, tt.want, decision.Kind)
})
}
}
func TestServiceErrorCodePreservation(t *testing.T) {
t.Parallel()
baseErr := errors.New("base")
err := ServiceUnavailable(baseErr)
assert.Equal(t, ErrorCodeServiceUnavailable, CodeOf(err))
assert.ErrorIs(t, err, baseErr)
}
func TestErrorCodeClassification(t *testing.T) {
t.Parallel()
publicCodes := []string{
ErrorCodeInvalidRequest,
ErrorCodeChallengeNotFound,
ErrorCodeChallengeExpired,
ErrorCodeInvalidCode,
ErrorCodeInvalidClientPublicKey,
ErrorCodeBlockedByPolicy,
ErrorCodeSessionLimitExceeded,
ErrorCodeServiceUnavailable,
}
for _, code := range publicCodes {
assert.Truef(t, IsPublicErrorCode(code), "IsPublicErrorCode(%q)", code)
assert.Falsef(t, IsInternalOnlyErrorCode(code), "IsInternalOnlyErrorCode(%q)", code)
}
internalOnlyCodes := []string{
ErrorCodeSessionNotFound,
ErrorCodeSubjectNotFound,
ErrorCodeInternalError,
}
for _, code := range internalOnlyCodes {
assert.Falsef(t, IsPublicErrorCode(code), "IsPublicErrorCode(%q)", code)
assert.Truef(t, IsInternalOnlyErrorCode(code), "IsInternalOnlyErrorCode(%q)", code)
}
}
func TestPublicUseCaseErrorCodeSets(t *testing.T) {
t.Parallel()
assert.True(t, IsSendEmailCodePublicErrorCode(ErrorCodeInvalidRequest))
assert.True(t, IsSendEmailCodePublicErrorCode(ErrorCodeServiceUnavailable))
assert.False(t, IsSendEmailCodePublicErrorCode(ErrorCodeBlockedByPolicy))
assert.False(t, IsSendEmailCodePublicErrorCode(ErrorCodeChallengeNotFound))
confirmCodes := []string{
ErrorCodeInvalidRequest,
ErrorCodeChallengeNotFound,
ErrorCodeChallengeExpired,
ErrorCodeInvalidCode,
ErrorCodeInvalidClientPublicKey,
ErrorCodeBlockedByPolicy,
ErrorCodeSessionLimitExceeded,
ErrorCodeServiceUnavailable,
}
for _, code := range confirmCodes {
assert.Truef(t, IsConfirmEmailCodePublicErrorCode(code), "IsConfirmEmailCodePublicErrorCode(%q)", code)
}
assert.False(t, IsConfirmEmailCodePublicErrorCode(ErrorCodeInternalError))
assert.False(t, IsConfirmEmailCodePublicErrorCode(ErrorCodeSessionNotFound))
}
func TestPublicHTTPStatusCode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code string
want int
}{
{name: "invalid request", code: ErrorCodeInvalidRequest, want: http.StatusBadRequest},
{name: "invalid client public key", code: ErrorCodeInvalidClientPublicKey, want: http.StatusBadRequest},
{name: "invalid code", code: ErrorCodeInvalidCode, want: http.StatusBadRequest},
{name: "challenge not found", code: ErrorCodeChallengeNotFound, want: http.StatusNotFound},
{name: "challenge expired", code: ErrorCodeChallengeExpired, want: http.StatusGone},
{name: "blocked by policy", code: ErrorCodeBlockedByPolicy, want: http.StatusForbidden},
{name: "session limit exceeded", code: ErrorCodeSessionLimitExceeded, want: http.StatusConflict},
{name: "service unavailable", code: ErrorCodeServiceUnavailable, want: http.StatusServiceUnavailable},
{name: "internal error normalized", code: ErrorCodeInternalError, want: http.StatusServiceUnavailable},
{name: "unknown normalized", code: "unknown", want: http.StatusServiceUnavailable},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, PublicHTTPStatusCode(tt.code))
})
}
}
func TestInternalHTTPStatusCode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code string
want int
}{
{name: "invalid request", code: ErrorCodeInvalidRequest, want: http.StatusBadRequest},
{name: "session not found", code: ErrorCodeSessionNotFound, want: http.StatusNotFound},
{name: "subject not found", code: ErrorCodeSubjectNotFound, want: http.StatusNotFound},
{name: "service unavailable", code: ErrorCodeServiceUnavailable, want: http.StatusServiceUnavailable},
{name: "internal error", code: ErrorCodeInternalError, want: http.StatusInternalServerError},
{name: "unknown normalized", code: "unknown", want: http.StatusInternalServerError},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, InternalHTTPStatusCode(tt.code))
})
}
}
func TestProjectPublicError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want PublicErrorProjection
}{
{
name: "invalid request keeps detailed message",
err: InvalidRequest("email must be a single valid email address"),
want: PublicErrorProjection{
StatusCode: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Message: "email must be a single valid email address",
},
},
{
name: "invalid code keeps canonical message",
err: NewServiceError(ErrorCodeInvalidCode, "custom detail should not leak", nil),
want: PublicErrorProjection{
StatusCode: http.StatusBadRequest,
Code: ErrorCodeInvalidCode,
Message: "confirmation code is invalid",
},
},
{
name: "service unavailable keeps generic message",
err: NewServiceError(ErrorCodeServiceUnavailable, "dependency timeout", errors.New("dependency timeout")),
want: PublicErrorProjection{
StatusCode: http.StatusServiceUnavailable,
Code: ErrorCodeServiceUnavailable,
Message: "service is unavailable",
},
},
{
name: "internal error is hidden",
err: InternalError(errors.New("broken invariant")),
want: PublicErrorProjection{
StatusCode: http.StatusServiceUnavailable,
Code: ErrorCodeServiceUnavailable,
Message: "service is unavailable",
},
},
{
name: "internal only session not found is hidden",
err: SessionNotFound(),
want: PublicErrorProjection{
StatusCode: http.StatusServiceUnavailable,
Code: ErrorCodeServiceUnavailable,
Message: "service is unavailable",
},
},
{
name: "non service error is hidden",
err: errors.New("boom"),
want: PublicErrorProjection{
StatusCode: http.StatusServiceUnavailable,
Code: ErrorCodeServiceUnavailable,
Message: "service is unavailable",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, ProjectPublicError(tt.err))
})
}
}
func TestProjectInternalError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want InternalErrorProjection
}{
{
name: "invalid request keeps detailed message",
err: InvalidRequest("reason_code must not be empty"),
want: InternalErrorProjection{
StatusCode: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Message: "reason_code must not be empty",
},
},
{
name: "session not found keeps canonical message",
err: NewServiceError(ErrorCodeSessionNotFound, "custom detail should not leak", nil),
want: InternalErrorProjection{
StatusCode: http.StatusNotFound,
Code: ErrorCodeSessionNotFound,
Message: "session not found",
},
},
{
name: "subject not found keeps canonical message",
err: SubjectNotFound(),
want: InternalErrorProjection{
StatusCode: http.StatusNotFound,
Code: ErrorCodeSubjectNotFound,
Message: "subject not found",
},
},
{
name: "service unavailable keeps generic message",
err: NewServiceError(ErrorCodeServiceUnavailable, "redis timeout", errors.New("redis timeout")),
want: InternalErrorProjection{
StatusCode: http.StatusServiceUnavailable,
Code: ErrorCodeServiceUnavailable,
Message: "service is unavailable",
},
},
{
name: "internal error uses internal server error message",
err: InternalError(errors.New("broken invariant")),
want: InternalErrorProjection{
StatusCode: http.StatusInternalServerError,
Code: ErrorCodeInternalError,
Message: "internal server error",
},
},
{
name: "unexpected error is hidden",
err: errors.New("boom"),
want: InternalErrorProjection{
StatusCode: http.StatusInternalServerError,
Code: ErrorCodeInternalError,
Message: "internal server error",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, ProjectInternalError(tt.err))
})
}
}
func revokedSessionFixture() devicesession.Session {
key, err := common.NewClientPublicKey(make([]byte, 32))
if err != nil {
panic(err)
}
revokedAt := time.Unix(20, 0).UTC()
return devicesession.Session{
ID: common.DeviceSessionID("device-session-1"),
UserID: common.UserID("user-1"),
ClientPublicKey: key,
Status: devicesession.StatusRevoked,
CreatedAt: time.Unix(10, 0).UTC(),
Revocation: &devicesession.Revocation{
At: revokedAt,
ReasonCode: devicesession.RevokeReasonLogoutAll,
ActorType: common.RevokeActorType("system"),
ActorID: "actor-1",
},
}
}