Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
35 changes: 35 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Go Tests

on:
pull_request:

jobs:
test:
runs-on: ubuntu-latest

strategy:
fail-fast: true
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 -v ./...
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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!
Expand Down
20 changes: 11 additions & 9 deletions example/example.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,45 +29,47 @@ 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)
}

}

// 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.
queryMap := gosteamauth.ValuesToMap(queryString)

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
}

// The below is purely for demonstrative purposes, typically you would move the
// 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.")
}

}
190 changes: 102 additions & 88 deletions src/gosteam.go
Original file line number Diff line number Diff line change
@@ -1,148 +1,162 @@
package gosteamauth

import (
"fmt"
"io/ioutil"
"errors"
"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 = 15 * 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.
// 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.
// Takes a map[string]string to be agnostic among various http clients that exist out there
// 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) {

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
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")
}

claimedID := results["openid.claimed_id"]
identity := results["openid.identity"]

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")
}

return "", false, nil
if !strings.HasPrefix(claimedID, steamIDPrefix) {
return "", false, errors.New("openid validation failed: identifier is not a steam openid")
}

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 {
Expand Down
Loading
Loading