// Package steam handles "Sign in through Steam" (OpenID 2.0) and SteamID64 validation. package steam import ( "context" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "sync" "time" ) const ( opEndpoint = "https://steamcommunity.com/openid/login" openIDNS = "http://specs.openid.net/auth/2.0" identifierSel = "http://specs.openid.net/auth/2.0/identifier_select" claimedPrefix = "https://steamcommunity.com/openid/id/" nonceMaxAge = 5 * time.Minute steamID64Base = 76561197960265728 steamID64Limit = steamID64Base + 1<<32 ) var steamID64Re = regexp.MustCompile(`^7656119[0-9]{10}$`) // ValidID reports whether s is an individual account's SteamID64. func ValidID(s string) bool { if !steamID64Re.MatchString(s) { return false } n, err := strconv.ParseUint(s, 10, 64) return err == nil && n > steamID64Base && n < steamID64Limit } // LoginURL is where to send the browser to sign in. returnTo must be under realm. func LoginURL(realm, returnTo string) string { q := url.Values{ "openid.ns": {openIDNS}, "openid.mode": {"checkid_setup"}, "openid.return_to": {returnTo}, "openid.realm": {realm}, "openid.identity": {identifierSel}, "openid.claimed_id": {identifierSel}, } return opEndpoint + "?" + q.Encode() } // Verify checks a callback from Steam with Steam itself and returns the signed-in SteamID64. func Verify(ctx context.Context, client *http.Client, returnTo string, q url.Values) (string, error) { if q.Get("openid.mode") != "id_res" { return "", errors.New("steam: sign-in was cancelled") } if q.Get("openid.op_endpoint") != opEndpoint { return "", errors.New("steam: wrong OpenID provider") } if q.Get("openid.return_to") != returnTo { return "", errors.New("steam: return address doesn't match") } claimed := q.Get("openid.claimed_id") if claimed != q.Get("openid.identity") || !strings.HasPrefix(claimed, claimedPrefix) { return "", errors.New("steam: unexpected identity") } id := strings.TrimPrefix(claimed, claimedPrefix) if !ValidID(id) { return "", errors.New("steam: identity isn't a SteamID64") } if err := checkNonce(q.Get("openid.response_nonce")); err != nil { return "", err } // A callback URL is a bearer credential until the nonce expires, so accept each one once. if !seen.claim(q.Get("openid.response_nonce")) { return "", errors.New("steam: this sign-in link was already used") } check := url.Values{} for k, v := range q { if strings.HasPrefix(k, "openid.") { check[k] = v } } check.Set("openid.mode", "check_authentication") req, err := http.NewRequestWithContext(ctx, http.MethodPost, opEndpoint, strings.NewReader(check.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("steam: verify: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) if err != nil { return "", fmt.Errorf("steam: verify: %w", err) } for line := range strings.SplitSeq(string(body), "\n") { if strings.TrimSpace(line) == "is_valid:true" { return id, nil } } return "", errors.New("steam: Steam didn't confirm the sign-in") } // The nonce starts with its UTC creation time, e.g. 2026-09-25T20:00:00Z0a1b2c. func checkNonce(nonce string) error { if len(nonce) < 20 { return errors.New("steam: missing nonce") } t, err := time.Parse(time.RFC3339, nonce[:20]) if err != nil { return errors.New("steam: bad nonce") } if age := time.Since(t); age > nonceMaxAge || age < -nonceMaxAge { return errors.New("steam: sign-in link expired, try again") } return nil } type nonceSet struct { mu sync.Mutex used map[string]time.Time } var seen = &nonceSet{used: map[string]time.Time{}} func (n *nonceSet) claim(nonce string) bool { n.mu.Lock() defer n.mu.Unlock() now := time.Now() for k, t := range n.used { if now.Sub(t) > 2*nonceMaxAge { delete(n.used, k) } } if _, ok := n.used[nonce]; ok { return false } n.used[nonce] = now return true }