From 2dcea01f75f47ebac9f3f246a1df6a20d1a4adc0 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:30:21 +0000 Subject: [PATCH 01/10] General improvements, remove regex & add tests --- README.md | 2 +- src/gosteam.go | 165 ++++++++++++++++++---------------------- src/gosteamauth_test.go | 116 ++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 90 deletions(-) create mode 100644 src/gosteamauth_test.go diff --git a/README.md b/README.md index e3378df..5224a62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/TeddiO/GoSteamAuth)](https://goreportcard.com/report/github.com/TeddiO/GoSteamAuth) # GoSteamAuth -A small set of utility functions to quickly process Steam OpenID 2.0 logins. +A small set of utility functions to quickly process OpenID 2.0 logins for Steam. Cousin to the Python library designed to do the same thing: [pySteamSignIn](https://github.com/TeddiO/pySteamSignIn) Similar to why the Python edition was wrote there's no straightforwards Steam authentication flow for Go, so this exists to fulfil the same purpose. Another language, same idea! diff --git a/src/gosteam.go b/src/gosteam.go index 41ef85f..61d4779 100644 --- a/src/gosteam.go +++ b/src/gosteam.go @@ -1,148 +1,135 @@ package gosteamauth import ( - "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" - "regexp" + "os" + "strconv" "strings" "time" ) -var validCredRx *regexp.Regexp -var steamRx *regexp.Regexp -var provider string = "https://steamcommunity.com/openid/login" - -func init() { - validCredRx = regexp.MustCompile("is_valid:true") - steamRx = regexp.MustCompile(`https://steamcommunity\.com/openid/id/(\d+)`) -} - -// StringMapToString is a utility function that aims to efficiently build a query string -// with a tiny footprint. theMap is expected to be a map of key strings with a value type of a string. -func StringMapToString(theMap map[string]string) string { - - mapLength := len(theMap) - strSeparator := "&" - i := 1 - - var builder strings.Builder - builder.Grow(66) // We already roughly know our base size. +const ( + provider = "https://steamcommunity.com/openid/login" + steamIDPrefix = "https://steamcommunity.com/openid/id/" + openIDNS = "http://specs.openid.net/auth/2.0" + identifierSel = "http://specs.openid.net/auth/2.0/identifier_select" +) - for k, v := range theMap { +var HTTPTimeout = 10 * time.Second - if i == mapLength { - strSeparator = "" +func init() { + if v := os.Getenv("STEAM_AUTH_HTTP_TIMEOUT"); v != "" { + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + HTTPTimeout = time.Duration(secs) * time.Second } - - i++ - - fmt.Fprintf(&builder, "%s=%s%s", k, url.QueryEscape(v), strSeparator) } +} - return builder.String() +type httpDoer interface { + Do(*http.Request) (*http.Response, error) } -// BuildQueryString is more or less building up a query string to be passed when reaching -// Steam's openid 2.0 provider (or technically any openid 2.0 provider). We only care -// that the Scheme is either http or https. Any other validation should really be done -// before using this function. -func BuildQueryString(responsePath string) string { +var httpClient httpDoer = &http.Client{ + Timeout: HTTPTimeout, +} - if responsePath[0:4] != "http" { +// BuildQueryString uses a url.Values map to correctly structure the paramters to send to Steam +// Strictly speaking we only care that the Scheme is either http or https. +// Any other validation should really be done before using this function. +func BuildQueryString(responsePath string) string { + if !strings.HasPrefix(responsePath, "http") { log.Fatal("http was not found in the responsePath!") } - if responsePath[4:5] != "s" { + if !strings.HasPrefix(responsePath, "https") { log.Println("https isn't being used! Is this intentional?") } - // Even though the below URLs no longer function, the oauth 2.0 process formally calls - // for them and Valve actively checks for their presence. - openIdParameters := map[string]string{ - "openid.mode": "checkid_setup", - "openid.return_to": responsePath, - "openid.realm": responsePath, - "openid.ns": "http://specs.openid.net/auth/2.0", - "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", - } + values := url.Values{} + values.Set("openid.mode", "checkid_setup") + values.Set("openid.return_to", responsePath) + values.Set("openid.realm", responsePath) + values.Set("openid.ns", openIDNS) + values.Set("openid.identity", identifierSel) + values.Set("openid.claimed_id", identifierSel) - return StringMapToString(openIdParameters) + return values.Encode() } // ValidateResponse is the real chunk of work that goes on. When the client comes back to our site -// we need to take what they give us in the query string and hit up the openid 2.0 provider directly -// to verify what we're being provided with is well, valid. -// If we end up with "is_valid:true" response from the Steam then isValid will always return true. -// In any other situation (credential failure, error etc) isValid will always return false. -// Takes a map[string]string to be agnostic among various http clients that exist out there +// we need to take what they give us in the query string and hit up Steam directly +// to verify what we're being provided with is valid. func ValidateResponse(results map[string]string) (steamID64 string, isValid bool, err error) { - - openIdValidation := map[string]string{ - "openid.assoc_handle": results["openid.assoc_handle"], - "openid.signed": results["openid.signed"], - "openid.sig": results["openid.sig"], - "openid.ns": results["openid.ns"], - "openid.mode": "check_authentication", - } + values := url.Values{} + values.Set("openid.mode", "check_authentication") + values.Set("openid.assoc_handle", results["openid.assoc_handle"]) + values.Set("openid.signed", results["openid.signed"]) + values.Set("openid.sig", results["openid.sig"]) + values.Set("openid.ns", results["openid.ns"]) signedParams := strings.Split(results["openid.signed"], ",") - - for _, value := range signedParams { - item := fmt.Sprintf("openid.%s", value) - if _, exists := openIdValidation[item]; !exists { - openIdValidation[item] = results[item] - } + for _, key := range signedParams { + fullKey := "openid." + key + values.Set(fullKey, results[fullKey]) } - urlObj, err := url.Parse(provider) + reqURL, err := url.Parse(provider) if err != nil { return "", false, err } + reqURL.RawQuery = values.Encode() - urlObj.RawQuery = StringMapToString(openIdValidation) - - httpClient := &http.Client{ - Timeout: 10 * time.Second, + req, err := http.NewRequest("GET", reqURL.String(), nil) + if err != nil { + return "", false, err } - validationResp, err := httpClient.Get(urlObj.String()) + validationResp, err := httpClient.Do(req) if err != nil { - log.Printf("Failed to validate %s. Error: %s ", results["openid.claimed_id"], err) + log.Printf("Failed to validate %s. Error: %s", results["openid.claimed_id"], err) return "", false, err } - defer validationResp.Body.Close() - returnedBytes, err := ioutil.ReadAll(validationResp.Body) + + returnedBytes, err := io.ReadAll(validationResp.Body) if err != nil { return "", false, err } - if validCredRx.MatchString(string(returnedBytes)) == true { - return steamRx.FindStringSubmatch(results["openid.claimed_id"])[1], true, nil + if !strings.Contains(string(returnedBytes), "is_valid:true") { + return "", false, nil + } + + claimedID := results["openid.claimed_id"] + identity := results["openid.identity"] + + if claimedID == "" || identity == "" || claimedID != identity { + return "", false, nil } - return "", false, nil + if !strings.HasPrefix(claimedID, steamIDPrefix) { + return "", false, nil + } + + steamID := strings.TrimPrefix(claimedID, steamIDPrefix) + return steamID, true, nil } // RedirectClient is a helper function that does the redirection to Steam with the -// correct properties on our behalf. Pass it the appropriate http request / response objects -// alongside the queryString and it'll get the user to the right place -func RedirectClient(response http.ResponseWriter, request *http.Request, queryString string) { - returnUrlObject, _ := url.Parse(provider) - returnUrlObject.RawQuery = queryString - - response.Header().Add("Content-Type", "application/x-www-form-urlencoded") - http.Redirect(response, request, returnUrlObject.String(), 303) - return +// correct properties on our behalf. +func RedirectClient(w http.ResponseWriter, r *http.Request, queryString string) { + u, _ := url.Parse(provider) + u.RawQuery = queryString + + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + http.Redirect(w, r, u.String(), http.StatusSeeOther) } // ValuesToMap is a boilerplate function designed to convert the results of a url.Values // in to a readable map[string]string for ValidateResponse. -// We don't get duplicate query keys supplied normally - but we'll always take the first one anyways func ValuesToMap(fakeMap url.Values) map[string]string { returnMap := map[string]string{} for k, v := range fakeMap { diff --git a/src/gosteamauth_test.go b/src/gosteamauth_test.go new file mode 100644 index 0000000..f3f80a8 --- /dev/null +++ b/src/gosteamauth_test.go @@ -0,0 +1,116 @@ +package gosteamauth + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type fakeHTTPClient struct { + body string + err error +} + +func (f *fakeHTTPClient) Do(req *http.Request) (*http.Response, error) { + if f.err != nil { + return nil, f.err + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(f.body)), + }, nil +} + +func validResults() map[string]string { + steamID := "12345678901234567" + claimed := steamIDPrefix + steamID + + return map[string]string{ + "openid.assoc_handle": "assoc", + "openid.signed": "claimed_id", + "openid.sig": "sig", + "openid.ns": openIDNS, + "openid.claimed_id": claimed, + "openid.identity": claimed, + } +} + +func TestValidateResponse_Valid(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{body: "is_valid:true\n"} + defer func() { httpClient = oldClient }() + + steamID, ok, err := ValidateResponse(validResults()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatalf("expected valid response") + } + if steamID != "12345678901234567" { + t.Fatalf("unexpected steamID: %s", steamID) + } +} + +func TestValidateResponse_Invalid(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{body: "is_valid:false\n"} + defer func() { httpClient = oldClient }() + + _, ok, _ := ValidateResponse(validResults()) + if ok { + t.Fatalf("expected invalid response") + } +} + +func TestValidateResponse_NetworkErrorFailsClosed(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{err: errors.New("network error")} + defer func() { httpClient = oldClient }() + + _, ok, err := ValidateResponse(validResults()) + if ok { + t.Fatalf("expected network error to fail closed") + } + if err == nil { + t.Fatalf("expected error to be returned on network failure") + } +} + +func TestValidateResponse_TimeoutFailsClosed(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{err: context.DeadlineExceeded} + defer func() { httpClient = oldClient }() + + _, ok, err := ValidateResponse(validResults()) + if ok { + t.Fatalf("expected timeout to fail closed") + } + if err == nil { + t.Fatalf("expected timeout error to be returned") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected context deadline exceeded, got: %v", err) + } +} + +func TestValidateResponse_IdentityMismatchFailsClosed(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{body: "is_valid:true\n"} + defer func() { httpClient = oldClient }() + + results := validResults() + results["openid.identity"] = steamIDPrefix + "00000000000000000" + + _, ok, err := ValidateResponse(results) + if ok { + t.Fatalf("expected identity mismatch to fail closed") + } + if err != nil { + t.Fatalf("did not expect error on identity mismatch, got: %v", err) + } +} From 7a418e7a9b30208a9b5c7c7ed0962d2f0e9a10c9 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:32:02 +0000 Subject: [PATCH 02/10] bump timeout to 15 --- src/gosteam.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gosteam.go b/src/gosteam.go index 61d4779..630a5d5 100644 --- a/src/gosteam.go +++ b/src/gosteam.go @@ -18,7 +18,7 @@ const ( identifierSel = "http://specs.openid.net/auth/2.0/identifier_select" ) -var HTTPTimeout = 10 * time.Second +var HTTPTimeout = 15 * time.Second func init() { if v := os.Getenv("STEAM_AUTH_HTTP_TIMEOUT"); v != "" { From c61c6965e99db20231018a1c7bce8eaddfa68f34 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:39:52 +0000 Subject: [PATCH 03/10] Include errors to be more verbose about where the failure is happening --- src/gosteam.go | 13 +++++++++---- src/gosteamauth_test.go | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/gosteam.go b/src/gosteam.go index 630a5d5..26dea9a 100644 --- a/src/gosteam.go +++ b/src/gosteam.go @@ -1,6 +1,7 @@ package gosteamauth import ( + "errors" "io" "log" "net/http" @@ -100,18 +101,22 @@ func ValidateResponse(results map[string]string) (steamID64 string, isValid bool } if !strings.Contains(string(returnedBytes), "is_valid:true") { - return "", false, nil + return "", false, errors.New("openid validation failed: steam returned is_valid:false") } claimedID := results["openid.claimed_id"] identity := results["openid.identity"] - if claimedID == "" || identity == "" || claimedID != identity { - return "", false, nil + if claimedID == "" || identity == "" { + return "", false, errors.New("openid validation failed: missing claimed_id or identity") + } + + if claimedID != identity { + return "", false, errors.New("openid validation failed: claimed_id and identity mismatch") } if !strings.HasPrefix(claimedID, steamIDPrefix) { - return "", false, nil + return "", false, errors.New("openid validation failed: identifier is not a steam openid") } steamID := strings.TrimPrefix(claimedID, steamIDPrefix) diff --git a/src/gosteamauth_test.go b/src/gosteamauth_test.go index f3f80a8..100dc47 100644 --- a/src/gosteamauth_test.go +++ b/src/gosteamauth_test.go @@ -110,7 +110,7 @@ func TestValidateResponse_IdentityMismatchFailsClosed(t *testing.T) { if ok { t.Fatalf("expected identity mismatch to fail closed") } - if err != nil { - t.Fatalf("did not expect error on identity mismatch, got: %v", err) + if err == nil { + t.Fatalf("expected error on identity mismatch") } } From 0a7ecc42cf02f6a1003130f1c542e1619cf85a37 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:43:14 +0000 Subject: [PATCH 04/10] Add dependabot config --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..adee0ed --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" \ No newline at end of file From 1141d2d91b7af78c38bc6d033cd8a590e91a1c36 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:47:30 +0000 Subject: [PATCH 05/10] Add tests --- .github/workflows/tests.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0426b97 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Go Tests + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + go-version: + - '1.16' + - '1.17' + - '1.18' + - '1.19' + - '1.20' + - '1.21' + - '1.22' + - '1.23' + - '1.24' + - '1.25' + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + + - name: Run tests + run: go test ./... From e987449bb7e59cb1c6930271745fe28c590b8bf8 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:48:29 +0000 Subject: [PATCH 06/10] be verbose about tests --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0426b97..9d467ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,4 +32,4 @@ jobs: go-version: ${{ matrix.go-version }} - name: Run tests - run: go test ./... + run: go test -v ./... From 83a73bea68e7ff091990b7cb6ae8a3b4fa42af7c Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:48:51 +0000 Subject: [PATCH 07/10] fail fast --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9d467ac..4ac50b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: - fail-fast: false + fail-fast: true matrix: go-version: - '1.16' From ca326aa0494ceea18f0ea5064bd6f01a1b5f3ee3 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 18:51:38 +0000 Subject: [PATCH 08/10] update comments --- src/gosteam.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/gosteam.go b/src/gosteam.go index 26dea9a..74563e0 100644 --- a/src/gosteam.go +++ b/src/gosteam.go @@ -63,6 +63,9 @@ func BuildQueryString(responsePath string) string { // ValidateResponse is the real chunk of work that goes on. When the client comes back to our site // we need to take what they give us in the query string and hit up Steam directly // to verify what we're being provided with is valid. +// If we end up with "is_valid:true" response from the Steam then isValid will always return true. +// In any other situation (credential failure, error etc) isValid will always return false and we aim +// to provide a descriptive error where possible. func ValidateResponse(results map[string]string) (steamID64 string, isValid bool, err error) { values := url.Values{} values.Set("openid.mode", "check_authentication") From 82c589f8bfd8d85757ffd4ce7c08898e346ecfd9 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 19:48:32 +0000 Subject: [PATCH 09/10] check very specifically for is_valid and true, rather than strings.contains --- src/gosteam.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/gosteam.go b/src/gosteam.go index 74563e0..2e078e3 100644 --- a/src/gosteam.go +++ b/src/gosteam.go @@ -103,7 +103,26 @@ func ValidateResponse(results map[string]string) (steamID64 string, isValid bool return "", false, err } - if !strings.Contains(string(returnedBytes), "is_valid:true") { + lines := strings.Split(string(returnedBytes), "\n") + + steamSaysValid := false + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + if parts[0] == "is_valid" && parts[1] == "true" { + steamSaysValid = true + break + } + } + + if !steamSaysValid { return "", false, errors.New("openid validation failed: steam returned is_valid:false") } From 1b4566f2e2ec99f734f89f46c3eb4662af211508 Mon Sep 17 00:00:00 2001 From: Teddi Date: Fri, 2 Jan 2026 19:53:59 +0000 Subject: [PATCH 10/10] alias a few bits to be more idomatic go --- example/example.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/example/example.go b/example/example.go index de08258..ad250b6 100644 --- a/example/example.go +++ b/example/example.go @@ -29,18 +29,18 @@ func main() { // ExamplePage is just your average default page handler. In this example // We're just using the one liner to redirect the client and at the same time notify // the openid provider (Steam) where to return us. -func ExamplePage(resp http.ResponseWriter, req *http.Request) { - queryString := req.URL.Query() +func ExamplePage(w http.ResponseWriter, r *http.Request) { + queryString := r.URL.Query() if queryString.Get("login") == "true" { - gosteamauth.RedirectClient(resp, req, gosteamauth.BuildQueryString("http://localhost:8080/process")) + gosteamauth.RedirectClient(w, r, gosteamauth.BuildQueryString("http://localhost:8080/process")) return } loadingTemplate := template.New("index.html") loadingTemplate, _ = template.ParseFiles("index.html") - if err := loadingTemplate.Execute(resp, nil); err != nil { + if err := loadingTemplate.Execute(w, nil); err != nil { fmt.Println(err) } @@ -48,8 +48,8 @@ func ExamplePage(resp http.ResponseWriter, req *http.Request) { // ProcessSteamLogin is where the real magic happens in terms of validation. // As long as isValid is true we should always be able to trust the SteamID64 returned. -func ProcessSteamLogin(resp http.ResponseWriter, req *http.Request) { - queryString, _ := url.ParseQuery(req.URL.RawQuery) +func ProcessSteamLogin(w http.ResponseWriter, r *http.Request) { + queryString, _ := url.ParseQuery(r.URL.RawQuery) // Due to ParseQuery() returning a url.Values in form map[string][]string we're going to // convert that data structure to map[string]string so we can validate. @@ -57,7 +57,9 @@ func ProcessSteamLogin(resp http.ResponseWriter, req *http.Request) { steamID64, isValid, err := gosteamauth.ValidateResponse(queryMap) if err != nil { - fmt.Fprintf(resp, "Failed to log in\nError: %s", err) + // You wouldn't typically raise the error to the end user like this, but for + // demonstration purposes we're attaching the error to the response + fmt.Fprintf(w, "Failed to log in\nError: %s", err) return } @@ -65,9 +67,9 @@ func ProcessSteamLogin(resp http.ResponseWriter, req *http.Request) { // client on away from this page, set cookies / sessions and so on. if isValid { - fmt.Fprintf(resp, "Successfully logged in!\nSteamID: %s", steamID64) + fmt.Fprintf(w, "Successfully logged in!\nSteamID: %s", steamID64) } else { - io.WriteString(resp, "Failed to log in.") + io.WriteString(w, "Failed to log in.") } }