feat: authsession service
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
// Package confirmemailcode implements the public confirm-email-code use case.
|
||||
package confirmemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/sessionlimit"
|
||||
"galaxy/authsession/internal/ports"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
"galaxy/authsession/internal/telemetry"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
|
||||
revokeActorTypeService common.RevokeActorType = "service"
|
||||
revokeActorIDService = "confirmemailcode"
|
||||
)
|
||||
|
||||
// Input describes one public confirm-email-code request.
|
||||
type Input struct {
|
||||
// ChallengeID identifies the challenge that should be confirmed.
|
||||
ChallengeID string
|
||||
|
||||
// Code is the cleartext confirmation code submitted by the caller.
|
||||
Code string
|
||||
|
||||
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key that
|
||||
// should be registered for the created device session.
|
||||
ClientPublicKey string
|
||||
}
|
||||
|
||||
// Result describes one public confirm-email-code response.
|
||||
type Result struct {
|
||||
// DeviceSessionID is the stable identifier of the created or idempotently
|
||||
// recovered device session.
|
||||
DeviceSessionID string
|
||||
}
|
||||
|
||||
// Service executes the public confirm-email-code use case.
|
||||
type Service struct {
|
||||
challengeStore ports.ChallengeStore
|
||||
sessionStore ports.SessionStore
|
||||
userDirectory ports.UserDirectory
|
||||
configProvider ports.ConfigProvider
|
||||
publisher ports.GatewaySessionProjectionPublisher
|
||||
idGenerator ports.IDGenerator
|
||||
codeHasher ports.CodeHasher
|
||||
clock ports.Clock
|
||||
logger *zap.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// New returns a confirm-email-code service wired to the required ports.
|
||||
func New(
|
||||
challengeStore ports.ChallengeStore,
|
||||
sessionStore ports.SessionStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
configProvider ports.ConfigProvider,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
clock ports.Clock,
|
||||
) (*Service, error) {
|
||||
return NewWithTelemetry(
|
||||
challengeStore,
|
||||
sessionStore,
|
||||
userDirectory,
|
||||
configProvider,
|
||||
publisher,
|
||||
idGenerator,
|
||||
codeHasher,
|
||||
clock,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// NewWithTelemetry returns a confirm-email-code service wired to the required
|
||||
// ports plus the optional Stage-17 telemetry runtime.
|
||||
func NewWithTelemetry(
|
||||
challengeStore ports.ChallengeStore,
|
||||
sessionStore ports.SessionStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
configProvider ports.ConfigProvider,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
clock ports.Clock,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
return NewWithObservability(
|
||||
challengeStore,
|
||||
sessionStore,
|
||||
userDirectory,
|
||||
configProvider,
|
||||
publisher,
|
||||
idGenerator,
|
||||
codeHasher,
|
||||
clock,
|
||||
nil,
|
||||
telemetryRuntime,
|
||||
)
|
||||
}
|
||||
|
||||
// NewWithObservability returns a confirm-email-code service wired to the
|
||||
// required ports plus optional structured logging and telemetry dependencies.
|
||||
func NewWithObservability(
|
||||
challengeStore ports.ChallengeStore,
|
||||
sessionStore ports.SessionStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
configProvider ports.ConfigProvider,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
clock ports.Clock,
|
||||
logger *zap.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
switch {
|
||||
case challengeStore == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: challenge store must not be nil")
|
||||
case sessionStore == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: session store must not be nil")
|
||||
case userDirectory == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: user directory must not be nil")
|
||||
case configProvider == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: config provider must not be nil")
|
||||
case publisher == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: projection publisher must not be nil")
|
||||
case idGenerator == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: id generator must not be nil")
|
||||
case codeHasher == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: code hasher must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: clock must not be nil")
|
||||
default:
|
||||
return &Service{
|
||||
challengeStore: challengeStore,
|
||||
sessionStore: sessionStore,
|
||||
userDirectory: userDirectory,
|
||||
configProvider: configProvider,
|
||||
publisher: publisher,
|
||||
idGenerator: idGenerator,
|
||||
codeHasher: codeHasher,
|
||||
clock: clock,
|
||||
logger: namedLogger(logger, "confirm_email_code"),
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates one challenge confirmation attempt, creates a device
|
||||
// session when policy allows it, and handles short-window idempotent retries.
|
||||
func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
|
||||
logFields := []zap.Field{
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
}
|
||||
defer func() {
|
||||
outcome := string(telemetry.ConfirmEmailCodeOutcomeSuccess)
|
||||
if err != nil {
|
||||
outcome = shared.CodeOf(err)
|
||||
if outcome == "" {
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
}
|
||||
}
|
||||
s.telemetry.RecordConfirmEmailCode(ctx, outcome)
|
||||
logFields = append(logFields, zap.String("outcome", outcome))
|
||||
if result.DeviceSessionID != "" {
|
||||
logFields = append(logFields, zap.String("device_session_id", result.DeviceSessionID))
|
||||
}
|
||||
shared.LogServiceOutcome(s.logger, ctx, "confirm email code completed", err, logFields...)
|
||||
}()
|
||||
|
||||
challengeID, err := shared.ParseChallengeID(input.ChallengeID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
logFields = append(logFields, zap.String("challenge_id", challengeID.String()))
|
||||
code, err := shared.ParseRequiredCode(input.Code)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
clientPublicKey, err := shared.ParseClientPublicKey(input.ClientPublicKey)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
|
||||
current, err := s.challengeStore.Get(ctx, challengeID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.ChallengeNotFound()
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
now := s.clock.Now().UTC()
|
||||
if expired, err := s.ensureChallengeNotExpired(ctx, current, now); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return Result{}, err
|
||||
} else if expired {
|
||||
return Result{}, shared.ChallengeExpired()
|
||||
}
|
||||
|
||||
switch {
|
||||
case current.Status.IsConfirmedRetryState():
|
||||
return s.handleConfirmedRetry(ctx, current, code, clientPublicKey)
|
||||
case !current.Status.AcceptsFreshConfirm():
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
|
||||
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if !match {
|
||||
if err := s.recordInvalidConfirmAttempt(ctx, current, now); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
|
||||
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, current.Email)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := ensureUserResult.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
s.telemetry.RecordUserDirectoryOutcome(ctx, "ensure_user_by_email", string(ensureUserResult.Outcome))
|
||||
if !ensureUserResult.UserID.IsZero() {
|
||||
logFields = append(logFields, zap.String("user_id", ensureUserResult.UserID.String()))
|
||||
}
|
||||
if ensureUserResult.Outcome == ports.EnsureUserOutcomeBlocked {
|
||||
if err := s.markChallengeFailed(ctx, current, now); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{}, shared.BlockedByPolicy()
|
||||
}
|
||||
|
||||
limitConfig, err := s.configProvider.LoadSessionLimit(ctx)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
decision, err := s.evaluateSessionLimit(ctx, ensureUserResult.UserID, limitConfig)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if decision.Kind == sessionlimit.KindExceeded {
|
||||
s.telemetry.RecordSessionLimitRejection(ctx)
|
||||
return Result{}, shared.SessionLimitExceeded()
|
||||
}
|
||||
|
||||
sessionRecord, err := s.createSession(ctx, ensureUserResult.UserID, clientPublicKey, now)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
next := current
|
||||
next.Status = challenge.StatusConfirmedPendingExpire
|
||||
next.ExpiresAt = now.Add(challenge.ConfirmedRetention)
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
next.Confirmation = &challenge.Confirmation{
|
||||
SessionID: sessionRecord.ID,
|
||||
ClientPublicKey: clientPublicKey,
|
||||
ConfirmedAt: now,
|
||||
}
|
||||
if err := next.Validate(); err != nil {
|
||||
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return s.handleCreateSessionCASConflict(ctx, challengeID, code, clientPublicKey, sessionRecord)
|
||||
}
|
||||
|
||||
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
// Publish the currently stored session view so a concurrent revoke/block
|
||||
// cannot overwrite source of truth with a stale active projection.
|
||||
currentSession, err := s.sessionStore.Get(ctx, sessionRecord.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: newly created session %q was not found", sessionRecord.ID))
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
if err := s.publishSession(ctx, currentSession, "confirm_email_code"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{DeviceSessionID: currentSession.ID.String()}, nil
|
||||
}
|
||||
|
||||
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: compare-and-swap retry limit exceeded"))
|
||||
}
|
||||
|
||||
func (s *Service) ensureChallengeNotExpired(ctx context.Context, current challenge.Challenge, now time.Time) (bool, error) {
|
||||
if current.IsExpiredAt(now) {
|
||||
if current.Status != challenge.StatusExpired && current.Status.CanTransitionTo(challenge.StatusExpired) {
|
||||
next := current
|
||||
next.Status = challenge.StatusExpired
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
next.Confirmation = nil
|
||||
if err := next.Validate(); err != nil {
|
||||
return true, shared.InternalError(err)
|
||||
}
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
if !errors.Is(err, ports.ErrConflict) {
|
||||
return true, shared.ServiceUnavailable(err)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleConfirmedRetry(ctx context.Context, current challenge.Challenge, code string, clientPublicKey common.ClientPublicKey) (Result, error) {
|
||||
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if !match {
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
if current.Confirmation == nil {
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed challenge is missing confirmation metadata"))
|
||||
}
|
||||
if current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
|
||||
record, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed session %q was not found", current.Confirmation.SessionID))
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
if err := s.publishSession(ctx, record, "confirm_email_code_retry"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{DeviceSessionID: record.ID.String()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordInvalidConfirmAttempt(ctx context.Context, current challenge.Challenge, now time.Time) error {
|
||||
next := current
|
||||
next.Attempts.Confirm++
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
if next.Attempts.Confirm >= challenge.MaxInvalidConfirmAttempts {
|
||||
next.Status = challenge.StatusFailed
|
||||
}
|
||||
if err := next.Validate(); err != nil {
|
||||
return shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
return err
|
||||
default:
|
||||
return shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) markChallengeFailed(ctx context.Context, current challenge.Challenge, now time.Time) error {
|
||||
next := current
|
||||
next.Status = challenge.StatusFailed
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
if err := next.Validate(); err != nil {
|
||||
return shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
return err
|
||||
default:
|
||||
return shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) evaluateSessionLimit(ctx context.Context, userID common.UserID, config ports.SessionLimitConfig) (sessionlimit.Decision, error) {
|
||||
activeSessionCount, err := s.sessionStore.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return sessionlimit.Decision{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
decision, err := shared.EvaluateSessionLimit(config, activeSessionCount)
|
||||
if err != nil {
|
||||
return sessionlimit.Decision{}, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, userID common.UserID, clientPublicKey common.ClientPublicKey, now time.Time) (devicesession.Session, error) {
|
||||
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
|
||||
deviceSessionID, err := s.idGenerator.NewDeviceSessionID()
|
||||
if err != nil {
|
||||
return devicesession.Session{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
record := devicesession.Session{
|
||||
ID: deviceSessionID,
|
||||
UserID: userID,
|
||||
ClientPublicKey: clientPublicKey,
|
||||
Status: devicesession.StatusActive,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return devicesession.Session{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.sessionStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return devicesession.Session{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
s.telemetry.RecordSessionCreated(ctx)
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
return devicesession.Session{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: session id conflict retry limit exceeded"))
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateSessionCASConflict(
|
||||
ctx context.Context,
|
||||
challengeID common.ChallengeID,
|
||||
code string,
|
||||
clientPublicKey common.ClientPublicKey,
|
||||
createdSession devicesession.Session,
|
||||
) (Result, error) {
|
||||
defer s.bestEffortRevokeSupersededSession(ctx, createdSession)
|
||||
|
||||
current, err := s.challengeStore.Get(ctx, challengeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ports.ErrNotFound) {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
if current.Status != challenge.StatusConfirmedPendingExpire || current.Confirmation == nil {
|
||||
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q changed to unexpected status %q after create", challengeID, current.Status))
|
||||
}
|
||||
|
||||
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if !match || current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
|
||||
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q was confirmed by a different payload", challengeID))
|
||||
}
|
||||
|
||||
winningSession, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: winning session %q was not found", current.Confirmation.SessionID))
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
if err := s.publishSession(ctx, winningSession, "confirm_email_code_race_winner"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{DeviceSessionID: winningSession.ID.String()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) bestEffortRevokeSupersededSession(ctx context.Context, record devicesession.Session) {
|
||||
revocation := devicesession.Revocation{
|
||||
At: s.clock.Now().UTC(),
|
||||
ReasonCode: revokeReasonConfirmRace,
|
||||
ActorType: revokeActorTypeService,
|
||||
ActorID: revokeActorIDService,
|
||||
}
|
||||
if err := revocation.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
revokeResult, err := s.sessionStore.Revoke(ctx, ports.RevokeSessionInput{
|
||||
DeviceSessionID: record.ID,
|
||||
Revocation: revocation,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session revoke failed",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", record.ID.String()),
|
||||
zap.String("reason_code", revocation.ReasonCode.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := revokeResult.Validate(); err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session revoke produced invalid result",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", record.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if revokeResult.Outcome == ports.RevokeSessionOutcomeRevoked {
|
||||
s.telemetry.RecordSessionRevocations(ctx, "confirm_email_code_race_cleanup", revocation.ReasonCode.String(), 1)
|
||||
}
|
||||
|
||||
snapshot, err := shared.ToGatewayProjectionSnapshot(revokeResult.Session)
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session snapshot mapping failed",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", revokeResult.Session.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := shared.PublishProjectionSnapshotWithTelemetry(ctx, s.publisher, snapshot, s.telemetry, "confirm_email_code_race_cleanup"); err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session publish failed",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", revokeResult.Session.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishSession(ctx context.Context, record devicesession.Session, operation string) error {
|
||||
return shared.PublishSessionProjectionWithTelemetry(ctx, s.publisher, record, s.telemetry, operation)
|
||||
}
|
||||
|
||||
func namedLogger(logger *zap.Logger, name string) *zap.Logger {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return logger.Named(name)
|
||||
}
|
||||
Reference in New Issue
Block a user