// 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 // TimeZone is the client-selected IANA time zone name that should be // forwarded as create-only registration context when the user does not yet // exist. TimeZone 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 } timeZone, err := shared.ParseTimeZone(input.TimeZone) 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, ports.EnsureUserInput{ Email: current.Email, RegistrationContext: &ports.RegistrationContext{ PreferredLanguage: shared.ResolvePreferredLanguage(current.PreferredLanguage), TimeZone: timeZone, }, }) 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) }