package authenticator import ( "encoding/json" "net/url" "regexp" "sort" "strings" ) // PollerAction mirrors the C# WinAuthenticator.SteamClient.PollerAction // enum. The integer values are persisted in SteamSession JSON, so they // MUST NOT be renumbered. type PollerAction int const ( PollerActionNone PollerAction = 0 PollerActionNotify PollerAction = 1 PollerActionAutoConfirm PollerAction = 2 PollerActionSilentAutoConfirm PollerAction = 3 ) // ConfirmationPoller is the background-poll configuration persisted // inside a SteamSession. A Duration of 0 means the poller is disabled // and the whole object serializes as the literal "null". type ConfirmationPoller struct { Duration int `json:"duration"` Action PollerAction `json:"action"` Ids []string `json:"ids,omitempty"` } // ToJSON returns the on-disk representation. Matches the C# // ConfirmationPoller.ToString output exactly so old WinAuth session // blobs round-trip. func (p *ConfirmationPoller) ToJSON() string { if p == nil || p.Duration == 0 { return "null" } b, _ := json.Marshal(p) return string(b) } // ParseConfirmationPoller restores a poller from its JSON form. Returns // nil for an empty / "null" / zero-duration payload, matching the C# // FromJSON contract. func ParseConfirmationPoller(s string) *ConfirmationPoller { s = strings.TrimSpace(s) if s == "" || s == "null" { return nil } var p ConfirmationPoller if err := json.Unmarshal([]byte(s), &p); err != nil { return nil } if p.Duration == 0 { return nil } return &p } // Confirmation is a single in-flight trade/market confirmation as // returned by /mobileconf/conf. Runtime-only; not persisted. type Confirmation struct { Id string Key string Offline bool IsNew bool Image string Details string Traded string When string } // SteamSession is the persistent half of a Steam mobile authenticator's // runtime state: the Steam ID, OAuth token, cookie jar, and the poller // config. UmqId / MessageId are intentionally NOT persisted (matches the // C# implementation, which comments them out of ToString). // // SECURITY: this struct holds OAuth tokens and login cookies. Callers // must never log it, embed it in error messages, or write it anywhere // other than the encrypted secretdata blob. type SteamSession struct { SteamId string OAuthToken string Cookies map[string]string UmqId string // runtime only, not serialized MessageId int // runtime only, not serialized Confirmations *ConfirmationPoller } // NewSteamSession returns an empty session. func NewSteamSession() *SteamSession { return &SteamSession{Cookies: map[string]string{}} } // ParseSteamSession decodes the JSON form written by ToJSON / by the // old C# client. An empty or invalid input yields an empty session // rather than an error, matching the C# constructor's behavior. func ParseSteamSession(s string) *SteamSession { sess := NewSteamSession() s = strings.TrimSpace(s) if s == "" { return sess } var raw struct { SteamId string `json:"steamid"` Cookies string `json:"cookies"` OAuthToken string `json:"oauthtoken"` Confs json.RawMessage `json:"confs"` } if err := json.Unmarshal([]byte(s), &raw); err != nil { return sess } sess.SteamId = raw.SteamId sess.OAuthToken = raw.OAuthToken sess.Cookies = parseCookieHeader(raw.Cookies) if len(raw.Confs) > 0 && string(raw.Confs) != "null" { sess.Confirmations = ParseConfirmationPoller(string(raw.Confs)) } return sess } // ToJSON serializes the session in the exact format the C# WinAuth // client writes — cookies as a single "name=value; name=value" header // string, confs as either "null" or a poller object, and no umqid / // messageid keys. func (s *SteamSession) ToJSON() string { type out struct { SteamId string `json:"steamid"` Cookies string `json:"cookies"` OAuthToken string `json:"oauthtoken"` Confs json.RawMessage `json:"confs"` } o := out{ SteamId: s.SteamId, Cookies: formatCookieHeader(s.Cookies), OAuthToken: s.OAuthToken, Confs: json.RawMessage(s.Confirmations.ToJSON()), } b, _ := json.Marshal(o) return string(b) } // SessionFromEnrollState builds a SteamSession from a completed // enrollment, snapshotting the community-domain cookies out of the // EnrollState's internal jar. Used by the UI after Enroll returns // Success so the persisted SessionData carries a usable cookie set. func SessionFromEnrollState(state *EnrollState) *SteamSession { sess := NewSteamSession() if state == nil { return sess } sess.SteamId = state.SteamID sess.OAuthToken = state.OAuthToken if state.jar != nil { u, _ := url.Parse(steamCommunityBase + "/") for _, c := range state.jar.Cookies(u) { sess.Cookies[c.Name] = c.Value } } return sess } // Clear wipes mutable session state, keeping the SteamId. Matches the // C# SteamSession.Clear behavior used by Logout. func (s *SteamSession) Clear() { s.OAuthToken = "" s.UmqId = "" s.MessageId = 0 s.Cookies = map[string]string{} s.Confirmations = nil } var cookieHeaderRe = regexp.MustCompile(`([^=;]+)=([^;]*);?`) // parseCookieHeader splits "name1=value1; name2=value2" into a map. // Whitespace around names/values is trimmed. Empty input returns an // empty (non-nil) map so callers can immediately .Set into it. func parseCookieHeader(h string) map[string]string { out := map[string]string{} for _, m := range cookieHeaderRe.FindAllStringSubmatch(h, -1) { name := strings.TrimSpace(m[1]) if name == "" { continue } out[name] = strings.TrimSpace(m[2]) } return out } // formatCookieHeader is the inverse. Keys are emitted in sorted order // so the JSON output is stable across saves (helps diffs and tests). func formatCookieHeader(cookies map[string]string) string { if len(cookies) == 0 { return "" } names := make([]string, 0, len(cookies)) for n := range cookies { names = append(names, n) } sort.Strings(names) var b strings.Builder for i, n := range names { if i > 0 { b.WriteString("; ") } b.WriteString(n) b.WriteByte('=') b.WriteString(cookies[n]) } return b.String() }