package domain import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "sync" "time" ) type SteamTicket struct { TicketID string SteamID string AppID uint64 ExpiresAt time.Time } type VerifiedIdentity struct { PlayerID string SteamID string } type TicketVerifier struct { mu sync.Mutex expectedApp uint64 consumed map[string]time.Time } type AuthAttemptState string const ( AuthPending AuthAttemptState = "PENDING" AuthAccepted AuthAttemptState = "ACCEPTED" AuthRejected AuthAttemptState = "REJECTED" AuthCancelled AuthAttemptState = "CANCELLED" ) type AuthAttempt struct { AttemptID string TicketID string State AuthAttemptState Identity VerifiedIdentity ExpiresAt time.Time } // AuthCoordinator models the asynchronous BeginAuthSession lifecycle. The // external Steam adapter calls Complete only after Steam confirms the ticket; // clients never supply the verified identity or transition state themselves. type AuthCoordinator struct { mu sync.Mutex attempts map[string]AuthAttempt } var ( ErrAuthAttemptRejected = fmt.Errorf("auth attempt rejected") ErrAuthAttemptPending = fmt.Errorf("auth attempt pending") ) func NewAuthCoordinator() *AuthCoordinator { return &AuthCoordinator{attempts: make(map[string]AuthAttempt)} } func (c *AuthCoordinator) Begin(attemptID string, ticket SteamTicket, now time.Time) error { if c == nil || attemptID == "" || ticket.TicketID == "" || ticket.ExpiresAt.IsZero() || !now.Before(ticket.ExpiresAt) { return ErrAuthAttemptRejected } c.mu.Lock() defer c.mu.Unlock() if _, exists := c.attempts[attemptID]; exists { return ErrAuthAttemptRejected } c.attempts[attemptID] = AuthAttempt{AttemptID: attemptID, TicketID: ticket.TicketID, State: AuthPending, ExpiresAt: ticket.ExpiresAt} return nil } func (c *AuthCoordinator) Complete(attemptID string, ticket SteamTicket, verifier *TicketVerifier, resolve func(string) (string, bool), now time.Time) (VerifiedIdentity, error) { if c == nil || verifier == nil { return VerifiedIdentity{}, ErrAuthAttemptRejected } c.mu.Lock() attempt, ok := c.attempts[attemptID] if !ok || attempt.State != AuthPending || attempt.TicketID != ticket.TicketID || !now.Before(attempt.ExpiresAt) { c.mu.Unlock() return VerifiedIdentity{}, ErrAuthAttemptRejected } identity, err := verifier.Verify(ticket, resolve, now) if err != nil { attempt.State = AuthRejected c.attempts[attemptID] = attempt c.mu.Unlock() return VerifiedIdentity{}, ErrAuthAttemptRejected } attempt.State = AuthAccepted attempt.Identity = identity c.attempts[attemptID] = attempt c.mu.Unlock() return identity, nil } func (c *AuthCoordinator) Cancel(attemptID string) error { if c == nil || attemptID == "" { return ErrAuthAttemptRejected } c.mu.Lock() defer c.mu.Unlock() attempt, ok := c.attempts[attemptID] if !ok || attempt.State != AuthPending { return ErrAuthAttemptRejected } attempt.State = AuthCancelled c.attempts[attemptID] = attempt return nil } func (c *AuthCoordinator) Get(attemptID string) (AuthAttempt, error) { if c == nil { return AuthAttempt{}, ErrAuthAttemptRejected } c.mu.Lock() defer c.mu.Unlock() attempt, ok := c.attempts[attemptID] if !ok { return AuthAttempt{}, ErrAuthAttemptRejected } if attempt.State != AuthAccepted { return attempt, ErrAuthAttemptPending } return attempt, nil } // Expire closes abandoned pending attempts. The caller should run this from // the auth maintenance loop; accepted and already terminal attempts are left // unchanged so audit/reconciliation can still inspect their outcome. func (c *AuthCoordinator) Expire(now time.Time) []AuthAttempt { if c == nil || now.IsZero() { return nil } c.mu.Lock() defer c.mu.Unlock() var expired []AuthAttempt for id, attempt := range c.attempts { if attempt.State == AuthPending && !now.Before(attempt.ExpiresAt) { attempt.State = AuthRejected c.attempts[id] = attempt expired = append(expired, attempt) } } return expired } var ( ErrTicketRejected = fmt.Errorf("steam ticket rejected") ErrSessionRejected = fmt.Errorf("session rejected") ) func NewTicketVerifier(expectedApp uint64) (*TicketVerifier, error) { if expectedApp == 0 { return nil, ErrTicketRejected } return &TicketVerifier{expectedApp: expectedApp, consumed: make(map[string]time.Time)}, nil } // Verify consumes a backend-validated ticket exactly once. In production the // adapter must obtain the Steam Web API response before calling this policy; // callers never get to choose the verified SteamID independently. func (v *TicketVerifier) Verify(ticket SteamTicket, resolve func(string) (string, bool), now time.Time) (VerifiedIdentity, error) { v.mu.Lock() defer v.mu.Unlock() if ticket.TicketID == "" || ticket.SteamID == "" || resolve == nil || ticket.AppID != v.expectedApp || ticket.ExpiresAt.IsZero() || !now.Before(ticket.ExpiresAt) { return VerifiedIdentity{}, ErrTicketRejected } if _, used := v.consumed[ticket.TicketID]; used { return VerifiedIdentity{}, ErrTicketRejected } playerID, ok := resolve(ticket.SteamID) if !ok || playerID == "" { return VerifiedIdentity{}, ErrTicketRejected } v.consumed[ticket.TicketID] = now return VerifiedIdentity{PlayerID: playerID, SteamID: ticket.SteamID}, nil } type Session struct { SessionID string PlayerID string ExpiresAt time.Time RevokedAt time.Time } type SessionStore struct { mu sync.Mutex sessions map[string]Session digests map[string]string } func NewSessionStore() *SessionStore { return &SessionStore{sessions: make(map[string]Session), digests: make(map[string]string)} } func (s *SessionStore) Issue(playerID string, lifetime time.Duration, now time.Time) (Session, string, error) { if playerID == "" || lifetime <= 0 { return Session{}, "", ErrSessionRejected } token, err := randomToken() if err != nil { return Session{}, "", err } sessionID, err := randomToken() if err != nil { return Session{}, "", err } session := Session{SessionID: sessionID, PlayerID: playerID, ExpiresAt: now.Add(lifetime)} s.mu.Lock() s.sessions[sessionID] = session s.digests[sessionID] = digestToken(token) s.mu.Unlock() return session, token, nil } func (s *SessionStore) Authenticate(sessionID, token string, now time.Time) (Session, error) { s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[sessionID] if !ok || session.RevokedAt != (time.Time{}) || !now.Before(session.ExpiresAt) || !constantTimeEqual(s.digests[sessionID], digestToken(token)) { return Session{}, ErrSessionRejected } return session, nil } func (s *SessionStore) Revoke(sessionID string, now time.Time) error { s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[sessionID] if !ok { return ErrSessionRejected } if session.RevokedAt.IsZero() { session.RevokedAt = now s.sessions[sessionID] = session } return nil } func randomToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } func digestToken(token string) string { digest := sha256.Sum256([]byte(token)) return hex.EncodeToString(digest[:]) } func constantTimeEqual(a, b string) bool { if len(a) != len(b) { return false } return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 }