diff --git a/providers/steam/steam.go b/providers/steam/steam.go index 79679def..5fb5e1c0 100644 --- a/providers/steam/steam.go +++ b/providers/steam/steam.go @@ -141,30 +141,46 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { // buildUserObject is an internal function to build a goth.User object // based in the data stored in r func buildUserObject(r io.Reader, u goth.User) (goth.User, error) { - // Response object from Steam - apiResponse := struct { + bits, err := io.ReadAll(r) + if err != nil { + return u, err + } + + // Decode the envelope only enough to reach the player object. Keep the + // player payload as RawMessage so we can decode it twice -- once into a + // typed struct for goth.User fields, once into u.RawData so callers can + // access Steam-specific fields that don't have a slot on goth.User + // (timecreated, primaryclanid, communityvisibilitystate, etc.) without + // mirroring every Steam field on the User struct. + envelope := struct { Response struct { - Players []struct { - UserID string `json:"steamid"` - NickName string `json:"personaname"` - Name string `json:"realname"` - AvatarURL string `json:"avatarfull"` - LocationCountryCode string `json:"loccountrycode"` - LocationStateCode string `json:"locstatecode"` - } `json:"players"` + Players []json.RawMessage `json:"players"` } `json:"response"` }{} - - err := json.NewDecoder(r).Decode(&apiResponse) - if err != nil { + if err := json.Unmarshal(bits, &envelope); err != nil { return u, err } - if l := len(apiResponse.Response.Players); l != 1 { + if l := len(envelope.Response.Players); l != 1 { return u, fmt.Errorf("Expected one player in API response. Got %d.", l) } + playerRaw := envelope.Response.Players[0] + + player := struct { + UserID string `json:"steamid"` + NickName string `json:"personaname"` + Name string `json:"realname"` + AvatarURL string `json:"avatarfull"` + LocationCountryCode string `json:"loccountrycode"` + LocationStateCode string `json:"locstatecode"` + }{} + if err := json.Unmarshal(playerRaw, &player); err != nil { + return u, err + } + if err := json.Unmarshal(playerRaw, &u.RawData); err != nil { + return u, err + } - player := apiResponse.Response.Players[0] u.UserID = player.UserID u.Name = player.Name if len(player.Name) == 0 { diff --git a/providers/steam/steam_test.go b/providers/steam/steam_test.go index f800bd0a..aac4ba42 100644 --- a/providers/steam/steam_test.go +++ b/providers/steam/steam_test.go @@ -1,6 +1,10 @@ package steam_test import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" "os" "testing" @@ -50,6 +54,105 @@ func Test_SessionFromJSON(t *testing.T) { a.Equal(s.ResponseNonce, "2016-03-13T16:56:30ZJ8tlKVquwHi9ZSPV4ElU5PY2dmI=") } +func Test_FetchUser(t *testing.T) { + // Regression test for the gap that left goth.User.RawData empty for the + // steam provider (originally raised in PR #518). Beyond the six typed + // fields the provider already mapped, RawData should expose the full + // Steam player payload so callers can read fields without a slot on + // goth.User -- communityvisibilitystate, primaryclanid, timecreated, etc. + apiUserSummaryPath := "/ISteamUser/GetPlayerSummaries/v0002/?key=%s&steamids=%s" + + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://steamcommunity.com/openid/login?openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.mode=checkid_setup&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.realm=%3A%2F%2F&openid.return_to=%2Ffoo","SteamID":"1234567890","CallbackURL":"http://localhost:3030/","ResponseNonce":"2016-03-13T16:56:30ZJ8tlKVquwHi9ZSPV4ElU5PY2dmI="}`) + a.NoError(err) + + expectedPath := fmt.Sprintf(apiUserSummaryPath, p.APIKey, "1234567890") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.Equal("application/json", r.Header.Get("Accept")) + a.Equal(http.MethodGet, r.Method) + a.Equal(expectedPath, r.URL.RequestURI()) + _, _ = w.Write([]byte(testUserSummaryBody)) + })) + defer ts.Close() + + p.HTTPClient = ts.Client() + p.HTTPClient.Transport = &httpTestTransport{server: ts} + + user, err := p.FetchUser(session) + a.NoError(err) + + // Typed fields land where they always did. + a.Equal("76561197960435530", user.UserID) + a.Equal("Robin", user.NickName) + a.Equal("Robin Walker", user.Name) + a.Equal("https://avatars.steamstatic.com/81b5478529dce13bf24b55ac42c1af7058aaf7a9_full.jpg", user.AvatarURL) + a.Equal("No email is provided by the Steam API", user.Email) + a.Equal("No description is provided by the Steam API", user.Description) + a.Equal("WA, US", user.Location) + + // RawData mirrors the six typed fields ... + a.Equal("76561197960435530", user.RawData["steamid"]) + a.Equal("Robin", user.RawData["personaname"]) + a.Equal("Robin Walker", user.RawData["realname"]) + a.Equal("https://avatars.steamstatic.com/81b5478529dce13bf24b55ac42c1af7058aaf7a9_full.jpg", user.RawData["avatarfull"]) + a.Equal("US", user.RawData["loccountrycode"]) + a.Equal("WA", user.RawData["locstatecode"]) + // ... and also the fields without a slot on goth.User. These are the + // ones consumers had no other way to reach before; locking them in + // prevents a future refactor from silently shrinking the payload. + a.EqualValues(3, user.RawData["communityvisibilitystate"]) + a.EqualValues(1, user.RawData["profilestate"]) + a.EqualValues(0, user.RawData["personastate"]) + a.Equal("103582791429521408", user.RawData["primaryclanid"]) + a.EqualValues(1063407589, user.RawData["timecreated"]) + a.Equal("https://steamcommunity.com/id/robinwalker/", user.RawData["profileurl"]) +} + func provider() *steam.Provider { return steam.New(os.Getenv("STEAM_KEY"), "/foo") } + +type httpTestTransport struct { + server *httptest.Server +} + +func (t *httpTestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + uri, err := url.Parse(t.server.URL) + if err != nil { + return nil, err + } + + req.URL.Scheme = uri.Scheme + req.URL.Host = uri.Host + + return http.DefaultTransport.RoundTrip(req) +} + +// Reference: https://developer.valvesoftware.com/wiki/Steam_Web_API +// Extended beyond the six fields the typed struct extracts so the test also +// guards the RawData fallthrough for fields that have no slot on goth.User. +var testUserSummaryBody = `{ + "response": { + "players": [ + { + "steamid": "76561197960435530", + "communityvisibilitystate": 3, + "profilestate": 1, + "personaname": "Robin", + "profileurl": "https://steamcommunity.com/id/robinwalker/", + "avatar": "https://avatars.steamstatic.com/81b5478529dce13bf24b55ac42c1af7058aaf7a9.jpg", + "avatarmedium": "https://avatars.steamstatic.com/81b5478529dce13bf24b55ac42c1af7058aaf7a9_medium.jpg", + "avatarfull": "https://avatars.steamstatic.com/81b5478529dce13bf24b55ac42c1af7058aaf7a9_full.jpg", + "personastate": 0, + "primaryclanid": "103582791429521408", + "timecreated": 1063407589, + "realname": "Robin Walker", + "loccountrycode": "US", + "locstatecode": "WA" + } + ] + } +}`